From 036c806116c983df51c7902cd2ced94c2f2e3ffe Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge <47730232+aarontrowbridge@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:40:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(amico-run):=20amico=20fleet=20digest=20?= =?UTF-8?q?=E2=80=94=20the=20fleet's=20Slack=20projection=20verb=20(#428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth rendering of the fleet verbs (CLI, dashboard, /fleet, Slack): reads the registry via readAllRecords, probes configured machines (--machines / AMICO_FLEET_MACHINES — no topology in product code), and posts a ≤6-line distilled block + full table thread reply through the amico-slack subprocess contract. Degrade-graceful by design: a down machine is a row, an empty registry is an honest '0 live', a missing CLI is errors-as-data — the digest ALWAYS renders. Hermetic test suite (17 tests): injected probe/post/now, never a real ssh or Slack call. --- packages/amico-run/src/fleet_digest.ts | 294 +++++++++++++++++++ packages/amico-run/src/fleet_verb.ts | 5 +- packages/amico-run/src/verbs.ts | 7 +- packages/amico-run/test/fleet_digest.test.ts | 283 ++++++++++++++++++ 4 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 packages/amico-run/src/fleet_digest.ts create mode 100644 packages/amico-run/test/fleet_digest.test.ts diff --git a/packages/amico-run/src/fleet_digest.ts b/packages/amico-run/src/fleet_digest.ts new file mode 100644 index 00000000..e175f23c --- /dev/null +++ b/packages/amico-run/src/fleet_digest.ts @@ -0,0 +1,294 @@ +// fleet_digest.ts — `amico fleet digest` (unified-fleet spec slice 1, amicode#428): +// amico fleet digest [--post ] [--dry-run] [--machines a,b] [--jobs-line ""] [--root D] +// +// THE FOURTH RENDERING. The fleet doctrine (fleet_verb.ts header) says every fleet +// surface — dashboard widget, in-chat /fleet, Amico answering "how's the fleet?" — is a +// rendering of the deterministic verbs, "so the fleet layer never has to speak and never +// has two answers." This module adds Slack as the fourth: it READS the registry (via +// readAllRecords, never recomputing state) and probes configured machines, formats a +// ≤6-line distilled block plus a full table for the thread reply, and posts through the +// `amico-slack` CLI as a SUBPROCESS CONTRACT. No Slack API code lives here — posting, +// voice, and footer logic are the CLI's job; if it is not on PATH, that is errors-as-data, +// not a crash. +// +// PURITY BOUNDARY (mirrors fleet_registry.ts): formatDigestBlock/formatDigestTable/ +// summarizeSessions are total functions of their arguments — no fs, no clock, no spawn. +// The impure edge (ssh probe, amico-slack subprocess, registry read, clock) is confined +// to fleetDigest() and is INJECTABLE via DigestDeps, which is how the test suite runs +// the whole verb hermetically. +// +// NO TOPOLOGY IN PRODUCT CODE (spec invariant): machine aliases come from --machines or +// AMICO_FLEET_MACHINES, the channel from --post or AMICO_SLACK_FLEET_CHANNEL — never a +// hardcoded host or channel name. Unconfigured machines render an honest "n/a" line; +// a down machine is a row, never a failed digest (degrade-graceful: the digest ALWAYS +// renders, because a digest that dies when one machine sleeps is worse than no digest). +// The Notturno wrapper (harmoniqs/amico#340) injects its jobs rollup via --jobs-line so +// this module never learns about Notturno. +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + FLEET_STATES, + fleetRoot, + isTerminal, + readAllRecords, + type FleetRecord, + type FleetState, +} from "./fleet_registry.js"; +import type { VerbResult } from "./verbs.js"; + +// ── the view (pure) ────────────────────────────────────────────────────────────── + +export interface MachineProbe { + alias: string; + ok: boolean; + detail: string; +} + +export interface DigestSessionRow { + session_id: string; + state: FleetState; + host: string; + age: string; // "" when started is unknown +} + +export interface DigestView { + date: string; // YYYY-MM-DD + machines: MachineProbe[] | null; // null = machine list not configured + sessions: { + total: number; + byState: Record; + live: DigestSessionRow[]; + terminal: number; + unreadable: number; + }; + jobsLine?: string; +} + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +/** Human age from a duration in ms: "12m" · "4h12m" · "3d2h". Sub-minute → "now". */ +export function formatAge(ms: number): string { + if (!Number.isFinite(ms) || ms < 60_000) return "now"; + const m = Math.floor(ms / 60_000); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h${m % 60}m`; + return `${Math.floor(h / 24)}d${h % 24}h`; +} + +export function summarizeSessions(records: FleetRecord[], unreadable: number, now: number): DigestView["sessions"] { + const byState: Record = {}; + for (const s of FLEET_STATES) byState[s] = 0; + for (const r of records) byState[r.state] += 1; + const live: DigestSessionRow[] = records + .filter((r) => !isTerminal(r.state)) + .map((r) => { + const started = r.started === "" ? NaN : Date.parse(r.started); + return { + session_id: r.session_id, + state: r.state, + host: r.host, + age: Number.isFinite(started) ? formatAge(now - started) : "", + }; + }) + .sort((a, b) => a.session_id.localeCompare(b.session_id)); + return { total: records.length, byState, live, terminal: byState.killed ?? 0, unreadable }; +} + +/** The ≤6-line distilled block — the only part that lands top-level in the channel. */ +export function formatDigestBlock(v: DigestView): string { + const lines: string[] = [`*Fleet — ${v.date}*`]; + if (v.machines === null) { + lines.push("machines: n/a (not configured)"); + } else { + lines.push( + "machines: " + + (v.machines.length === 0 + ? "n/a (none configured)" + : v.machines.map((m) => `${m.alias} ${m.ok ? "✓" : "✗"}`).join(" · ")), + ); + } + const s = v.sessions; + lines.push( + s.total === 0 + ? "sessions: 0 live (registry empty)" + : `sessions: ${s.live.length} live of ${s.total} (${FLEET_STATES.filter((st) => s.byState[st] > 0) + .map((st) => `${st} ${s.byState[st]}`) + .join(" · ")})`, + ); + if (v.jobsLine !== undefined && v.jobsLine !== "") lines.push(`jobs: ${v.jobsLine}`); + return lines.join("\n"); +} + +/** The full table — posted as the thread reply; the block is the summary, this is the data. */ +export function formatDigestTable(v: DigestView, rootPath: string): string { + const lines: string[] = []; + lines.push("*Machines*"); + if (v.machines === null || v.machines.length === 0) { + lines.push(" (not configured — pass --machines or set AMICO_FLEET_MACHINES)"); + } else { + for (const m of v.machines) { + lines.push(` ${m.alias.padEnd(18)} ${m.ok ? "✓" : "✗"} ${m.detail}`); + } + } + lines.push(""); + lines.push(`*Sessions* — registry ${rootPath}`); + const s = v.sessions; + if (s.total === 0) { + lines.push(" 0 records — nothing has populated the registry yet"); + } else { + lines.push(` total ${s.total} (${FLEET_STATES.map((st) => `${st} ${s.byState[st]}`).join(" · ")})`); + if (s.live.length === 0) { + lines.push(" no live sessions"); + } else { + for (const r of s.live) { + lines.push(` ${r.session_id} ${r.state} ${r.host}${r.age ? ` ${r.age}` : ""}`); + } + } + } + if (s.unreadable > 0) lines.push(` ⚠️ ${s.unreadable} unreadable record(s) in the registry root`); + return lines.join("\n"); +} + +// ── the impure edge (injectable) ───────────────────────────────────────────────── + +export type DigestPoster = ( + channel: string, + block: string, + table: string, +) => { ok: boolean; ts?: string; errors?: string[]; warnings?: string[] }; + +export interface DigestDeps { + probe?: (alias: string) => MachineProbe; + post?: DigestPoster; + now?: () => number; +} + +/** One machine probe over the SSH mesh (the /fleet skill's reachability check, in code). */ +export function sshProbe(alias: string): MachineProbe { + const r = spawnSync( + "ssh", + ["-o", "BatchMode=yes", "-o", "ConnectTimeout=6", alias, "echo ok; hostname"], + { encoding: "utf8", timeout: 8000 }, + ); + if (r.status === 0) { + const out = (r.stdout || "").trim().split("\n").filter(Boolean); + return { alias, ok: true, detail: out[out.length - 1] ?? alias }; + } + const err = ((r.stderr || "") as string).trim().split("\n")[0] || `exit ${r.status ?? "?"}`; + return { alias, ok: false, detail: err.slice(0, 80) }; +} + +/** The subprocess contract: `amico-slack send --file [--thread ]` → "sent → … ts=". */ +function amicoSlackSend(args: string[]): { ok: boolean; stdout: string; error?: string } { + const r = spawnSync("amico-slack", args, { encoding: "utf8", timeout: 60_000 }); + if (r.error) { + const code = (r.error as NodeJS.ErrnoException).code; + const hint = + code === "ENOENT" + ? "amico-slack not found on PATH — install the CLI (unified-fleet slice 2 ships it) or run with --dry-run" + : String(r.error.message ?? r.error); + return { ok: false, stdout: "", error: hint }; + } + if (r.status !== 0) { + const tail = ((r.stderr || "") + "\n" + (r.stdout || "")).trim().split("\n").pop() ?? ""; + return { ok: false, stdout: r.stdout || "", error: tail || `amico-slack exit ${r.status}` }; + } + return { ok: true, stdout: r.stdout || "" }; +} + +/** Default poster: block top-level, table as a thread reply. The block going out outranks + * the thread reply — a table failure is a warning, not a failed digest. */ +export function postViaAmicoSlack(channel: string, block: string, table: string): { + ok: boolean; + ts?: string; + errors?: string[]; + warnings?: string[]; +} { + const dir = mkdtempSync(join(tmpdir(), "amico-fleet-digest-")); + try { + const blockFile = join(dir, "block.md"); + writeFileSync(blockFile, block, "utf8"); + const r1 = amicoSlackSend(["send", channel, "--file", blockFile]); + if (!r1.ok) return { ok: false, errors: [r1.error ?? "amico-slack send failed"] }; + const ts = /ts=([0-9.]+)/.exec(r1.stdout)?.[1]; + const tableFile = join(dir, "table.md"); + writeFileSync(tableFile, table, "utf8"); + const args = ["send", channel, "--file", tableFile]; + if (ts) args.push("--thread", ts); + const r2 = amicoSlackSend(args); + if (!r2.ok) return { ok: true, ts, warnings: [`thread table not posted: ${r2.error ?? "failed"}`] }; + return { ok: true, ts }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// ── the verb ───────────────────────────────────────────────────────────────────── + +export function fleetDigest(argv: string[], deps: DigestDeps = {}): VerbResult { + const subcommand = "digest"; + // channel resolution: --post flag > AMICO_SLACK_FLEET_CHANNEL env; absent → dry-run + const channel = flagValue(argv, "--post") ?? process.env.AMICO_SLACK_FLEET_CHANNEL ?? ""; + const isPost = channel !== "" && !argv.includes("--dry-run"); + const machinesArg = flagValue(argv, "--machines") ?? process.env.AMICO_FLEET_MACHINES ?? ""; + const jobsLine = flagValue(argv, "--jobs-line"); + const root = fleetRoot(flagValue(argv, "--root")); + const now = deps.now ? deps.now() : Date.now(); + + const machines: MachineProbe[] | null = + machinesArg.trim() === "" + ? null + : machinesArg + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + .map((alias) => (deps.probe ? deps.probe(alias) : sshProbe(alias))); + + const { records, unreadable } = readAllRecords(root); + const view: DigestView = { + date: new Date(now).toISOString().slice(0, 10), + machines, + sessions: summarizeSessions(records, unreadable.length, now), + ...(jobsLine !== undefined ? { jobsLine } : {}), + }; + + const block = formatDigestBlock(view); + const table = formatDigestTable(view, root); + + const base: Record = { + verb: "fleet", + subcommand, + ok: true, + root, + channel, + machines: machines ?? [], + sessions: { + total: view.sessions.total, + live: view.sessions.live.length, + byState: view.sessions.byState, + unreadable: view.sessions.unreadable, + }, + block, + table, + }; + + if (!isPost) { + return { json: { ...base, dry_run: true }, code: 0 }; + } + + const post = deps.post ?? postViaAmicoSlack; + const res = post(channel, block, table); + if (!res.ok) { + return { json: { ...base, ok: false, dry_run: false, errors: res.errors ?? ["post failed"] }, code: 64 }; + } + return { + json: { ...base, dry_run: false, posted: true, ...(res.ts ? { ts: res.ts } : {}), ...(res.warnings ? { warnings: res.warnings } : {}) }, + code: 0, + }; +} diff --git a/packages/amico-run/src/fleet_verb.ts b/packages/amico-run/src/fleet_verb.ts index 9e5d5f21..03a628c8 100644 --- a/packages/amico-run/src/fleet_verb.ts +++ b/packages/amico-run/src/fleet_verb.ts @@ -33,6 +33,7 @@ // `--json` is accepted on every subcommand and is a no-op: these verbs are JSON-out by // construction (amico.ts prints `VerbResult.json`), exactly like the other spine verbs. import { FRONTIER_MODELS, ladderRungs } from "./ledger_dispatch.js"; +import { fleetDigest } from "./fleet_digest.js"; import { applyEvent, enqueueSignal, @@ -409,7 +410,8 @@ export function fleetSweep(argv: string[]): VerbResult { const USAGE = "amico fleet list [--state ] [--root D] | amico fleet status --session | " + 'amico fleet steer --session --message "" | amico fleet stop --session [--reason ""] | ' + - "amico fleet re-tier --session --model [--variant ] | amico fleet sweep [--dry-run]"; + "amico fleet re-tier --session --model [--variant ] | amico fleet sweep [--dry-run] | " + + "amico fleet digest [--post ] [--machines a,b] [--jobs-line \"\"] [--dry-run] [--root D]"; /** The `fleet` verb body: route on the subcommand. Backs BOTH the CLI (amico.ts) and the * MCP facade (mcp_serve.ts) — one impl, two transports. */ @@ -422,6 +424,7 @@ export function fleetVerb(argv: string[]): VerbResult { if (sub === "stop") return fleetStop(rest); if (sub === "re-tier") return fleetRetier(rest); if (sub === "sweep") return fleetSweep(rest); + if (sub === "digest") return fleetDigest(rest); return { json: { verb: "fleet", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: USAGE }, code: 64, diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 5f773c98..28f233cd 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -133,6 +133,9 @@ const profile: Verb = { // `stop`, `re-tier`) ENQUEUE SIGNAL FILES and never touch a record, because at most one // writer holds a record at a time (extension while `spooling`, harness after the handoff). // `sweep` is the one exception that writes, and only for an orphaned holder pid. +// `digest` is the fourth RENDERING (unified-fleet spec slice 1): it reads the registry, +// probes configured machines, and posts the distilled block through the amico-slack +// contract — a projection, never a second state machine. // // Deliberate contrast with `ledger` above: the ledger is an append-only immutable JSONL // event log; this registry is mutable per-session TOML state. They share record I/O @@ -140,8 +143,8 @@ const profile: Verb = { const fleet: Verb = { name: "fleet", summary: - "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard", - generalizes: "the fleet view + in-chat /fleet + Amico's conversational fleet questions, over ~/.amico/ops/fleet", + "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard, digest as the Slack projection", + generalizes: "the fleet view + in-chat /fleet + Amico's conversational fleet questions + the Slack digest, over ~/.amico/ops/fleet", slice: "fleet substrate (§9 step 2)", run: fleetVerb, }; diff --git a/packages/amico-run/test/fleet_digest.test.ts b/packages/amico-run/test/fleet_digest.test.ts new file mode 100644 index 00000000..a320b6aa --- /dev/null +++ b/packages/amico-run/test/fleet_digest.test.ts @@ -0,0 +1,283 @@ +// `amico fleet digest` (unified-fleet spec slice 1, amicode#428) — the fourth rendering. +// +// The three properties this suite exists to defend: +// 1. THE DIGEST ALWAYS RENDERS. A down machine is a row ("✗"), an unconfigured machine +// list is an honest "n/a" line, an empty registry is "0 live" — none of it is ever a +// failed digest. A digest that dies when one machine sleeps is worse than no digest. +// 2. IT IS A PROJECTION. Sessions come from the registry via readAllRecords; the module +// never recomputes state and never writes anything. +// 3. POSTING IS A SUBPROCESS CONTRACT. The amico-slack CLI does the posting; absent +// from PATH → errors-as-data, never a crash; block posted + table failed → ok:true +// with a warning (the block going out outranks the thread reply). +// +// HERMETIC BY CONSTRUCTION: every behavior test calls fleetDigest DIRECTLY with injected +// probe/post/now deps — never through the fleetVerb router (which takes no deps), so no +// test can ever reach a real ssh probe or the real amico-slack CLI. The router is covered +// by exactly one dry-run smoke. +// Run: pnpm --filter @amicode/amico-run test fleet_digest +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fleetVerb } from "../src/fleet_verb.js"; +import { + fleetDigest, + formatAge, + formatDigestBlock, + formatDigestTable, + summarizeSessions, + type DigestDeps, + type DigestPoster, + type MachineProbe, +} from "../src/fleet_digest.js"; +import { normalizeRecord, writeRecord, type FleetRecord, type FleetState } from "../src/fleet_registry.js"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fleet-digest-")); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const NOW = Date.parse("2026-08-18T12:00:00.000Z"); + +function record(session_id: string, state: FleetState, over: Partial = {}): FleetRecord { + return normalizeRecord({ + session_id, + state, + started: "2026-08-18T08:00:00.000Z", // 4h before NOW + pid: process.pid, + host: "test-host", + tokens: 1200, + runtime: 90, + current_step: "s1", + profile: { + name: "researcher", + base: "executor", + model: "anthropic/claude-opus-5", + variant: "high", + task_type: "plan", + skills: ["amico-vault"], + gates: ["schema-validate"], + permissions: { vault: "rw", packages: "ro", ops: "none", work: "rw", device: "none", task: "deny" }, + }, + ...over, + }); +} + +function put(session_id: string, state: FleetState, over: Partial = {}): FleetRecord { + const rec = record(session_id, state, over); + writeRecord(root, rec); + return rec; +} + +/** Hermetic deps: no ssh, no amico-slack, frozen clock — the whole verb runs in-process. */ +function hermetic(machines: Record, post?: DigestPoster): DigestDeps { + return { + probe: (alias) => machines[alias] ?? { alias, ok: false, detail: "unknown alias" }, + post, + now: () => NOW, + }; +} + +function run(args: string[], deps: DigestDeps): { json: Record; code: number } { + return fleetDigest([...args, "--root", root], deps) as { json: Record; code: number }; +} + +// ── the pure formatters ────────────────────────────────────────────────────────── + +describe("formatAge", () => { + it("renders sub-minute as now, minutes, hours+minutes, days+hours", () => { + expect(formatAge(30_000)).toBe("now"); + expect(formatAge(12 * 60_000)).toBe("12m"); + expect(formatAge((4 * 60 + 12) * 60_000)).toBe("4h12m"); + expect(formatAge((50 * 60 + 2) * 60_000)).toBe("2d2h"); + }); +}); + +describe("summarizeSessions", () => { + it("counts by state, keeps live rows non-terminal, ages from started", () => { + const s = summarizeSessions([record("s1", "running"), record("s2", "settled"), record("s3", "killed")], 0, NOW); + expect(s.total).toBe(3); + expect(s.byState.running).toBe(1); + expect(s.byState.settled).toBe(1); + expect(s.byState.killed).toBe(1); + expect(s.live.map((r) => r.session_id)).toEqual(["s1", "s2"]); // killed is terminal + expect(s.live[0].age).toBe("4h0m"); + expect(s.terminal).toBe(1); + }); +}); + +// ── the block (≤6 lines, distilled) ────────────────────────────────────────────── + +describe("formatDigestBlock", () => { + it("renders machines, sessions, and an optional jobs line in ≤6 lines", () => { + const block = formatDigestBlock({ + date: "2026-08-18", + machines: [ + { alias: "alpha", ok: true, detail: "host-a" }, + { alias: "beta", ok: false, detail: "ssh: timeout" }, + ], + sessions: { + total: 2, + byState: { running: 1, settled: 1 }, + live: [ + { session_id: "s1", state: "running", host: "host-a", age: "1h" }, + { session_id: "s2", state: "settled", host: "host-a", age: "2h" }, + ], + terminal: 0, + unreadable: 0, + }, + jobsLine: "all green (11/11)", + }); + const lines = block.split("\n"); + expect(lines.length).toBeLessThanOrEqual(6); + expect(lines[0]).toBe("*Fleet — 2026-08-18*"); + expect(lines[1]).toBe("machines: alpha ✓ · beta ✗"); + expect(lines[2]).toBe("sessions: 2 live of 2 (running 1 · settled 1)"); + expect(lines[3]).toBe("jobs: all green (11/11)"); + }); + + it("is honest when nothing is configured or populated", () => { + const block = formatDigestBlock({ + date: "2026-08-18", + machines: null, + sessions: { total: 0, byState: {}, live: [], terminal: 0, unreadable: 0 }, + }); + expect(block).toContain("machines: n/a (not configured)"); + expect(block).toContain("sessions: 0 live (registry empty)"); + }); +}); + +describe("formatDigestTable", () => { + it("flags unreadable records — corruption is surfaced, never swallowed", () => { + const table = formatDigestTable( + { + date: "2026-08-18", + machines: null, + sessions: { total: 0, byState: {}, live: [], terminal: 0, unreadable: 2 }, + }, + "/registry/root", + ); + expect(table).toContain("⚠️ 2 unreadable record(s)"); + }); +}); + +// ── the verb end-to-end (hermetic) ─────────────────────────────────────────────── + +describe("amico fleet digest — dry-run default", () => { + it("defaults to dry-run: renders block + table, exit 0, posts nothing", () => { + put("s1", "running"); + let posted = 0; + const r = run([], hermetic({}, () => ({ ok: true, ts: "1.0", posted: ++posted } as any))); + expect(r.code).toBe(0); + expect(r.json.dry_run).toBe(true); + expect(r.json.block).toContain("*Fleet — 2026-08-18*"); + expect(r.json.table).toContain("s1"); + expect(r.json.table).toContain("running"); + expect(posted).toBe(0); + }); + + it("degrade-graceful: a down machine is a row, never a failed digest", () => { + const r = run( + ["--machines", "alpha,beta"], + hermetic({ + alpha: { alias: "alpha", ok: true, detail: "host-a" }, + beta: { alias: "beta", ok: false, detail: "ssh: connect timed out" }, + }), + ); + expect(r.code).toBe(0); + expect(r.json.ok).toBe(true); + expect(r.json.block).toContain("alpha ✓ · beta ✗"); + expect(r.json.table).toMatch(/beta\s+✗\s+ssh: connect timed out/); + }); + + it("unconfigured machines render n/a, not an error", () => { + const r = run([], hermetic({})); + expect(r.code).toBe(0); + expect(r.json.block).toContain("machines: n/a (not configured)"); + }); + + it("empty registry renders 0 live sessions honestly", () => { + const r = run([], hermetic({})); + expect(r.code).toBe(0); + expect(r.json.block).toContain("sessions: 0 live (registry empty)"); + expect(r.json.table).toContain("0 records"); + }); + + it("--jobs-line renders as the jobs line (the Notturno wrapper's injection point)", () => { + const r = run(["--jobs-line", "10/11 green, eod-checkin failed"], hermetic({})); + expect(r.json.block).toContain("jobs: 10/11 green, eod-checkin failed"); + }); + + it("terminal records show in the counts; live rows carry age", () => { + put("live-1", "running", { started: "2026-08-18T11:30:00.000Z" }); + put("dead-1", "killed"); + const r = run([], hermetic({})); + expect(r.json.block).toContain("sessions: 1 live of 2 (running 1 · killed 1)"); + expect(r.json.table).toContain("live-1 running test-host 30m"); + expect(r.json.table).toContain("killed 1"); + }); +}); + +describe("amico fleet digest --post", () => { + it("posts the block then the table as a thread reply", () => { + put("s1", "running"); + const calls: Array> = []; + const post: DigestPoster = (channel, block, table) => { + calls.push({ channel, block, table }); + return { ok: true, ts: "1787000000.000100" }; + }; + const r = run(["--post", "#ops-fleet"], hermetic({}, post)); + expect(r.code).toBe(0); + expect(r.json.posted).toBe(true); + expect(r.json.ts).toBe("1787000000.000100"); + expect(calls).toHaveLength(1); + expect(calls[0].channel).toBe("#ops-fleet"); + expect(calls[0].block).toContain("*Fleet — 2026-08-18*"); + expect(calls[0].table).toContain("s1"); + }); + + it("a failed table post is a warning, not a failed digest (the block outranks the reply)", () => { + const r = run( + ["--post", "#ops-fleet"], + hermetic({}, () => ({ ok: true, ts: "1.0", warnings: ["thread table not posted: boom"] })), + ); + expect(r.code).toBe(0); + expect(r.json.posted).toBe(true); + expect((r.json.warnings as string[]).join("; ")).toContain("thread table not posted: boom"); + }); + + it("poster failure → errors-as-data at exit 64, never a stack trace", () => { + const r = run(["--post", "#ops-fleet"], hermetic({}, () => ({ ok: false, errors: ["amico-slack not found on PATH"] }))); + expect(r.code).toBe(64); + expect(r.json.ok).toBe(false); + expect((r.json.errors as string[])[0]).toContain("amico-slack not found"); + }); + + it("--dry-run beats the resolved channel (explicit dry-run with --post)", () => { + let posted = 0; + const r = run(["--post", "#ops-fleet", "--dry-run"], hermetic({}, () => ({ ok: true, ts: "1.0", posted: ++posted } as any))); + expect(r.code).toBe(0); + expect(r.json.dry_run).toBe(true); + expect(posted).toBe(0); + }); +}); + +// ── the router + the topology invariant ────────────────────────────────────────── + +describe("the fleetVerb router", () => { + it("routes `fleet digest` (dry-run smoke — no machines, no post, so nothing impure)", () => { + const r = fleetVerb(["digest", "--root", root]) as { json: Record; code: number }; + expect(r.code).toBe(0); + expect(r.json.subcommand).toBe("digest"); + expect(r.json.dry_run).toBe(true); + }); +}); + +describe("no topology in code (spec invariant)", () => { + it("the module source names no channels and no hosts (word-boundary match)", () => { + const src = readFileSync(new URL("../src/fleet_digest.ts", import.meta.url), "utf8"); + expect(src).not.toMatch(/#fleet|#amico|#general|#amicode/); + expect(src).not.toMatch(/\b(mini|macbook|erlich)\b/); + }); +});