feat: add channel event log

This commit is contained in:
artale 2026-06-18 00:07:58 +02:00
parent c7cd28c404
commit ff5b3dc937
6 changed files with 99 additions and 0 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ TEMP/
.DS_Store
*.tsbuildinfo
.fable/
.runs/

View File

@ -204,6 +204,8 @@ fable-agent plinius godmode "improve explanation quality"
- `-d, --domain <domain>`
- `fable5 models`
- `fable5 flue`
- `fable5 channels`
- `--run <id>`
- `fable5 verify <task>`
- `--repo <path>`
- `fable5 goal <text>`

View File

@ -0,0 +1,42 @@
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 { channelPath, emitChannelEvent, readChannelEvents } from "./channel.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-channel-"));
roots.push(root);
return root;
}
describe("channel event log", () => {
it("creates a JSONL channel file", () => {
const root = tmpRoot();
const event = emitChannelEvent("demo/run", { source: "workflow", type: "started", data: { step: "x" } }, root, new Date("2026-06-16T00:00:00.000Z"));
expect(event.runId).toBe("demo/run");
expect(event.ts).toBe("2026-06-16T00:00:00.000Z");
expect(fs.existsSync(channelPath("demo/run", root))).toBe(true);
});
it("preserves multiple events in order", () => {
const root = tmpRoot();
emitChannelEvent("run-1", { source: "intake", type: "started", data: { n: 1 } }, root);
emitChannelEvent("run-1", { source: "gate", type: "done", data: { n: 2 } }, root);
const events = readChannelEvents("run-1", root);
expect(events.map((event) => event.source)).toEqual(["intake", "gate"]);
expect(events.map((event) => event.type)).toEqual(["started", "done"]);
});
it("returns an empty list for missing channels", () => {
expect(readChannelEvents("missing", tmpRoot())).toEqual([]);
});
});

37
src/fable5/channel.ts Normal file
View File

@ -0,0 +1,37 @@
import * as fs from "node:fs";
import * as path from "node:path";
export type ChannelEventSource = "agent" | "tool" | "workflow" | "gate" | "intake";
export type ChannelEventType = "started" | "output" | "receipt" | "error" | "done";
export interface ChannelEvent {
runId: string;
ts: string;
source: ChannelEventSource;
type: ChannelEventType;
data: unknown;
}
export type NewChannelEvent = Omit<ChannelEvent, "runId" | "ts">;
export function channelPath(runId: string, root = ".runs"): string {
return path.join(root, safeRunId(runId), "channel.jsonl");
}
export function emitChannelEvent(runId: string, event: NewChannelEvent, root = ".runs", now = new Date()): ChannelEvent {
const full: ChannelEvent = { runId, ts: now.toISOString(), ...event };
const file = channelPath(runId, root);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, `${JSON.stringify(full)}\n`);
return full;
}
export function readChannelEvents(runId: string, root = ".runs"): ChannelEvent[] {
const file = channelPath(runId, root);
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, "utf-8").trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line) as ChannelEvent);
}
function safeRunId(runId: string): string {
return runId.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-|-$/g, "") || "run";
}

View File

@ -50,5 +50,8 @@ export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService
export { FLUE_INTEROP_ROWS, formatFlueInteropReport, summarizeFlueInterop } from "./flue-interop.js";
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 { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js";
export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js";

View File

@ -1587,6 +1587,20 @@ fable
console.log(formatFlueInteropReport());
});
fable
.command("channels")
.description("Show Flue-style channel event log location")
.option("--run <id>", "Run id", "demo")
.action(async (opts: { run?: string }) => {
const { channelPath, readChannelEvents } = await import("./fable5/channel.js");
const runId = opts.run ?? "demo";
const events = readChannelEvents(runId);
console.log(`
Channel: ${channelPath(runId)}`);
console.log(` Events: ${events.length}`);
console.log(``);
});
fable
.command("verify <task>")
.description("Run independent verifier against a task")