fable-agent/src/fable5/mission-heartbeat.ts

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);
}