diff --git a/CLAUDE.md b/CLAUDE.md index 796f88bb4..318ba412c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Sixteen top-level commands (run `genie --help` for detail): | `setup` | Configure Genie; authenticated `setup --codex` owns Codex activation and managed convergence; `setup --orchestration-mode ` selects the lifecycle authority | | `shortcuts` | Manage tmux keyboard shortcuts | | `task` | Task state (SQLite, zero-daemon) | -| `ui-bridge` | UI-owned stdio MCP bridge into genie.db (reads + roster writes + change-push) | +| `ui-bridge` | Retired — prints the stable non-zero UI-bridge-retirement diagnostic (the Orca integration is the supported UI surface) | | `uninstall` | Remove Genie CLI and clean up hooks | | `update` | Update Genie CLI to the latest GitHub Release | | `help` | `genie help [command]` | @@ -182,7 +182,7 @@ Biome's `noExcessiveCognitiveComplexity` is set to `maxAllowedComplexity: 25` (w - **Codex ships exactly H3/H4/H6, and all remain untrusted after an edit** — H3 is bounded read-only SessionStart context; H4 is deterministic local PreToolUse guarding; H6 is matcher-scoped PermissionRequest approval and is the only retained hook that may write approval-queue state. No Codex lifecycle hook installs, updates, synchronizes, or scaffolds. Run `genie update` to deliver signed bytes, then authenticated `genie setup --codex` to activate them; inspect changed hashes with `/hooks` and start a new task. Codex hook commands run outside the model sandbox, so hard controls still belong in sandbox permissions and server-side branch protection. - **Hook dispatch is provider-aware and fail-closed** — the plugin-local Codex launcher selects only the canonical `$GENIE_HOME/bin/genie`, preserves stdin/stdout/signals, bounds child time/output, validates event-specific JSON, and converts launch/timeout/schema failures into a reasoned deny. Claude dispatch remains a short-lived in-process `genie hook dispatch` fork. Neither path is a daemon. - **`AskUserQuestion` is the one PreToolUse carve-out** — it is in `NON_INTERCEPTABLE_PRE_TOOL_USE_TOOLS` and MUST get an EMPTY response, not the neutral `{ decision: 'block' }` block form. Empirically CC consumes any additionalContext as the synthesized answer, so a fail-closed block would corrupt the inline picker. The fail-closed envelope special-cases this tool. -- **The product MCP server and its launchers are retired** — `genie mcp` is a stub that writes a stable diagnostic to stderr and exits 1; no plugin ships an MCP declaration or launcher, and `genie init` only retires proven Genie-owned historical routes (in `.mcp.json` it retires only the dead `genie mcp` entry — a genie binary with args exactly `["mcp"]` — backing the file up first and preserving every other server byte-for-byte; `genie doctor` warns while that entry is still present). The UI-owned `genie ui-bridge` is a separate private stdio bridge and is NOT the retired product integration. +- **The product MCP server and its launchers are retired** — `genie mcp` is a stub that writes a stable diagnostic to stderr and exits 1; no plugin ships an MCP declaration or launcher, and `genie init` only retires proven Genie-owned historical routes (in `.mcp.json` it retires only the dead `genie mcp` entry — a genie binary with args exactly `["mcp"]` — backing the file up first and preserving every other server byte-for-byte; `genie doctor` warns while that entry is still present). The UI-owned `genie ui-bridge` is retired too — there is no Genie UI any more, the Orca integration is the only UI surface — so it is the same shape of stub (stable stderr diagnostic, exit 1), and its private transport (`mcp-server.ts`), tool registry (`mcp-tools.ts`), and change watcher (`bridge-watcher.ts`) are deleted. - **Lifecycle authority is an explicit mode, never inferred** — `standalone` is the default (including when `orchestration.mode` is absent); installing or opening Orca changes nothing. `genie setup --orchestration-mode orca` probes the shipped plugin payload and a compatible Orca runtime before atomically switching, after which Genie refuses local `genie.db` lifecycle reads/writes and roadmap writes/syncs/exports. Switching back with `--orchestration-mode standalone` imports no Orca state. There is no fallback database in either direction. - **Two `genie.db` files, never cross-import** — per-repo `.genie/genie.db` (`genie-db.ts`, task/board/wish) and global `~/.genie/genie.db` (`global-db.ts`, omni queue + inbox) are independent databases with their own schemas and `user_version`. `global-db.ts` shares only `sqlite-open.ts` with the per-repo one — do not reach across for path constants. - **Codex integration health is native state, not OTel** — the old Genie exporter at `127.0.0.1:14318` has no relay and is removed by an exact-match, backup-first migration. Preserve unrelated OTel settings and `disable_paste_burst`. diff --git a/README.md b/README.md index ec0bea3f3..ca04c1569 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ genie --help | `genie board` | Kanban view of task state, derived live by query | | `genie idea` | Capture an idea into the roadmap board Idea lane (creates the board if absent) | | `genie task` | Inspect and drive task state (SQLite, zero-daemon) | -| `genie ui-bridge` | Run the UI-owned stdio MCP bridge into genie.db (reads + roster writes + change-push) | +| `genie ui-bridge` | Return the stable non-zero UI-bridge-retirement diagnostic | | `genie install` | Finish a verified install and deliver selected integrations; Codex activation is deferred to setup | | `genie mcp` | Return the stable non-zero MCP-retirement diagnostic | | `genie omni` | Bridge agents to WhatsApp via Omni — remote approvals + inbound one-shots (`serve`, `status`, `inbox`, `handshake`) | @@ -252,9 +252,12 @@ The legacy cross-client MCP server, its write tools, plugin launchers, and Genie pre-A7 signed release.` to stderr and exits 1 without reading or speaking MCP. `genie init` removes only historical registrations proven to be Genie-owned; unowned same-name routes and every unrelated config key remain untouched. -The UI-owned `genie ui-bridge` is not the retired product MCP integration. It retains its private stdio transport and -read/roster surface for the Genie UI. Standalone `genie task` and `genie board` retain their existing behavior in -standalone mode; Orca mode continues to use the public `orca orchestration ... --json` adapter as its sole authority. +The UI-owned `genie ui-bridge` is retired on the same terms: there is no separate Genie UI any more, the Orca +integration is the supported UI surface, and the private stdio transport, tool registry, and change watcher behind the +bridge are deleted. `genie ui-bridge` prints `Error: genie ui-bridge has been retired; the Orca integration is the +supported UI surface, or roll back to a pre-retirement signed release.` to stderr and exits 1. Standalone `genie task` +and `genie board` retain their existing behavior in standalone mode; Orca mode continues to use the public +`orca orchestration ... --json` adapter as its sole authority. ## Hermes-native surface diff --git a/knip.json b/knip.json index 4c2c42635..a7f0ba89a 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@6.29.0/schema.json", "entry": ["src/hooks/dispatch-command.ts"], "project": ["src/**/*.ts"], - "ignoreBinaries": ["ldd", "omni", "ss"], + "ignoreBinaries": ["ldd", "omni"], "ignoreExportsUsedInFile": true, "ignore": ["src/term-commands/hook/trust.ts"] } diff --git a/src/__tests__/claude-md-drift.test.ts b/src/__tests__/claude-md-drift.test.ts index 58511a86a..6a168df71 100644 --- a/src/__tests__/claude-md-drift.test.ts +++ b/src/__tests__/claude-md-drift.test.ts @@ -47,7 +47,6 @@ const REQUIRED_V5_COMMANDS: ReadonlyArray = [ 'setup', 'shortcuts', 'task', - 'ui-bridge', 'uninstall', 'update', ]; diff --git a/src/lib/interactivity.ts b/src/lib/interactivity.ts index 4917f437f..41b212f6d 100644 --- a/src/lib/interactivity.ts +++ b/src/lib/interactivity.ts @@ -84,9 +84,10 @@ const WORKSPACE_EXEMPT = new Set([ // exits 1. It touches no workspace state, so the legacy workspace gate must not // exit 2 and mask the retirement diagnostic callers are told to expect. 'mcp', - // `ui-bridge` is the UI-owned stdio MCP bridge (reads + roster writes + push). - // It self-resolves the shared genie.db and speaks JSON-RPC on stdio; - // the legacy workspace gate must not exit 2 before the handshake can happen. + // `ui-bridge` is now a retirement stub too: the Orca integration replaced the + // UI-owned stdio bridge, so the command only writes its stable diagnostic to + // stderr and exits 1. It touches no workspace state, so the legacy workspace + // gate must not exit 2 and mask the diagnostic callers are told to expect. 'ui-bridge', ]); diff --git a/src/lib/v5/UI-BRIDGE.md b/src/lib/v5/UI-BRIDGE.md deleted file mode 100644 index 036ae10b1..000000000 --- a/src/lib/v5/UI-BRIDGE.md +++ /dev/null @@ -1,152 +0,0 @@ -# genie ui-bridge — protocol contract - -The page the separate-repo genie UI (the dash fork) is built against. `genie -ui-bridge` is a long-lived **stdio** MCP server the UI spawns and owns: five -read tools, exactly two roster write tools, and change-push -notifications from an in-child watcher. Genie stays zero-daemon — the child lives -and dies with the UI and **never opens a socket or port**. - -> This private UI transport is retained independently of the retired `genie -> mcp` product integration. Its roster write surface exists only here. - -## Versions - -| Name | Value | Meaning | -|------|-------|---------| -| **Bridge protocol version** | **`1.0`** | Versions the bridge CONTRACT: tool set, notification semantics, write surface. Reported by the handshake as `bridgeProtocolVersion`. | -| MCP wire version | `2024-11-05` | The private bridge transport version reported as `protocolVersion`. | -| Genie version | `` | Reported as `genieVersion` (and `serverInfo.version`). Independent release cadence. | - -The exact protocol version string the handshake reports is **`1.0`**. - -## Transport - -Newline-delimited JSON-RPC 2.0 — exactly one JSON object per line on -stdin/stdout, no `Content-Length` framing, no embedded newlines. Requests carry -an `id`; the id-less lines the server emits are notifications (below). - -## Handshake — `initialize` - -The client SHOULD declare its bridge protocol version in the initialize params: - -```json -{ "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { "protocolVersion": "2024-11-05", "bridgeProtocolVersion": "1.0" } } -``` - -Server reply on a compatible (or unstated) version: - -```json -{ "jsonrpc": "2.0", "id": 1, "result": { - "protocolVersion": "2024-11-05", - "bridgeProtocolVersion": "1.0", - "genieVersion": "5.x.y", - "capabilities": { "tools": {}, "experimental": { "genieChangeNotifications": true } }, - "serverInfo": { "name": "genie-ui-bridge", "version": "5.x.y" } } } -``` - -### Skew policy - -Compatibility is by **MAJOR** version. A client that declares -`bridgeProtocolVersion` whose major differs from the server's receives a -**structured JSON-RPC error — never silence**: - -```json -{ "jsonrpc": "2.0", "id": 1, "error": { - "code": -32001, - "message": "Incompatible genie ui-bridge protocol version: client declared \"2.0\", server speaks \"1.0\"", - "data": { "serverBridgeProtocolVersion": "1.0", "clientBridgeProtocolVersion": "2.0" } } } -``` - -A client that declares **no** `bridgeProtocolVersion` is accepted best-effort and -left to decide from the reported `bridgeProtocolVersion` / `genieVersion` -(the UI warns or refuses on a mismatch it cares about). The `genie.db` schema is -**never** part of this contract — it stays private to genie permanently; skew is -survived only through this negotiated handshake. - -## Tools - -### Read tools (retained after MCP retirement, identical semantics) - -| Tool | Purpose | Required args | -|------|---------|---------------| -| `genie_board` | Board status counts + tasks | — (`board`, `wish` optional) | -| `genie_wish_status` | A wish's execution groups (DAG) + tasks | `wish` | -| `genie_worktree_context` | Resolve a `wish/-` branch → wish/group/tasks | — (`branch` optional) | -| `genie_task` | Full detail for one task by id | `id` | -| `genie_active` | All `in_progress` tasks + who claimed each | — | - -Read results are a read-only projection of `.genie/genie.db`; they degrade to an -empty board when the db is absent. Every task/board/wish row they surface -corresponds to the same underlying rows as `genie task export` -(`exportState()`) — the projections differ, the data does not. - -### Write tools (bridge-owned — exactly the two roster tools) - -`roster_hire` and `roster_unhire` call genie's own `task-state.ts` roster ops -(never raw SQL) over a WRITE-capable handle from the canonical `genie-db` → -`sqlite-open` open path (WAL + `busy_timeout`). The write surface is EXACTLY -these two tools; any future write tool is a separate, recorded decision. - -**`roster_hire`** — idempotent upsert keyed on `(wish, agentAdapterId)`; a re-hire -refreshes `profile`/`worktree`/`state` but preserves the original `hiredAt`. - -```json -{ "name": "roster_hire", "arguments": { - "wish": "w-alpha", // required — wish slug - "agentAdapterId": "codex-1", // required — agent adapter id (runtime/provider slot) - "worktree": "/wt/codex-1", // required — see "worktree is required" below - "profile": "high", // optional — provider profile - "state": "hired" } } // optional — lifecycle state (defaults to "hired") -``` - -Result: the persisted `HireRosterRow` -(`{ wish, agentAdapterId, profile, worktree, hiredAt, state }`). - -**`roster_unhire`** — idempotent delete. - -```json -{ "name": "roster_unhire", "arguments": { "wish": "w-alpha", "agentAdapterId": "codex-1" } } -``` - -Result: `{ wish, agentAdapterId, removed }` — `removed` is `false` when no row -existed (a no-op), `true` on a real removal. - -**`worktree` is required on hire.** This confirms the `hire_roster.worktree NOT -NULL` design: a worktree is always known at hire time because dash binds the -genie-launch worktree when it hires an agent. The bridge enforces it — a -`roster_hire` missing any required field returns an in-band -`{ "error": "invalid_arguments", "missing": [...] }` payload (isError-style, -consistent with `genie_task`'s `not_found`), not a protocol crash. - -## Notifications — change push - -An in-child watcher polls `PRAGMA data_version` every 250–500 ms (default 300 ms) -and fs-watches the db directory as a wake hint. When the db changes (any other -connection committed — an external `genie task create`, a roster write, etc.) the -server emits an **id-less** notification line: - -```json -{ "jsonrpc": "2.0", "method": "notifications/genie/changed", "params": { "dataVersion": 42 } } -``` - -Semantics: a wake signal, not a diff. The UI re-reads via the read tools; it must -not assume the notification carries the change. Latency bound: an external write -yields a notification within **1 s** (2–4× the poll interval; the fs-watch wake -usually delivers it far sooner). SQLite has no cross-process notify — this poll is -the honest daemon-free ceiling, and is ample for a kanban. - -## Lifetime contract - -- **stdin EOF → exit within 2 s.** A dead parent closes the pipe write-end; the - child sees EOF, stops its watcher + backstop, closes its handles, flushes - stdout, and exits `0` promptly. -- **ppid backstop.** For the case where the parent dies but the stdin write-end - stays open (a subreaper, or an inherited pipe), the child polls its parent pid - and exits when it changes from the one captured at startup. Subreaper-aware: it - does **not** assume reparenting to pid 1, and treats the `0` unknown-ppid - sentinel as "not orphaned". -- **No socket, ever.** The bridge holds zero listening sockets; it speaks only on - stdio. Verify with `ss -tlnp` / `lsof` against the child pid — no rows. -- **Zero-daemon.** Nothing survives the UI; the child is fork-and-die under the - validated trust model retained from the retired MCP implementation. diff --git a/src/lib/v5/authority-barriers.test.ts b/src/lib/v5/authority-barriers.test.ts index 582fa8b6f..8ca3616ea 100644 --- a/src/lib/v5/authority-barriers.test.ts +++ b/src/lib/v5/authority-barriers.test.ts @@ -14,7 +14,6 @@ import { join } from 'node:path'; import { openReadonlyHandle } from '../../term-commands/context.js'; import { InvalidOrchestrationAuthorityError, LocalLifecycleDisabledError } from '../orchestration-mode.js'; import { openDb } from './genie-db.js'; -import { openReadonlyDb } from './mcp-tools.js'; import { writeSnapshotFile } from './roadmap-sync.js'; const originalGenieHome = process.env.GENIE_HOME; @@ -87,7 +86,6 @@ describe('Orca authority barriers', () => { const beforeMtime = statSync(dbPath).mtimeMs; expect(() => openReadonlyHandle(dbPath)).toThrow(LocalLifecycleDisabledError); - expect(() => openReadonlyDb(join(root, 'repo'))).toThrow(LocalLifecycleDisabledError); expect(readdirSync(directory)).toEqual(beforeNames); expect(readFileSync(dbPath)).toEqual(beforeBytes); @@ -177,7 +175,6 @@ describe('Orca authority barriers', () => { expect(() => openDb({ path: dbPath })).toThrow(fixtureCase.error); expect(() => openReadonlyHandle(dbPath)).toThrow(fixtureCase.error); - expect(() => openReadonlyDb(repo)).toThrow(fixtureCase.error); expect(() => writeSnapshotFile(roadmap, { forbidden: true })).toThrow(fixtureCase.error); expect(existsSync(dbPath)).toBe(false); diff --git a/src/lib/v5/bridge-watcher.test.ts b/src/lib/v5/bridge-watcher.test.ts deleted file mode 100644 index 045854a53..000000000 --- a/src/lib/v5/bridge-watcher.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Unit tests for the ui-bridge lifetime primitives — the change watcher and the - * ppid backstop. Both inject their db-read / ppid-read so these are deterministic - * and process-free: no real reparenting, no clock racing. - */ - -import { afterEach, describe, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { type Stoppable, isOrphaned, startChangeWatcher, startPpidBackstop } from './bridge-watcher.js'; - -/** Deadline-based wait for a predicate, polling cheaply. Avoids arbitrary sleeps. */ -async function waitUntil(pred: () => boolean, timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (pred()) return true; - await new Promise((r) => setTimeout(r, 5)); - } - return pred(); -} - -const started: Stoppable[] = []; -function track(s: T): T { - started.push(s); - return s; -} -afterEach(() => { - for (const s of started.splice(0)) s.stop(); -}); - -// ============================================================================ -// Change watcher -// ============================================================================ - -describe('startChangeWatcher', () => { - test('fires onChange only when data_version increments', async () => { - let version = 10; - const changes: number[] = []; - track( - startChangeWatcher({ - dbPath: join(tmpdir(), 'nonexistent-dir-xyz', 'genie.db'), - readDataVersion: () => version, - onChange: (v) => changes.push(v), - pollMs: 10, - }), - ); - - // No change yet → no notification. - await new Promise((r) => setTimeout(r, 40)); - expect(changes).toEqual([]); - - // External commit bumps data_version → exactly one notification. - version = 11; - expect(await waitUntil(() => changes.length === 1)).toBe(true); - expect(changes).toEqual([11]); - - // Steady state → no repeats. - await new Promise((r) => setTimeout(r, 40)); - expect(changes).toEqual([11]); - }); - - test('null (db unavailable) never fires onChange', async () => { - const changes: number[] = []; - track( - startChangeWatcher({ - dbPath: join(tmpdir(), 'nope', 'genie.db'), - readDataVersion: () => null, - onChange: (v) => changes.push(v), - pollMs: 10, - }), - ); - await new Promise((r) => setTimeout(r, 50)); - expect(changes).toEqual([]); - }); - - test('fs-watch on the db directory wakes the poll early', async () => { - const dir = mkdtempSync(join(tmpdir(), 'genie-watch-')); - const dbPath = join(dir, 'genie.db'); - writeFileSync(dbPath, ''); - let version = 1; - const changes: number[] = []; - // Long poll interval: if the change is seen quickly, it must be the fs-watch - // wake hint firing, not the (500ms) interval. - track( - startChangeWatcher({ - dbPath, - readDataVersion: () => version, - onChange: (v) => changes.push(v), - pollMs: 500, - }), - ); - version = 2; - writeFileSync(join(dir, 'genie.db-wal'), 'x'); // touch a sibling → dir event - const seen = await waitUntil(() => changes.length === 1, 300); // < pollMs - rmSync(dir, { recursive: true, force: true }); - expect(seen).toBe(true); - expect(changes).toEqual([2]); - }); - - test('stop() is idempotent and halts further notifications', async () => { - let version = 0; - const changes: number[] = []; - const w = startChangeWatcher({ - dbPath: join(tmpdir(), 'x', 'genie.db'), - readDataVersion: () => version, - onChange: (v) => changes.push(v), - pollMs: 10, - }); - w.stop(); - w.stop(); // idempotent — no throw - version = 5; - await new Promise((r) => setTimeout(r, 40)); - expect(changes).toEqual([]); - }); -}); - -// ============================================================================ -// ppid backstop -// ============================================================================ - -describe('isOrphaned', () => { - test('true when ppid changed, false when unchanged, false on the 0 sentinel', () => { - expect(isOrphaned(4321, 4321)).toBe(false); - expect(isOrphaned(4321, 9999)).toBe(true); - // Subreaper-aware: never assume reparent-to-1; and 0 (unknown) is not orphaned. - expect(isOrphaned(4321, 1)).toBe(true); - expect(isOrphaned(4321, 0)).toBe(false); - }); -}); - -describe('startPpidBackstop', () => { - test('fires onOrphaned exactly once when the parent pid changes', async () => { - let ppid = 1000; - let orphaned = 0; - track( - startPpidBackstop({ - originalPpid: 1000, - getPpid: () => ppid, - onOrphaned: () => { - orphaned++; - }, - intervalMs: 10, - }), - ); - // Parent alive → no trip. - await new Promise((r) => setTimeout(r, 40)); - expect(orphaned).toBe(0); - - // Parent changed (died + reparented) → exactly one trip, then latched. - ppid = 1; - expect(await waitUntil(() => orphaned === 1)).toBe(true); - await new Promise((r) => setTimeout(r, 40)); - expect(orphaned).toBe(1); - }); - - test('stop() halts the backstop', async () => { - let ppid = 500; - let orphaned = 0; - const b = startPpidBackstop({ - originalPpid: 500, - getPpid: () => ppid, - onOrphaned: () => { - orphaned++; - }, - intervalMs: 10, - }); - b.stop(); - ppid = 777; - await new Promise((r) => setTimeout(r, 40)); - expect(orphaned).toBe(0); - }); -}); diff --git a/src/lib/v5/bridge-watcher.ts b/src/lib/v5/bridge-watcher.ts deleted file mode 100644 index 206f89b81..000000000 --- a/src/lib/v5/bridge-watcher.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Genie v5 ui-bridge lifetime primitives — the in-child change watcher and the - * ppid backstop that keep the bridge daemon-free. - * - * SQLite has no cross-process notify, so the bridge polls `PRAGMA data_version` - * (which increments on any OTHER connection's commit) and uses an fs-watch on - * the db directory as a wake hint. Both concerns are pure mechanism here — the - * db read and the ppid read are injected — so they are unit-testable without - * spawning processes or racing the clock. - */ - -import { type FSWatcher, existsSync, watch } from 'node:fs'; -import { dirname } from 'node:path'; - -/** A started background loop that must be stopped for the event loop to drain. */ -export interface Stoppable { - stop(): void; -} - -// ============================================================================ -// Change watcher (PRAGMA data_version poll + fs-watch wake hint) -// ============================================================================ - -export interface ChangeWatcherOptions { - /** Absolute path to `genie.db`; its containing dir is fs-watched as a hint. */ - dbPath: string; - /** - * Read the current `PRAGMA data_version` from a dedicated read connection, or - * `null` when the db is unavailable. Injected so tests drive it deterministically. - */ - readDataVersion: () => number | null; - /** Invoked with the new data_version whenever the db changed since the last poll. */ - onChange: (dataVersion: number) => void; - /** Poll interval (design range 250–500 ms). Default 300. */ - pollMs?: number; -} - -/** - * Start watching for external db changes. Returns a handle whose `stop()` clears - * the interval and closes the fs watcher — required for the bridge to exit on - * stdin EOF. The poll is the correctness guarantee (bounded by `pollMs`); the - * fs-watch only wakes the poll early to cut latency, and is best-effort. - */ -export function startChangeWatcher(opts: ChangeWatcherOptions): Stoppable { - const pollMs = opts.pollMs ?? 300; - let last = opts.readDataVersion(); - - const poll = (): void => { - const current = opts.readDataVersion(); - if (current !== null && current !== last) { - last = current; - opts.onChange(current); - } - }; - - const interval = setInterval(poll, pollMs); - const watcher = startDirWatch(opts.dbPath, poll); - - return { - stop(): void { - clearInterval(interval); - if (watcher) { - try { - watcher.close(); - } catch { - /* already closed */ - } - } - }, - }; -} - -/** - * Watch the db's directory (catching `genie.db-wal` writes AND the `-wal` file's - * creation on the first write) and call `wake` on any event. Best-effort: a - * missing dir or an unsupported platform degrades to poll-only. - */ -function startDirWatch(dbPath: string, wake: () => void): FSWatcher | null { - try { - const dir = dirname(dbPath); - if (!existsSync(dir)) return null; - return watch(dir, () => wake()); - } catch { - return null; - } -} - -// ============================================================================ -// ppid backstop (orphan detection when stdin EOF never arrives) -// ============================================================================ - -/** - * True when the current parent pid differs from the one recorded at startup — - * i.e. the original parent died and the child was reparented. Subreaper-aware: - * it does NOT assume reparenting to pid 1, so it works under systemd/user - * subreapers too. `0` (the platform "unknown ppid" sentinel) is never treated - * as reparenting, guarding against a spurious orphan trip. - */ -export function isOrphaned(originalPpid: number, currentPpid: number): boolean { - if (currentPpid === 0) return false; - return currentPpid !== originalPpid; -} - -export interface PpidBackstopOptions { - /** The parent pid captured at bridge startup. */ - originalPpid: number; - /** Invoked once when the parent is detected to have changed. */ - onOrphaned: () => void; - /** Read the current parent pid. Injected for tests; defaults to `process.ppid`. */ - getPpid?: () => number; - /** Poll interval. Default 1000 ms. */ - intervalMs?: number; -} - -/** - * Start the orphan backstop. Complements stdin EOF: a parent that dies while - * something else holds the stdin write-end open would leave the child running, - * so this polls the parent pid and fires `onOrphaned` on change. Returns a - * handle whose `stop()` clears the interval. - */ -export function startPpidBackstop(opts: PpidBackstopOptions): Stoppable { - const getPpid = opts.getPpid ?? (() => process.ppid); - const intervalMs = opts.intervalMs ?? 1000; - let fired = false; - - const timer = setInterval(() => { - if (fired) return; - if (isOrphaned(opts.originalPpid, getPpid())) { - fired = true; - opts.onOrphaned(); - } - }, intervalMs); - - return { - stop(): void { - clearInterval(timer); - }, - }; -} diff --git a/src/lib/v5/genie-db.test.ts b/src/lib/v5/genie-db.test.ts index 7a3993d0d..946df89ec 100644 --- a/src/lib/v5/genie-db.test.ts +++ b/src/lib/v5/genie-db.test.ts @@ -440,9 +440,10 @@ describe('busy classification', () => { // index, so probing for it fleet-wide fires on healthy contended databases and // the journal-mode churn that follows crashed bun's Linux shm handling under a // live multi-process fleet. `openDb` therefore stays exactly what it was before -// the heal: open, pragma, ensure schema. The heal is scoped to the component -// that CREATES the poison — see "the REAL poison a degraded session leaves -// behind heals on the next write open" in mcp-tools.test.ts. +// the heal: open, pragma, ensure schema. The heal stays scoped to a component +// that CREATES the poison — a degraded read-only session, a shape the retired +// MCP/ui-bridge servers were the last to have; see sqlite-open.test.ts for the +// recovery helper's own contract. // // This test fails the moment the heal is re-wired into the shared primitive. // --------------------------------------------------------------------------- diff --git a/src/lib/v5/identity.ts b/src/lib/v5/identity.ts index 5a34b52aa..cb067d06d 100644 --- a/src/lib/v5/identity.ts +++ b/src/lib/v5/identity.ts @@ -2,18 +2,17 @@ * Genie v5 acting-identity resolution — the ONE place the environment is read * to answer "who is writing this card change, and from what runtime". * - * Every writer shares these resolvers: the CLI verbs in - * `src/term-commands/v5-task.ts` and the operative MCP write tools in - * `mcp-tools.ts`. They were duplicated once (the MCP copy was byte-for-byte the - * CLI's) and the chains inside the CLI itself diverged before that — the claim + * Every writer shares these resolvers. They were duplicated once — the CLI + * verbs in `src/term-commands/v5-task.ts` and the since-retired MCP write tools + * kept byte-for-byte copies — and the chains inside the CLI itself diverged + * before that: the claim * chain ignored `GENIE_AGENT_ID` and floored at 'cli' while the complete chain * preferred NAME then ID and floored at null, so a `GENIE_AGENT_ID`-only runtime * claimed as 'cli' but attributed its events to the ID. One module, one chain, * no drift. * * Pure env reads: nothing here touches `bun:sqlite` (the `EventAuthor` import is - * type-only and erased), so importing it costs the lazy-loaded MCP surface - * nothing. + * type-only and erased), so importing it costs a startup-path module nothing. */ import type { EventAuthor } from './task-state.js'; diff --git a/src/lib/v5/mcp-server.test.ts b/src/lib/v5/mcp-server.test.ts deleted file mode 100644 index 12b74555b..000000000 --- a/src/lib/v5/mcp-server.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * mcp-server — shared stdio loop SEAM tests (Group 1 of wish mcp-write-tools). - * - * Drives a stub server (stub tools + injected open) as a subprocess to prove - * the two seam contracts this group owns: - * - the injected open is general — whatever handle `openDb` produces is - * validated and served (the live read-only ui-bridge plus isolated seams); - * - the opt-in per-result error channel UNWRAPS tagged results before - * serialization — the wire payload is the inner value and the envelope - * sets `isError: true`, while every untagged result keeps `isError: false` - * (an `error`-keyed payload is DATA, never an implicit error channel). - */ - -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { isToolError, toolError, unwrapToolError } from './mcp-server.js'; - -let dir: string; -let harness: string; - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'genie-mcp-server-')); - harness = join(dir, 'harness.ts'); -}); - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }); -}); - -/** Write a stub server whose `tagged` tool returns toolError(payloadJson). */ -function writeHarness(payloadJson: string): void { - writeFileSync( - harness, - `import { runMcpServerLoop, toolError } from ${JSON.stringify(join(import.meta.dir, 'mcp-server.ts'))}; -const fakeDb = { - query: () => ({ get: () => ({ user_version: 1 }) }), - close: () => {}, -}; -await runMcpServerLoop({ - tools: [ - { name: 'tagged', description: 'd', inputSchema: {}, handler: () => toolError(${payloadJson}) }, - { name: 'plain', description: 'd', inputSchema: {}, handler: () => ({ error: 'not_found' }) }, - ], - openDb: () => fakeDb, - initialize: () => ({ - result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 't', version: '0' } }, - }), -}); -`, - ); -} - -/** - * Write a stub server whose injected open serves DEGRADED (read-only) handles - * for the first `degradedOpens` opens and a write-capable one after that — the - * "filesystem repaired mid-session" shape. The `probe` tool reports what the - * loop is currently holding. - */ -function writeDegradeHarness(degradedOpens: number): void { - writeFileSync( - harness, - `import { runMcpServerLoop } from ${JSON.stringify(join(import.meta.dir, 'mcp-server.ts'))}; -const degraded = new WeakSet(); -let opens = 0; -let closes = 0; -await runMcpServerLoop({ - tools: [ - { - name: 'probe', - description: 'd', - inputSchema: {}, - handler: (ctx) => ({ degraded: degraded.has(ctx.db), opens, closes }), - }, - ], - openDb: () => { - opens += 1; - const db = { query: () => ({ get: () => ({ user_version: 1 }) }), close: () => { closes += 1; } }; - if (opens <= ${degradedOpens}) degraded.add(db); - return db; - }, - isDegradedHandle: (db) => degraded.has(db), - initialize: () => ({ - result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 't', version: '0' } }, - }), -}); -`, - ); -} - -/** Null/busy promotion attempts reopen degraded read fallbacks, then promote. */ -function writeInterruptedPromotionHarness(): void { - writeFileSync( - harness, - `import { notify, runMcpServerLoop } from ${JSON.stringify(join(import.meta.dir, 'mcp-server.ts'))}; -const degraded = new WeakSet(); -const closed = []; -let writeOpens = 0; -let readonlyOpens = 0; -await runMcpServerLoop({ - tools: [{ - name: 'probe', description: 'd', inputSchema: {}, - handler: (ctx) => ({ handle: ctx.db?.id ?? null, degraded: ctx.db ? degraded.has(ctx.db) : false, writeOpens, readonlyOpens, closed: [...closed] }), - }], - openDb: () => { - writeOpens += 1; - if (writeOpens === 2 || writeOpens === 3) return null; - const id = writeOpens; - const db = { - id, - query: () => ({ get: () => ({ user_version: 1 }) }), - close: () => { closed.push(id); notify('test/closed', { id }); }, - }; - if (writeOpens === 1) degraded.add(db); - return db; - }, - openReadonlyDb: () => { - readonlyOpens += 1; - const id = readonlyOpens + 1; - const db = { - id, - query: () => ({ get: () => ({ user_version: 1 }) }), - close: () => { closed.push(id); notify('test/closed', { id }); }, - }; - degraded.add(db); - return db; - }, - isDegradedHandle: (db) => degraded.has(db), - initialize: () => ({ result: {} }), -}); -`, - ); -} - -interface RpcMessage { - id?: number | string | null; - method?: string; - params?: { id?: number }; - result?: { content?: Array<{ type: string; text: string }>; isError?: boolean; structuredContent?: unknown }; - error?: { code: number }; -} - -async function drive(requests: Record[]): Promise { - const proc = Bun.spawn(['bun', harness], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, NO_COLOR: '1' }, - }); - proc.stdin.write(`${requests.map((r) => JSON.stringify(r)).join('\n')}\n`); - await proc.stdin.end(); - const stdout = await new Response(proc.stdout).text(); - await proc.exited; - return stdout - .split('\n') - .filter((l) => l.trim().length > 0) - .map((l) => JSON.parse(l) as RpcMessage); -} - -const INIT = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } }, -}; - -function toolPayload(res: RpcMessage): T { - return JSON.parse((res.result?.content as Array<{ type: string; text: string }>)[0].text) as T; -} - -describe('per-result error channel helpers', () => { - test('toolError tags a value; isToolError/unwrapToolError round-trip it', () => { - const tagged = toolError({ error: 'typed_failure', detail: 'x' }); - expect(isToolError(tagged)).toBe(true); - expect(unwrapToolError(tagged)).toEqual({ error: 'typed_failure', detail: 'x' }); - }); - - test('an error-KEYED payload is data, not a tagged error', () => { - expect(isToolError({ error: 'not_found' })).toBe(false); - expect(isToolError({ error: 'invalid_arguments', missing: ['worktree'] })).toBe(false); - expect(isToolError(null)).toBe(false); - expect(isToolError('string')).toBe(false); - }); -}); - -describe('runMcpServerLoop seam', () => { - test('a tagged handler result is unwrapped before serialization: inner payload on the wire, isError true', async () => { - writeHarness(JSON.stringify({ error: 'typed_failure', detail: 'x' })); - const responses = await drive([ - INIT, - { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'tagged', arguments: {} } }, - ]); - const res = responses.find((r) => r.id === 2); - expect(res?.error).toBeUndefined(); - expect(res?.result?.isError).toBe(true); - // The wire payload is the INNER value — no tag key leaks, shapes unchanged. - expect(toolPayload>(res!)).toEqual({ error: 'typed_failure', detail: 'x' }); - expect(res?.result?.structuredContent).toEqual({ error: 'typed_failure', detail: 'x' }); - }); - - test('a degraded read-only handle is retried per call and promoted the moment the write open succeeds', async () => { - // Opens 1 (session start) and 2 (the first retry) are degraded; open 3 is - // write-capable — the filesystem was repaired between the two tool calls. - writeDegradeHarness(2); - const probe = (id: number) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'probe' } }); - const responses = await drive([INIT, probe(2), probe(3)]); - - // Call 1: the retry is still degraded, so the replacement keeps serving - // reads after the incumbent releases its WAL mapping. - const first = toolPayload<{ degraded: boolean; opens: number; closes: number }>(responses.find((r) => r.id === 2)!); - expect(first).toEqual({ degraded: true, opens: 2, closes: 1 }); - - // Call 2: the write open finally succeeds. The degrade never latched, and - // the handle it replaced was closed (no leak). - const second = toolPayload<{ degraded: boolean; opens: number; closes: number }>( - responses.find((r) => r.id === 3)!, - ); - expect(second).toEqual({ degraded: false, opens: 3, closes: 2 }); - }); - - test('degraded reads survive null/busy retries, then promote without leaking handles', async () => { - writeInterruptedPromotionHarness(); - const probe = (id: number) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'probe' } }); - const responses = await drive([INIT, probe(2), probe(3), probe(4)]); - type ProbePayload = { - handle: number; - degraded: boolean; - writeOpens: number; - readonlyOpens: number; - closed: number[]; - }; - - expect(toolPayload(responses.find((r) => r.id === 2)!)).toEqual({ - handle: 2, - degraded: true, - writeOpens: 2, - readonlyOpens: 1, - closed: [1], - }); - expect(toolPayload(responses.find((r) => r.id === 3)!)).toEqual({ - handle: 3, - degraded: true, - writeOpens: 3, - readonlyOpens: 2, - closed: [1, 2], - }); - expect(toolPayload(responses.find((r) => r.id === 4)!)).toEqual({ - handle: 4, - degraded: false, - writeOpens: 4, - readonlyOpens: 2, - closed: [1, 2, 3], - }); - expect(responses.filter((r) => r.method === 'test/closed').map((r) => r.params?.id)).toEqual([1, 2, 3, 4]); - }); - - test('an untagged error-keyed handler result stays isError false', async () => { - writeHarness(JSON.stringify({ error: 'typed_failure' })); - const responses = await drive([ - INIT, - { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'plain', arguments: {} } }, - ]); - const res = responses.find((r) => r.id === 2); - expect(res?.error).toBeUndefined(); - expect(res?.result?.isError).toBe(false); - expect(toolPayload>(res!)).toEqual({ error: 'not_found' }); - }); -}); diff --git a/src/lib/v5/mcp-server.ts b/src/lib/v5/mcp-server.ts deleted file mode 100644 index 3f9b41f50..000000000 --- a/src/lib/v5/mcp-server.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Genie v5 shared stdio MCP server loop — the newline-delimited JSON-RPC 2.0 - * transport originally extracted from the retired MCP command. The live - * `genie ui-bridge` (read + roster writes + push) remains its supported caller. - * - * Transport: one JSON object per line on stdin/stdout — NOT LSP - * `Content-Length` framing. Speaks MCP protocol `2024-11-05`. - * - * The stdio writes (`process.stdout.write`) ARE the server's protocol output by - * design — not stray logging — so they satisfy biome's no-console rule. - * - * LAZY-LOAD contract: this module imports the read-tool types and `Database` - * TYPE-ONLY (erased at compile time), and takes the runtime `openDb` + tool - * registry via {@link McpServerConfig}. It therefore pulls NEITHER - * `bun:sqlite` NOR `mcp-tools.ts` into any static import graph — callers - * dynamic-import their heavy deps and inject them here. `mcp.test.ts`'s - * import-graph probe locks that contract. - */ - -import type { Database } from 'bun:sqlite'; -import { createInterface } from 'node:readline'; -import type { ProjectContext, ProjectDatabaseBinding } from './genie-db.js'; -import type { McpTool, ToolContext } from './mcp-tools.js'; - -// ============================================================================ -// JSON-RPC 2.0 message shapes -// ============================================================================ - -export interface JsonRpcRequest { - jsonrpc: '2.0'; - id?: number | string | null; - method: string; - params?: Record; -} - -export interface JsonRpcError { - code: number; - message: string; - data?: unknown; -} - -/** MCP wire protocol version — unchanged from the original `genie mcp`. */ -export const PROTOCOL_VERSION = '2024-11-05'; - -// JSON-RPC standard error codes. -export const METHOD_NOT_FOUND = -32601; -export const INTERNAL_ERROR = -32603; - -// ============================================================================ -// Low-level writers (the protocol channel) -// ============================================================================ - -/** Newline-delimited: exactly one JSON object per line, no embedded newlines. */ -export function writeMessage(msg: object): void { - process.stdout.write(`${JSON.stringify(msg)}\n`); -} - -export function ok(id: number | string | null, result: unknown): void { - writeMessage({ jsonrpc: '2.0', id, result }); -} - -export function err(id: number | string | null, code: number, message: string): void { - writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); -} - -/** Write a full JSON-RPC error object (with optional `data`). */ -export function writeError(id: number | string | null, error: JsonRpcError): void { - writeMessage({ jsonrpc: '2.0', id, error }); -} - -/** - * Emit an id-less JSON-RPC notification line. Used by the bridge's change - * watcher to push `notifications/genie/changed` out of band. Each write is a - * complete line, so it never interleaves mid-response. - */ -export function notify(method: string, params?: Record): void { - writeMessage(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }); -} - -// ============================================================================ -// Per-result error channel (opt-in) -// ============================================================================ - -const TOOL_ERROR_TAG: unique symbol = Symbol('genie.mcp.toolError'); - -/** - * A tool-handler result tagged as an MCP `isError: true` result. The loop - * UNWRAPS the tag before serialization: `content[0].text` / `structuredContent` - * carry the inner `value` (wire shapes unchanged) and the envelope sets - * `isError: true`. Untagged handler results keep `isError: false` — an - * `error`-keyed payload (e.g. `genie_task` not-found, ui-bridge roster - * `invalid_arguments`) is DATA, never an implicit error channel. - */ -export interface ToolErrorResult { - readonly [TOOL_ERROR_TAG]: T; -} - -/** Tag a handler result as a typed MCP tool error (see {@link ToolErrorResult}). */ -export function toolError(value: T): ToolErrorResult { - return { [TOOL_ERROR_TAG]: value } as ToolErrorResult; -} - -/** True when the handler returned a tagged error result. */ -export function isToolError(result: unknown): result is ToolErrorResult { - return typeof result === 'object' && result !== null && TOOL_ERROR_TAG in result; -} - -/** The inner payload of a tagged error result — what goes on the wire. */ -export function unwrapToolError(result: ToolErrorResult): unknown { - return result[TOOL_ERROR_TAG]; -} - -// ============================================================================ -// Server configuration -// ============================================================================ - -/** The result of an `initialize` request: an MCP result, or a structured error. */ -export type InitializeOutcome = { result: unknown } | { error: JsonRpcError }; - -export interface McpServerConfig { - /** The tool registry surfaced by `tools/list` and dispatched by `tools/call`. */ - tools: McpTool[]; - /** - * DB open (net-new; returns `null` when the file is absent or cannot be - * opened). MUST NOT THROW — return `null` for every failure; the loop calls - * this outside any `try`. The live ui-bridge uses a read-only handle; injected - * test seams may use other handles. The loop itself never writes - * through the handle; read tools read and write tools mutate through it. A - * fail-closed consumer with `resolveContext` turns null into - * `project-database-unavailable`; legacy consumers may still degrade to an - * empty projection. Injected so this module never statically imports - * `bun:sqlite` / `mcp-tools`. - */ - openDb: (target: string | ProjectDatabaseBinding) => Database | null; - /** Must-not-throw readonly restore after a close-first promotion returns null/busy. */ - openReadonlyDb?: (target: string | ProjectDatabaseBinding) => Database | null; - /** - * Optional pure-read schema validator applied to whichever handle `openDb` - * produced (writable or degraded-readonly). The fail-closed MCP command - * supplies the strict per-repo Genie validator; legacy consumers may omit it. - */ - validateReadonlyDb?: (db: Database) => boolean; - /** - * Optional predicate identifying a handle `openDb` served READ-ONLY because - * its write path was impossible. When supplied, the loop re-attempts the open - * on every `tools/call` while the held handle is degraded and promotes on - * success — so a repaired filesystem restores writes without restarting the - * server, matching the per-open (never latched) derivation the write open - * itself uses. Consumers whose open is read-only by design omit it. - */ - isDegradedHandle?: (db: Database) => boolean; - /** - * OPT-IN fail-closed resolver (net-new). When provided, the loop resolves the - * project context once per `tools/call` and, on any non-`ok` kind, returns a - * typed structured error (`{ error, detail }`, `isError: true`) instead of - * dispatching the tool against an outer/cache-root empty board. Consumers that - * omit it (e.g. `genie ui-bridge`) keep the legacy cwd-based degrade-to-empty. - */ - resolveContext?: (cwd: string) => ProjectContext; - /** Build the `initialize` reply from the client's declared params. */ - initialize: (params: Record | undefined) => InitializeOutcome; - /** - * Optional cleanup run once when stdin closes, BEFORE the read handle is - * closed and stdout flushed — the bridge stops its watcher + ppid backstop - * and closes its write handle here so the event loop can drain. - */ - onClose?: () => void; -} - -// ============================================================================ -// Server run loop -// ============================================================================ - -/** - * Drive the stdio MCP server until stdin closes. Opens a NET-NEW db handle via - * the injected {@link McpServerConfig.openDb} and dispatches each - * newline-delimited JSON-RPC message per {@link McpServerConfig}. With a - * project resolver, a null open fails closed. - */ -export async function runMcpServerLoop(config: McpServerConfig): Promise { - const cwd = process.cwd(); - // Resolve the fail-closed context up front when a resolver is injected; only an - // `ok` context may hold a db handle. Without a resolver, keep the legacy open. - const initialContext = config.resolveContext?.(cwd); - const openValidatedWith = ( - opener: (target: string | ProjectDatabaseBinding) => Database | null, - context?: ProjectContext, - ): Database | null => { - let target: string | ProjectDatabaseBinding = cwd; - if (context !== undefined) { - if (context.kind !== 'ok' || context.databaseBinding === undefined) return null; - target = context.databaseBinding; - } - const db = opener(target); - if (db === null) return null; - try { - // Bun may construct a handle for malformed bytes and fail only on the - // first statement. Validate version + schema before any tool handler can - // see the handle. Legacy consumers retain the lightweight readability - // probe when they do not opt into a schema validator. - let valid = true; - if (config.validateReadonlyDb) valid = config.validateReadonlyDb(db); - else db.query('PRAGMA user_version').get(); - if (!valid) { - db.close(); - return null; - } - return db; - } catch { - db.close(); - return null; - } - }; - const openValidatedDb = (context?: ProjectContext): Database | null => openValidatedWith(config.openDb, context); - const openHandle = (context: ProjectContext | undefined): Database | null => openValidatedDb(context); - // Single source of truth for the db handle: the per-call reopen below writes - // back to ctx.db, and close() reads ctx.db — so a mid-session reopen is always - // the one that gets closed (no stale/leaked handle). - const ctx: ToolContext = { db: openHandle(initialContext), cwd, context: initialContext }; - const toolByName = new Map(config.tools.map((t) => [t.name, t] as const)); - - /** - * Serialize a non-`ok` project context as a stable structured MCP error. This - * is the ONE place the fail-closed server refuses to serve an empty board - * when repository context or the genie.db is missing/unsupported. - */ - function failClosed(id: number | string | null, context: ProjectContext): void { - const payload = { error: context.kind, detail: (context as { detail?: string }).detail ?? context.kind }; - ok(id, { content: [{ type: 'text', text: JSON.stringify(payload) }], structuredContent: payload, isError: true }); - } - - /** - * Swap a degraded (read-only) handle for a write-capable one as soon as the - * open can produce it. Without this, ONE transient write-open failure at - * session start would serve `read_only_database` for every write tool until - * the client restarts the server — a latched session state the write open - * deliberately does not have. A retry that is still degraded replaces the - * incumbent; a null/busy retry restores a separately opened readable handle. - */ - function promoteDegradedHandle(): void { - if (!ctx.db || !config.isDegradedHandle?.(ctx.db)) return; - const degraded = ctx.db; - // The degraded connection must release its read-only WAL mapping before a - // write open: a candidate opened beside it can pass read validation yet - // fail its first write with "database is locked". If that close-first open - // reports busy/null, immediately reopen a fallback so reads keep serving - // while a later call retries promotion. - degraded.close(); - const replacement = openValidatedDb(ctx.context); - if (replacement === null) { - ctx.db = config.openReadonlyDb ? openValidatedWith(config.openReadonlyDb, ctx.context) : null; - return; - } - ctx.db = replacement; - } - - function handleToolsCall(id: number | string | null, params: Record | undefined): void { - const name = typeof params?.name === 'string' ? params.name : ''; - const args = (params?.arguments as Record | undefined) ?? {}; - const tool = toolByName.get(name); - if (!tool) { - // Surface as an isError tool result (not a protocol error) per MCP. - ok(id, { - content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_tool', name }) }], - isError: true, - }); - return; - } - // Fail-closed gate (opt-in). Re-resolve ONLY while not yet `ok` so a db/context - // created mid-session (e.g. a fresh `genie init`) is picked up without spending - // git probes on every call once the context is settled. Any non-`ok` state - // becomes a typed error rather than an outer/cache-root empty board. - if (config.resolveContext) { - const context = ctx.context?.kind === 'ok' ? ctx.context : config.resolveContext(cwd); - ctx.context = context; - if (context.kind !== 'ok') { - if (ctx.db) { - ctx.db.close(); - ctx.db = null; - } - failClosed(id, context); - return; - } - } - // The db may have been absent when the server started. Re-attempt the open - // per call so a db created mid-session (e.g. a fresh `genie init` or a - // bridge write) is picked up. - if (!ctx.db) ctx.db = openValidatedDb(ctx.context); - else promoteDegradedHandle(); - // Existence is not openability: a directory, malformed file, unreadable - // path, or failed open can still arrive with an `ok` path context. - // Never dispatch a fail-closed MCP tool with a null handle, because every - // tool interprets null as a healthy empty projection. - if (config.resolveContext && !ctx.db && ctx.context?.kind === 'ok') { - const unavailable: ProjectContext = { - ...ctx.context, - kind: 'project-database-unavailable', - detail: `unable to open Genie database at ${ctx.context.dbPath}`, - }; - // Preserve retry behavior: a later call re-resolves this non-ok context - // and may recover after the path is repaired. - ctx.context = unavailable; - failClosed(id, unavailable); - return; - } - // Opt-in per-result error channel: a TAGGED handler result is unwrapped - // before serialization — the wire payload is the inner value (shapes - // unchanged) and the envelope flips isError. Every untagged result keeps - // isError: false, so `error`-keyed DATA payloads stay success results. - const raw = tool.handler(ctx, args); - const tagged = isToolError(raw); - const payload = tagged ? unwrapToolError(raw) : raw; - ok(id, { - content: [{ type: 'text', text: JSON.stringify(payload) }], - structuredContent: payload, - isError: tagged, - }); - } - - function dispatch(req: JsonRpcRequest): void { - // A message with no id (or null id) is a notification: process side effects - // but send NO reply (e.g. notifications/initialized). - const isNotification = req.id === undefined || req.id === null; - const id = req.id ?? null; - - switch (req.method) { - case 'initialize': { - const outcome = config.initialize(req.params); - if ('error' in outcome) writeError(id, outcome.error); - else ok(id, outcome.result); - return; - } - case 'ping': - if (!isNotification) ok(id, {}); - return; - case 'tools/list': - if (!isNotification) { - ok(id, { - tools: config.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })), - }); - } - return; - case 'tools/call': - if (!isNotification) handleToolsCall(id, req.params); - return; - default: - // Notifications (e.g. notifications/initialized, notifications/*) get no - // reply. An unknown method WITH an id is a JSON-RPC method-not-found. - if (!isNotification) err(id, METHOD_NOT_FOUND, `Method not found: ${req.method}`); - return; - } - } - - function handleLine(line: string): void { - const trimmed = line.trim(); - if (!trimmed) return; - let req: JsonRpcRequest; - try { - req = JSON.parse(trimmed) as JsonRpcRequest; - } catch { - // Unparseable line → cannot attribute an id; drop it (no id to reply to). - return; - } - // A JSON-RPC request must be a non-null object. `JSON.parse('null')` and bare - // primitives are valid JSON but carry no id to attribute — drop them like an - // unparseable line. Without this, `dispatch(null)` (and the catch below) throw - // on `null.id`, and the uncaught error crashes the server on a `null` line. - if (req === null || typeof req !== 'object') return; - try { - dispatch(req); - } catch (e) { - const id = req.id ?? null; - if (id !== null) err(id, INTERNAL_ERROR, e instanceof Error ? e.message : String(e)); - } - } - - const rl = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY }); - rl.on('line', handleLine); - - await new Promise((resolve) => { - rl.on('close', () => { - config.onClose?.(); - ctx.db?.close(); - // Flush stdout BEFORE exiting: the empty-write callback fires after the - // stream's buffer (including all prior response writes) drains to the OS, - // so the final response is never truncated. - process.stdout.write('', () => resolve()); - }); - }); -} diff --git a/src/lib/v5/mcp-tools.test.ts b/src/lib/v5/mcp-tools.test.ts deleted file mode 100644 index df32e3e33..000000000 --- a/src/lib/v5/mcp-tools.test.ts +++ /dev/null @@ -1,1313 +0,0 @@ -/** - * mcp-tools — the fail-closed project-context resolver (Group A) and the - * read-only tool projections it guards. - * - * The resolver remains re-exported from mcp-tools.ts for the read-only bridge - * adapter, which can refuse to serialize an outer/cache-root empty board without - * dragging the readonly bun:sqlite open into the eager genie.ts import graph. - * - * Every fixture is a real git repo in a tmpdir (per repo convention) so the four - * production values — effectiveLaunchCwd, worktreeConfigRoot, absolute - * gitCommonDir, and genieStorageRoot = dirname(gitCommonDir) — are exercised - * against actual `git rev-parse` behavior, not mocks. - */ - -import { Database } from 'bun:sqlite'; -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { execFileSync } from 'node:child_process'; -import { - accessSync, - chmodSync, - copyFileSync, - existsSync, - constants as fsConstants, - linkSync, - mkdirSync, - mkdtempSync, - realpathSync, - renameSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; -import { BusyDbError, STAGE_LOG_BACKFILL_KEY, isCurrentGenieDb, openDb } from './genie-db.js'; -import { type ToolErrorResult, isToolError, unwrapToolError } from './mcp-server.js'; -import { - MCP_TOOLS, - MCP_WRITE_TOOLS, - type TaskSummary, - type ToolContext, - isDegradedReadonlyDb, - openReadonlyDb, - openReadonlyDbHealingStaleSchema, - openWriteableDb, - readonlyDatabaseHandleMatchesPath, - resolveProjectContext, -} from './mcp-tools.js'; -import { hasStaleReadonlyWalIndex } from './sqlite-open.js'; -import { - blockTask, - createBoard, - createTask, - getDependencies, - getTask, - getTaskEvents, - getTaskLane, -} from './task-state.js'; - -let base: string; - -function git(cwd: string, ...args: string[]): void { - execFileSync('git', args, { - cwd, - stdio: 'ignore', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'Test', - GIT_AUTHOR_EMAIL: 'test@example.com', - GIT_COMMITTER_NAME: 'Test', - GIT_COMMITTER_EMAIL: 'test@example.com', - }, - }); -} - -/** Real git repo with one commit at `dir` (created if absent). */ -function initRepo(dir: string): string { - mkdirSync(dir, { recursive: true }); - git(dir, 'init', '-b', 'main'); - git(dir, 'commit', '--allow-empty', '-m', 'init'); - return dir; -} - -/** Create a real, seeded genie.db under `/.genie/genie.db`. */ -function seedDb(storageRoot: string): void { - const db = openDb({ cwd: storageRoot }); - const board = createBoard(db, 'repo'); - createTask(db, { title: 'seed', boardId: board.id, wish: 'w', group: 'g' }); - // Fold pending WAL frames into the main db and close before any reader opens, - // so readonly consumers aren't racing an open WAL writer under cross-file - // test contention (would otherwise surface as "database is locked"). - db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - db.close(); -} - -/** - * Canonicalize by realpath-ing the nearest EXISTING ancestor and re-appending - * the (possibly absent) tail — so macOS /private symlinks never cause false - * diffs, and an intentionally-absent db path still compares cleanly. - */ -function canon(p: string): string { - let existing = p; - const tail: string[] = []; - while (!existsSync(existing) && dirname(existing) !== existing) { - tail.unshift(basename(existing)); - existing = dirname(existing); - } - return tail.length > 0 ? join(realpathSync(existing), ...tail) : realpathSync(existing); -} - -function samePath(actual: string | undefined, expected: string): void { - expect(actual).toBeDefined(); - expect(canon(actual as string)).toBe(canon(expected)); -} - -beforeEach(() => { - base = mkdtempSync(join(tmpdir(), 'genie-ctx-')); -}); - -afterEach(() => { - rmSync(base, { recursive: true, force: true }); -}); - -// ============================================================================ -// resolveProjectContext — the four-value model + typed fail-closed states -// ============================================================================ - -describe('resolveProjectContext: supported layouts', () => { - test('repository root with a genie.db resolves all four values and never changes launch cwd', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const ctx = resolveProjectContext(repo); - expect(ctx.kind).toBe('ok'); - if (ctx.kind !== 'ok') throw new Error('expected ok'); - samePath(ctx.effectiveLaunchCwd, repo); // launch cwd is the input, unchanged - samePath(ctx.worktreeConfigRoot, repo); - samePath(ctx.gitCommonDir, join(repo, '.git')); - samePath(ctx.genieStorageRoot, repo); - samePath(ctx.dbPath, join(repo, '.genie', 'genie.db')); - // genieStorageRoot is exactly dirname(gitCommonDir). - samePath(dirname(ctx.gitCommonDir), ctx.genieStorageRoot); - }); - - test('an ordinary nested subdirectory resolves to the same storage root, not the subdir', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const deep = join(repo, 'src', 'deep'); - mkdirSync(deep, { recursive: true }); - const ctx = resolveProjectContext(deep); - expect(ctx.kind).toBe('ok'); - if (ctx.kind !== 'ok') throw new Error('expected ok'); - samePath(ctx.effectiveLaunchCwd, deep); // cwd stays the subdir - samePath(ctx.genieStorageRoot, repo); // but storage is the repo root - samePath(ctx.dbPath, join(repo, '.genie', 'genie.db')); - }); - - test('an initialized nested repository uses its OWN storage root, never the outer db', () => { - const outer = initRepo(join(base, 'outer')); - seedDb(outer); - const nested = initRepo(join(outer, 'vendor', 'nested')); - seedDb(nested); - const ctx = resolveProjectContext(nested); - expect(ctx.kind).toBe('ok'); - if (ctx.kind !== 'ok') throw new Error('expected ok'); - samePath(ctx.genieStorageRoot, nested); - samePath(ctx.dbPath, join(nested, '.genie', 'genie.db')); - expect(canon(ctx.dbPath)).not.toBe(canon(join(outer, '.genie', 'genie.db'))); - }); - - test('a linked worktree keeps config under the linked root but the db under the main common root', () => { - const main = initRepo(join(base, 'main')); - seedDb(main); // db lives ONLY at the main common root - const linked = join(base, 'linked'); - git(main, 'worktree', 'add', '-b', 'wt', linked); - const ctx = resolveProjectContext(linked); - expect(ctx.kind).toBe('ok'); - if (ctx.kind !== 'ok') throw new Error('expected ok'); - samePath(ctx.worktreeConfigRoot, linked); // config stays in the linked worktree - samePath(ctx.gitCommonDir, join(main, '.git')); // common dir is the MAIN repo's .git - samePath(ctx.genieStorageRoot, main); // dirname(gitCommonDir) == main - samePath(ctx.dbPath, join(main, '.genie', 'genie.db')); // sentinel read from the main db - // Neither the main-worktree cwd nor a cache is substituted as launch context. - samePath(ctx.effectiveLaunchCwd, linked); - }); -}); - -describe('resolveProjectContext: fail-closed states', () => { - test('a non-git directory is project-context-unavailable (never falls outward)', () => { - const plain = join(base, 'plain'); - mkdirSync(plain, { recursive: true }); - const ctx = resolveProjectContext(plain); - expect(ctx.kind).toBe('project-context-unavailable'); - samePath(ctx.effectiveLaunchCwd, plain); - }); - - test('a git repo with no genie.db is project-database-unavailable and names the exact candidate', () => { - const repo = initRepo(join(base, 'repo')); - const ctx = resolveProjectContext(repo); - expect(ctx.kind).toBe('project-database-unavailable'); - if (ctx.kind === 'ok') throw new Error('expected error'); - samePath(ctx.genieStorageRoot as string, repo); - samePath(ctx.dbPath as string, join(repo, '.genie', 'genie.db')); - expect(ctx.detail).toContain('.genie/genie.db'); - }); - - test('a .genie symlink to another repository with a valid database is rejected', () => { - const repo = initRepo(join(base, 'repo')); - const other = initRepo(join(base, 'other')); - seedDb(other); - symlinkSync(join(other, '.genie'), join(repo, '.genie'), 'dir'); - - const ctx = resolveProjectContext(repo); - expect(ctx.kind).toBe('project-database-unavailable'); - if (ctx.kind === 'ok') throw new Error('expected error'); - expect(ctx.detail).toContain('physical directory'); - expect(openReadonlyDb(repo)).toBeNull(); - }); - - test('a genie.db symlink to another repository valid database is rejected', () => { - const repo = initRepo(join(base, 'repo')); - const other = initRepo(join(base, 'other')); - seedDb(other); - mkdirSync(join(repo, '.genie')); - symlinkSync(join(other, '.genie', 'genie.db'), join(repo, '.genie', 'genie.db'), 'file'); - - const ctx = resolveProjectContext(repo); - expect(ctx.kind).toBe('project-database-unavailable'); - if (ctx.kind === 'ok') throw new Error('expected error'); - expect(ctx.detail).toContain('physical regular file'); - expect(openReadonlyDb(repo)).toBeNull(); - }); - - test('a hardlinked genie.db physical alias is rejected', () => { - const repo = initRepo(join(base, 'repo')); - const other = initRepo(join(base, 'other')); - seedDb(other); - mkdirSync(join(repo, '.genie')); - linkSync(join(other, '.genie', 'genie.db'), join(repo, '.genie', 'genie.db')); - - const ctx = resolveProjectContext(repo); - expect(ctx.kind).toBe('project-database-unavailable'); - if (ctx.kind === 'ok') throw new Error('expected error'); - expect(ctx.detail).toContain('hardlink'); - expect(openReadonlyDb(repo)).toBeNull(); - }); - - test('an exact validated binding refuses a later genie.db substitution with another valid database', () => { - const repo = initRepo(join(base, 'repo')); - const other = initRepo(join(base, 'other')); - seedDb(repo); - seedDb(other); - const ctx = resolveProjectContext(repo); - if (ctx.kind !== 'ok' || ctx.databaseBinding === undefined) throw new Error('expected bound database'); - - rmSync(ctx.dbPath); - symlinkSync(join(other, '.genie', 'genie.db'), ctx.dbPath, 'file'); - - expect(openReadonlyDb(ctx.databaseBinding)).toBeNull(); - }); - - test('an uninitialized nested repository fails at its own boundary, never reading the outer db', () => { - const outer = initRepo(join(base, 'outer')); - seedDb(outer); // outer HAS a db — the nested boundary must not fall through to it - const nested = initRepo(join(outer, 'nested')); // no db here - const ctx = resolveProjectContext(nested); - expect(ctx.kind).toBe('project-database-unavailable'); - if (ctx.kind === 'ok') throw new Error('expected error'); - samePath(ctx.dbPath as string, join(nested, '.genie', 'genie.db')); - expect(canon(ctx.dbPath as string)).not.toBe(canon(join(outer, '.genie', 'genie.db'))); - }); - - test('a bare repository is unsupported-project-layout before any db lookup', () => { - const bare = join(base, 'bare.git'); - git(base, 'init', '--bare', bare); - const ctx = resolveProjectContext(bare); - expect(ctx.kind).toBe('unsupported-project-layout'); - expect(ctx.dbPath).toBeUndefined(); - }); - - test('a submodule working tree is unsupported-project-layout', () => { - const sub = initRepo(join(base, 'subrepo')); - const superRepo = initRepo(join(base, 'super')); - execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', sub, 'mysub'], { - cwd: superRepo, - stdio: 'ignore', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'T', - GIT_AUTHOR_EMAIL: 't@e.com', - GIT_COMMITTER_NAME: 'T', - GIT_COMMITTER_EMAIL: 't@e.com', - }, - }); - seedDb(superRepo); // even with an outer db present, the submodule must not use it - const ctx = resolveProjectContext(join(superRepo, 'mysub')); - expect(ctx.kind).toBe('unsupported-project-layout'); - expect(ctx.dbPath).toBeUndefined(); - }); - - test('an external/separate-git-dir layout is unsupported-project-layout', () => { - const work = join(base, 'work'); - const external = join(base, 'external-gitdir'); - mkdirSync(work, { recursive: true }); - git(base, 'init', `--separate-git-dir=${external}`, work); - const ctx = resolveProjectContext(work); - expect(ctx.kind).toBe('unsupported-project-layout'); - expect(ctx.dbPath).toBeUndefined(); - }); -}); - -// ============================================================================ -// Opened-handle identity — SQLite VFS binding closes the swap/restore window -// ============================================================================ - -describe('openReadonlyDb: exact opened handle', () => { - test('HAS_MOVED accepts only a zero SQLite result with a zero moved flag', () => { - const handle = (result: number, moved: number, throws = false): Pick => ({ - fileControl: ((_op: number, arg?: ArrayBufferView | number) => { - if (throws) throw new Error('fixture unsupported VFS'); - if (arg instanceof Int32Array) arg[0] = moved; - return result; - }) as Database['fileControl'], - }); - - expect(readonlyDatabaseHandleMatchesPath(handle(0, 0))).toBe(true); - expect(readonlyDatabaseHandleMatchesPath(handle(0, 1))).toBe(false); - expect(readonlyDatabaseHandleMatchesPath(handle(12, 0))).toBe(false); - expect(readonlyDatabaseHandleMatchesPath(handle(0, 0, true))).toBe(false); - }); - - test('an injected moved-handle refusal closes the opened database', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const ctx = resolveProjectContext(repo); - if (ctx.kind !== 'ok' || ctx.databaseBinding === undefined) throw new Error('expected bound database'); - let closed = false; - const fakeDb = { - close: () => { - closed = true; - }, - } as Database; - - const opened = openReadonlyDb(ctx.databaseBinding, { - openDatabase: () => fakeDb, - verifyOpenedHandle: () => false, - }); - - expect(opened).toBeNull(); - expect(closed).toBe(true); - }); - - test.skipIf(process.platform === 'win32')( - 'a real A→B constructor swap then B→A restore is rejected by the opened VFS handle', - () => { - const repo = initRepo(join(base, 'repo')); - const other = initRepo(join(base, 'other')); - seedDb(repo); - seedDb(other); - const ctx = resolveProjectContext(repo); - if (ctx.kind !== 'ok' || ctx.databaseBinding === undefined) throw new Error('expected bound database'); - const otherDbPath = join(other, '.genie', 'genie.db'); - const parkedOriginal = join(base, 'parked-original.db'); - - const raced = openReadonlyDb(ctx.databaseBinding, { - openDatabase: (path) => { - renameSync(path, parkedOriginal); - renameSync(otherDbPath, path); - let opened: Database; - try { - opened = new Database(path, { readonly: true }); - } finally { - renameSync(path, otherDbPath); - renameSync(parkedOriginal, path); - } - return opened; - }, - }); - - expect(raced).toBeNull(); - const restored = openReadonlyDb(ctx.databaseBinding); - expect(restored).not.toBeNull(); - restored?.close(); - }, - ); -}); - -// ============================================================================ -// The read tools serve real state when the context is ok -// ============================================================================ - -describe('tools serve real state under an ok context', () => { - test('genie_board reflects the seeded db opened from the resolved storage root', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const ctx = resolveProjectContext(repo); - if (ctx.kind !== 'ok') throw new Error('expected ok'); - const db = openReadonlyDb(ctx.effectiveLaunchCwd); - expect(db).not.toBeNull(); - const board = MCP_TOOLS.find((t) => t.name === 'genie_board'); - const payload = board?.handler({ db, cwd: ctx.effectiveLaunchCwd, context: ctx }, {}) as { - counts: { total: number }; - }; - expect(payload.counts.total).toBe(1); - db?.close(); - }); -}); - -// ============================================================================ -// openReadonlyDbHealingStaleSchema — additive-lag self-heal (the N→T dogfood -// regression: a DB stamped by an older build fails isCurrentGenieDb on the -// readonly path until a write-path open backfills the additive columns, and a -// pure-MCP consumer never performs one) -// ============================================================================ - -describe('openReadonlyDbHealingStaleSchema', () => { - /** Rewind a current DB to an older build's shape: same user_version, missing additive columns. */ - function makeAdditiveLagDb(repo: string): string { - seedDb(repo); - const path = join(repo, '.genie', 'genie.db'); - const db = new Database(path); - for (const column of ['agent_kind', 'heartbeat_at', 'blocked_by', 'blocked_reason']) { - db.exec(`ALTER TABLE tasks DROP COLUMN ${column}`); - } - db.exec('ALTER TABLE boards DROP COLUMN lanes'); - db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - db.close(); - return path; - } - - test('heals an older build database in place and returns a validating handle', () => { - const repo = initRepo(join(base, 'repo')); - makeAdditiveLagDb(repo); - - const stale = openReadonlyDb(repo); - expect(stale).not.toBeNull(); - expect(isCurrentGenieDb(stale as Database)).toBe(false); - stale?.close(); - - const healed = openReadonlyDbHealingStaleSchema(repo); - expect(healed).not.toBeNull(); - expect(isCurrentGenieDb(healed as Database)).toBe(true); - // The seeded task is served through the healed handle, additive columns included. - const row = (healed as Database).query('SELECT title, wish, agent_kind FROM tasks').get() as { - title: string; - wish: string; - agent_kind: string | null; - }; - expect(row.title).toBe('seed'); - expect(row.wish).toBe('w'); - healed?.close(); - - // The heal is durable on disk: a plain readonly reopen validates current. - const reopened = openReadonlyDb(repo); - expect(isCurrentGenieDb(reopened as Database)).toBe(true); - reopened?.close(); - }); - - test('accepts the databaseBinding target form the MCP loop passes', () => { - const repo = initRepo(join(base, 'repo')); - makeAdditiveLagDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) { - throw new Error(`expected ok context with binding, got ${context.kind}`); - } - const healed = openReadonlyDbHealingStaleSchema(context.databaseBinding); - expect(healed).not.toBeNull(); - expect(isCurrentGenieDb(healed as Database)).toBe(true); - healed?.close(); - }); - - test('never creates an absent database', () => { - const repo = initRepo(join(base, 'repo')); - expect(openReadonlyDbHealingStaleSchema(repo)).toBeNull(); - expect(existsSync(join(repo, '.genie', 'genie.db'))).toBe(false); - }); - - test('refuses to heal through a binding whose database was substituted', () => { - const repo = initRepo(join(base, 'repo')); - makeAdditiveLagDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) { - throw new Error(`expected ok context with binding, got ${context.kind}`); - } - const binding = context.databaseBinding; - // Replace the bound database after the binding was captured — the heal - // must fail closed, never write-open the substituted file (openDb would - // otherwise run ensureSchema DDL on it). The substitute is created while - // the original still exists so it is guaranteed a distinct inode even on - // filesystems that recycle inode numbers immediately (ext4), then renamed - // over the original — the realistic atomic-swap shape. - const dbPath = join(repo, '.genie', 'genie.db'); - const substitute = join(repo, '.genie', 'genie.db.substitute'); - copyFileSync(dbPath, substitute); - renameSync(substitute, dbPath); - expect(openReadonlyDbHealingStaleSchema(binding)).toBeNull(); - // The substituted database was left untouched by any write-path open. - const untouched = openReadonlyDb(repo); - expect(isCurrentGenieDb(untouched as Database)).toBe(false); - untouched?.close(); - }); - - test('serves a marker-only-stale database readonly when the heal write is impossible', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const path = join(repo, '.genie', 'genie.db'); - const stale = new Database(path); - // Shape is fully current; only the data-only backfill marker is pending — - // the one staleness a read never depends on. - stale.query('DELETE FROM meta WHERE key = ?').run(STAGE_LOG_BACKFILL_KEY); - stale.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - stale.close(); - - // Write-protect the database and its directory: the sandboxed/CI shape - // where the consumer can read .genie/genie.db but never write it, so the - // write-path heal is impossible. - const genieDir = join(repo, '.genie'); - chmodSync(path, 0o444); - chmodSync(genieDir, 0o555); - try { - // Root (some CI containers) ignores file modes, so the heal write would - // succeed and this test would assert the wrong branch — the degrade - // path is only expressible where chmod actually revokes write access. - try { - accessSync(path, fsConstants.W_OK); - return; // still writable (running as root) — skip - } catch { - // write access revoked as intended — proceed - } - const served = openReadonlyDbHealingStaleSchema(repo); - if (served === null) { - // Null is only acceptable when this platform's SQLite cannot read a - // write-protected WAL database AT ALL (read-only WAL support varies - // by VFS state — ubuntu CI hits this). Probe a plain readonly open + - // query: if that works, the degrade fallback should have served the - // handle and returning null is a real regression. - const probe = openReadonlyDb(repo); - let readable = false; - try { - if (probe !== null) { - probe.query('SELECT 1 FROM meta').get(); - readable = true; - } - } catch { - // unreadable — the scenario is inexpressible in this environment - } - probe?.close(); - expect(readable).toBe(false); - return; - } - // Still not strictly current (marker pending) — but readable and served. - expect(isCurrentGenieDb(served)).toBe(false); - const row = served.query('SELECT title FROM tasks').get() as { title: string }; - expect(row.title).toBe('seed'); - served.close(); - } finally { - chmodSync(genieDir, 0o755); - chmodSync(path, 0o644); - } - }); - - test('fails closed on a future user_version instead of healing it', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const path = join(repo, '.genie', 'genie.db'); - const db = new Database(path); - db.exec('PRAGMA user_version = 99'); - db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - db.close(); - expect(openReadonlyDbHealingStaleSchema(repo)).toBeNull(); - // The refusal left the foreign version untouched. - const check = new Database(path, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(99); - check.close(); - }); -}); - -// ============================================================================ -// Poisoned-WAL-index fixtures. The MCP write open is the ONE caller of the -// recovery (`openWithWalIndexRecovery`, whose mechanics are tested directly in -// sqlite-open.test.ts) because it is the one path that CREATES the poison: its -// degrade fallback hands out a read-only handle whose close writes the header. -// The shared `openSqlite` deliberately does not heal — genie-db.test.ts pins -// that. What is asserted HERE is the whole rescoped scenario end to end -// (degrade → poison → repaired filesystem → write open heals → writes succeed) -// and that the busy carve-out above it is intact. NOTE: on this machine the -// REAL poison is byte-for-byte identical to the virgin header bun writes on any -// healthy fresh open, and the write open does NOT throw on it (bun hands back a -// writable-looking handle whose writes fail "disk I/O error") — only the -// post-open write probe sees it. -// ============================================================================ - -/** - * The exact read-only WAL-index header macOS/bun leaves in `-shm` when a - * DEGRADED readonly connection closes while the db file is write-protected: - * iVersion + isInit still set, iChange (offset 8) and nPage (offset 20) zeroed. - * Observed live on macOS (bun 1.x): a write-protected db opened without the - * readonly flag silently opens READONLY, writes fail with "attempt to write a - * readonly database", and the close leaves this header. - */ -function poisonShmHeader(): Buffer { - const header = Buffer.alloc(32768); - header.writeUInt32LE(0x002de218, 0); // iVersion (matches the observed value) - header.writeUInt32LE(1, 12); // isInit - return header; -} - -// ============================================================================ -// openWriteableDb — hardened write path + read-only-degrade fallback (Group 1 -// retained from the historical mcp-write-tools contract) -// ============================================================================ - -describe('openWriteableDb', () => { - /** Probe whether a handle can take the write lock (BEGIN IMMEDIATE), then roll back. */ - function expectWritable(db: Database | null): void { - expect(db).not.toBeNull(); - // A readonly/degraded handle raises SQLITE_READONLY here; a writable handle - // takes the write lock and rolls back, leaving no side effect. - (db as Database).exec('BEGIN IMMEDIATE'); - (db as Database).exec('ROLLBACK'); - } - - test('opens a WRITABLE handle through the standard CLI path in a healthy repo', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) throw new Error('expected bound database'); - const db = openWriteableDb(context.databaseBinding); - expectWritable(db); - expect(isDegradedReadonlyDb(db)).toBe(false); - // The seeded task is served through the writable handle. - const row = (db as Database).query('SELECT title FROM tasks').get() as { title: string }; - expect(row.title).toBe('seed'); - (db as Database).close(); - }); - - test('degrades to a readable-but-degraded readonly handle on a write-protected fully-current db', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) throw new Error('expected bound database'); - const path = context.databaseBinding.physicalPath; - const genieDir = dirname(path); - chmodSync(path, 0o444); - chmodSync(genieDir, 0o555); - try { - // Root (some CI containers) ignores file modes, so the write open would - // succeed and this test would assert the wrong branch — the degrade path - // is only expressible where chmod actually revokes write access. - try { - accessSync(path, fsConstants.W_OK); - return; // still writable (running as root) — skip - } catch { - // write access revoked as intended — proceed - } - const served = openWriteableDb(context.databaseBinding); - if (served === null) { - // Null is only acceptable when this platform's SQLite cannot read a - // write-protected WAL database AT ALL (read-only WAL support varies by - // VFS state). Probe a plain readonly open + query: if that works, the - // degrade fallback should have served the handle and null is a regression. - const probe = openReadonlyDb(context.databaseBinding); - let readable = false; - try { - if (probe !== null) { - probe.query('SELECT 1 FROM meta').get(); - readable = true; - } - } catch { - // unreadable — the scenario is inexpressible in this environment - } - probe?.close(); - expect(readable).toBe(false); - return; - } - // The fully-current db's reads still serve through the degraded handle... - const row = served.query('SELECT title FROM tasks').get() as { title: string }; - expect(row.title).toBe('seed'); - // ...and the handle is observable as degraded (a write through it fails). - expect(isDegradedReadonlyDb(served)).toBe(true); - served.close(); - } finally { - chmodSync(genieDir, 0o755); - chmodSync(path, 0o644); - } - }); - - test('a subsequent write open on a repaired filesystem yields a fresh NON-degraded handle (never latched)', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) throw new Error('expected bound database'); - const path = context.databaseBinding.physicalPath; - const genieDir = dirname(path); - chmodSync(path, 0o444); - chmodSync(genieDir, 0o555); - try { - try { - accessSync(path, fsConstants.W_OK); - return; // root — the degrade branch is inexpressible, skip - } catch { - // proceed - } - const degraded = openWriteableDb(context.databaseBinding); - if (degraded === null) { - // Inexpressible on this platform (same probe as the degrade test). - const probe = openReadonlyDb(context.databaseBinding); - let readable = false; - try { - if (probe !== null) { - probe.query('SELECT 1 FROM meta').get(); - readable = true; - } - } catch { - // unreadable - } - probe?.close(); - if (!readable) return; - } - expect(isDegradedReadonlyDb(degraded)).toBe(true); - degraded?.close(); - } finally { - chmodSync(genieDir, 0o755); - chmodSync(path, 0o644); - } - // Repaired filesystem: the NEXT open goes through the write path and the - // fresh handle is NOT degraded — the degraded state was derived from the - // per-open outcome, never latched across opens. - const restored = openWriteableDb(context.databaseBinding); - expectWritable(restored); - expect(isDegradedReadonlyDb(restored)).toBe(false); - (restored as Database).close(); - }); - - test('never creates an absent database (resolver ordering: a non-ok context never reaches the open)', () => { - const repo = initRepo(join(base, 'repo')); - expect(openWriteableDb(repo)).toBeNull(); - expect(existsSync(join(repo, '.genie', 'genie.db'))).toBe(false); - }); - - test('fails closed (null) on foreign, unversioned-foreign, and malformed databases', () => { - const repo = initRepo(join(base, 'repo')); - const dbPath = join(repo, '.genie', 'genie.db'); - mkdirSync(dirname(dbPath), { recursive: true }); - - // Foreign: user_version = 99 with a lookalike tasks table. - const foreign = new Database(dbPath); - foreign.exec( - 'CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT NOT NULL); PRAGMA user_version = 99', - ); - foreign.close(); - expect(openWriteableDb(repo)).toBeNull(); - - // Unversioned foreign: user_version = 0 with existing foreign tables. - rmSync(dbPath); - const v0 = new Database(dbPath); - v0.exec('CREATE TABLE inventory (id TEXT PRIMARY KEY)'); - v0.close(); - expect(openWriteableDb(repo)).toBeNull(); - - // Malformed: not a SQLite database at all. - rmSync(dbPath); - writeFileSync(dbPath, 'not a sqlite database'); - expect(openWriteableDb(repo)).toBeNull(); - }); - - test('the REAL poison a degraded session leaves behind heals on the next write open (no seam)', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) throw new Error('expected bound database'); - const binding = context.databaseBinding; - const path = binding.physicalPath; - const genieDir = dirname(path); - - // Build the REAL poison in-process: the degraded readonly fallback's close - // writes the zeroed read-only wal-index header into -shm. Crafted header - // bytes canNOT stand in for it — the real poison is byte-identical to a - // healthy virgin header and only fails through the HANDLE. - chmodSync(path, 0o444); - chmodSync(genieDir, 0o555); - try { - try { - accessSync(path, fsConstants.W_OK); - return; // root — the degrade branch is inexpressible, skip - } catch { - // write access revoked as intended — proceed - } - const degraded = openWriteableDb(binding); - if (degraded === null) { - // Inexpressible on this platform (same probe as the degrade test). - const probe = openReadonlyDb(binding); - let readable = false; - try { - if (probe !== null) { - probe.query('SELECT 1 FROM meta').get(); - readable = true; - } - } catch { - // unreadable — the scenario is inexpressible in this environment - } - probe?.close(); - if (!readable) return; - } - expect(isDegradedReadonlyDb(degraded)).toBe(true); - degraded?.close(); // writes the REAL poison marker into -shm - } finally { - chmodSync(genieDir, 0o755); - chmodSync(path, 0o644); - } - if (!hasStaleReadonlyWalIndex(binding.physicalPath)) return; // no poison produced here - - // Repaired filesystem: the write open runs the MCP-scoped recovery — the - // open itself succeeds, its post-open probe rejects the poison, the - // sidecars are rebuilt, and the handle served is writable and NOT degraded. - const db = openWriteableDb(binding); - expectWritable(db); - expect(isDegradedReadonlyDb(db)).toBe(false); - // A real write lands (the probe alone would not prove the heal), and the - // poisoned header is gone. - (db as Database).exec("INSERT INTO meta (key, value) VALUES ('after-poison', '1')"); - expect( - ((db as Database).query("SELECT value FROM meta WHERE key = 'after-poison'").get() as { value: string }).value, - ).toBe('1'); - (db as Database).close(); - expect(hasStaleReadonlyWalIndex(binding.physicalPath)).toBe(false); - }); - - test('a BusyDbError write open returns null WITHOUT the readonly degrade fallback or sidecar recovery', () => { - const repo = initRepo(join(base, 'repo')); - seedDb(repo); - const context = resolveProjectContext(repo); - if (context.kind !== 'ok' || context.databaseBinding === undefined) throw new Error('expected bound database'); - const binding = context.databaseBinding; - const shmPath = `${binding.physicalPath}-shm`; - - // Even with a poisoned -shm present, a busy open must NOT run sidecar - // recovery (the sidecars belong to a live writer) and must NOT degrade to - // readonly: the loop's per-call reopen retries the write open next call. - rmSync(shmPath, { force: true }); - writeFileSync(shmPath, poisonShmHeader()); - - const db = openWriteableDb(binding, { - openDatabase: () => { - throw new BusyDbError(binding.physicalPath, new Error('write lock held by another process')); - }, - }); - expect(db).toBeNull(); - expect(isDegradedReadonlyDb(db)).toBe(false); // not mislabeled read-only - // The poisoned -shm was left untouched — recovery never ran for a busy db. - expect(hasStaleReadonlyWalIndex(binding.physicalPath)).toBe(true); - }); -}); -// ============================================================================ -// MCP_WRITE_TOOLS — the 12 operative write tools (Group 2) -// ============================================================================ -// -// Every verb is driven through the retired registry in-process, then its -// observable db effect is read back via `task-state.ts` queries. Standalone -// cross-process behavior is anchored separately through the supported task CLI. - -interface TaskCliRun { - stdout: string; - stderr: string; - exitCode: number; -} - -/** Spawn one supported standalone `genie task` command. */ -async function spawnTaskCli(cwd: string, args: string[]): Promise { - const proc = Bun.spawn(['bun', join(import.meta.dir, '..', '..', 'genie.ts'), 'task', ...args], { - cwd, - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, NO_COLOR: '1', GENIE_TEST_SKIP_PGSERVE: '1' }, - }); - const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); - return { stdout, stderr, exitCode: await proc.exited }; -} - -describe('MCP_WRITE_TOOLS — the 12 operative write tools', () => { - let repo: string; - let db: Database; - let ctx: ToolContext; - - beforeEach(() => { - repo = initRepo(join(base, 'write-repo')); - db = openDb({ cwd: repo }); - ctx = { db, cwd: repo }; - }); - - afterEach(() => { - db?.close(); - }); - - function call(name: string, args: Record): unknown { - const tool = MCP_WRITE_TOOLS.find((t) => t.name === name); - if (!tool) throw new Error(`no write tool ${name}`); - return tool.handler(ctx, args); - } - - function expectOk>(result: unknown): T { - expect(isToolError(result)).toBe(false); - return result as T; - } - - function expectError(result: unknown, code: string): Record { - expect(isToolError(result)).toBe(true); - const payload = unwrapToolError(result as ToolResult) as Record; - expect(payload.error).toBe(code); - return payload; - } - - function readyTask(title: string): string { - return createTask(db, { title }).id; - } - - test('MCP_TOOLS stays the 5 read tools — ui-bridge splice untouched', () => { - expect(MCP_TOOLS.map((t) => t.name).sort()).toEqual([ - 'genie_active', - 'genie_board', - 'genie_task', - 'genie_wish_status', - 'genie_worktree_context', - ]); - }); - - test('MCP_WRITE_TOOLS exposes exactly the 12 operative-core verbs', () => { - expect(MCP_WRITE_TOOLS.map((t) => t.name).sort()).toEqual([ - 'genie_task_add_dependency', - 'genie_task_block', - 'genie_task_checkout', - 'genie_task_comment', - 'genie_task_create', - 'genie_task_done', - 'genie_task_heartbeat', - 'genie_task_move', - 'genie_task_release', - 'genie_task_report', - 'genie_task_set_wish', - 'genie_task_unblock', - ]); - }); - - // --- genie_task_create ---------------------------------------------------- - - test('genie_task_create mirrors task create: the row lands with CLI-identical fields', () => { - const board = createBoard(db, 'repo'); - const result = expectOk<{ task: TaskSummary }>( - call('genie_task_create', { title: 'new card', board: 'repo', wish: 'mcp-write-tools', group: 'g2' }), - ); - expect(result.task.title).toBe('new card'); - const row = getTask(db, result.task.id); - expect(row).not.toBeNull(); - expect(row?.status).toBe('ready'); - expect(row?.wish).toBe('mcp-write-tools'); - expect(row?.group).toBe('g2'); - expect(row?.boardId).toBe(board.id); - expect(row?.claimedBy).toBeNull(); - }); - - test('genie_task_create rejects missing title and group-without-wish as invalid_arguments', () => { - expectError(call('genie_task_create', {}), 'invalid_arguments'); - expectError(call('genie_task_create', { title: 'x', group: 'g2' }), 'invalid_arguments'); - expect(getTask(db, 't_missing')).toBeNull(); - }); - - test('genie_task_create with an unknown board returns typed not_found, never -32603', () => { - const payload = expectError(call('genie_task_create', { title: 'x', board: 'no_such_board' }), 'not_found'); - expect(payload.detail).toBe('no_such_board'); - expect(String(payload.message)).toContain('Board not found'); - expect(getTask(db, 't_missing')).toBeNull(); - }); - - // --- genie_task_checkout -------------------------------------------------- - - test('genie_task_checkout claims atomically: two workers claim two claimed_by values on one server', () => { - const a = readyTask('a'); - const b = readyTask('b'); - const ra = expectOk<{ task: TaskSummary }>(call('genie_task_checkout', { id: a, worker: 'w1' })); - const rb = expectOk<{ task: TaskSummary }>(call('genie_task_checkout', { id: b, worker: 'w2' })); - expect(ra.task.claimedBy).toBe('w1'); - expect(rb.task.claimedBy).toBe('w2'); - expect(getTask(db, a)?.status).toBe('in_progress'); - expect(getTask(db, b)?.status).toBe('in_progress'); - // the claim event attributes the env fallback (no author arg given) - expect(getTaskEvents(db, a)[0]?.kind).toBe('claim'); - }); - - test('genie_task_checkout on an already-claimed card → typed claim_conflict', () => { - const t = readyTask('a'); - call('genie_task_checkout', { id: t, worker: 'w1' }); - const payload = expectError(call('genie_task_checkout', { id: t, worker: 'w2' }), 'claim_conflict'); - expect(payload.taskId).toBe(t); - expect(getTask(db, t)?.claimedBy).toBe('w1'); - }); - - test('genie_task_checkout on an enforced-blocked card → refused_transition (TaskBlockedError)', () => { - const t = readyTask('a'); - blockTask(db, t, 'waiting on design', { author: 'orch', authorKind: 'human' }); - const payload = expectError(call('genie_task_checkout', { id: t, worker: 'w1' }), 'refused_transition'); - expect(payload.detail).toBe('TaskBlockedError'); - }); - - test('genie_task_checkout rejects unknown ids and missing worker', () => { - expectError(call('genie_task_checkout', { id: 't_missing', worker: 'w1' }), 'not_found'); - const t = readyTask('a'); - expectError(call('genie_task_checkout', { id: t }), 'invalid_arguments'); - }); - - // --- genie_task_done ------------------------------------------------------ - - test('genie_task_done completes a card and recomputes the ready set (dependent flips to ready)', () => { - const parent = readyTask('parent'); - const child = createTask(db, { title: 'child', dependsOn: [parent] }).id; - expect(getTask(db, child)?.status).toBe('blocked'); - call('genie_task_checkout', { id: parent, worker: 'w1' }); - const result = expectOk<{ task: TaskSummary }>(call('genie_task_done', { id: parent, author: 'orch' })); - expect(result.task.status).toBe('done'); - // recomputeReady ran inside completeTask — the dependent is ready now - expect(getTask(db, child)?.status).toBe('ready'); - // the release event carries the explicit author - const events = getTaskEvents(db, parent); - expect(events.some((e) => e.kind === 'release' && e.author === 'orch')).toBe(true); - }); - - test('genie_task_done refuses blocked and already-done cards → refused_transition with the class name', () => { - const parent = readyTask('parent'); - const child = createTask(db, { title: 'child', dependsOn: [parent] }).id; - const blocked = expectError(call('genie_task_done', { id: child }), 'refused_transition'); - expect(blocked.detail).toBe('TaskNotReadyError'); - call('genie_task_done', { id: parent }); - const alreadyDone = expectError(call('genie_task_done', { id: parent }), 'refused_transition'); - expect(alreadyDone.detail).toBe('TaskCompleteError'); - expectError(call('genie_task_done', { id: 't_missing' }), 'not_found'); - }); - - // --- genie_task_move ------------------------------------------------------ - - test('genie_task_move moves a card to a defined lane, mirroring task move', () => { - const board = createBoard(db, 'kanban', [{ name: 'Idea' }, { name: 'Doing' }, { name: 'Done' }]); - const t = createTask(db, { title: 'a', boardId: board.id }).id; - const result = expectOk<{ task: TaskSummary; from: string | null; to: string }>( - call('genie_task_move', { id: t, to: 'Doing', author: 'w1' }), - ); - expect(result.to).toBe('Doing'); - expect(result.from).toBeNull(); - expect(getTaskLane(db, t)).toBe('Doing'); - expect(getTaskEvents(db, t).some((e) => e.kind === 'move' && e.author === 'w1')).toBe(true); - }); - - test('genie_task_move rejects undefined and no-lane targets → invalid_lane', () => { - const board = createBoard(db, 'kanban', [{ name: 'Idea' }, { name: 'Done' }]); - const t = createTask(db, { title: 'a', boardId: board.id }).id; - const unknown = expectError(call('genie_task_move', { id: t, to: 'Nope' }), 'invalid_lane'); - expect(String(unknown.message)).toContain('Idea'); - const noLanes = createTask(db, { title: 'b', boardId: createBoard(db, 'repo').id }).id; - expectError(call('genie_task_move', { id: noLanes, to: 'Doing' }), 'invalid_lane'); - expectError(call('genie_task_move', { id: 't_missing', to: 'Doing' }), 'not_found'); - }); - - // --- genie_task_block / genie_task_unblock -------------------------------- - - test('genie_task_block places an enforced block (checkout refused) and unblock clears it', () => { - const t = readyTask('a'); - const blocked = expectOk<{ task: TaskSummary }>( - call('genie_task_block', { id: t, reason: 'waiting', author: 'orch' }), - ); - expect(blocked.task.status).toBe('ready'); // a block does not change status - expect(getTaskEvents(db, t).some((e) => e.kind === 'block' && e.note === 'waiting' && e.author === 'orch')).toBe( - true, - ); - expectError(call('genie_task_checkout', { id: t, worker: 'w1' }), 'refused_transition'); - expectOk(call('genie_task_unblock', { id: t, author: 'orch' })); - expectOk(call('genie_task_checkout', { id: t, worker: 'w1' })); // claimable again - }); - - test('genie_task_block records a hold kind and both verbs reject unknown ids', () => { - const t = readyTask('a'); - call('genie_task_block', { id: t, reason: 'parked', hold: true }); - expectError(call('genie_task_checkout', { id: t, worker: 'w1' }), 'refused_transition'); - expectError(call('genie_task_block', { id: 't_missing', reason: 'x' }), 'not_found'); - expectError(call('genie_task_unblock', { id: 't_missing' }), 'not_found'); - expectError(call('genie_task_block', { id: t }), 'invalid_arguments'); - }); - - // --- genie_task_release --------------------------------------------------- - - test('genie_task_release returns a claimed card to the ready queue', () => { - const t = readyTask('a'); - call('genie_task_checkout', { id: t, worker: 'w1' }); - const result = expectOk<{ task: TaskSummary }>(call('genie_task_release', { id: t, author: 'w1' })); - expect(result.task.status).toBe('ready'); - expect(result.task.claimedBy).toBeNull(); - expect(getTask(db, t)?.claimedBy).toBeNull(); - expect(getTaskEvents(db, t).some((e) => e.kind === 'release' && e.note === 'released')).toBe(true); - }); - - test('genie_task_release on an unclaimed ready card → refused_transition (TaskReleaseError)', () => { - const t = readyTask('a'); - const payload = expectError(call('genie_task_release', { id: t }), 'refused_transition'); - expect(payload.detail).toBe('TaskReleaseError'); - expectError(call('genie_task_release', { id: 't_missing' }), 'not_found'); - }); - - // --- genie_task_comment / genie_task_report ------------------------------- - - test('genie_task_comment and genie_task_report append authored timeline events', () => { - const t = readyTask('a'); - const comment = expectOk<{ taskId: string; event: { kind: string; note: string; author: string | null } }>( - call('genie_task_comment', { id: t, text: 'look at this', author: 'w1' }), - ); - expect(comment.event.kind).toBe('comment'); - expect(comment.event.note).toBe('look at this'); - const report = expectOk<{ taskId: string; event: { kind: string; note: string; author: string | null } }>( - call('genie_task_report', { id: t, text: 'blocked on review', author: 'w1' }), - ); - expect(report.event.kind).toBe('report'); - const events = getTaskEvents(db, t); - expect(events.map((e) => e.kind)).toEqual(['comment', 'report']); - expect(events.every((e) => e.author === 'w1')).toBe(true); - }); - - test('genie_task_comment / genie_task_report reject unknown ids and empty text', () => { - expectError(call('genie_task_comment', { id: 't_missing', text: 'x' }), 'not_found'); - expectError(call('genie_task_report', { id: 't_missing', text: 'x' }), 'not_found'); - const t = readyTask('a'); - expectError(call('genie_task_comment', { id: t, text: ' ' }), 'invalid_arguments'); - expectError(call('genie_task_report', { id: t }), 'invalid_arguments'); - }); - - // --- genie_task_heartbeat ------------------------------------------------- - - test('genie_task_heartbeat records heartbeat_at as a bare timestamp write, not an event', () => { - const t = readyTask('a'); - call('genie_task_checkout', { id: t, worker: 'w1' }); - const result = expectOk<{ taskId: string; heartbeatAt: number }>(call('genie_task_heartbeat', { id: t })); - expect(result.heartbeatAt).toBeGreaterThan(0); - expect(getTaskEvents(db, t)).toHaveLength(1); // only the claim event - expectError(call('genie_task_heartbeat', { id: 't_missing' }), 'not_found'); - }); - - // --- genie_task_set_wish -------------------------------------------------- - - test('genie_task_set_wish attaches, re-points, and clears the wish identity', () => { - const t = readyTask('a'); - const attach = expectOk<{ - task: TaskSummary; - from: { wish: string | null; group: string | null }; - to: { wish: string | null; group: string | null }; - }>(call('genie_task_set_wish', { id: t, wish: 'alpha', author: 'orch' })); - expect(attach.from).toEqual({ wish: null, group: null }); - expect(attach.to).toEqual({ wish: 'alpha', group: null }); - expect(getTask(db, t)?.wish).toBe('alpha'); - expect(getTaskEvents(db, t).some((e) => e.kind === 'wish' && e.author === 'orch')).toBe(true); - - expectOk(call('genie_task_set_wish', { id: t, wish: 'beta', group: 'g2' })); - expect(getTask(db, t)?.wish).toBe('beta'); - expect(getTask(db, t)?.group).toBe('g2'); - - expectOk(call('genie_task_set_wish', { id: t, clear: true })); - expect(getTask(db, t)?.wish).toBeNull(); - expect(getTask(db, t)?.group).toBeNull(); - }); - - test('genie_task_set_wish enforces the CLI arg guards and unknown ids', () => { - const t = readyTask('a'); - expectError(call('genie_task_set_wish', { id: t, group: 'g2' }), 'invalid_arguments'); - expectError(call('genie_task_set_wish', { id: t, wish: 'x', clear: true }), 'invalid_arguments'); - expectError(call('genie_task_set_wish', { id: t }), 'invalid_arguments'); - expectError(call('genie_task_set_wish', { id: 't_missing', wish: 'x' }), 'not_found'); - }); - - test('genie_task_set_wish rejects a supplied-but-empty group instead of dropping it (CLI parity)', () => { - const t = readyTask('a'); - // `task set-wish --group ''` fails with "--group must not be empty."; the - // tool must not silently attach the wish with no group and report success. - for (const group of ['', ' ', 42]) { - const payload = expectError(call('genie_task_set_wish', { id: t, wish: 'w1', group }), 'invalid_arguments'); - expect(String(payload.detail)).toContain('group must not be empty'); - } - expect(getTask(db, t)?.wish).toBeNull(); - // An omitted group stays optional: the wish attaches with a null group. - expectOk(call('genie_task_set_wish', { id: t, wish: 'w1' })); - expect(getTask(db, t)?.wish).toBe('w1'); - expect(getTask(db, t)?.group).toBeNull(); - }); - - // --- genie_task_add_dependency -------------------------------------------- - - test('genie_task_add_dependency inserts the edge and rejects cycles and unknown tasks', () => { - const a = readyTask('a'); - const b = readyTask('b'); - const result = expectOk<{ taskId: string; dependsOnId: string }>( - call('genie_task_add_dependency', { id: b, depends_on: a }), - ); - expect(result).toEqual({ taskId: b, dependsOnId: a }); - expect(getDependencies(db, b)).toEqual([a]); - expectError(call('genie_task_add_dependency', { id: a, depends_on: a }), 'dependency_cycle'); // self - expectError(call('genie_task_add_dependency', { id: a, depends_on: b }), 'dependency_cycle'); // transitive - expectError(call('genie_task_add_dependency', { id: 't_missing', depends_on: a }), 'not_found'); - expectError(call('genie_task_add_dependency', { id: a }), 'invalid_arguments'); - }); - - // --- identity / error-channel contracts ----------------------------------- - - test('the server env identity is the fallback when author/worker args are absent (Decision 8)', () => { - const t = readyTask('a'); - const keys = ['GENIE_AGENT_NAME', 'GENIE_AGENT_KIND', 'CODEX_THREAD_ID'] as const; - const prev = keys.map((key) => [key, process.env[key]] as const); - try { - process.env.GENIE_AGENT_NAME = 'env-agent'; - process.env.GENIE_AGENT_KIND = 'codex'; - Reflect.deleteProperty(process.env, 'CODEX_THREAD_ID'); - call('genie_task_checkout', { id: t, worker: 'w1' }); // no author arg - const events = getTaskEvents(db, t); - expect(events[0]?.author).toBe('env-agent'); - expect(events[0]?.authorKind).toBe('codex'); - } finally { - for (const [key, value] of prev) { - if (value === undefined) Reflect.deleteProperty(process.env, key); - else process.env[key] = value; - } - } - // The identity env is process-global: a leak here would silently re-attribute - // every later test in this process. - for (const [key, value] of prev) expect(process.env[key]).toBe(value); - }); - - test('a write reaching a raw readonly handle maps to read_only_database, never -32603', () => { - const t = readyTask('a'); - db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - db.close(); - const readonly = new Database(join(repo, '.genie', 'genie.db'), { readonly: true }); - const roCtx: ToolContext = { db: readonly, cwd: repo }; - try { - const result = callOn(roCtx, 'genie_task_comment', { id: t, text: 'x' }); - const payload = expectError(result, 'read_only_database'); - expect(String(payload.detail)).toMatch(/readonly/i); - } finally { - readonly.close(); - } - }); - - test('on a write-protected fully-current db, write tools return read_only_database while reads still serve', () => { - const roRepo = initRepo(join(base, 'ro-repo')); - seedDb(roRepo); - const path = join(roRepo, '.genie', 'genie.db'); - const genieDir = join(roRepo, '.genie'); - chmodSync(path, 0o444); - chmodSync(genieDir, 0o555); - try { - try { - accessSync(path, fsConstants.W_OK); - return; // running as root — the degrade scenario is inexpressible here - } catch { - // write access revoked as intended — proceed - } - const handle = openWriteableDb(roRepo); - if (handle === null) { - // This platform cannot even read a write-protected WAL db — inexpressible. - return; - } - expect(isDegradedReadonlyDb(handle)).toBe(true); - const roCtx: ToolContext = { db: handle, cwd: roRepo }; - const payload = expectError(callOn(roCtx, 'genie_task_create', { title: 'x' }), 'read_only_database'); - expect(String(payload.detail)).toContain('read-only'); - // the degraded handle still serves reads (strict validator intact) - const board = MCP_TOOLS.find((x) => x.name === 'genie_board')!.handler(roCtx, {}); - expect((board as { counts: { total: number } }).counts.total).toBe(1); - handle.close(); - } finally { - chmodSync(genieDir, 0o755); - chmodSync(path, 0o644); - } - }); - - test('cross-process claim contention through standalone task CLI: exactly one winner and one conflict', async () => { - const race = initRepo(join(base, 'race-repo')); - const seed = openDb({ cwd: race }); - createBoard(seed, 'repo'); - const task = createTask(seed, { title: 'race' }); - seed.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - seed.close(); - - const drive = (worker: string) => spawnTaskCli(race, ['checkout', task.id, '--worker', worker]); - - const [a, b] = await Promise.all([drive('w1'), drive('w2')]); - const outcomes = [a, b]; - const winners = outcomes.filter((run) => run.exitCode === 0); - const losers = outcomes.filter((run) => run.exitCode === 1); - expect(winners).toHaveLength(1); - expect(losers).toHaveLength(1); - expect(winners[0].stdout).toContain('in_progress'); - expect(winners[0].stderr).toBe(''); - expect(losers[0].stdout).toBe(''); - expect(losers[0].stderr).toContain('not claimable'); - const check = openDb({ cwd: race }); - try { - const claimedBy = getTask(check, task.id)?.claimedBy ?? null; - if (claimedBy === null) throw new Error('no claim persisted'); - expect(['w1', 'w2']).toContain(claimedBy); // exactly one claim persisted - } finally { - check.close(); - } - }, 30_000); // two cold standalone CLI boots race here; loaded CI runners exceed the 5s default -}); - -function callOn(ctx: ToolContext, name: string, args: Record): unknown { - const tool = MCP_WRITE_TOOLS.find((t) => t.name === name); - if (!tool) throw new Error(`no write tool ${name}`); - return tool.handler(ctx, args); -} - -type ToolResult = ToolErrorResult>; diff --git a/src/lib/v5/mcp-tools.ts b/src/lib/v5/mcp-tools.ts deleted file mode 100644 index 8246e62ac..000000000 --- a/src/lib/v5/mcp-tools.ts +++ /dev/null @@ -1,1126 +0,0 @@ -/** - * Genie v5 tool projections and historical dispatch handlers for - * `.genie/genie.db`: 5 read tools (`MCP_TOOLS`) plus the retained 12 operative - * write handlers (`MCP_WRITE_TOOLS`). The public write-capable MCP command is - * retired; standalone board/task commands remain authoritative. - * - * The live read-only bridge loads this module without pulling its SQLite opens - * into unrelated `genie board`, `genie task`, or `genie --help` paths. - * - * The retained {@link openWriteableDb} contract covers the historical hardened - * write path and its degrade behavior. Readonly open internals remain live for - * `genie ui-bridge`, which injects its own readonly open. - */ - -import { constants, Database } from 'bun:sqlite'; -import { execFileSync } from 'node:child_process'; -import { accessSync, constants as fsConstants } from 'node:fs'; -import { assertLocalLifecycleEnabled } from '../orchestration-mode.js'; -import { - BusyDbError, - type ProjectContext, - type ProjectDatabaseBinding, - isCurrentGenieDb, - isReadableGenieDb, - openDb, - resolveDbPath, - resolveProjectDatabaseBinding, -} from './genie-db.js'; -import { resolveEventAuthor } from './identity.js'; -import { type ToolErrorResult, isToolError, toolError } from './mcp-server.js'; -import { resolveWishBranch } from './resolve-wish-branch.js'; -import { BUSY_TIMEOUT_MS, isBusyError, openWithWalIndexRecovery } from './sqlite-open.js'; - -// Re-exported for the fail-closed bridge context path while keeping bun:sqlite -// opens out of the eager genie.ts import graph. -export { type ProjectContext, resolveProjectContext } from './genie-db.js'; -import { - type BlockKind, - CheckoutConflictError, - CycleError, - type EventAuthor, - type FrozenTaskRow, - LaneError, - TaskBlockedError, - TaskCompleteError, - type TaskFilter, - TaskNotReadyError, - TaskReleaseError, - type TaskRow, - UnknownBoardError, - UnknownTaskError, - type WishGroupRow, - addDependency, - appendTaskEvent, - blockTask, - claimTask, - completeTask, - createTask, - getBoardByName, - getTask, - listTasks, - listWishSlugs, - moveTask, - recordHeartbeat, - releaseTask, - resolveBoard, - setTaskWish, - toFrozenTaskRow, - unblockTask, -} from './task-state.js'; - -// ============================================================================ -// Read-only DB open (exact validated binding; null on any mismatch) -// ============================================================================ - -export interface ReadonlyDbOpenDependencies { - /** Test seam around the constructor; production always opens Bun SQLite readonly. */ - openDatabase?: (path: string) => Database; - /** Test seam for deterministic moved/unsupported-handle refusal. */ - verifyOpenedHandle?: (db: Database) => boolean; -} - -/** - * Ask SQLite's opened VFS handle whether its pathname still names that handle. - * A nonzero return code, moved flag, or exception (including an unsupported - * VFS) is a fail-closed mismatch. - */ -export function readonlyDatabaseHandleMatchesPath(db: Pick): boolean { - const moved = new Int32Array(1); - try { - const result = db.fileControl(constants.SQLITE_FCNTL_HAS_MOVED, moved); - return result === 0 && moved[0] === 0; - } catch { - return false; - } -} - -function closeReadonlyDb(db: Database | null): void { - try { - db?.close(); - } catch { - // The caller is already failing closed; cleanup must not turn null into a throw. - } -} - -/** - * Open the repo's shared `.genie/genie.db` READ-ONLY. A project MCP caller - * supplies the exact physical binding produced by `resolveProjectContext`; - * legacy callers may supply cwd and receive the same path validation here. - * Symlinks, non-regular files, and identity substitutions return `null`. - * - * The read-only connection is given the SAME `busy_timeout` as the shared write - * primitive (see sqlite-open.ts): under concurrent access a straggling WAL - * writer must be waited out, not surfaced as an instant `-32603 "database is - * locked"`. `busy_timeout` is valid on a readonly connection and does not - * mutate the file. The binding is revalidated both before and after SQLite opens - * it. SQLite's `SQLITE_FCNTL_HAS_MOVED` check additionally binds the opened VFS - * handle itself, catching an A→B constructor race even if the path is restored - * to A before post-open lstat. Unsupported VFS implementations fail closed. - */ -export function openReadonlyDb( - target?: string | ProjectDatabaseBinding, - dependencies: ReadonlyDbOpenDependencies = {}, -): Database | null { - assertLocalLifecycleEnabled(); - const initial = - typeof target === 'object' - ? resolveProjectDatabaseBinding(target.logicalPath, target) - : resolveProjectDatabaseBinding(resolveDbPath(target)); - if (!initial.ok) return null; - let db: Database | null = null; - const verifyOpenedHandle = dependencies.verifyOpenedHandle ?? readonlyDatabaseHandleMatchesPath; - try { - db = - dependencies.openDatabase?.(initial.binding.physicalPath) ?? - new Database(initial.binding.physicalPath, { readonly: true }); - if (!verifyOpenedHandle(db)) { - closeReadonlyDb(db); - return null; - } - const revalidated = resolveProjectDatabaseBinding(initial.binding.logicalPath, initial.binding); - if (!revalidated.ok) { - closeReadonlyDb(db); - return null; - } - if (!verifyOpenedHandle(db)) { - closeReadonlyDb(db); - return null; - } - db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); - return db; - } catch { - closeReadonlyDb(db); - return null; - } -} - -/** - * Read-only open that self-heals an additive-lag schema before failing closed. - * - * Additive columns land within `user_version = 1` and are backfilled only by - * write-path opens (`openDb` → `ensureSchema`), so a database stamped by an - * earlier build stays permanently rejected by `isCurrentGenieDb` for a consumer - * that only ever reads — such as the read-only bridge. When (and only when) a successful - * readonly open validates stale, run the same idempotent write-path open every - * CLI command already performs, then reopen readonly through the full hardened - * binding path. - * - * An ABSENT database never reaches the write open (`openReadonlyDb` returns - * null first), preserving this module's no-create contract. A foreign, future - * (`user_version` ≠ current), or malformed database is refused by `openDb`'s - * typed guards, so everything that is not additive lag still fails closed — - * with one carve-out: when the heal WRITE itself is impossible (read-only - * filesystem) but the schema shape is already current, the database is served - * readonly anyway via {@link isReadableGenieDb}. - */ -export function openReadonlyDbHealingStaleSchema(target?: string | ProjectDatabaseBinding): Database | null { - const db = openReadonlyDb(target); - if (db === null) return null; - let current = false; - try { - current = isCurrentGenieDb(db); - } catch { - closeReadonlyDb(db); - return null; - } - if (current) return db; - closeReadonlyDb(db); - try { - if (typeof target === 'object') { - // The validated readonly handle is closed, so nothing pins the path's - // identity while openDb creates a WRITABLE handle. Revalidate the exact - // binding at the last moment so a racing substitution cannot route - // ensureSchema's DDL into a swapped file or symlink target; the - // post-heal hardened reopen below re-verifies both the binding and the - // opened handle before any tool can observe a healed database. - if (!resolveProjectDatabaseBinding(target.logicalPath, target).ok) return null; - openDb({ path: target.physicalPath }).close(); - } else { - openDb({ path: resolveDbPath(target) }).close(); - } - } catch { - // The write-path heal itself failed — read-only mount, a sandboxed process - // with read-but-not-write access to .genie/genie.db, a CI checkout. When - // the database's SHAPE is already current and only a data-only migration - // marker is pending, every read still works: degrade to serving it instead - // of failing closed on state no read depends on. A shape-stale database - // (missing columns this build queries) stays refused. - return reopenReadableDespiteFailedHeal(target); - } - return openReadonlyDb(target); -} - -/** Post-heal-failure fallback: serve the database readonly iff its shape is current. */ -function reopenReadableDespiteFailedHeal(target?: string | ProjectDatabaseBinding): Database | null { - const db = openReadonlyDb(target); - if (db === null) return null; - try { - if (isReadableGenieDb(db)) return db; - } catch { - // malformed while inspecting — fall through to the close below - } - closeReadonlyDb(db); - return null; -} - -// ============================================================================ -// Write-capable DB open (hardened write path + read-only-degrade fallback) -// ============================================================================ - -/** - * Handles served by the read-only-degrade fallback inside {@link openWriteableDb}. - * Membership is DERIVED FROM WHICH OPEN PRODUCED THE HANDLE — never a latched - * session flag: a handle added here was produced by the readonly healing open - * after the write path failed, so writes through it would raise SQLITE_READONLY. - * The operative write tools (Group 2) consult {@link isDegradedReadonlyDb} to - * return a typed `read_only_database` error instead of a protocol failure. The - * per-open derivation means a repaired filesystem restores writes on the next - * successful write open (fresh handle, no membership) and a later failure - * re-degrades (fresh handle, membership again) — and the server loop re-opens - * per call while its handle is degraded (`isDegradedHandle`), so that recovery - * lands inside a live session instead of waiting for a client restart. - */ -const degradedReadonlyHandles = new WeakSet(); - -/** True when `db` was produced by the read-only-degrade fallback (writes would fail). */ -export function isDegradedReadonlyDb(db: Database | null | undefined): boolean { - return db !== null && db !== undefined && degradedReadonlyHandles.has(db); -} - -/** Open a readonly fallback and mark it so writes stay typed and promotion keeps retrying. */ -export function openDegradedReadonlyDb(target?: string | ProjectDatabaseBinding): Database | null { - const degraded = openReadonlyDbHealingStaleSchema(target); - if (degraded !== null) degradedReadonlyHandles.add(degraded); - return degraded; -} - -/** - * Open the repo's shared `.genie/genie.db` WRITE-CAPABLE through the standard - * hardened CLI path, degrading to the readonly healing open when the write is - * impossible. Retained for its hardened open/degrade contract tests. - * - * 1. WRITE PATH — revalidate the exact `resolveProjectDatabaseBinding` binding - * (Decision 4: writes into a substituted/symlinked db are strictly worse - * than reads), refuse up front when the file is not writable (bun:sqlite - * silently opens a READONLY connection on some platforms instead of - * throwing — a pre-check keeps degrade deterministic everywhere), then - * `openDb` (WAL + `busy_timeout` + idempotent schema — the exact primitive - * every CLI command uses). EVERY throw (`MalformedDbError`, `ForeignDbError`, - * anything) is caught and translated to the loop's `null` contract: the - * injected open never lets an exception escape (the loop calls it outside - * any `try`). Post-open binding + VFS-handle revalidation mirrors the - * readonly path: a mid-open substitution is bounded to the open itself — - * DDL that already ran against a substituted file cannot be undone, but the - * mismatched handle is discarded before any tool can observe or mutate it - * (the same residual as the readonly healing path). - * 2. STALE-READONLY-SIDECAR RECOVERY — a poisoned WAL index (the read-only - * header the degrade fallback's own close leaves in `-shm`) is healed around - * the write open by `openWithWalIndexRecovery` (see {@link tryWriteOpen}). - * The heal is scoped to THIS path — never the fleet-wide `openSqlite` — for - * the reason spelled out in sqlite-open.ts: only here is the poison created, - * and only here is the concurrency low enough for the probe to be safe. A - * poison the heal cannot repair (un-checkpointed frames, or a live peer) - * surfaces as a typed `WalIndexPoisonError` and falls through to (4). - * 3. BUSY CARVE-OUT — when the write-open failure is a `BusyDbError` (the - * write lock was contended past `busy_timeout` + backoff — the db is - * healthy, another process is writing), return `null` WITHOUT the - * read-only-degrade fallback: a fully-writable db that merely lost a lock - * race must not be marked degraded for the session (write tools would - * otherwise report `read_only_database` for a merely-busy db). The loop's - * per-call reopen retries the write open on the next call. - * 4. READ-ONLY-DEGRADE FALLBACK — when the write path still fails - * (write-protected file/filesystem, malformed, foreign), fall back to the - * readonly healing open. The loop's strict `validateReadonlyDb: - * isCurrentGenieDb` adjudicates the fallback handle: exactly the - * fully-current database is served, unchanged from today's behavior. The - * degraded handle is marked in {@link isDegradedReadonlyDb}; the state is - * recomputed per open, never latched. - * - * The no-create guarantee rests on resolver ordering (Decision 5): a non-`ok` - * project context never reaches this open, so `openDb`'s mkdir/create side - * effects are unreachable outside a healthy genie repo — MCP never creates - * `.genie/` or `genie.db`. - */ -export interface WriteableDbOpenDependencies { - /** Test seam around the write-path open; production always uses `genie-db`'s `openDb`. */ - openDatabase?: (path: string) => Database; -} - -/** Result of one write-path attempt: a validated handle, or a null with its cause. */ -interface WriteOpenAttempt { - db: Database | null; - /** True when the open failed on transient write-lock contention (`BusyDbError`). */ - busy: boolean; -} - -export function openWriteableDb( - target?: string | ProjectDatabaseBinding, - dependencies: WriteableDbOpenDependencies = {}, -): Database | null { - const initial = - typeof target === 'object' - ? resolveProjectDatabaseBinding(target.logicalPath, target) - : resolveProjectDatabaseBinding(resolveDbPath(target)); - let db: Database | null = null; - if (initial.ok) { - const attempt = tryWriteOpen(initial.binding, dependencies); - if (attempt.busy) return null; // contended write lock — never degrade a healthy-but-busy db - db = attempt.db; - } - if (db !== null) return db; - return openDegradedReadonlyDb(target); -} - -/** True when the db file itself is writable (bun:sqlite would open it read-write). */ -function isFileWritable(path: string): boolean { - try { - accessSync(path, fsConstants.W_OK); - return true; - } catch { - return false; - } -} - -/** - * One write-path attempt: writability gate → open (under WAL-index recovery) → - * post-open revalidation. - * - * This is the ONE place `openWithWalIndexRecovery` is wired in, because this is - * the one place that CREATES the poison it heals: the degrade fallback below - * hands out a read-only handle, and closing that handle (the loop's - * `promoteDegradedHandle`) is what writes the read-only WAL-index header into - * `-shm`. Scoping the heal here keeps the probe's journal-mode churn out of the - * fleet-wide `openSqlite` hot path, where the virgin-vs-poison header ambiguity - * would fire it on healthy contended databases (see the recovery section in - * sqlite-open.ts). A single-owner MCP session is exactly the low-concurrency - * context the heal is safe in. - */ -function tryWriteOpen(binding: ProjectDatabaseBinding, dependencies: WriteableDbOpenDependencies): WriteOpenAttempt { - if (!isFileWritable(binding.physicalPath)) return { db: null, busy: false }; - const open = (): Database => - dependencies.openDatabase?.(binding.physicalPath) ?? openDb({ path: binding.physicalPath }); - let db: Database | null = null; - try { - db = openWithWalIndexRecovery(binding.physicalPath, open); - // Post-open revalidation: the binding AND the opened VFS handle must still - // name the validated file before any tool can observe or mutate it. This - // bounds a mid-open substitution to the open itself — DDL that already ran - // against a substituted file cannot be undone, but the mismatched handle is - // discarded here, never visible to a tool. - if (!resolveProjectDatabaseBinding(binding.logicalPath, binding).ok || !readonlyDatabaseHandleMatchesPath(db)) { - closeReadonlyDb(db); - return { db: null, busy: false }; - } - return { db, busy: false }; - } catch (err) { - // A contended lock (typed by the shared open, or raw from an injected one) - // is transient: the caller must not degrade the session for it. - closeReadonlyDb(db); - return { db: null, busy: err instanceof BusyDbError || isBusyError(err) }; - } -} - -// ============================================================================ -// Payload shapes -// ============================================================================ - -export interface TaskSummary { - id: string; - title: string; - status: TaskRow['status']; - claimedBy: string | null; - wish: string | null; - group: string | null; -} - -function toSummary(t: TaskRow): TaskSummary { - return { id: t.id, title: t.title, status: t.status, claimedBy: t.claimedBy, wish: t.wish, group: t.group }; -} - -interface StatusCounts { - blocked: number; - ready: number; - in_progress: number; - done: number; - total: number; -} - -function tally(tasks: TaskRow[]): StatusCounts { - const counts: StatusCounts = { blocked: 0, ready: 0, in_progress: 0, done: 0, total: 0 }; - for (const t of tasks) { - counts[t.status]++; - counts.total++; - } - return counts; -} - -// ============================================================================ -// Git branch resolution (for genie_worktree_context) -// ============================================================================ - -/** Current git branch of `cwd`, or `null` when unavailable (detached / not a repo). */ -function currentBranch(cwd: string): string | null { - try { - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - return branch && branch !== 'HEAD' ? branch : null; - } catch { - return null; - } -} - -/** - * `wish/[-]` disambiguation lives in the shared - * {@link resolveWishBranch} module — same implementation the SessionStart hook - * bundles (see resolve-wish-branch.ts). The board supplies genie.db slugs, - * longest-first; the hook supplies its own merged slug list. - */ - -// ============================================================================ -// Tool context + registry -// ============================================================================ - -export interface ToolContext { - /** Database handle for this call (write-capable or degraded-readonly), or `null` when absent. */ - db: Database | null; - /** Working directory for git branch resolution. Defaults to `process.cwd()`. */ - cwd: string; - /** - * The fail-closed project context resolved by the server loop when a resolver - * is injected. When its `kind` is not `ok`, the loop returns a typed error for - * every tool call instead of an empty board (see mcp-server.ts). Absent for - * consumers (e.g. ui-bridge) that do not opt into fail-closed resolution. - */ - context?: ProjectContext; -} - -export interface McpTool { - name: string; - description: string; - inputSchema: Record; - handler(ctx: ToolContext, args: Record): unknown; -} - -function argString(args: Record, key: string): string | undefined { - const v = args[key]; - return typeof v === 'string' && v.length > 0 ? v : undefined; -} - -// --- genie_board ----------------------------------------------------------- - -interface BoardPayload { - board: string | null; - counts: StatusCounts; - tasks: TaskSummary[]; -} - -function genieBoard(ctx: ToolContext, args: Record): BoardPayload { - const emptyCounts: StatusCounts = { blocked: 0, ready: 0, in_progress: 0, done: 0, total: 0 }; - const boardArg = argString(args, 'board'); - const wishArg = argString(args, 'wish'); - if (!ctx.db) return { board: boardArg ?? null, counts: emptyCounts, tasks: [] }; - - const filter: TaskFilter = {}; - let boardName: string | null = null; - if (boardArg) { - const board = getBoardByName(ctx.db, boardArg); - // Unknown board name → empty projection (never throws at caller). - if (!board) return { board: boardArg, counts: emptyCounts, tasks: [] }; - boardName = board.name; - filter.boardId = board.id; - } - if (wishArg) filter.wish = wishArg; - - const tasks = listTasks(ctx.db, filter); - return { board: boardName, counts: tally(tasks), tasks: tasks.map(toSummary) }; -} - -// --- genie_wish_status ----------------------------------------------------- - -interface WishStatusPayload { - wish: string; - groups: Array>; - tasks: TaskSummary[]; -} - -function genieWishStatus(ctx: ToolContext, args: Record): WishStatusPayload { - const wish = argString(args, 'wish') ?? ''; - if (!ctx.db) return { wish, groups: [], tasks: [] }; - const groups: WishStatusPayload['groups'] = []; // wish-group machinery is production-dead — literal empty - const tasks = listTasks(ctx.db, { wish }); - return { wish, groups, tasks: tasks.map(toSummary) }; -} - -// --- genie_worktree_context ------------------------------------------------ - -interface WorktreeContextPayload { - branch: string | null; - resolved: boolean; - wish: string | null; - group: string | null; - tasks: TaskSummary[]; -} - -function genieWorktreeContext(ctx: ToolContext, args: Record): WorktreeContextPayload { - const branch = argString(args, 'branch') ?? currentBranch(ctx.cwd); - const parsed = branch ? resolveWishBranch(ctx.db ? listWishSlugs(ctx.db) : [], branch) : null; - - if (parsed) { - const wishTasks = ctx.db ? listTasks(ctx.db, { wish: parsed.wish }) : []; - // Top-level wish branch (no group) → all of the wish's tasks; a group branch → just that group. - const tasks = parsed.group === null ? wishTasks : wishTasks.filter((t) => t.group === parsed.group); - return { branch, resolved: true, wish: parsed.wish, group: parsed.group, tasks: tasks.map(toSummary) }; - } - - // Non-wish branch (or none) → repo-board fallback: all tasks, unresolved. - const tasks = ctx.db ? listTasks(ctx.db, {}) : []; - return { branch, resolved: false, wish: null, group: null, tasks: tasks.map(toSummary) }; -} - -// --- genie_task ------------------------------------------------------------ - -// The frozen `genie_task` payload is exactly the pre-assignment TaskRow key -// set (WISH Decision 7) — the shared projection lives in task-state.ts next -// to TaskRow, so `task list --json` and this MCP shape cannot drift apart. - -function genieTask( - ctx: ToolContext, - args: Record, -): FrozenTaskRow | { error: 'not_found'; id: string } { - const id = argString(args, 'id') ?? ''; - const task = ctx.db ? getTask(ctx.db, id) : null; - return task ? toFrozenTaskRow(task) : { error: 'not_found', id }; -} - -// --- genie_active ---------------------------------------------------------- - -interface ActiveTask extends TaskSummary { - claimedAt: number | null; -} - -function genieActive(ctx: ToolContext): { tasks: ActiveTask[] } { - if (!ctx.db) return { tasks: [] }; - const tasks = listTasks(ctx.db, { status: 'in_progress' }); - return { tasks: tasks.map((t) => ({ ...toSummary(t), claimedAt: t.claimedAt })) }; -} - -// ============================================================================ -// The 5 read tools (the read registry ui-bridge splices) -// ============================================================================ - -export const MCP_TOOLS: McpTool[] = [ - { - name: 'genie_board', - description: 'Board status counts and tasks; optional board name and wish-slug filters.', - inputSchema: { - type: 'object', - properties: { - board: { type: 'string', description: 'board name; default repo board' }, - wish: { type: 'string', description: 'filter to a wish slug' }, - }, - required: [], - }, - handler: genieBoard, - }, - { - name: 'genie_wish_status', - description: "A wish's execution groups (DAG progress) and its tasks.", - inputSchema: { - type: 'object', - properties: { wish: { type: 'string', description: 'wish slug' } }, - required: ['wish'], - }, - handler: genieWishStatus, - }, - { - name: 'genie_worktree_context', - description: - "Resolve a wish/- git branch to its wish, group, and tasks (the pane's 'what am I here for').", - inputSchema: { - type: 'object', - properties: { branch: { type: 'string', description: 'override; default = current git branch' } }, - required: [], - }, - handler: genieWorktreeContext, - }, - { - name: 'genie_task', - description: 'Full detail for a single task by id.', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'task id, e.g. t_...' } }, - required: ['id'], - }, - handler: genieTask, - }, - { - name: 'genie_active', - description: 'All in-progress tasks and who claimed each.', - inputSchema: { type: 'object', properties: {}, required: [] }, - handler: (ctx) => genieActive(ctx), - }, -]; - -// ============================================================================ -// Operative write tools (Group 2 of wish mcp-write-tools) -// ============================================================================ -// -// The 12 operative-core mutation verbs as thin wrappers over the EXACT -// `task-state.ts` functions `genie task` uses (Decision 3: never reimplement -// the mutation semantics). Each tool: -// 1. refuses a null/degraded handle up front (typed `read_only_database`); -// 2. dispatches to the same function + argument shapes as v5-task.ts; -// 3. wraps the mutation in ONE catch boundary mapping the task-state domain -// error classes to typed `isError: true` payload codes. -// -// Error payload codes (documented per tool in `description`): -// claim_conflict — CheckoutConflictError (lost the claim race) -// not_found — UnknownTaskError / UnknownBoardError (board ref in `detail`) -// invalid_lane — LaneError -// dependency_cycle — CycleError -// refused_transition— TaskBlockedError / TaskNotReadyError / -// TaskCompleteError / TaskReleaseError (class name in `detail`) -// read_only_database— degraded readonly handle (checked first) OR a raw -// SQLite readonly-class write failure past that check -// invalid_arguments — missing/malformed tool arguments -// database_unavailable — defensive null-handle guard (unreachable in the -// fail-closed server, which refuses to dispatch on null) -// Unexpected errors are NOT mapped — they propagate to the loop's `-32603` -// backstop. - -/** A mapped write-tool error payload: `error` code plus per-code fields. */ -type WriteErrorPayload = Record & { error: string }; - -/** - * True for raw SQLite readonly-class write failures (`SQLITE_READONLY` and its - * extended variants) — the belt-and-suspenders backstop for a write that - * reaches a degraded readonly handle past the {@link isDegradedReadonlyDb} - * check. bun surfaces these as `SQLiteError` with `code: 'SQLITE_READONLY'` - * and `errno: 8` (the primary SQLite result code; extended codes keep it in - * the low byte). - */ -function isSqliteReadonlyError(err: Error): boolean { - const errno = (err as { errno?: unknown }).errno; - if (typeof errno === 'number' && (errno & 0xff) === 8) return true; - return /SQLITE_READONLY|readonly database/i.test(err.message); -} - -/** Map a thrown task-state domain error to a typed payload, or null to rethrow. */ -function mapWriteError(err: unknown): WriteErrorPayload | null { - if (err instanceof CheckoutConflictError) { - return { error: 'claim_conflict', taskId: err.taskId, message: err.message }; - } - if (err instanceof UnknownTaskError) { - return { error: 'not_found', id: err.id, message: err.message }; - } - if (err instanceof UnknownBoardError) { - return { error: 'not_found', detail: err.ref, message: err.message }; - } - if (err instanceof LaneError) { - return { error: 'invalid_lane', message: err.message }; - } - if (err instanceof CycleError) { - return { error: 'dependency_cycle', message: err.message }; - } - if ( - err instanceof TaskBlockedError || - err instanceof TaskNotReadyError || - err instanceof TaskCompleteError || - err instanceof TaskReleaseError - ) { - return { error: 'refused_transition', detail: err.constructor.name, message: err.message }; - } - if (err instanceof Error && isSqliteReadonlyError(err)) { - return { error: 'read_only_database', detail: err.message }; - } - return null; // unexpected — propagate to the loop's -32603 backstop -} - -/** ONE catch boundary per tool: run the mutation, mapping domain errors. */ -function runWrite(mutation: () => T): T | ToolErrorResult { - try { - return mutation(); - } catch (err) { - const mapped = mapWriteError(err); - if (mapped !== null) return toolError(mapped); - throw err; - } -} - -/** - * Shared write-tool guard: a null or degraded-readonly handle is a typed error, - * checked FIRST so no mutation reaches a handle that cannot write. - */ -function requireWriteHandle(ctx: ToolContext): Database | ToolErrorResult { - if (!ctx.db) { - // Unreachable in the fail-closed server (the loop refuses to dispatch with - // a null handle) — defensive for any future legacy consumer. - return toolError({ error: 'database_unavailable', detail: 'no database handle' }); - } - if (isDegradedReadonlyDb(ctx.db)) { - return toolError({ - error: 'read_only_database', - detail: 'the database is served read-only (write path unavailable); restore write access and retry', - }); - } - return ctx.db; -} - -// ============================================================================ -// Identity (the shared resolvers in identity.ts — Decision 8) -// ============================================================================ - -/** - * Per-call `author` arg wins; the server-process env identity resolved by the - * shared {@link resolveEventAuthor} is the fallback (Decision 8), so multiple - * agents on one long-lived server attribute correctly. - */ -function resolveAuthor(args: Record): EventAuthor { - const explicit = argString(args, 'author'); - return explicit === undefined ? resolveEventAuthor() : { author: explicit, authorKind: null }; -} - -/** Require a non-empty string arg, returning a typed error payload when absent. */ -function requireArg(args: Record, key: string): string | ToolErrorResult { - const value = argString(args, key); - return value === undefined ? toolError({ error: 'invalid_arguments', detail: `${key} is required.` }) : value; -} - -// --- genie_task_create ------------------------------------------------------ - -function genieTaskCreate(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const title = argString(args, 'title')?.trim(); - if (!title) return toolError({ error: 'invalid_arguments', detail: 'title is required and must not be empty.' }); - const wish = argString(args, 'wish'); - const group = argString(args, 'group'); - if (group && !wish) return toolError({ error: 'invalid_arguments', detail: 'group requires wish.' }); - const board = argString(args, 'board'); - return runWrite(() => { - const boardId = board ? resolveBoard(db, board).id : undefined; - const task = createTask(db, { title, boardId, wish, group }); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_checkout ---------------------------------------------------- - -function genieTaskCheckout(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const worker = requireArg(args, 'worker'); - if (isToolError(worker)) return worker; - return runWrite(() => { - const task = claimTask(db, id, worker, { author: resolveAuthor(args) }); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_done -------------------------------------------------------- - -function genieTaskDone(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - return runWrite(() => { - // completeTask runs recomputeReady inside its winning transaction — the - // same ready-set recompute the CLI's `task done` triggers. - const task = completeTask(db, id, resolveAuthor(args)); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_move -------------------------------------------------------- - -function genieTaskMove(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const to = requireArg(args, 'to'); - if (isToolError(to)) return to; - return runWrite(() => { - const result = moveTask(db, id, to, resolveAuthor(args)); - return { task: toSummary(result.task), from: result.from, to: result.to }; - }); -} - -// --- genie_task_block ------------------------------------------------------- - -function genieTaskBlock(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const reason = argString(args, 'reason')?.trim(); - if (!reason) return toolError({ error: 'invalid_arguments', detail: 'reason is required and must not be empty.' }); - const kind: BlockKind = args.hold === true ? 'hold' : 'work'; - return runWrite(() => { - const task = blockTask(db, id, reason, resolveAuthor(args), kind); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_unblock ----------------------------------------------------- - -function genieTaskUnblock(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - return runWrite(() => { - const task = unblockTask(db, id, resolveAuthor(args)); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_release ----------------------------------------------------- - -function genieTaskRelease(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - return runWrite(() => { - const task = releaseTask(db, id, resolveAuthor(args)); - return { task: toSummary(task) }; - }); -} - -// --- genie_task_comment / genie_task_report --------------------------------- - -function appendNoteEvent(ctx: ToolContext, args: Record, kind: 'comment' | 'report'): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const text = argString(args, 'text')?.trim(); - if (!text) return toolError({ error: 'invalid_arguments', detail: 'text is required and must not be empty.' }); - return runWrite(() => { - if (!getTask(db, id)) throw new UnknownTaskError(id); - const author = resolveAuthor(args); - const event = appendTaskEvent(db, id, { - kind, - note: text, - authorKind: author.authorKind ?? undefined, - author: author.author ?? undefined, - }); - return { taskId: id, event }; - }); -} - -function genieTaskComment(ctx: ToolContext, args: Record): unknown { - return appendNoteEvent(ctx, args, 'comment'); -} - -function genieTaskReport(ctx: ToolContext, args: Record): unknown { - return appendNoteEvent(ctx, args, 'report'); -} - -// --- genie_task_heartbeat --------------------------------------------------- - -function genieTaskHeartbeat(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - return runWrite(() => { - if (!getTask(db, id)) throw new UnknownTaskError(id); - const heartbeatAt = recordHeartbeat(db, id); - return { taskId: id, heartbeatAt }; - }); -} - -// --- genie_task_set_wish ---------------------------------------------------- - -function genieTaskSetWish(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const wish = argString(args, 'wish')?.trim() || undefined; - const group = argString(args, 'group')?.trim() || undefined; - const clear = args.clear === true; - // A SUPPLIED but empty/blank group fails loudly, exactly as `task set-wish - // --group ''` does — silently dropping it would attach the card to the wish - // with no group and report success (CLI parity, v5-task.ts handleSetWish). - if (args.group !== undefined && !group) { - return toolError({ error: 'invalid_arguments', detail: 'group must not be empty.' }); - } - if (group && !wish) return toolError({ error: 'invalid_arguments', detail: 'group requires wish.' }); - if (clear && wish) return toolError({ error: 'invalid_arguments', detail: 'clear cannot be combined with wish.' }); - if (!clear && !wish) return toolError({ error: 'invalid_arguments', detail: 'wish or clear is required.' }); - return runWrite(() => { - const to = { wish: wish ?? null, group: group ?? null }; - const result = setTaskWish(db, id, to, resolveAuthor(args)); - return { task: toSummary(result.task), from: result.from, to: result.to }; - }); -} - -// --- genie_task_add_dependency ---------------------------------------------- - -function genieTaskAddDependency(ctx: ToolContext, args: Record): unknown { - const db = requireWriteHandle(ctx); - if (isToolError(db)) return db; - const id = requireArg(args, 'id'); - if (isToolError(id)) return id; - const dependsOn = requireArg(args, 'depends_on'); - if (isToolError(dependsOn)) return dependsOn; - return runWrite(() => { - addDependency(db, id, dependsOn); - return { taskId: id, dependsOnId: dependsOn }; - }); -} - -// ============================================================================ -// The 12 operative write tools -// ============================================================================ - -export const MCP_WRITE_TOOLS: McpTool[] = [ - { - name: 'genie_task_create', - description: - 'Create a task. Errors: invalid_arguments (missing title, or group without wish), not_found (unknown board), read_only_database (db served read-only), database_unavailable.', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'task title (required, non-empty)' }, - board: { type: 'string', description: 'board id or name; default repo board' }, - wish: { type: 'string', description: 'wish slug this task belongs to' }, - group: { type: 'string', description: 'wish-group name (requires wish)' }, - }, - required: ['title'], - }, - handler: genieTaskCreate, - }, - { - name: 'genie_task_checkout', - description: - "Atomically claim a ready task for a worker (the CLI's task checkout). Errors: claim_conflict (already claimed or not ready), refused_transition (enforced block), not_found (unknown id), invalid_arguments (missing id/worker), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - worker: { type: 'string', description: 'worker identity recorded in claimed_by (required)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id', 'worker'], - }, - handler: genieTaskCheckout, - }, - { - name: 'genie_task_done', - description: - "Mark a task done and recompute the ready set (the CLI's task done). Errors: not_found (unknown id), refused_transition (blocked or already-done task), invalid_arguments (missing id), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id'], - }, - handler: genieTaskDone, - }, - { - name: 'genie_task_move', - description: - "Move a card to a lane defined by its board (the CLI's task move). Errors: not_found (unknown id), invalid_lane (no lanes or unknown target lane), invalid_arguments (missing id/to), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - to: { type: 'string', description: 'target lane name (required)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id', 'to'], - }, - handler: genieTaskMove, - }, - { - name: 'genie_task_block', - description: - "Place an enforced block on a card, refusing checkout until cleared (the CLI's task block). Errors: not_found (unknown id), invalid_arguments (missing id/reason), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - reason: { type: 'string', description: 'why the card is blocked (required, non-empty)' }, - hold: { type: 'boolean', description: 'record as a deliberate hold (parked) rather than a work problem' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id', 'reason'], - }, - handler: genieTaskBlock, - }, - { - name: 'genie_task_unblock', - description: - "Clear an enforced block from a card (the CLI's task unblock). Errors: not_found (unknown id), invalid_arguments (missing id), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id'], - }, - handler: genieTaskUnblock, - }, - { - name: 'genie_task_release', - description: - "Release a claim, returning an in-progress card to the ready queue (the CLI's task release). Errors: not_found (unknown id), refused_transition (not in_progress), invalid_arguments (missing id), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id'], - }, - handler: genieTaskRelease, - }, - { - name: 'genie_task_comment', - description: - "Append an authored comment to the card timeline (the CLI's task comment). Errors: not_found (unknown id), invalid_arguments (missing id/text), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - text: { type: 'string', description: 'comment text (required, non-empty)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id', 'text'], - }, - handler: genieTaskComment, - }, - { - name: 'genie_task_report', - description: - "Append an authored worker report to the card timeline (the CLI's task report). Errors: not_found (unknown id), invalid_arguments (missing id/text), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - text: { type: 'string', description: 'report text (required, non-empty)' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id', 'text'], - }, - handler: genieTaskReport, - }, - { - name: 'genie_task_heartbeat', - description: - "Record a liveness heartbeat for a claimed card (the CLI's task heartbeat). Errors: not_found (unknown id), invalid_arguments (missing id), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - }, - required: ['id'], - }, - handler: genieTaskHeartbeat, - }, - { - name: 'genie_task_set_wish', - description: - "Attach, re-point, or clear the wish identity on a card (the CLI's task set-wish). Errors: not_found (unknown id), invalid_arguments (missing id, empty group, group without wish, clear with wish, or neither wish nor clear), read_only_database, database_unavailable.", - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - wish: { type: 'string', description: 'wish slug to attach the card to' }, - group: { type: 'string', description: 'wish-group name (requires wish)' }, - clear: { type: 'boolean', description: 'remove the wish and group from the card' }, - author: { type: 'string', description: 'event attribution; defaults to the server env identity' }, - }, - required: ['id'], - }, - handler: genieTaskSetWish, - }, - { - name: 'genie_task_add_dependency', - description: - 'Insert a dependency edge taskId depends_on dependsOnId, rejecting self-deps and cycles (the task-state addDependency the CLI uses at create). Errors: not_found (unknown task), dependency_cycle (self or transitive cycle), invalid_arguments (missing id/depends_on), read_only_database, database_unavailable.', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'task id, e.g. t_... (required)' }, - depends_on: { type: 'string', description: 'id of the task this task depends on (required)' }, - }, - required: ['id', 'depends_on'], - }, - handler: genieTaskAddDependency, - }, -]; diff --git a/src/lib/v5/resolve-wish-branch.test.ts b/src/lib/v5/resolve-wish-branch.test.ts index 71c4578f4..fb01dd970 100644 --- a/src/lib/v5/resolve-wish-branch.test.ts +++ b/src/lib/v5/resolve-wish-branch.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { resolveWishBranch } from './resolve-wish-branch.js'; -// Longest-first ordering is part of the contract (listWishSlugs provides it). +// Longest-first ordering is part of the contract (the caller provides it). const KNOWN = ['genie-mcp', 'genie', 'hooks-v2']; describe('resolveWishBranch — the one shared wish-branch resolver', () => { diff --git a/src/lib/v5/resolve-wish-branch.ts b/src/lib/v5/resolve-wish-branch.ts index ed82f7ffe..fda1ea6f1 100644 --- a/src/lib/v5/resolve-wish-branch.ts +++ b/src/lib/v5/resolve-wish-branch.ts @@ -1,9 +1,8 @@ /** * Shared `wish/[-]` branch resolution — the ONE implementation - * every consumer uses. The board (`mcp-tools.ts`) resolves against genie.db - * slugs; the SessionStart hook resolves against the same list read from - * genie.db, falling back to the wish-file scan's slugs when the db is - * unreadable. A second implementation is the drift class hooks-v2 exists to + * every consumer uses. Callers resolve against genie.db slugs; the SessionStart + * hook resolves against the same list read from genie.db, falling back to the + * wish-file scan's slugs when the db is unreadable. A second implementation is the drift class hooks-v2 exists to * kill, so this module is PURE: caller-supplied slugs, no sqlite import, no * filesystem, no environment — the esbuild hook bundle inlines it without ever * touching `bun:sqlite` (the hook runs as `node …/session-context.cjs`, where @@ -26,7 +25,7 @@ export interface ResolvedWishBranch { * 3. no known wish (brand-new branch) → last-dash heuristic, else whole rest. * Returns `null` only when the branch is not a `wish/…` branch. * - * The caller supplies `knownSlugs` longest-first (`listWishSlugs` orders it; the + * The caller supplies `knownSlugs` longest-first (the * hook re-sorts its merged db/file slug list). There is no * verified-launch-worktree step: wish-group rows are production-dead (no * writer), so a `-` branch can never be confirmed against a live diff --git a/src/lib/v5/sqlite-open.test.ts b/src/lib/v5/sqlite-open.test.ts index 463b64f3a..abe11c395 100644 --- a/src/lib/v5/sqlite-open.test.ts +++ b/src/lib/v5/sqlite-open.test.ts @@ -3,11 +3,11 @@ * poisoned-WAL-index recovery helper it deliberately does NOT call. * * `openSqlite` is the fleet hot path (`genie task`, `task sync`, the git-hook - * sync, the global omni database, MCP reads) and stays churn-free; the recovery - * is opt-in and wired only into the MCP write open, the path that creates the - * poison. These tests drive the helper directly — predicates, retry contract, - * and the live-peer skips that keep it from touching a contended database. The - * real end-to-end poison is exercised where it is scoped, in mcp-tools.test.ts; + * sync, the global omni database) and stays churn-free; the recovery is opt-in + * and was wired only into the retired MCP write open, the path that created the + * poison. No shipped caller remains, so these tests are the whole contract: + * they drive the helper directly — predicates, retry contract, and the + * live-peer skips that keep it from touching a contended database. * genie-db.test.ts pins that the shared path does not heal it. */ diff --git a/src/lib/v5/sqlite-open.ts b/src/lib/v5/sqlite-open.ts index c893942b9..51443aaa6 100644 --- a/src/lib/v5/sqlite-open.ts +++ b/src/lib/v5/sqlite-open.ts @@ -225,10 +225,13 @@ function openInitialized(opts: OpenSqliteOptions): Database { // plain WAL open/close never performs; run from every process of a live fleet // (`genie task` workers, omni-queue resolvers, git-hook sync) it drove bun's // Linux shm handling into SIGBUS. Recovery therefore belongs to the ONE -// component that CREATES the poison: the MCP server's degraded read-only -// session, whose close writes the header (`openWriteableDb` in mcp-tools.ts, -// re-opened by `promoteDegradedHandle` in mcp-server.ts). That path is -// single-owner and low-concurrency — the fleet never runs the probe. +// component that CREATES the poison: a degraded read-only session whose close +// writes the header. That component was the retired MCP/ui-bridge server pair, +// so today NO shipped path creates the poison and NO shipped path calls the +// recovery below (see {@link openWithWalIndexRecovery}). The scoping contract is +// retained verbatim because it is the reason the fleet primitive must stay +// churn-free: any future single-owner, low-concurrency writer that reintroduces +// a degraded read-only session opts in here and nowhere else. // // "Single-owner" is an assumption about the CALLER, and one MCP server per // database does honour it. Two MCP servers on one `.genie/genie.db` do not: both @@ -473,8 +476,11 @@ function releaseRecoveryLock(lockPath: string | null): void { * from scratch rather than assuming anything: by then the previous holder has * usually healed the database, and the ordinary healthy path just proceeds. * - * CALLERS: the MCP write path only (`tryWriteOpen` in mcp-tools.ts). See the - * section header above for why this must never wrap the fleet-wide + * CALLERS: none shipped. The sole caller was the MCP write path (`tryWriteOpen` + * in `mcp-tools.ts`), retired together with `genie mcp` and `genie ui-bridge`; + * with it went the only component that creates the poison. The helper and its + * tests are retained as the opt-in seam for a future single-owner writer. See + * the section header above for why this must never wrap the fleet-wide * {@link openSqlite}. * * Never runs for a contended database. Two independent guards enforce that: a diff --git a/src/lib/v5/task-state.ts b/src/lib/v5/task-state.ts index 5d5fa6a4a..a036d1a9f 100644 --- a/src/lib/v5/task-state.ts +++ b/src/lib/v5/task-state.ts @@ -243,16 +243,6 @@ export const DEFAULT_STALE_MS = 15 * 60 * 1000; export type WishGroupStatus = 'blocked' | 'ready' | 'in_progress' | 'done'; -export interface WishGroupRow { - wish: string; - name: string; - status: WishGroupStatus; - dependsOn: string[]; - assignee: string | null; - startedAt: number | null; - completedAt: number | null; -} - /** A single hire-roster entry: one agent adapter hired into one wish. */ export interface HireRosterRow { /** Wish slug this hire belongs to. */ @@ -1498,27 +1488,11 @@ export function linkTaskToWish( } // ============================================================================ - -/** - * Distinct known wish slugs (from tasks + wish_groups), longest first. Used to - * disambiguate a `wish/-` branch when the slug itself contains - * hyphens (`genie-mcp` vs a `genie` wish with an `mcp` group). - */ -export function listWishSlugs(db: Database): string[] { - const rows = db - .query( - // UNION already de-duplicates; order longest-first for prefix disambiguation. - `SELECT wish FROM ( - SELECT wish FROM tasks WHERE wish IS NOT NULL - UNION SELECT wish FROM wish_groups WHERE wish IS NOT NULL - ) ORDER BY LENGTH(wish) DESC`, - ) - .all() as Array<{ wish: string }>; - return rows.map((r) => r.wish); -} - -// ============================================================================ -// Hire roster (single-row upsert / delete — the bridge's write surface) +// Hire roster (single-row upsert / delete) +// +// The retired `genie ui-bridge` was this surface's only shipped writer; the rows +// remain part of exported/imported board state (roadmap sync excludes them as +// machine-local), so the accessors stay. // ============================================================================ interface RawHire { diff --git a/src/term-commands/ui-bridge.test.ts b/src/term-commands/ui-bridge.test.ts index a2f43588f..82ca33be0 100644 --- a/src/term-commands/ui-bridge.test.ts +++ b/src/term-commands/ui-bridge.test.ts @@ -1,508 +1,48 @@ -/** - * genie ui-bridge — CLI-level tests. Drives the real `genie.ts ui-bridge` stdio - * server as a subprocess (as the dash-fork UI would), speaking newline-delimited - * JSON-RPC 2.0 against throwaway git-repo fixtures seeded via the real v5 state - * layer. Also drives `genie mcp` to prove the plumbing extraction left it - * unchanged (regression: zero write tools + pinned initialize response). - * - * Timing tests are deadline-based with generous margins (≤1s push, ≤2s - * shutdown) to stay flake-free in CI. - */ - -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { openDb } from '../lib/v5/genie-db.js'; -import { claimTask, createBoard, createTask, exportState, getHire, listHires } from '../lib/v5/task-state.js'; const GENIE = join(import.meta.dir, '..', 'genie.ts'); +const RETIRED_DIAGNOSTIC = + 'Error: genie ui-bridge has been retired; the Orca integration is the supported UI surface, or roll back to a pre-retirement signed release.\n'; -// ============================================================================ -// Fixtures -// ============================================================================ - -let repo: string; -let genieHome: string; - -function git(cwd: string, ...args: string[]): void { - execFileSync('git', args, { - cwd, - stdio: 'ignore', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'Test', - GIT_AUTHOR_EMAIL: 'test@example.com', - GIT_COMMITTER_NAME: 'Test', - GIT_COMMITTER_EMAIL: 'test@example.com', - }, - }); -} - -beforeEach(() => { - repo = mkdtempSync(join(tmpdir(), 'genie-bridge-')); - genieHome = mkdtempSync(join(tmpdir(), 'genie-home-')); - git(repo, 'init', '-b', 'main'); - git(repo, 'commit', '--allow-empty', '-m', 'init'); -}); - -afterEach(() => { - rmSync(repo, { recursive: true, force: true }); - rmSync(genieHome, { recursive: true, force: true }); -}); - -function bridgeEnv(): Record { - return { ...process.env, NO_COLOR: '1', GENIE_TEST_SKIP_PGSERVE: '1', GENIE_HOME: genieHome } as Record< - string, - string - >; -} - -// ============================================================================ -// JSON-RPC shapes + drivers -// ============================================================================ - -interface RpcMessage { - jsonrpc: '2.0'; - id?: number | string | null; - method?: string; - params?: Record; - result?: Record; - error?: { code: number; message: string; data?: unknown }; -} - -/** One-shot driver: write all requests, close stdin, collect every output line. */ -async function driveOnce(cmd: string, cwd: string, requests: Record[]): Promise { - const proc = Bun.spawn(['bun', GENIE, cmd], { - cwd, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - env: bridgeEnv(), - }); - proc.stdin.write(`${requests.map((r) => JSON.stringify(r)).join('\n')}\n`); - await proc.stdin.end(); - const stdout = await new Response(proc.stdout).text(); - await proc.exited; - return parseLines(stdout); -} - -function parseLines(stdout: string): RpcMessage[] { - return stdout - .split('\n') - .filter((l) => l.trim().length > 0) - .map((l) => JSON.parse(l) as RpcMessage); -} - -/** Streaming client for long-lived interactions (push, shutdown, sockets). */ -class BridgeClient { - readonly proc: Bun.Subprocess<'pipe', 'pipe', 'pipe'>; - readonly messages: RpcMessage[] = []; - private buf = ''; - - constructor(cwd: string) { - this.proc = Bun.spawn(['bun', GENIE, 'ui-bridge'], { - cwd, - stdin: 'pipe', +describe('genie ui-bridge retirement', () => { + test('returns the stable non-zero retirement diagnostic without speaking the bridge protocol', () => { + const result = Bun.spawnSync(['bun', GENIE, 'ui-bridge'], { + stdin: Buffer.from('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'), stdout: 'pipe', stderr: 'pipe', - env: bridgeEnv(), - }); - void this.pump(); - } - - private async pump(): Promise { - const reader = this.proc.stdout.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { value, done } = await reader.read(); - if (done) break; - this.buf += decoder.decode(value, { stream: true }); - let nl = this.buf.indexOf('\n'); - while (nl !== -1) { - const line = this.buf.slice(0, nl).trim(); - this.buf = this.buf.slice(nl + 1); - if (line.length > 0) this.messages.push(JSON.parse(line) as RpcMessage); - nl = this.buf.indexOf('\n'); - } - } - } - - send(obj: Record): void { - this.proc.stdin.write(`${JSON.stringify(obj)}\n`); - } - - async waitFor(pred: (m: RpcMessage) => boolean, timeoutMs = 4000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const hit = this.messages.find(pred); - if (hit) return hit; - await new Promise((r) => setTimeout(r, 5)); - } - return this.messages.find(pred) ?? null; - } - - async endStdin(): Promise { - await this.proc.stdin.end(); - } - - kill(): void { - try { - this.proc.kill(); - } catch { - /* already gone */ - } - } -} - -const INIT_OK = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05', bridgeProtocolVersion: '1.0', clientInfo: { name: 't', version: '0' } }, -}; - -function toolPayload(res: RpcMessage): T { - const content = (res.result?.content as Array<{ type: string; text: string }>) ?? []; - return JSON.parse(content[0].text) as T; -} - -function call(id: number, name: string, args: Record = {}): Record { - return { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }; -} - -function seed(cwd: string): { taskIds: string[]; boardName: string } { - const db = openDb({ cwd }); - const board = createBoard(db, 'repo'); - const t1 = createTask(db, { title: 'alpha g1', boardId: board.id, wish: 'w-alpha', group: 'g1' }); - const t2 = createTask(db, { - title: 'alpha g2', - boardId: board.id, - wish: 'w-alpha', - group: 'g2', - dependsOn: [t1.id], - }); - const t3 = createTask(db, { title: 'loose', boardId: board.id }); - claimTask(db, t1.id, 'worker-1'); // t1 → in_progress - db.close(); - return { taskIds: [t1.id, t2.id, t3.id], boardName: 'repo' }; -} - -// ============================================================================ -// Success Criterion 1 — handshake (happy + incompatible) -// ============================================================================ - -describe('ui-bridge handshake', () => { - test('reports bridge protocol version + genie version on a compatible client', async () => { - const [res] = await driveOnce('ui-bridge', repo, [INIT_OK]); - expect(res.id).toBe(1); - expect(res.result?.bridgeProtocolVersion).toBe('1.0'); - expect(typeof res.result?.genieVersion).toBe('string'); - expect(res.result?.protocolVersion).toBe('2024-11-05'); - const serverInfo = res.result?.serverInfo as { name: string; version: string }; - expect(serverInfo.name).toBe('genie-ui-bridge'); - expect(res.error).toBeUndefined(); - }); - - test('an incompatible declared version gets a structured error, not silence', async () => { - const [res] = await driveOnce('ui-bridge', repo, [ - { jsonrpc: '2.0', id: 1, method: 'initialize', params: { bridgeProtocolVersion: '2.0' } }, - ]); - expect(res.result).toBeUndefined(); - expect(res.error?.code).toBe(-32001); - const data = res.error?.data as { serverBridgeProtocolVersion: string; clientBridgeProtocolVersion: string }; - expect(data.serverBridgeProtocolVersion).toBe('1.0'); - expect(data.clientBridgeProtocolVersion).toBe('2.0'); - }); - - test('a client declaring no bridge version is accepted (best-effort)', async () => { - const [res] = await driveOnce('ui-bridge', repo, [ - { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }, - ]); - expect(res.error).toBeUndefined(); - expect(res.result?.bridgeProtocolVersion).toBe('1.0'); - }); - - test('exposes the 5 read tools plus the 2 roster write tools; roster_hire requires worktree', async () => { - const responses = await driveOnce('ui-bridge', repo, [INIT_OK, { jsonrpc: '2.0', id: 2, method: 'tools/list' }]); - const list = responses.find((r) => r.id === 2); - const tools = list?.result?.tools as Array<{ name: string; inputSchema: { required?: string[] } }>; - expect(tools.map((t) => t.name).sort()).toEqual([ - 'genie_active', - 'genie_board', - 'genie_task', - 'genie_wish_status', - 'genie_worktree_context', - 'roster_hire', - 'roster_unhire', - ]); - const hire = tools.find((t) => t.name === 'roster_hire'); - expect(hire?.inputSchema.required).toEqual(['wish', 'agentAdapterId', 'worktree']); - }); -}); - -// ============================================================================ -// Success Criterion 2 — read parity vs exportState() -// ============================================================================ - -describe('ui-bridge read parity', () => { - test('every task/board/wish row the read tools surface matches exportState()', async () => { - const { boardName } = seed(repo); - const db = openDb({ cwd: repo }); - const exp = exportState(db); - db.close(); - - const responses = await driveOnce('ui-bridge', repo, [ - INIT_OK, - call(3, 'genie_board'), - call(4, 'genie_board', { board: boardName }), - call(5, 'genie_wish_status', { wish: 'w-alpha' }), - call(6, 'genie_active'), - ]); - - // Task rows: same ids, statuses, wish/group, claimant as the export. - const boardPayload = toolPayload<{ tasks: Array> }>(responses.find((r) => r.id === 3)!); - const byId = new Map(boardPayload.tasks.map((t) => [t.id as string, t])); - expect(byId.size).toBe(exp.tasks.length); - for (const raw of exp.tasks) { - const t = byId.get(raw.id); - expect(t).toBeDefined(); - expect(t?.status).toBe(raw.status); - expect(t?.wish).toBe(raw.wish); - expect(t?.group).toBe(raw.group_name); - expect(t?.claimedBy).toBe(raw.claimed_by); - } - - // Board projection: the named board resolves and carries its own tasks. - const namedBoard = toolPayload<{ board: string; tasks: unknown[] }>(responses.find((r) => r.id === 4)!); - const boardRow = exp.boards.find((b) => b.name === boardName); - expect(boardRow).toBeDefined(); - expect(namedBoard.board).toBe(boardName); - expect(namedBoard.tasks.length).toBe(exp.tasks.filter((t) => t.board_id === boardRow?.id).length); - - // Wish groups: genie_wish_status is a hardcoded [] (wish-group machinery is - // production-dead); exportState carries the field, also empty. No seeding, - // no cross-comparison — the MCP side never reads the table anymore. - const wish = toolPayload<{ groups: Array<{ name: string; status: string; dependsOn: string[] }> }>( - responses.find((r) => r.id === 5)!, - ); - expect(wish.groups).toEqual([]); - - // Active projection: exactly the in_progress rows from the export. - const active = toolPayload<{ tasks: Array<{ id: string }> }>(responses.find((r) => r.id === 6)!); - expect(active.tasks.map((t) => t.id).sort()).toEqual( - exp.tasks - .filter((t) => t.status === 'in_progress') - .map((t) => t.id) - .sort(), - ); - }); -}); - -// ============================================================================ -// Success Criterion 3 — roster write tools + concurrency -// ============================================================================ - -describe('ui-bridge roster write tools', () => { - test('roster_hire / roster_unhire round-trip through task-state and surface in exportState()', async () => { - const responses = await driveOnce('ui-bridge', repo, [ - INIT_OK, - call(2, 'roster_hire', { wish: 'w-alpha', agentAdapterId: 'codex-1', worktree: '/wt/codex-1', profile: 'high' }), - call(3, 'roster_unhire', { wish: 'w-alpha', agentAdapterId: 'codex-1' }), - call(4, 'roster_unhire', { wish: 'w-alpha', agentAdapterId: 'never' }), - ]); - - const hired = toolPayload<{ wish: string; agentAdapterId: string; worktree: string; profile: string }>( - responses.find((r) => r.id === 2)!, - ); - expect(hired).toMatchObject({ - wish: 'w-alpha', - agentAdapterId: 'codex-1', - worktree: '/wt/codex-1', - profile: 'high', + env: { ...process.env, NO_COLOR: '1' }, }); - const removed = toolPayload<{ removed: boolean }>(responses.find((r) => r.id === 3)!); - expect(removed.removed).toBe(true); - const absent = toolPayload<{ removed: boolean }>(responses.find((r) => r.id === 4)!); - expect(absent.removed).toBe(false); - - // Final state: hire then unhire → gone. (Prove it went through the real db.) - const db = openDb({ cwd: repo }); - expect(getHire(db, 'w-alpha', 'codex-1')).toBeNull(); - db.close(); - }); - - test('roster_hire persists a row visible in a fresh export', async () => { - await driveOnce('ui-bridge', repo, [ - INIT_OK, - call(2, 'roster_hire', { wish: 'w-beta', agentAdapterId: 'a1', worktree: '/wt/a1' }), - ]); - const db = openDb({ cwd: repo }); - const exp = exportState(db); - db.close(); - expect(exp.hire_roster.some((h) => h.wish === 'w-beta' && h.agent_adapter_id === 'a1')).toBe(true); - }); - - test('missing required argument returns a structured invalid_arguments payload', async () => { - const responses = await driveOnce('ui-bridge', repo, [ - INIT_OK, - call(2, 'roster_hire', { wish: 'w', agentAdapterId: 'a' }), // no worktree - ]); - const payload = toolPayload<{ error: string; missing: string[] }>(responses.find((r) => r.id === 2)!); - expect(payload.error).toBe('invalid_arguments'); - expect(payload.missing).toEqual(['worktree']); - }); - - test('concurrent CLI writer during bridge roster writes: no corruption, no busy-failure', async () => { - seed(repo); - const N = 8; - const requests: Record[] = [INIT_OK]; - for (let i = 0; i < N; i++) { - requests.push( - call(100 + i, 'roster_hire', { wish: 'w-alpha', agentAdapterId: `agent-${i}`, worktree: `/wt/${i}` }), - ); - } - - // A parallel, separate-connection CLI-style writer racing the bridge's writes. - const cliWriter = (async () => { - const db = openDb({ cwd: repo }); - for (let i = 0; i < N; i++) createTask(db, { title: `concurrent-${i}`, wish: 'w-alpha', group: 'g1' }); - db.close(); - })(); - - const [responses] = await Promise.all([driveOnce('ui-bridge', repo, requests), cliWriter]); - - // Every roster write succeeded (no SQLITE_BUSY escaping as a JSON-RPC error). - for (let i = 0; i < N; i++) { - const res = responses.find((r) => r.id === 100 + i); - expect(res?.error).toBeUndefined(); - expect(res?.result?.isError).toBe(false); - } - - // Final state is consistent: all roster rows AND all concurrent tasks landed. - const db = openDb({ cwd: repo }); - const hires = listHires(db, 'w-alpha'); - const tasks = exportState(db).tasks.filter((t) => t.title.startsWith('concurrent-')); - db.close(); - expect(hires.length).toBe(N); - expect(tasks.length).toBe(N); - }); -}); - -// ============================================================================ -// Success Criterion 4 — push within 1s -// ============================================================================ - -describe('ui-bridge change push', () => { - test('an external genie task create yields a change notification within 1s', async () => { - seed(repo); - const client = new BridgeClient(repo); - try { - client.send(INIT_OK); - const init = await client.waitFor((m) => m.id === 1 && m.result !== undefined); - expect(init?.result?.bridgeProtocolVersion).toBe('1.0'); - - // External write on a separate connection while the bridge is running. - const t0 = Date.now(); - const db = openDb({ cwd: repo }); - createTask(db, { title: 'pushed', wish: 'w-alpha', group: 'g1' }); - db.close(); - - const note = await client.waitFor((m) => m.method === 'notifications/genie/changed', 3000); - const elapsed = Date.now() - t0; - expect(note).not.toBeNull(); - expect(typeof (note?.params?.dataVersion as number)).toBe('number'); - expect(elapsed).toBeLessThanOrEqual(1000); - } finally { - client.kill(); - } - }); -}); - -// ============================================================================ -// Success Criterion 5 — lifetime (shutdown ≤2s + zero listening sockets) -// ============================================================================ - -describe('ui-bridge lifetime', () => { - test('closing client stdin ends the bridge within 2s', async () => { - const client = new BridgeClient(repo); - client.send(INIT_OK); - await client.waitFor((m) => m.id === 1); - const t0 = Date.now(); - await client.endStdin(); - const code = await client.proc.exited; - const elapsed = Date.now() - t0; - expect(code).toBe(0); - expect(elapsed).toBeLessThanOrEqual(2000); + expect(result.exitCode).toBe(1); + expect(result.stdout.toString()).toBe(''); + expect(result.stderr.toString()).toBe(RETIRED_DIAGNOSTIC); }); - test('holds zero listening TCP sockets while running', async () => { - const client = new BridgeClient(repo); + test('opens no database and creates no repository state', () => { + const root = mkdtempSync(join(tmpdir(), 'genie-ui-bridge-retired-')); try { - client.send(INIT_OK); - await client.waitFor((m) => m.id === 1); - const pid = client.proc.pid; - // `ss` is Linux-only (iproute2); macOS ships lsof instead, which filters - // by pid directly and exits 1 — with no output — when nothing is listening. - const owned = - process.platform === 'darwin' - ? (() => { - try { - return execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-p', String(pid)], { - encoding: 'utf-8', - }); - } catch { - return ''; - } - })() - : execFileSync('ss', ['-H', '-tlnp'], { encoding: 'utf-8' }) - .split('\n') - .filter((l) => new RegExp(`pid=${pid}\\b`).test(l)) - .join('\n'); - expect(owned).toBe(''); + const result = Bun.spawnSync(['bun', GENIE, 'ui-bridge'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, GENIE_HOME: join(root, 'home'), NO_COLOR: '1' }, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toBe(RETIRED_DIAGNOSTIC); + expect(existsSync(join(root, '.genie'))).toBe(false); + expect(readdirSync(root)).toEqual([]); } finally { - client.kill(); + rmSync(root, { recursive: true, force: true }); } }); -}); - -describe('genie mcp retirement remains separate from ui-bridge', () => { - test('rejects MCP input with the stable diagnostic instead of starting a server', () => { - const invocation = Bun.spawnSync([process.execPath, GENIE, 'mcp'], { - cwd: repo, - stdin: Buffer.from('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'), - stdout: 'pipe', - stderr: 'pipe', - }); - - expect(invocation.exitCode).toBe(1); - expect(invocation.stdout.toString()).toBe(''); - expect(invocation.stderr.toString()).toBe( - 'Error: genie mcp has been retired; use `genie task` and `genie board`, or roll back to a pre-A7 signed release.\n', - ); - }); -}); - -describe('orca lifecycle authority', () => { - test('refuses with one clean Error line and exit 1 instead of an uncaught throw', () => { - writeFileSync(join(genieHome, 'config.json'), '{"orchestration":{"mode":"orca"}}'); - - const invocation = Bun.spawnSync([process.execPath, GENIE, 'ui-bridge'], { - cwd: repo, - stdin: Buffer.from('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'), - stdout: 'pipe', - stderr: 'pipe', - env: bridgeEnv(), - }); - expect(invocation.exitCode).toBe(1); - // Refused BEFORE any handle, watcher, or backstop exists — no protocol - // traffic, and no raw stack trace out of the first lazy read-only open. - expect(invocation.stdout.toString()).toBe(''); - expect(invocation.stderr.toString()).toBe( - 'Error: local_lifecycle_disabled_in_orca_mode: local Genie lifecycle state is disabled because orchestration.mode is "orca"\n', - ); + test('documents retirement in command help', () => { + const result = Bun.spawnSync(['bun', GENIE, 'ui-bridge', '--help'], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain('retired'); }); }); diff --git a/src/term-commands/ui-bridge.ts b/src/term-commands/ui-bridge.ts index fad00b378..3151f5634 100644 --- a/src/term-commands/ui-bridge.ts +++ b/src/term-commands/ui-bridge.ts @@ -1,307 +1,14 @@ -/** - * genie ui-bridge — the UI-owned stdio channel into CLI-only genie. - * - * A long-lived stdio MCP server the separate-repo genie UI (the dash fork) - * spawns and OWNS: it retains the shared newline-JSON-RPC transport + five - * read tools, and ADDS the net-new surface — a version-negotiating `initialize` - * handshake, two roster write tools (`roster_hire` / `roster_unhire`) calling - * genie's own `task-state.ts` code, and change-push notifications driven by an - * in-child watcher. Genie stays zero-daemon: the child lives and dies with the - * UI (stdin EOF exit + ppid backstop) and NEVER opens a socket or port. - * - * Contract: `src/lib/v5/UI-BRIDGE.md` (protocol version, tools, notifications, - * skew policy, lifetime). - * - * LAZY-LOAD contract: the shared read-tool registry - * (write-capable open + read registry) is `await import`-ed inside the action, - * so it never enters the genie startup import graph. The transport loop, - * watcher, and state ops are ordinary static imports (none pulls in - * `mcp-tools.ts`). - */ - import type { Command } from 'commander'; -import { assertLocalLifecycleEnabled } from '../lib/orchestration-mode.js'; -import { startChangeWatcher, startPpidBackstop } from '../lib/v5/bridge-watcher.js'; -import { openDb, resolveDbPath } from '../lib/v5/genie-db.js'; -import { - type InitializeOutcome, - type JsonRpcError, - PROTOCOL_VERSION, - notify, - runMcpServerLoop, -} from '../lib/v5/mcp-server.js'; -import type { McpTool } from '../lib/v5/mcp-tools.js'; -import { hireAgent, unhireAgent } from '../lib/v5/task-state.js'; -import { VERSION } from '../lib/version.js'; - -// ============================================================================ -// Protocol constants (documented in UI-BRIDGE.md) -// ============================================================================ - -/** - * The genie ui-bridge protocol version reported by the handshake. Distinct from - * the MCP wire version (`2024-11-05`): this versions the bridge CONTRACT (tool - * set, notification semantics, write surface) so the UI and genie can release on - * separate cadences. `major.minor`; compatibility is by MAJOR. - */ -export const BRIDGE_PROTOCOL_VERSION = '1.0'; - -/** The id-less notification method emitted when the db changed. */ -export const CHANGE_NOTIFICATION_METHOD = 'notifications/genie/changed'; - -/** JSON-RPC error code (server-defined range) for a declared-incompatible client. */ -export const INCOMPATIBLE_VERSION_CODE = -32001; - -// ============================================================================ -// Version negotiation -// ============================================================================ - -/** MAJOR component of a `major[.minor…]` version string, or null when unparseable. */ -function majorOf(version: string): number | null { - const m = /^(\d+)/.exec(version.trim()); - return m ? Number.parseInt(m[1], 10) : null; -} - -/** Same-MAJOR compatibility against {@link BRIDGE_PROTOCOL_VERSION}. */ -function isCompatibleBridgeVersion(declared: string): boolean { - const declaredMajor = majorOf(declared); - const serverMajor = majorOf(BRIDGE_PROTOCOL_VERSION); - return declaredMajor !== null && declaredMajor === serverMajor; -} - -/** - * Build the `initialize` reply. A client that declares - * `params.bridgeProtocolVersion` with an incompatible MAJOR receives a - * structured JSON-RPC error (never silence). A client that declares nothing is - * accepted best-effort and left to decide from the server's reported versions. - */ -export function bridgeInitialize(params: Record | undefined): InitializeOutcome { - const declared = typeof params?.bridgeProtocolVersion === 'string' ? params.bridgeProtocolVersion : undefined; - if (declared !== undefined && !isCompatibleBridgeVersion(declared)) { - const error: JsonRpcError = { - code: INCOMPATIBLE_VERSION_CODE, - message: `Incompatible genie ui-bridge protocol version: client declared "${declared}", server speaks "${BRIDGE_PROTOCOL_VERSION}"`, - data: { serverBridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION, clientBridgeProtocolVersion: declared }, - }; - return { error }; - } - return { - result: { - protocolVersion: PROTOCOL_VERSION, - bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION, - genieVersion: VERSION, - capabilities: { tools: {}, experimental: { genieChangeNotifications: true } }, - serverInfo: { name: 'genie-ui-bridge', version: VERSION }, - }, - }; -} - -// ============================================================================ -// Roster write tools (the bridge's only write surface) -// ============================================================================ - -function nonEmptyString(args: Record, key: string): string | null { - const v = args[key]; - return typeof v === 'string' && v.length > 0 ? v : null; -} - -function optionalString(args: Record, key: string): string | undefined { - const v = args[key]; - return typeof v === 'string' && v.length > 0 ? v : undefined; -} - -/** - * The two roster write tools, over a lazily-opened WRITE-capable handle. They - * call `task-state.ts` ops exclusively (never raw SQL) so writer invariants stay - * in one codebase. `worktree` is REQUIRED on hire — a worktree is always known at - * hire time (dash binds the genie-launch worktree when hiring), confirming the - * `hire_roster.worktree NOT NULL` design. - */ -export function buildRosterTools(getWriteDb: () => import('bun:sqlite').Database): McpTool[] { - return [ - { - name: 'roster_hire', - description: 'Hire an agent adapter into a wish (idempotent upsert on (wish, agentAdapterId)).', - inputSchema: { - type: 'object', - properties: { - wish: { type: 'string', description: 'wish slug' }, - agentAdapterId: { type: 'string', description: 'agent adapter id (runtime/provider slot)' }, - worktree: { type: 'string', description: 'worktree binding (required — bound at hire time)' }, - profile: { type: 'string', description: 'optional provider profile' }, - state: { type: 'string', description: 'optional lifecycle state (defaults to "hired")' }, - }, - required: ['wish', 'agentAdapterId', 'worktree'], - }, - handler: (_ctx, args) => { - const wish = nonEmptyString(args, 'wish'); - const agentAdapterId = nonEmptyString(args, 'agentAdapterId'); - const worktree = nonEmptyString(args, 'worktree'); - if (!wish || !agentAdapterId || !worktree) { - return { - error: 'invalid_arguments', - missing: [ - ...(wish ? [] : ['wish']), - ...(agentAdapterId ? [] : ['agentAdapterId']), - ...(worktree ? [] : ['worktree']), - ], - }; - } - return hireAgent(getWriteDb(), { - wish, - agentAdapterId, - worktree, - profile: optionalString(args, 'profile'), - state: optionalString(args, 'state'), - }); - }, - }, - { - name: 'roster_unhire', - description: 'Remove an agent adapter from a wish (idempotent; removed=false when absent).', - inputSchema: { - type: 'object', - properties: { - wish: { type: 'string', description: 'wish slug' }, - agentAdapterId: { type: 'string', description: 'agent adapter id' }, - }, - required: ['wish', 'agentAdapterId'], - }, - handler: (_ctx, args) => { - const wish = nonEmptyString(args, 'wish'); - const agentAdapterId = nonEmptyString(args, 'agentAdapterId'); - if (!wish || !agentAdapterId) { - return { - error: 'invalid_arguments', - missing: [...(wish ? [] : ['wish']), ...(agentAdapterId ? [] : ['agentAdapterId'])], - }; - } - const removed = unhireAgent(getWriteDb(), wish, agentAdapterId); - return { wish, agentAdapterId, removed }; - }, - }, - ]; -} - -// ============================================================================ -// Watch reader (dedicated read connection for PRAGMA data_version polling) -// ============================================================================ - -interface WatchReader { - readDataVersion: () => number | null; - close: () => void; -} - -/** - * A long-lived read connection whose `PRAGMA data_version` increments whenever - * ANY other connection commits — the honest daemon-free change signal. Reopens - * lazily so a db created mid-session (first bridge write, or a fresh `genie - * init`) is picked up instead of polling `null` forever. - */ -function openWatchReader( - openReadonlyDb: (cwd: string) => import('bun:sqlite').Database | null, - cwd: string, -): WatchReader { - let handle = openReadonlyDb(cwd); - return { - readDataVersion(): number | null { - if (!handle) handle = openReadonlyDb(cwd); - if (!handle) return null; - try { - const row = handle.query('PRAGMA data_version').get() as { data_version: number } | null; - return row?.data_version ?? null; - } catch { - return null; - } - }, - close(): void { - handle?.close(); - handle = null; - }, - }; -} - -// ============================================================================ -// Runner -// ============================================================================ - -/** - * Run the ui-bridge stdio server until stdin EOF (or the ppid backstop fires). - * Composes the reused read tools + roster write tools + change watcher + ppid - * backstop over the shared transport loop, then exits promptly. - */ -export async function runUiBridge(): Promise { - // Lifecycle authority first: under Orca the local store is not this repo's - // truth, so the bridge refuses BEFORE any handle, watcher, or backstop - // exists instead of throwing out of the first lazy read-only open. - assertLocalLifecycleEnabled(); - // Lazy: keep the read-only bun:sqlite open + read tools out of genie startup. - const { MCP_TOOLS, openReadonlyDb } = await import('../lib/v5/mcp-tools.js'); - const cwd = process.cwd(); - - // Lazily-opened WRITE handle — the bridge stays non-mutating until an actual - // roster write is requested. One canonical open path (genie-db → sqlite-open), - // never a second raw `new Database`. - let writeDb: import('bun:sqlite').Database | null = null; - const getWriteDb = (): import('bun:sqlite').Database => { - if (!writeDb) writeDb = openDb({ cwd }); - return writeDb; - }; - - const reader = openWatchReader(openReadonlyDb, cwd); - const watcher = startChangeWatcher({ - dbPath: resolveDbPath(cwd), - readDataVersion: reader.readDataVersion, - onChange: (dataVersion) => notify(CHANGE_NOTIFICATION_METHOD, { dataVersion }), - }); - const backstop = startPpidBackstop({ - originalPpid: process.ppid, - onOrphaned: () => shutdown(true), - }); - - let stopped = false; - function shutdown(exit: boolean): void { - if (!stopped) { - stopped = true; - watcher.stop(); - backstop.stop(); - reader.close(); - writeDb?.close(); - writeDb = null; - } - // Orphaned path: stdin EOF will never arrive, so force a prompt exit. - if (exit) process.exit(0); - } - - await runMcpServerLoop({ - tools: [...MCP_TOOLS, ...buildRosterTools(getWriteDb)], - openDb: openReadonlyDb, - initialize: bridgeInitialize, - onClose: () => shutdown(false), - }); - - // stdin EOF path: the loop resolved after cleanup + flush. Exit promptly and - // deterministically (well within the 2 s lifetime bound) rather than waiting - // for the event loop to drain. - process.exit(0); -} -// ============================================================================ -// Registration -// ============================================================================ +export const UI_BRIDGE_RETIRED_DIAGNOSTIC = + 'Error: genie ui-bridge has been retired; the Orca integration is the supported UI surface, or roll back to a pre-retirement signed release.'; export function registerUiBridgeCommand(program: Command): void { program .command('ui-bridge') - .description('Run the UI-owned stdio MCP bridge (reads + roster writes + change-push) into genie.db') - .action(async () => { - try { - await runUiBridge(); - } catch (err) { - // The same one-line rendering `genie task`, `genie idea`, and `genie - // board` use — never a raw stack trace on a typed refusal. - process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); - } + .description('Report that the legacy Genie UI bridge is retired') + .action(() => { + process.stderr.write(`${UI_BRIDGE_RETIRED_DIAGNOSTIC}\n`); + process.exitCode = 1; }); }