From 3a235a8c9cabcec556ae0145854948818df704f4 Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 03:21:27 +0200 Subject: [PATCH] feat: add mission heartbeat bridge --- COMMANDS.md | 11 ++++ src/fable5/channel.ts | 2 +- src/fable5/index.ts | 3 + src/fable5/mission-heartbeat.test.ts | 71 ++++++++++++++++++++++++ src/fable5/mission-heartbeat.ts | 82 ++++++++++++++++++++++++++++ src/index.ts | 33 +++++++++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/fable5/mission-heartbeat.test.ts create mode 100644 src/fable5/mission-heartbeat.ts diff --git a/COMMANDS.md b/COMMANDS.md index dad4888..c05c1a3 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -233,6 +233,17 @@ fable-agent plinius godmode "improve explanation quality" - `fable5 flue` - `fable5 channels` - `--run ` +- `fable5 heartbeat` + - `--id ` + - `--name ` + - `--status ` + - `--task ` + - `--completed ` + - `--cost ` + - `--run ` + - `--url ` + - `--secret ` + - `--out ` - `fable5 verify ` - `--repo ` - `fable5 goal ` diff --git a/src/fable5/channel.ts b/src/fable5/channel.ts index 33ba764..a88d6f0 100644 --- a/src/fable5/channel.ts +++ b/src/fable5/channel.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -export type ChannelEventSource = "agent" | "tool" | "workflow" | "gate" | "intake"; +export type ChannelEventSource = "agent" | "tool" | "workflow" | "gate" | "intake" | "mission"; export type ChannelEventType = "started" | "output" | "receipt" | "error" | "done"; export interface ChannelEvent { diff --git a/src/fable5/index.ts b/src/fable5/index.ts index fdf0d06..7b8ed5f 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -59,6 +59,9 @@ export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js"; export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; +export { sendMissionHeartbeat, writeMissionHeartbeatReceipt } from "./mission-heartbeat.js"; +export type { MissionHeartbeatOptions, MissionHeartbeatReceipt, MissionHeartbeatStatus } from "./mission-heartbeat.js"; + export { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js"; export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js"; diff --git a/src/fable5/mission-heartbeat.test.ts b/src/fable5/mission-heartbeat.test.ts new file mode 100644 index 0000000..bbff45b --- /dev/null +++ b/src/fable5/mission-heartbeat.test.ts @@ -0,0 +1,71 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { sendMissionHeartbeat } from "./mission-heartbeat.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function tmpRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-mission-heartbeat-")); + roots.push(root); + return root; +} + +describe("mission heartbeat", () => { + it("always writes a local channel event", async () => { + const root = tmpRoot(); + const receipt = await sendMissionHeartbeat({ id: "fable-agent", status: "working", currentTask: "gate", channelRoot: root, now: new Date("2026-06-18T00:00:00.000Z") }); + + expect(receipt.remote_attempted).toBe(false); + expect(receipt.reason).toContain("remote skipped"); + expect(receipt.channel_event.source).toBe("mission"); + expect(fs.readFileSync(path.join(root, "fable-agent", "channel.jsonl"), "utf-8")).toContain("gate"); + }); + + it("posts remotely when URL and secret are present", async () => { + const calls: unknown[] = []; + const receipt = await sendMissionHeartbeat({ + id: "agent-1", + status: "done", + url: "https://mission.example/api/agents/state", + secret: "secret", + channelRoot: tmpRoot(), + fetcher: async (_url, init) => { + calls.push(init); + return { status: 200 }; + }, + }); + + expect(receipt.remote_attempted).toBe(true); + expect(receipt.remote_ok).toBe(true); + expect(JSON.stringify(calls[0])).toContain("Bearer secret"); + }); + + it("records remote failures without failing local heartbeat", async () => { + const receipt = await sendMissionHeartbeat({ + id: "agent-1", + status: "error", + url: "https://mission.example/api/agents/state", + secret: "secret", + channelRoot: tmpRoot(), + fetcher: async () => ({ status: 500 }), + }); + + expect(receipt.remote_attempted).toBe(true); + expect(receipt.remote_ok).toBe(false); + expect(receipt.reason).toBe("HTTP 500"); + }); + + it("writes a receipt", async () => { + const root = tmpRoot(); + const out = path.join(root, "receipt.json"); + await sendMissionHeartbeat({ id: "agent-1", status: "idle", channelRoot: root, out }); + + expect(fs.readFileSync(out, "utf-8")).toContain("agent-1"); + }); +}); diff --git a/src/fable5/mission-heartbeat.ts b/src/fable5/mission-heartbeat.ts new file mode 100644 index 0000000..e3680a5 --- /dev/null +++ b/src/fable5/mission-heartbeat.ts @@ -0,0 +1,82 @@ +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); +} diff --git a/src/index.ts b/src/index.ts index 916d5a9..2ae2dca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1675,6 +1675,39 @@ fable console.log(``); }); +fable + .command("heartbeat") + .description("Write a mission-control heartbeat locally and optionally POST it remotely") + .requiredOption("--id ", "Agent id") + .option("--name ", "Agent display name") + .option("--status ", "idle|working|blocked|done|error", "working") + .option("--task ", "Current task") + .option("--completed ", "Tasks completed", (v) => Number(v), 0) + .option("--cost ", "Total cost", (v) => Number(v), 0) + .option("--run ", "Local channel run id") + .option("--url ", "Mission Control /api/agents/state URL; defaults to MISSION_CONTROL_URL") + .option("--secret ", "Mission Control secret; defaults to MISSION_CONTROL_SECRET") + .option("--out ", "Receipt output path") + .action(async (opts: { id: string; name?: string; status?: string; task?: string; completed?: number; cost?: number; run?: string; url?: string; secret?: string; out?: string }) => { + const { sendMissionHeartbeat } = await import("./fable5/mission-heartbeat.js"); + const out = opts.out ?? path.join(".fable", "mission", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`); + const receipt = await sendMissionHeartbeat({ + id: opts.id, + name: opts.name, + status: (opts.status ?? "working") as never, + currentTask: opts.task, + tasksCompleted: opts.completed, + totalCost: opts.cost, + runId: opts.run, + url: opts.url ?? process.env.MISSION_CONTROL_URL, + secret: opts.secret ?? process.env.MISSION_CONTROL_SECRET, + out, + }); + console.log(JSON.stringify(receipt, null, 2)); + console.log(`\n Receipt: ${out}\n`); + if (receipt.remote_attempted && !receipt.remote_ok) process.exit(1); + }); + fable .command("verify ") .description("Run independent verifier against a task")