83 lines
2.7 KiB
TypeScript
83 lines
2.7 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { emitChannelEvent, type ChannelEvent } from "./channel.js";
|
|
|
|
export type MissionHeartbeatStatus = "idle" | "working" | "blocked" | "done" | "error";
|
|
|
|
export interface MissionHeartbeatOptions {
|
|
id: string;
|
|
name?: string;
|
|
status: MissionHeartbeatStatus;
|
|
currentTask?: string;
|
|
tasksCompleted?: number;
|
|
totalCost?: number;
|
|
runId?: string;
|
|
channelRoot?: string;
|
|
url?: string;
|
|
secret?: string;
|
|
out?: string;
|
|
now?: Date;
|
|
fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number }>;
|
|
}
|
|
|
|
export interface MissionHeartbeatReceipt {
|
|
created_at: string;
|
|
payload: {
|
|
id: string;
|
|
name?: string;
|
|
status: MissionHeartbeatStatus;
|
|
currentTask?: string;
|
|
tasksCompleted: number;
|
|
totalCost: number;
|
|
};
|
|
channel_event: ChannelEvent;
|
|
remote_attempted: boolean;
|
|
remote_status?: number;
|
|
remote_ok: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export async function sendMissionHeartbeat(opts: MissionHeartbeatOptions): Promise<MissionHeartbeatReceipt> {
|
|
const payload = {
|
|
id: opts.id,
|
|
name: opts.name,
|
|
status: opts.status,
|
|
currentTask: opts.currentTask,
|
|
tasksCompleted: opts.tasksCompleted ?? 0,
|
|
totalCost: opts.totalCost ?? 0,
|
|
};
|
|
const now = opts.now ?? new Date();
|
|
const event = emitChannelEvent(opts.runId ?? opts.id, { source: "mission", type: "output", data: payload }, opts.channelRoot, now);
|
|
const receipt: MissionHeartbeatReceipt = { created_at: now.toISOString(), payload, channel_event: event, remote_attempted: false, remote_ok: false };
|
|
|
|
if (opts.url && opts.secret) {
|
|
receipt.remote_attempted = true;
|
|
try {
|
|
const res = await (opts.fetcher ?? fetchStatus)(opts.url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.secret}` },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
receipt.remote_status = res.status;
|
|
receipt.remote_ok = res.status >= 200 && res.status < 300;
|
|
if (!receipt.remote_ok) receipt.reason = `HTTP ${res.status}`;
|
|
} catch (error) {
|
|
receipt.reason = error instanceof Error ? error.message : String(error);
|
|
}
|
|
} else {
|
|
receipt.reason = "remote skipped: missing url or secret";
|
|
}
|
|
|
|
if (opts.out) writeMissionHeartbeatReceipt(opts.out, receipt);
|
|
return receipt;
|
|
}
|
|
|
|
export function writeMissionHeartbeatReceipt(file: string, receipt: MissionHeartbeatReceipt): void {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
}
|
|
|
|
async function fetchStatus(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
|
|
return fetch(url, init);
|
|
}
|