From a37a39375534462c4f0b1e6a093cb254e8ce1a15 Mon Sep 17 00:00:00 2001 From: Heretek-OpenCode Coordinator Date: Mon, 12 Oct 2026 12:49:03 -0400 Subject: [PATCH] feat(homelab): compose topology, studio CLI, autonomy dial, cockpit projections, E2E mission - compose.yaml: distro :2.0.26-r1 (4096+healthcheck), gateway (POST /mcp), fleet (bun run), valkey; frontend (gateway-egress-only) + internal (isolated) networks; state vs workspace-disposable volumes; all rootless - compose.override.dev.yaml: local live mounts, no privilege escalation - scripts/studio (POSIX sh, +x): init refuses privileged-dev without ALLOW_PRIVILEGED=1, generates OPENCODE_PASSWORD; up/down/logs/status wrap docker compose - scripts/test-e2e-mission.ts: mock-adapter canonical mission (grill 3 criteria, research provenance envelope+hash, 1-task DAG, tmp patch, GateReport via SDK schema, dual QA reports, PR evidence table, prev-hash journal verify); 19 PASS, no network/docker - config/autonomy.json + src/autonomy.ts: advisory/balanced/full dial gate - cockpit projections (pure Fleet-event fold + MissionViewer) with tests - package @heretek-ai/homelab + READMEs --- bun.lock | 55 +++++ packages/opencode-homelab/.gitignore | 3 + packages/opencode-homelab/README.md | 72 ++++++ packages/opencode-homelab/cockpit/README.md | 18 ++ .../opencode-homelab/cockpit/package.json | 10 + .../cockpit/src/components/MissionViewer.ts | 59 +++++ .../cockpit/src/components/NOTES.md | 14 ++ .../cockpit/src/projections/missions.ts | 180 +++++++++++++++ .../cockpit/test/missions.test.ts | 85 ++++++++ .../compose.override.dev.yaml | 26 +++ packages/opencode-homelab/compose.yaml | 137 ++++++++++++ .../opencode-homelab/config/autonomy.json | 6 + packages/opencode-homelab/package.json | 19 ++ packages/opencode-homelab/scripts/studio | 100 +++++++++ .../scripts/test-e2e-mission.ts | 205 ++++++++++++++++++ packages/opencode-homelab/src/autonomy.ts | 71 ++++++ .../opencode-homelab/test/autonomy.test.ts | 52 +++++ 17 files changed, 1112 insertions(+) create mode 100644 bun.lock create mode 100644 packages/opencode-homelab/.gitignore create mode 100644 packages/opencode-homelab/README.md create mode 100644 packages/opencode-homelab/cockpit/README.md create mode 100644 packages/opencode-homelab/cockpit/package.json create mode 100644 packages/opencode-homelab/cockpit/src/components/MissionViewer.ts create mode 100644 packages/opencode-homelab/cockpit/src/components/NOTES.md create mode 100644 packages/opencode-homelab/cockpit/src/projections/missions.ts create mode 100644 packages/opencode-homelab/cockpit/test/missions.test.ts create mode 100644 packages/opencode-homelab/compose.override.dev.yaml create mode 100644 packages/opencode-homelab/compose.yaml create mode 100644 packages/opencode-homelab/config/autonomy.json create mode 100644 packages/opencode-homelab/package.json create mode 100755 packages/opencode-homelab/scripts/studio create mode 100644 packages/opencode-homelab/scripts/test-e2e-mission.ts create mode 100644 packages/opencode-homelab/src/autonomy.ts create mode 100644 packages/opencode-homelab/test/autonomy.test.ts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..72261e8 --- /dev/null +++ b/bun.lock @@ -0,0 +1,55 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "heretek-opencode-monorepo", + "devDependencies": { + "@types/node": "^22.0.0", + "oxlint": "^0.15.0", + "typescript": "^5.5.0", + "zod": "^3.23.8", + }, + }, + "packages/opencode-homelab": { + "name": "@heretek-ai/homelab", + "version": "0.1.0", + "dependencies": { + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + }, + }, + }, + "packages": { + "@heretek-ai/homelab": ["@heretek-ai/homelab@workspace:packages/opencode-homelab"], + + "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@0.15.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg=="], + + "@oxlint/darwin-x64": ["@oxlint/darwin-x64@0.15.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow=="], + + "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@0.15.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA=="], + + "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@0.15.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA=="], + + "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@0.15.15", "", { "os": "linux", "cpu": "x64" }, "sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng=="], + + "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@0.15.15", "", { "os": "linux", "cpu": "x64" }, "sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ=="], + + "@oxlint/win32-arm64": ["@oxlint/win32-arm64@0.15.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg=="], + + "@oxlint/win32-x64": ["@oxlint/win32-x64@0.15.15", "", { "os": "win32", "cpu": "x64" }, "sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg=="], + + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], + + "oxlint": ["oxlint@0.15.15", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "0.15.15", "@oxlint/darwin-x64": "0.15.15", "@oxlint/linux-arm64-gnu": "0.15.15", "@oxlint/linux-arm64-musl": "0.15.15", "@oxlint/linux-x64-gnu": "0.15.15", "@oxlint/linux-x64-musl": "0.15.15", "@oxlint/win32-arm64": "0.15.15", "@oxlint/win32-x64": "0.15.15" }, "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/packages/opencode-homelab/.gitignore b/packages/opencode-homelab/.gitignore new file mode 100644 index 0000000..ddc3c2a --- /dev/null +++ b/packages/opencode-homelab/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules/ +dist/ diff --git a/packages/opencode-homelab/README.md b/packages/opencode-homelab/README.md new file mode 100644 index 0000000..c5241eb --- /dev/null +++ b/packages/opencode-homelab/README.md @@ -0,0 +1,72 @@ +# @heretek-ai/homelab + +Homelab & integration tier (STC successor): compose topology, `studio` operator +CLI, manager autonomy dial, cockpit mission projections, and the canonical +end-to-end mission test. + +## Layout + +``` +packages/opencode-homelab/ +├── compose.yaml # distro + gateway + fleet + valkey topology +├── compose.override.dev.yaml # local live mounts (no privilege escalation) +├── config/autonomy.json # autonomy dial setting (default: balanced) +├── src/autonomy.ts # dial parse + stage gate +├── scripts/studio # operator CLI: init|up|down|logs|status +├── scripts/test-e2e-mission.ts # canonical E2E (mock adapters, no docker) +├── cockpit/src/projections/missions.ts # Fleet events -> view-model +├── cockpit/src/components/ # minimal MissionViewer + notes +└── test/ # unit tests +``` + +## Autonomy dial (from STC #8) + +One explicit knob, `config/autonomy.json` → `{ "version": 1, "mode": ... }`: + +| mode | semantics | +|---|---| +| `advisory` | stops at **every** stage for human confirmation | +| `balanced` (default) | stops only at **spec** and at the **human PR** gate | +| `full` | runs continuously, stops only for the **human PR** gate | + +Merge is **always human-only** (branch protection) regardless of mode. +`requiresStop(mode, stage)` in `src/autonomy.ts` is the single decision +function; unknown modes/stages fail closed. + +## Network boundaries + +- `frontend`: gateway egress **only**. The gateway is the sole service with + an external route (engines, registries). Nothing else is attached. +- `internal` (`internal: true`): distro, fleet, valkey. **No outside route** — + lanes reach the network exclusively through gateway `POST /mcp`. +- All services rootless (`user: "1000:1000"`, `cap_drop: [ALL]`, + `no-new-privileges`). No `privileged`, no `docker.sock`, no host network. + +## Volumes & drills + +- `homelab-state` (durable): fleet journal/snapshots, gateway audit chain, + valkey dump. Survives `down` and image updates. +- `workspace-disposable` (ephemeral): per-task worktrees/lane mounts. + Drill: `scripts/studio down -v` then `up` — missions must resume from + `homelab-state` alone; anything that does not resume was state in the + wrong volume. + +## First run + +```sh +scripts/studio init # writes rootless .env (0600), generates OPENCODE_PASSWORD +scripts/studio up # docker compose up -d +scripts/studio status # ps + config check +``` + +`init` **refuses** privileged-dev (`PRIVILEGED_DEV=1`) unless +`ALLOW_PRIVILEGED=1` is explicitly set (STC #9). Dev live mounts: +`docker compose -f compose.yaml -f compose.override.dev.yaml up`. + +## Verification + +```sh +bun test packages/opencode-homelab/test packages/opencode-homelab/cockpit/test +bun run scripts/test-e2e-mission.ts # from packages/opencode-homelab; no docker +docker compose -f packages/opencode-homelab/compose.yaml config -q +``` diff --git a/packages/opencode-homelab/cockpit/README.md b/packages/opencode-homelab/cockpit/README.md new file mode 100644 index 0000000..dbbb28d --- /dev/null +++ b/packages/opencode-homelab/cockpit/README.md @@ -0,0 +1,18 @@ +# @heretek-ai/homelab-cockpit + +Framework-free cockpit surfaces for the homelab tier. + +- `src/projections/missions.ts` — pure fold of Fleet JSON-RPC events into + `{ missions, dagNodes, lanes, gates, judges }`. Events apply in `seq` + order, so out-of-order notifications are harmless. No network, no clock + inside reducers (deterministic tests). +- `src/components/MissionViewer.ts` — minimal render helpers + (`renderMissionSummary`, `renderMissionCard`) returning strings any shell + (React/Solid/terminal) can embed. Never fetches, never mutates; shows a + staleness badge when `asOfSeq < headSeq` (STC hub-and-spoke pattern). +- `src/components/NOTES.md` — shell integration notes (escaping, dual-QA + inspector plan, gate chips). + +```sh +bun test ./test # from packages/opencode-homelab/cockpit +``` diff --git a/packages/opencode-homelab/cockpit/package.json b/packages/opencode-homelab/cockpit/package.json new file mode 100644 index 0000000..4694864 --- /dev/null +++ b/packages/opencode-homelab/cockpit/package.json @@ -0,0 +1,10 @@ +{ + "name": "@heretek-ai/homelab-cockpit", + "version": "0.1.0", + "private": true, + "description": "Cockpit mission projections + minimal viewer (framework-free)", + "type": "module", + "scripts": { + "test": "bun test ./test" + } +} diff --git a/packages/opencode-homelab/cockpit/src/components/MissionViewer.ts b/packages/opencode-homelab/cockpit/src/components/MissionViewer.ts new file mode 100644 index 0000000..bd010f1 --- /dev/null +++ b/packages/opencode-homelab/cockpit/src/components/MissionViewer.ts @@ -0,0 +1,59 @@ +// Minimal MissionViewer: framework-free render helpers + integration notes. +// +// NOTE (for the cockpit shell owner): this package intentionally ships no +// React/Solid dependency. `renderMissionSummary` returns plain text/HTML +// strings so any shell (React via dangerouslySetInnerHTML, Solid, terminal) +// can embed them. State always comes from `projectMissions` — the viewer +// never fetches, never mutates, shows a staleness badge when `asOfSeq` +// lags the stream head (STC hub-and-spoke `useProjection` pattern). +import type { MissionsViewModel } from "../projections/missions"; +import { readyNodes } from "../projections/missions"; + +export interface ViewerMeta { + asOfSeq: number; + headSeq: number; +} + +export function isStale(meta: ViewerMeta): boolean { + return meta.asOfSeq < meta.headSeq; +} + +/** One-line per mission + ready-task counts, suitable for terminal or card. */ +export function renderMissionSummary(vm: MissionsViewModel, meta: ViewerMeta): string { + const lines: string[] = []; + if (isStale(meta)) lines.push(`[STALE asOf=${meta.asOfSeq} head=${meta.headSeq}]`); + for (const m of vm.missions) { + const ready = readyNodes(vm, m.id).length; + const total = vm.dagNodes.filter((n) => n.missionId === m.id).length; + const gates = vm.gates.filter((g) => + vm.dagNodes.some((n) => n.missionId === m.id && n.id === g.taskId), + ); + const fails = gates.filter((g) => g.verdict !== "pass").length; + lines.push( + `${m.id} [${m.state}] ready=${ready}/${total} gatesFail=${fails} :: ${m.intent}`, + ); + } + return lines.join("\n"); +} + +/** Minimal HTML card for one mission (shell escapes intent — see NOTES). */ +export function renderMissionCard( + vm: MissionsViewModel, + missionId: string, + meta: ViewerMeta, +): string { + const m = vm.missions.find((x) => x.id === missionId); + if (!m) return `
unknown mission ${escapeHtml(missionId)}
`; + const nodes = vm.dagNodes.filter((n) => n.missionId === missionId); + const stale = isStale(meta) + ? `stale asOf=${meta.asOfSeq} head=${meta.headSeq}` + : `live seq=${meta.headSeq}`; + const items = nodes + .map((n) => `
  • ${escapeHtml(n.id)} · ${escapeHtml(n.stage)} · ${escapeHtml(n.state)}
  • `) + .join(""); + return `

    ${escapeHtml(m.id)} [${escapeHtml(m.state)}]

    ${stale}

    ${escapeHtml(m.intent)}

    `; +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/packages/opencode-homelab/cockpit/src/components/NOTES.md b/packages/opencode-homelab/cockpit/src/components/NOTES.md new file mode 100644 index 0000000..06ed2cd --- /dev/null +++ b/packages/opencode-homelab/cockpit/src/components/NOTES.md @@ -0,0 +1,14 @@ +# MissionViewer integration notes (cockpit shell owner) + +- Source of truth: Fleet JSON-RPC notification stream → `projectMissions(events)`. + The viewer receives `{ vm, meta: { asOfSeq, headSeq } }` as props; it never + fetches and never mutates (STC hub-and-spoke pattern). +- Staleness: show the `[STALE]` badge whenever `asOfSeq < headSeq`; the shell + owns the 1s poll cadence and per-view error slots (cf. STC `useProjection`). +- Escaping: `renderMissionCard` escapes all mission-controlled strings. + Intents and task ids are operator input — never interpolate unescaped. +- Dual QA inspector (next step): render `vm.judges` grouped by `seat` + (`qa-functional` vs `qa-adversarial`), linking each verdict to its + `taskId` evidence; disagreements on the same criterion escalate visually. +- Gate chips: `vm.gates` verdicts color per task chip; non-`pass` blocks the + merge affordance (human-only merge stays enforced server-side). diff --git a/packages/opencode-homelab/cockpit/src/projections/missions.ts b/packages/opencode-homelab/cockpit/src/projections/missions.ts new file mode 100644 index 0000000..beca024 --- /dev/null +++ b/packages/opencode-homelab/cockpit/src/projections/missions.ts @@ -0,0 +1,180 @@ +// Pure projection: Fleet JSON-RPC events -> cockpit missions view-model. +// Framework-free (mirrors STC `useProjection` hub-and-spoke: the UI never +// owns state, it projects a single event stream). No network, no Date.now() +// inside reducers — pass timestamps in events for deterministic tests. + +export type FleetEventType = + | "mission.created" + | "mission.state" + | "task.upsert" + | "task.state" + | "lane.status" + | "gate.report" + | "judge.report"; + +export interface FleetEvent { + seq: number; + at: string; + type: FleetEventType; + missionId: string; + payload: Record; +} + +export interface MissionView { + id: string; + intent: string; + state: string; +} + +export interface DagNode { + id: string; + missionId: string; + stage: string; + state: string; + deps: string[]; +} + +export interface LaneView { + id: string; + tier: string; + status: string; + taskId: string | null; +} + +export interface GateView { + taskId: string; + verdict: string; + checks: Array<{ id: string; exit: number }>; +} + +export interface JudgeView { + seat: string; + missionId: string; + taskId: string | null; + verdict: string; + findings: number; +} + +export interface MissionsViewModel { + missions: MissionView[]; + dagNodes: DagNode[]; + lanes: LaneView[]; + gates: GateView[]; + judges: JudgeView[]; +} + +export function emptyView(): MissionsViewModel { + return { missions: [], dagNodes: [], lanes: [], gates: [], judges: [] }; +} + +function str(v: unknown, fallback = ""): string { + return typeof v === "string" ? v : fallback; +} + +/** Fold one event into the view-model (pure; returns a new object). */ +export function applyEvent(vm: MissionsViewModel, ev: FleetEvent): MissionsViewModel { + const next: MissionsViewModel = { + missions: [...vm.missions], + dagNodes: [...vm.dagNodes], + lanes: [...vm.lanes], + gates: [...vm.gates], + judges: [...vm.judges], + }; + const p = ev.payload ?? {}; + switch (ev.type) { + case "mission.created": { + if (!next.missions.some((m) => m.id === ev.missionId)) { + next.missions.push({ + id: ev.missionId, + intent: str(p["intent"], ev.missionId), + state: "pending", + }); + } + break; + } + case "mission.state": { + const m = next.missions.find((m) => m.id === ev.missionId); + if (m) m.state = str(p["state"], m.state); + else next.missions.push({ id: ev.missionId, intent: ev.missionId, state: str(p["state"], "pending") }); + break; + } + case "task.upsert": + case "task.state": { + const id = str(p["id"] ?? p["taskId"]); + if (!id) break; + const node: DagNode = { + id, + missionId: ev.missionId, + stage: str(p["stage"], "build"), + state: str(p["state"], "pending"), + deps: Array.isArray(p["deps"]) ? (p["deps"] as unknown[]).filter((d): d is string => typeof d === "string") : [], + }; + const i = next.dagNodes.findIndex((n) => n.id === id); + if (i >= 0) next.dagNodes[i] = { ...next.dagNodes[i], ...node }; + else next.dagNodes.push(node); + break; + } + case "lane.status": { + const id = str(p["id"] ?? p["lane"]); + if (!id) break; + const lane: LaneView = { + id, + tier: str(p["tier"], "docker"), + status: str(p["status"], "unknown"), + taskId: typeof p["taskId"] === "string" ? p["taskId"] : null, + }; + const i = next.lanes.findIndex((l) => l.id === id); + if (i >= 0) next.lanes[i] = lane; + else next.lanes.push(lane); + break; + } + case "gate.report": { + const taskId = str(p["taskId"]); + if (!taskId) break; + const gate: GateView = { + taskId, + verdict: str(p["verdict"], "fail"), + checks: Array.isArray(p["checks"]) + ? (p["checks"] as Array>).map((c) => ({ + id: str(c["id"]), + exit: typeof c["exit"] === "number" ? c["exit"] : -1, + })) + : [], + }; + const i = next.gates.findIndex((g) => g.taskId === taskId); + if (i >= 0) next.gates[i] = gate; + else next.gates.push(gate); + break; + } + case "judge.report": { + next.judges.push({ + seat: str(p["seat"]), + missionId: ev.missionId, + taskId: typeof p["taskId"] === "string" ? p["taskId"] : null, + verdict: str(p["verdict"], "fail"), + findings: typeof p["findings"] === "number" ? p["findings"] : 0, + }); + break; + } + } + return next; +} + +/** + * Project an unordered batch of Fleet JSON-RPC events into the view-model. + * Events are applied in `seq` order (stable); out-of-order delivery from + * JSON-RPC notifications is therefore harmless. + */ +export function projectMissions(events: FleetEvent[]): MissionsViewModel { + const ordered = [...events].sort((a, b) => a.seq - b.seq); + return ordered.reduce(applyEvent, emptyView()); +} + +/** Ready-set: dag nodes whose deps are all done and which are not done. */ +export function readyNodes(vm: MissionsViewModel, missionId: string): DagNode[] { + const nodes = vm.dagNodes.filter((n) => n.missionId === missionId); + const done = new Set(nodes.filter((n) => n.state === "done").map((n) => n.id)); + return nodes.filter( + (n) => n.state !== "done" && n.state !== "cancelled" && n.deps.every((d) => done.has(d)), + ); +} diff --git a/packages/opencode-homelab/cockpit/test/missions.test.ts b/packages/opencode-homelab/cockpit/test/missions.test.ts new file mode 100644 index 0000000..dcd9312 --- /dev/null +++ b/packages/opencode-homelab/cockpit/test/missions.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + applyEvent, + emptyView, + projectMissions, + readyNodes, + type FleetEvent, +} from "../src/projections/missions"; +import { isStale, renderMissionCard, renderMissionSummary } from "../src/components/MissionViewer"; + +function ev(seq: number, type: FleetEvent["type"], payload: Record = {}, missionId = "m-1"): FleetEvent { + return { seq, at: `2026-10-12T00:00:${String(seq).padStart(2, "0")}Z`, type, missionId, payload }; +} + +describe("missions projection", () => { + test("projects out-of-order events in seq order", () => { + const vm = projectMissions([ + ev(3, "task.upsert", { id: "t-1", stage: "build", state: "done", deps: [] }), + ev(1, "mission.created", { intent: "Add a healthcheck endpoint" }), + ev(2, "mission.state", { state: "active" }), + ]); + expect(vm.missions).toHaveLength(1); + expect(vm.missions[0].intent).toBe("Add a healthcheck endpoint"); + expect(vm.missions[0].state).toBe("active"); + expect(vm.dagNodes).toHaveLength(1); + }); + + test("task.state upserts; lane/gate/judge events fold", () => { + let vm = emptyView(); + vm = applyEvent(vm, ev(1, "mission.created", { intent: "x" })); + vm = applyEvent(vm, ev(2, "task.upsert", { id: "t-1", stage: "build", state: "running", deps: [] })); + vm = applyEvent(vm, ev(3, "task.state", { id: "t-1", stage: "build", state: "done", deps: [] })); + vm = applyEvent(vm, ev(4, "lane.status", { id: "lane-1", tier: "docker", status: "idle", taskId: "t-1" })); + vm = applyEvent(vm, ev(5, "gate.report", { taskId: "t-1", verdict: "pass", checks: [{ id: "lint", exit: 0 }] })); + vm = applyEvent(vm, ev(6, "judge.report", { seat: "qa-functional", taskId: "t-1", verdict: "pass", findings: 0 })); + expect(vm.dagNodes[0].state).toBe("done"); + expect(vm.lanes[0].status).toBe("idle"); + expect(vm.gates[0].verdict).toBe("pass"); + expect(vm.judges[0].seat).toBe("qa-functional"); + }); + + test("gate re-report replaces; judge reports append", () => { + let vm = emptyView(); + vm = applyEvent(vm, ev(1, "gate.report", { taskId: "t-1", verdict: "fail", checks: [] })); + vm = applyEvent(vm, ev(2, "gate.report", { taskId: "t-1", verdict: "pass", checks: [] })); + expect(vm.gates).toHaveLength(1); + expect(vm.gates[0].verdict).toBe("pass"); + vm = applyEvent(vm, ev(3, "judge.report", { seat: "qa-functional", verdict: "pass", findings: 0 })); + vm = applyEvent(vm, ev(4, "judge.report", { seat: "qa-adversarial", verdict: "pass", findings: 1 })); + expect(vm.judges).toHaveLength(2); + }); + + test("readyNodes respects deps", () => { + const vm = projectMissions([ + ev(1, "mission.created", { intent: "x" }), + ev(2, "task.upsert", { id: "t-1", stage: "build", state: "done", deps: [] }), + ev(3, "task.upsert", { id: "t-2", stage: "build", state: "pending", deps: ["t-1"] }), + ev(4, "task.upsert", { id: "t-3", stage: "build", state: "pending", deps: ["t-2"] }), + ]); + expect(readyNodes(vm, "m-1").map((n) => n.id)).toEqual(["t-2"]); + }); + + test("viewer summary + staleness badge", () => { + const vm = projectMissions([ + ev(1, "mission.created", { intent: "Add a healthcheck endpoint" }), + ev(2, "task.upsert", { id: "t-1", stage: "build", state: "running", deps: [] }), + ]); + const fresh = renderMissionSummary(vm, { asOfSeq: 2, headSeq: 2 }); + expect(fresh).toContain("m-1"); + expect(fresh).not.toContain("STALE"); + expect(isStale({ asOfSeq: 1, headSeq: 2 })).toBe(true); + const stale = renderMissionSummary(vm, { asOfSeq: 1, headSeq: 2 }); + expect(stale).toContain("STALE"); + const card = renderMissionCard(vm, "m-1", { asOfSeq: 2, headSeq: 2 }); + expect(card).toContain("healthcheck"); + expect(renderMissionCard(vm, "nope", { asOfSeq: 2, headSeq: 2 })).toContain("unknown mission"); + }); + + test("card escapes operator-controlled strings", () => { + const vm = projectMissions([ + ev(1, "mission.created", { intent: "" }), + ]); + expect(renderMissionCard(vm, "m-1", { asOfSeq: 1, headSeq: 1 })).not.toContain("