feat: add mission heartbeat bridge
This commit is contained in:
parent
72578ae7f7
commit
3a235a8c9c
11
COMMANDS.md
11
COMMANDS.md
|
|
@ -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>`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
33
src/index.ts
33
src/index.ts
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue