diff --git a/.gitignore b/.gitignore index 3889649..380255d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ TEMP/ .DS_Store *.tsbuildinfo .fable/ +.runs/ diff --git a/COMMANDS.md b/COMMANDS.md index 577e1ec..9ea1405 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -204,6 +204,8 @@ fable-agent plinius godmode "improve explanation quality" - `-d, --domain ` - `fable5 models` - `fable5 flue` +- `fable5 channels` + - `--run ` - `fable5 verify ` - `--repo ` - `fable5 goal ` diff --git a/src/fable5/channel.test.ts b/src/fable5/channel.test.ts new file mode 100644 index 0000000..ed139ad --- /dev/null +++ b/src/fable5/channel.test.ts @@ -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([]); + }); +}); diff --git a/src/fable5/channel.ts b/src/fable5/channel.ts new file mode 100644 index 0000000..33ba764 --- /dev/null +++ b/src/fable5/channel.ts @@ -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; + +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"; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index ee985ea..a41d361 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index 70b10d9..3b6d162 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1587,6 +1587,20 @@ fable console.log(formatFlueInteropReport()); }); +fable + .command("channels") + .description("Show Flue-style channel event log location") + .option("--run ", "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 ") .description("Run independent verifier against a task")