feat: add mission heartbeat bridge

This commit is contained in:
artale 2026-06-18 03:21:27 +02:00
parent 72578ae7f7
commit 3a235a8c9c
6 changed files with 201 additions and 1 deletions

View File

@ -233,6 +233,17 @@ fable-agent plinius godmode "improve explanation quality"
- `fable5 flue` - `fable5 flue`
- `fable5 channels` - `fable5 channels`
- `--run <id>` - `--run <id>`
- `fable5 heartbeat`
- `--id <id>`
- `--name <name>`
- `--status <status>`
- `--task <text>`
- `--completed <n>`
- `--cost <n>`
- `--run <id>`
- `--url <url>`
- `--secret <secret>`
- `--out <path>`
- `fable5 verify <task>` - `fable5 verify <task>`
- `--repo <path>` - `--repo <path>`
- `fable5 goal <text>` - `fable5 goal <text>`

View File

@ -1,7 +1,7 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; 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 type ChannelEventType = "started" | "output" | "receipt" | "error" | "done";
export interface ChannelEvent { export interface ChannelEvent {

View File

@ -59,6 +59,9 @@ export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";
export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js";
export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } 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 { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js";
export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js"; export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js";

View File

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

View File

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

View File

@ -1675,6 +1675,39 @@ fable
console.log(``); console.log(``);
}); });
fable
.command("heartbeat")
.description("Write a mission-control heartbeat locally and optionally POST it remotely")
.requiredOption("--id <id>", "Agent id")
.option("--name <name>", "Agent display name")
.option("--status <status>", "idle|working|blocked|done|error", "working")
.option("--task <text>", "Current task")
.option("--completed <n>", "Tasks completed", (v) => Number(v), 0)
.option("--cost <n>", "Total cost", (v) => Number(v), 0)
.option("--run <id>", "Local channel run id")
.option("--url <url>", "Mission Control /api/agents/state URL; defaults to MISSION_CONTROL_URL")
.option("--secret <secret>", "Mission Control secret; defaults to MISSION_CONTROL_SECRET")
.option("--out <path>", "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 fable
.command("verify <task>") .command("verify <task>")
.description("Run independent verifier against a task") .description("Run independent verifier against a task")