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; 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 { 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; body: string }) { return fetch(url, init); }