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 channels`
|
||||
- `--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>`
|
||||
- `--repo <path>`
|
||||
- `fable5 goal <text>`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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(``);
|
||||
});
|
||||
|
||||
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
|
||||
.command("verify <task>")
|
||||
.description("Run independent verifier against a task")
|
||||
|
|
|
|||
Loading…
Reference in New Issue