From 649ce71eff76a3b78a4cee0eaaaa592e79206b67 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 2 Jun 2026 21:00:00 +0000 Subject: [PATCH] feat: single-source cross-app wire contracts in @boocode/contracts (v2.7.13) Move all hand-synced cross-app wire contracts into one built workspace package, @boocode/contracts, consumed by server/web/coder/coder-web via workspace:* + a per-subpath exports map. The ws-frames and provider-config Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason, AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are each single-sourced. Deletes the byte-identical copies and their parity tests, fixes a live AgentSessionConfig drift (coder dead copy removed, unified to the web required/nullable shape), removes the dead pending_change WS arms in the fallback SPA, and inverts the build order (contracts builds first) across root build, Dockerfile, and the coder deploy docs. Reverses the shared-package decision declined in v2.5.12. Co-Authored-By: Claude Opus 4.8 --- BOOCODER.md | 2 +- CHANGELOG.md | 4 + CLAUDE.md | 8 +- Dockerfile | 4 + README.md | 2 +- apps/coder/CLAUDE.md | 2 +- apps/coder/package.json | 1 + apps/coder/src/index.ts | 2 +- apps/coder/src/routes/messages.ts | 2 +- apps/coder/src/routes/skills.ts | 2 +- .../__tests__/provider-types-parity.test.ts | 64 --- apps/coder/src/services/acp-dispatch.ts | 2 +- .../src/services/agent-status-publish.ts | 2 +- apps/coder/src/services/dispatcher.ts | 2 +- apps/coder/src/services/provider-config.ts | 52 +-- apps/coder/src/services/provider-types.ts | 69 +-- apps/coder/src/services/worktrees.ts | 21 +- apps/coder/web/package.json | 1 + apps/coder/web/src/api/types.ts | 33 +- apps/coder/web/src/components/DiffPane.tsx | 23 +- apps/coder/web/src/hooks/useSessionStream.ts | 39 +- apps/coder/web/src/pages/Session.tsx | 7 +- apps/server/CLAUDE.md | 2 +- apps/server/package.json | 5 +- apps/server/src/index.ts | 8 +- .../src/services/__tests__/ws-frames.test.ts | 148 +------ apps/server/src/services/broker.ts | 2 +- apps/server/src/types/api.ts | 65 +-- apps/web/package.json | 1 + apps/web/src/api/types.ts | 156 +------ apps/web/src/api/ws-frames.ts | 408 ------------------ apps/web/src/hooks/useSessionStream.ts | 2 +- apps/web/src/hooks/useUserEvents.ts | 2 +- apps/web/src/main.tsx | 1 - docs/ARCHITECTURE.md | 2 +- docs/DEFERRED-WORK.md | 10 +- docs/STALE-DEPRECATED.md | 2 +- docs/coder-backends.md | 6 +- .../cross-app-contract-parity.md | 136 +++--- docs/project-discovery.md | 2 +- openspec/changes/contracts-ssot/proposal.md | 102 +++++ openspec/changes/contracts-ssot/tasks.md | 109 +++++ package.json | 2 +- packages/contracts/package.json | 46 ++ .../contracts/src/__tests__/ws-frames.test.ts | 136 ++++++ packages/contracts/src/index.ts | 5 + packages/contracts/src/message-metadata.ts | 45 ++ packages/contracts/src/provider-config.ts | 25 ++ packages/contracts/src/provider-snapshot.ts | 52 +++ packages/contracts/src/worktree-risk.ts | 13 + .../contracts/src}/ws-frames.ts | 25 +- packages/contracts/tsconfig.json | 15 + packages/contracts/vitest.config.ts | 9 + pnpm-lock.yaml | 25 ++ pnpm-workspace.yaml | 1 + 55 files changed, 804 insertions(+), 1108 deletions(-) delete mode 100644 apps/coder/src/services/__tests__/provider-types-parity.test.ts delete mode 100644 apps/web/src/api/ws-frames.ts create mode 100644 openspec/changes/contracts-ssot/proposal.md create mode 100644 openspec/changes/contracts-ssot/tasks.md create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/__tests__/ws-frames.test.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/message-metadata.ts create mode 100644 packages/contracts/src/provider-config.ts create mode 100644 packages/contracts/src/provider-snapshot.ts create mode 100644 packages/contracts/src/worktree-risk.ts rename {apps/server/src/types => packages/contracts/src}/ws-frames.ts (91%) create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/contracts/vitest.config.ts diff --git a/BOOCODER.md b/BOOCODER.md index 2f64a88..d9c50aa 100644 --- a/BOOCODER.md +++ b/BOOCODER.md @@ -104,7 +104,7 @@ Either way, **adding to config does NOT install the binary.** Until the CLI is o ### Deploy + smoke Two deploy targets: -- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` +- **Routes (host service):** `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` - **Web UI (container):** `docker compose up --build -d boocode` Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3b538..d7439f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.7.13-contracts-ssot — 2026-06-02 + +Creates `@boocode/contracts` (`packages/contracts`), a new workspace package that becomes the single source of truth for every cross-app wire contract — reversing the decision recorded in `v2.5.12-provider-lifecycle-phase4` that declined a shared types package as not worth the Docker/build-order risk at solo scale; a live `AgentSessionConfig` drift that had since appeared between `apps/coder` and `apps/web` justified the investment. Six contracts are now defined exactly once: the `WsFrameSchema` Zod runtime schema, the provider snapshot types (`ProviderSnapshotEntry` and family), the Zod provider-config schemas, `MessageMetadata` + `ErrorReason`, `AgentSessionConfig`, and `WorktreeRiskReport`; both Zod-backed contracts use `z.infer` so validator and type derive from the same definition and cannot drift independently. All four consumers — `apps/server`, `apps/web`, `apps/coder`, and the fallback SPA `apps/coder/web` — import via `workspace:*` through a per-subpath exports map consuming built dist only (no tsconfig project references); the hand-synced copies and their parity tests (`provider-types-parity.test.ts`; the ws-frames byte-parity assertion) are deleted while the KNOWN_FRAME_TYPES drift test and broker fail-closed tests are preserved. Build order is inverted in the root build script, Dockerfile, and coder deploy docs; `apps/coder/web`'s migration also removed dead `pending_change_*` reducer arms (no frame publisher exists for these — pending changes are HTTP-delivered), closing a latent missing-default-arm crash, and reconciled field-type conflicts with the canonical `WsFrame`; zod is pinned to a single version across the workspace. Server 543 / coder 293 / contracts 11 tests passing; human smoke verified on the live stack 2026-06-02. + ## v2.7.11-coder-model-snapshot — 2026-06-02 Hotfix for the coder model-attribution chip vanishing on refresh. The chip showed during a live turn (the `message_complete` frame carries `model`) but disappeared when a BooCoder session was reloaded — only in the coder, not BooChat. Root cause: `CoderPane`'s `useCoderMessages` hydrates from two sources on load — the HTTP `listMessages` fetch (whose SELECT includes `model`, added `v2.7.8`) AND the WS `snapshot` frame — and the WS snapshot's query in `apps/coder/src/routes/ws.ts` had its own column list that omitted `model`. The client's `snapshot` handler `setMessages`-overwrites the HTTP load, so the model-less rows won, and with no later `message_complete` for historical messages the chip stayed gone. Fix is one column: add `model` to the WS snapshot SELECT so both hydration paths agree. The `apps/coder/CLAUDE.md` "update every mapper" note now lists the WS snapshot SELECT explicitly (it was the one place not enumerated). apps/server + apps/coder builds green; deployed via `systemctl restart boocoder` (host service — the earlier `v2.7.10` docker deploy rebuilt only the container, never this route). Fixes the chip shipped in `v2.7.8-ember-coder-tabs-model-chips` / completed in `v2.7.9-mcp-keys-docs-coder-fixes`. diff --git a/CLAUDE.md b/CLAUDE.md index f0f54d2..65cdc9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only). -BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`. +BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`. - `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls. - Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch. @@ -113,9 +113,9 @@ Cross-cutting only. Per-app conventions live in the matching `apps/*/CLAUDE.md`. - No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key. - TypeScript strict mode. Both apps share `tsconfig.base.json`. Server + coder use NodeNext module resolution (`.js` extensions in imports). - Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`). -- **Adding a new WS frame type** (cross-app) requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse. -- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`. -- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together. +- **Adding a new WS frame type** (cross-app): add it to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts` (single source of truth; rebuild with `pnpm -C packages/contracts build`). The server's `InferenceFrame` loose union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) still exist separately and also need updating. Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse. +- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. `MessageMetadata` is single-sourced in `@boocode/contracts` (`packages/contracts/src/message-metadata.ts`). A new kind requires updating that file and rebuilding the package, plus a render branch in `apps/web/src/components/MessageBubble.tsx`. +- **Provider snapshot types** (`ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, `ThinkingOption`, `AgentCommand`, `ProviderSnapshotStatus`) are single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`); `apps/coder/src/services/provider-types.ts` re-exports them. Edit the package source; there is no hand-synced web copy to update. - **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of an object/array). Pattern in `parts.ts`, `settings.ts`. - Skills live in `data/skills//`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`, `systematic-debugging`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists. diff --git a/Dockerfile b/Dockerfile index 740514f..c8a54d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,15 @@ RUN corepack enable WORKDIR /build COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY packages/contracts/package.json ./packages/contracts/ COPY apps/server/package.json ./apps/server/ COPY apps/web/package.json ./apps/web/ RUN pnpm install --frozen-lockfile +# @boocode/contracts must be present before `pnpm build`, which builds it FIRST +# (root build script) so apps/web can resolve its compiled dist via the exports map. +COPY packages/contracts ./packages/contracts COPY apps/server ./apps/server COPY apps/web ./apps/web diff --git a/README.md b/README.md index cc69135..f94b95d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ upstream and inject `Remote-User`. Postgres binds loopback only. BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker: ```bash -pnpm -C apps/server build && pnpm -C apps/coder build +pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build sudo systemctl restart boocoder curl http://100.114.205.53:9502/api/health ``` diff --git a/apps/coder/CLAUDE.md b/apps/coder/CLAUDE.md index cbffe31..95f2617 100644 --- a/apps/coder/CLAUDE.md +++ b/apps/coder/CLAUDE.md @@ -13,7 +13,7 @@ ## Build, deploy, dispatch - **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. **apps/server must build FIRST.** -- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`. +- Build + deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`. - After `pnpm -C apps/coder build` the host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler shape). Restart, don't re-debug. - `:9502/api/health` is down ~15–20s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy. - Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`. diff --git a/apps/coder/package.json b/apps/coder/package.json index bfc65ab..dfd18b6 100644 --- a/apps/coder/package.json +++ b/apps/coder/package.json @@ -13,6 +13,7 @@ "test": "vitest run" }, "dependencies": { + "@boocode/contracts": "workspace:*", "@agentclientprotocol/sdk": "^0.22.1", "@anthropic-ai/claude-agent-sdk": "^0.3.159", "@boocode/server": "workspace:*", diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index e52c412..5166926 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -16,7 +16,7 @@ import { createInferenceRunner } from '@boocode/server/inference'; import { createBroker } from '@boocode/server/broker'; import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools'; import type { Config as ServerConfig } from '@boocode/server/config'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; // v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. import { WRITE_TOOLS } from './services/tools/index.js'; import { adaptWriteTool } from './services/tools/adapter.js'; diff --git a/apps/coder/src/routes/messages.ts b/apps/coder/src/routes/messages.ts index 854344c..0c7ab7e 100644 --- a/apps/coder/src/routes/messages.ts +++ b/apps/coder/src/routes/messages.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; import { resolveChatId } from './chat-resolve.js'; const AnswerUserInputBody = z.object({ diff --git a/apps/coder/src/routes/skills.ts b/apps/coder/src/routes/skills.ts index 40b7fca..f1edad6 100644 --- a/apps/coder/src/routes/skills.ts +++ b/apps/coder/src/routes/skills.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; import { getSkillBody } from '@boocode/server/skills'; import { buildSkillInvokeSyntheticFrames, diff --git a/apps/coder/src/services/__tests__/provider-types-parity.test.ts b/apps/coder/src/services/__tests__/provider-types-parity.test.ts deleted file mode 100644 index b47b0bd..0000000 --- a/apps/coder/src/services/__tests__/provider-types-parity.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** - * Parity guard between the two copies of the provider snapshot types: - * apps/coder/src/services/provider-types.ts (backend source of truth) - * apps/web/src/api/types.ts (web wire copy) - * - * APPROACH: text-identity of each shared type block (mirrors the repo's existing - * ws-frames.test.ts byte-parity convention). A compile-time bidirectional- - * assignability check was attempted first (a web-side file importing coder's - * import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite - * project and rejects out-of-include files with TS6307 — so cross-project type - * import is structurally blocked. This runtime guard FAILS on any field - * add/remove/rename/loosen in either copy, including the nested model/mode/ - * command types that ProviderSnapshotEntry references. Single-source-of-truth - * (shared workspace package) is deferred as a Tier-2 follow-up. - */ -const here = dirname(fileURLToPath(import.meta.url)); -const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8'); -const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8'); - -function extractBlock(src: string, name: string): string { - const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`)); - const alias = src.match(new RegExp(`export type ${name} =[^;]*;`)); - const block = iface?.[0] ?? alias?.[0]; - if (!block) throw new Error(`type block '${name}' not found`); - // Normalize to type structure: drop blank + comment lines (//, /* */, *), - // trim each line. Field add/remove/rename/loosen still changes a field line. - return block - .split('\n') - .map((l) => l.trim()) - .filter( - (l) => - l.length > 0 && - !l.startsWith('//') && - !l.startsWith('/*') && - !l.startsWith('*'), - ) - .join('\n'); -} - -describe('provider snapshot type parity (coder ↔ web)', () => { - // Includes the nested types ProviderSnapshotEntry references, so structural - // drift anywhere in the snapshot surface is caught. - const names = [ - 'ProviderSnapshotStatus', - 'ProviderSnapshotEntry', - 'ProviderModel', - 'ProviderMode', - 'ThinkingOption', - 'AgentCommand', - ]; - for (const name of names) { - it(`${name} is identical in both copies`, () => { - expect( - extractBlock(webSrc, name), - `${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`, - ).toBe(extractBlock(coderSrc, name)); - }); - } -}); diff --git a/apps/coder/src/services/acp-dispatch.ts b/apps/coder/src/services/acp-dispatch.ts index 404c5e2..b7d33f8 100644 --- a/apps/coder/src/services/acp-dispatch.ts +++ b/apps/coder/src/services/acp-dispatch.ts @@ -23,7 +23,7 @@ import { type ClientSideConnection as ConnectionType, } from '@agentclientprotocol/sdk'; import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; import { spawn } from 'node:child_process'; import { findThoughtLevelConfigId } from './acp-derive.js'; import { resolveLaunchSpec } from './acp-spawn.js'; diff --git a/apps/coder/src/services/agent-status-publish.ts b/apps/coder/src/services/agent-status-publish.ts index b707ae8..bad1818 100644 --- a/apps/coder/src/services/agent-status-publish.ts +++ b/apps/coder/src/services/agent-status-publish.ts @@ -8,7 +8,7 @@ * (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web. */ import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { AgentStatus } from './normalize-agent-status.js'; // The exact slice of Broker we need — accepting just the bound method keeps call diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index d8ca014..dfe28a0 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -1,7 +1,7 @@ import type { Sql } from '../db.js'; import type { FastifyBaseLogger } from 'fastify'; import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; +import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { Config } from '../config.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; import { createCheckpoint } from './checkpoints.js'; diff --git a/apps/coder/src/services/provider-config.ts b/apps/coder/src/services/provider-config.ts index 8816e3d..6fc5fd7 100644 --- a/apps/coder/src/services/provider-config.ts +++ b/apps/coder/src/services/provider-config.ts @@ -5,42 +5,28 @@ * (see provider-config-registry.ts). Loading NEVER throws at startup (design.md * §2.1): a missing file, invalid JSON, or schema mismatch all fall back to * `{ providers: {} }` (built-ins only, all enabled). + * + * Schemas are defined once in @boocode/contracts/provider-config and re-exported + * here so existing importers (routes, tests, registry) don't need path changes. */ import { readFileSync, writeFileSync } from 'node:fs'; -import { z } from 'zod'; +import { + ProviderOverrideSchema, + CoderProvidersFileSchema, + ProviderConfigPatchSchema, + type ProviderOverride, + type CoderProvidersFile, + type ProviderConfigPatch, +} from '@boocode/contracts/provider-config'; -// Schemas verbatim from design.md §2.2. -export const ProviderOverrideSchema = z.object({ - extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends - label: z.string().min(1).optional(), - description: z.string().optional(), - command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args] - env: z.record(z.string()).optional(), - enabled: z.boolean().optional(), // default true - order: z.number().int().optional(), // UI sort key - models: z.array(z.object({ id: z.string(), label: z.string() })).optional(), - additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), -}); - -export const CoderProvidersFileSchema = z.object({ - providers: z.record(ProviderOverrideSchema).default({}), -}); - -export type ProviderOverride = z.infer; -export type CoderProvidersFile = z.infer; - -/** - * PATCH body schema (design.md §6.2). A partial providers map where each value - * is either a full override object (REPLACES that id's override) or `null` - * (DELETES the override → revert to the built-in default). Ids absent from the - * patch are left untouched. The route validates the body against this first - * (malformed → 422) so a bad shape can never reach the merge/save step. - */ -export const ProviderConfigPatchSchema = z.object({ - providers: z.record(ProviderOverrideSchema.nullable()).default({}), -}); - -export type ProviderConfigPatch = z.infer; +export { + ProviderOverrideSchema, + CoderProvidersFileSchema, + ProviderConfigPatchSchema, + type ProviderOverride, + type CoderProvidersFile, + type ProviderConfigPatch, +}; /** * Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in diff --git a/apps/coder/src/services/provider-types.ts b/apps/coder/src/services/provider-types.ts index 5496181..5082f43 100644 --- a/apps/coder/src/services/provider-types.ts +++ b/apps/coder/src/services/provider-types.ts @@ -1,61 +1,10 @@ -/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */ +/** Provider snapshot types — re-exported from @boocode/contracts for local consumers. */ -export interface ProviderMode { - id: string; - label: string; - description?: string; - /** Auto-approve tool permissions when this mode is selected. */ - isUnattended?: boolean; -} - -export interface ThinkingOption { - id: string; - label: string; - isDefault?: boolean; -} - -export interface ProviderModel { - id: string; - label: string; - description?: string; - isDefault?: boolean; - thinkingOptions?: ThinkingOption[]; - defaultThinkingOptionId?: string; -} - -// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable' -// (disabled or not installed) restored alongside the terminal 'ready' | 'error'. -export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error'; - -export interface AgentCommand { - name: string; - description?: string; - // v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command). - // Drives the icon split in the coder slash menu. Undefined → command. - kind?: 'command' | 'skill'; -} - -// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is -// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift). -export interface ProviderSnapshotEntry { - name: string; - label: string; - description?: string; - transport: string; - status: ProviderSnapshotStatus; - enabled: boolean; - installed: boolean; - models: ProviderModel[]; - modes: ProviderMode[]; - defaultModeId: string | null; - commands: AgentCommand[]; - error?: string; - fetchedAt?: string; -} - -export interface AgentSessionConfig { - provider: string; - model?: string; - modeId?: string; - thinkingOptionId?: string; -} +export type { + ProviderMode, + ThinkingOption, + ProviderModel, + ProviderSnapshotStatus, + AgentCommand, + ProviderSnapshotEntry, +} from '@boocode/contracts/provider-snapshot'; diff --git a/apps/coder/src/services/worktrees.ts b/apps/coder/src/services/worktrees.ts index 704d16a..8121e00 100644 --- a/apps/coder/src/services/worktrees.ts +++ b/apps/coder/src/services/worktrees.ts @@ -8,6 +8,7 @@ */ import type { Sql } from '../db.js'; import { hostExec } from './host-exec.js'; +import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk'; export const WORKTREE_BASE = '/tmp/booworktrees'; @@ -379,22 +380,8 @@ export async function rebaselineWorktreeAfterApply( } // ─── Session-delete work-loss guard ───────────────────────────────────────── - -/** - * Risk report for a single worktree, returned by checkWorktreeWorkAtRisk. - * `atRisk` is the gate the server reads before allowing a session delete. - * A git error never silently passes — it forces `atRisk` true and surfaces - * the message in `error` (fail-closed). - */ -export interface RiskReport { - worktreePath: string; - branch: string; - dirty: boolean; // uncommitted working-tree changes (incl. untracked) - unpushed: number; // commits ahead of upstream, or -1 if no upstream is set - unmerged: number; // commits on this branch not in the project default branch - atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error - error?: string; // populated on a git failure; presence forces atRisk -} +// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here. +export type { WorktreeRiskReport }; /** * Resolve the project's default branch as a git-usable ref (e.g. "origin/main"). @@ -448,7 +435,7 @@ async function detectDefaultBranchRef( export async function checkWorktreeWorkAtRisk( worktreePath: string, opts?: { signal?: AbortSignal }, -): Promise { +): Promise { // Branch name — also doubles as the "is this still a git worktree?" probe. const br = await hostExec( `git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`, diff --git a/apps/coder/web/package.json b/apps/coder/web/package.json index 2eb5c7c..46b344a 100644 --- a/apps/coder/web/package.json +++ b/apps/coder/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@boocode/contracts": "workspace:*", "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/coder/web/src/api/types.ts b/apps/coder/web/src/api/types.ts index 34d5b74..79acf91 100644 --- a/apps/coder/web/src/api/types.ts +++ b/apps/coder/web/src/api/types.ts @@ -1,5 +1,22 @@ // Minimal types for the BooCoder frontend. // Shared DB entities (same schema as BooChat). +// +// WS wire contracts are single-sourced from @boocode/contracts (the canonical +// Zod-backed schema). The DB entity types below (Project/Session/Chat/Message/ +// ToolCall/ToolResult/PendingChange) are an intentional minimal SPA-local subset +// and are NOT cross-app contracts — they stay defined here. + +import type { WsFrame } from '@boocode/contracts/ws-frames'; + +// Re-export the canonical WebSocket frame union (single source of truth). The +// coder backend publishes the full frame set; this SPA's reducer handles the +// subset it renders and ignores the rest. +export type { WsFrame }; + +// The error frame's `reason`, single-sourced from the canonical schema's +// frame-level reason enum (derived from WsFrame so it cannot drift from the +// wire). Distinct from message-metadata's ErrorReason, which is a different set. +export type ErrorReason = NonNullable['reason']>; export interface Project { id: string; @@ -39,7 +56,9 @@ export interface ToolResult { tool_call_id: string; output: unknown; truncated?: boolean; - error?: boolean; + // Canonical wire shape: the failure message string (present only on error), + // not a boolean. ToolResultBubble treats it as truthy → renders error styling. + error?: string; } // Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] } @@ -96,15 +115,3 @@ export interface PendingChange { created_at: string; applied_at: string | null; } - -// WebSocket frame types (subset of what the coder backend publishes) -export type WsFrame = - | { type: 'snapshot'; messages: Message[] } - | { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] } - | { type: 'delta'; message_id: string; chat_id: string; content: string } - | { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall } - | { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean } - | { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown } - | { type: 'error'; message_id?: string; error: string; reason?: string } - | { type: 'pending_change_added'; change: PendingChange } - | { type: 'pending_change_updated'; change: PendingChange }; diff --git a/apps/coder/web/src/components/DiffPane.tsx b/apps/coder/web/src/components/DiffPane.tsx index a2bfac8..4e9aef0 100644 --- a/apps/coder/web/src/components/DiffPane.tsx +++ b/apps/coder/web/src/components/DiffPane.tsx @@ -5,10 +5,9 @@ import { api } from '@/api/client'; interface Props { sessionId: string; - onPendingChange: (cb: (change: PendingChange) => void) => () => void; } -export function DiffPane({ sessionId, onPendingChange }: Props) { +export function DiffPane({ sessionId }: Props) { const [changes, setChanges] = useState([]); const [loading, setLoading] = useState(true); const [expandedId, setExpandedId] = useState(null); @@ -24,27 +23,13 @@ export function DiffPane({ sessionId, onPendingChange }: Props) { } }, [sessionId]); - // Initial load + // Initial load. Pending changes are delivered over HTTP (list + apply/reject/ + // rewind below); there is no WS pending-change frame, so the list refreshes on + // mount, on the Refresh button, and optimistically as the user acts on it. useEffect(() => { fetchPending(); }, [fetchPending]); - // Listen for WS pending change events - useEffect(() => { - const unsub = onPendingChange((change) => { - setChanges((prev) => { - const idx = prev.findIndex((c) => c.id === change.id); - if (idx >= 0) { - const next = [...prev]; - next[idx] = change; - return next; - } - return [...prev, change]; - }); - }); - return unsub; - }, [onPendingChange]); - const pendingChanges = changes.filter((c) => c.status === 'pending'); const resolvedChanges = changes.filter((c) => c.status !== 'pending'); diff --git a/apps/coder/web/src/hooks/useSessionStream.ts b/apps/coder/web/src/hooks/useSessionStream.ts index 873331d..1852dee 100644 --- a/apps/coder/web/src/hooks/useSessionStream.ts +++ b/apps/coder/web/src/hooks/useSessionStream.ts @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import type { Message, WsFrame, PendingChange } from '@/api/types'; +import { useEffect, useRef, useState } from 'react'; +import type { Message, WsFrame } from '@/api/types'; interface State { messages: Message[]; @@ -10,7 +10,9 @@ interface State { function applyFrame(state: State, frame: WsFrame): State { switch (frame.type) { case 'snapshot': { - return { ...state, messages: frame.messages }; + // Canonical SnapshotFrame.messages is opaque (z.array(z.unknown())); the + // coder backend sends Message-shaped rows, so cast to the SPA's local type. + return { ...state, messages: frame.messages as Message[] }; } case 'message_started': { const exists = state.messages.some((m) => m.id === frame.message_id); @@ -18,7 +20,7 @@ function applyFrame(state: State, frame: WsFrame): State { const newMsg: Message = { id: frame.message_id, session_id: '', - chat_id: frame.chat_id, + chat_id: frame.chat_id ?? '', role: frame.role, content: '', kind: 'message', @@ -72,7 +74,7 @@ function applyFrame(state: State, frame: WsFrame): State { const newMsg: Message = { id: frame.tool_message_id, session_id: '', - chat_id: frame.chat_id, + chat_id: frame.chat_id ?? '', role: 'tool', content: '', kind: 'message', @@ -119,9 +121,12 @@ function applyFrame(state: State, frame: WsFrame): State { : state.messages; return { ...state, messages: next, error: frame.error }; } - case 'pending_change_added': - case 'pending_change_updated': - // These are handled by the pending changes listener, not the message state + default: + // The canonical WsFrame carries the full set of frames the coder backend + // can publish; this SPA only renders the subset handled above and safely + // ignores the rest (reasoning_delta, usage, permission_*, agent_*, and the + // per-user sidebar frames). pending_change_* frames have no publisher — + // pending changes are delivered over HTTP, so there is nothing to handle. return state; } } @@ -134,14 +139,11 @@ interface SessionStreamResult { connected: boolean; error: string | null; isStreaming: boolean; - /** Listeners for pending change frames */ - onPendingChange: (cb: (change: PendingChange) => void) => () => void; } export function useSessionStream(sessionId: string | undefined): SessionStreamResult { const [state, setState] = useState({ messages: [], connected: false, error: null }); const wsRef = useRef(null); - const pendingListenersRef = useRef void>>(new Set()); useEffect(() => { if (!sessionId) return; @@ -172,13 +174,6 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe return; } - // Notify pending change listeners - if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') { - for (const cb of pendingListenersRef.current) { - cb(frame.change); - } - } - setState((s) => applyFrame(s, frame)); }; @@ -213,18 +208,10 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe const isStreaming = state.messages.some((m) => m.status === 'streaming'); - const onPendingChange = useCallback((cb: (change: PendingChange) => void) => { - pendingListenersRef.current.add(cb); - return () => { - pendingListenersRef.current.delete(cb); - }; - }, []); - return { messages: state.messages, connected: state.connected, error: state.error, isStreaming, - onPendingChange, }; } diff --git a/apps/coder/web/src/pages/Session.tsx b/apps/coder/web/src/pages/Session.tsx index da8b17c..f9d446a 100644 --- a/apps/coder/web/src/pages/Session.tsx +++ b/apps/coder/web/src/pages/Session.tsx @@ -14,8 +14,7 @@ export function Session() { const [chat, setChat] = useState(null); const [loading, setLoading] = useState(true); - const { messages, connected, isStreaming, onPendingChange } = - useSessionStream(sessionId); + const { messages, connected, isStreaming } = useSessionStream(sessionId); // Get or create a chat for this session useEffect(() => { @@ -78,9 +77,7 @@ export function Session() { connected={connected} /> } - diffPane={ - - } + diffPane={} /> ); } diff --git a/apps/server/CLAUDE.md b/apps/server/CLAUDE.md index 36f35ee..f3dca50 100644 --- a/apps/server/CLAUDE.md +++ b/apps/server/CLAUDE.md @@ -21,7 +21,7 @@ - **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn. - **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up. - **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream. -- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; `ws-frames.test.ts` enforces parity. Don't add raw `broker.publish()`/`publishUser()` calls. +- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema single-sourced in `@boocode/contracts` (`packages/contracts/src/ws-frames.ts`); the package's `ws-frames.test.ts` enforces schema correctness. Don't add raw `broker.publish()`/`publishUser()` calls. - **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable. - **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream//props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `...` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported. - **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`). diff --git a/apps/server/package.json b/apps/server/package.json index 4aba86e..7a6ff25 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,10 +53,6 @@ "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" }, - "./ws-frames": { - "types": "./dist/types/ws-frames.d.ts", - "default": "./dist/types/ws-frames.js" - }, "./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" @@ -81,6 +77,7 @@ "test": "vitest run" }, "dependencies": { + "@boocode/contracts": "workspace:*", "@ai-sdk/openai-compatible": "^2.0.47", "@fastify/static": "^7.0.4", "@fastify/websocket": "^10.0.1", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d083d46..b211e3e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -140,7 +140,7 @@ async function main() { publish: (sessionId, frame) => { // v1.13.11-b: route through the typed publishFrame so the broker's // Zod gate validates every inference frame before delivery. - broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame); + broker.publishFrame(sessionId, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame); }, // v1.11: broker handle for compaction.process to publish 'compacted' // frames on the per-session channel. Inference's regular publish path @@ -149,7 +149,7 @@ async function main() { broker, }, (user, frame) => { - broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame); + broker.publishUserFrame(user, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame); } ); registerMessageRoutes(app, sql, config, broker, { @@ -194,7 +194,7 @@ async function main() { }); }, publishSessionFrame: (sessionId, frame) => { - broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame); + broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame); }, }); registerArtifactRoutes(app, sql); @@ -222,7 +222,7 @@ async function main() { }); }, publishSessionFrame: (sessionId, frame) => { - broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame); + broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame); }, }); registerWebSocket(app, sql, broker); diff --git a/apps/server/src/services/__tests__/ws-frames.test.ts b/apps/server/src/services/__tests__/ws-frames.test.ts index 6f40dac..7e33258 100644 --- a/apps/server/src/services/__tests__/ws-frames.test.ts +++ b/apps/server/src/services/__tests__/ws-frames.test.ts @@ -1,159 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { - WsFrameSchema, - KNOWN_FRAME_TYPES, type WsFrame, -} from '../../types/ws-frames.js'; +} from '@boocode/contracts/ws-frames'; import { createBroker } from '../broker.js'; const VALID_UUID_A = '00000000-0000-0000-0000-000000000001'; const VALID_UUID_B = '00000000-0000-0000-0000-000000000002'; -const VALID_UUID_C = '00000000-0000-0000-0000-000000000003'; const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z'; -describe('WsFrameSchema (v1.13.11-a)', () => { - it('accepts a well-formed chat_status frame', () => { - const result = WsFrameSchema.safeParse({ - type: 'chat_status', - chat_id: VALID_UUID_A, - status: 'streaming', - at: VALID_TIMESTAMP, - }); - expect(result.success).toBe(true); - }); - - it('rejects an unknown frame type', () => { - const result = WsFrameSchema.safeParse({ - type: 'cosmic_ray_strike', - chat_id: VALID_UUID_A, - }); - expect(result.success).toBe(false); - }); - - it('rejects a chat_status frame with invalid status enum', () => { - // v1.12.1 dropped the legacy 'working' status. Any frame still emitting it - // should fail validation — that's a drift catcher. - const result = WsFrameSchema.safeParse({ - type: 'chat_status', - chat_id: VALID_UUID_A, - status: 'working', - at: VALID_TIMESTAMP, - }); - expect(result.success).toBe(false); - }); - - it('rejects a UUID field with a non-UUID string', () => { - const result = WsFrameSchema.safeParse({ - type: 'chat_status', - chat_id: 'not-a-uuid', - status: 'idle', - at: VALID_TIMESTAMP, - }); - expect(result.success).toBe(false); - }); - - it('rejects negative token counts in usage frame', () => { - const result = WsFrameSchema.safeParse({ - type: 'usage', - message_id: VALID_UUID_A, - chat_id: VALID_UUID_B, - completion_tokens: -1, - ctx_used: 100, - ctx_max: 1000, - }); - expect(result.success).toBe(false); - }); - - it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => { - const result = WsFrameSchema.safeParse({ - type: 'usage', - message_id: VALID_UUID_A, - chat_id: VALID_UUID_B, - completion_tokens: null, - ctx_used: null, - ctx_max: null, - }); - expect(result.success).toBe(true); - }); - - it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => { - // Model-emitted tool_call_ids look like "call_abc123", not UUIDs. - const result = WsFrameSchema.safeParse({ - type: 'tool_result', - tool_message_id: VALID_UUID_A, - chat_id: VALID_UUID_B, - tool_call_id: 'call_abc123', - output: { whatever: true }, - truncated: false, - }); - expect(result.success).toBe(true); - }); - - it('accepts a compacted frame', () => { - const result = WsFrameSchema.safeParse({ - type: 'compacted', - session_id: VALID_UUID_A, - chat_id: VALID_UUID_B, - summary_message_id: VALID_UUID_C, - }); - expect(result.success).toBe(true); - }); - - it('accepts a session_workspace_updated frame', () => { - const result = WsFrameSchema.safeParse({ - type: 'session_workspace_updated', - session_id: VALID_UUID_A, - workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }], - }); - expect(result.success).toBe(true); - }); - - it('accepts a message_complete frame with a null model (external coder, no model selected)', () => { - // Regression guard: the dispatcher publishes `model: task.model` (string | - // null). When null, this MUST validate or publishFrame fail-closes and drops - // the whole frame, incl. the status:'complete' transition. - const result = WsFrameSchema.safeParse({ - type: 'message_complete', - message_id: VALID_UUID_A, - chat_id: VALID_UUID_B, - model: null, - }); - expect(result.success).toBe(true); - }); - - it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => { - // Probe each known type by attempting a minimal valid construction. - // Failure here means the union and the KNOWN_FRAME_TYPES list drifted. - for (const type of KNOWN_FRAME_TYPES) { - const probe = WsFrameSchema.safeParse({ type, __dummy__: true }); - // We expect FAILURE on every type because we're missing required fields, - // but the failure must be ABOUT the missing fields, not about an unknown - // type. A "Invalid discriminator value" error means the type isn't in - // the union — that's a drift. - if (probe.success) continue; - const issues = probe.error.issues; - const hasInvalidDiscriminator = issues.some( - (i) => i.code === 'invalid_union_discriminator', - ); - expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false); - } - }); -}); - -describe('ws-frames.ts file mirror parity', () => { - it('apps/server and apps/web copies are byte-identical', () => { - const here = fileURLToPath(import.meta.url); - const serverPath = resolve(here, '../../../types/ws-frames.ts'); - const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts'); - const serverContent = readFileSync(serverPath, 'utf8'); - const webContent = readFileSync(webPath, 'utf8'); - expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent); - }); -}); - describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => { let logErrors: Array<{ obj: unknown; msg: string }>; let mockLog: Parameters[0]; diff --git a/apps/server/src/services/broker.ts b/apps/server/src/services/broker.ts index b4838df..eed859a 100644 --- a/apps/server/src/services/broker.ts +++ b/apps/server/src/services/broker.ts @@ -1,5 +1,5 @@ import type { FastifyBaseLogger } from 'fastify'; -import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js'; +import { WsFrameSchema, type WsFrame } from '@boocode/contracts/ws-frames'; export type Frame = Record & { type: string }; export type Listener = (frame: Frame) => void; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index ce03d45..936497f 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -25,19 +25,9 @@ export interface AvailableProject { export type SessionStatus = 'open' | 'archived'; -// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when -// a delete is blocked because the session's worktree holds work at risk. The -// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through -// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog. -export interface WorktreeRiskReport { - worktreePath: string; - branch: string; - dirty: boolean; - unpushed: number; // commits ahead of upstream, or -1 if no upstream - unmerged: number; // commits not in the project default branch - atRisk: boolean; - error?: string; -} +// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here. +import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk'; +export type { WorktreeRiskReport }; export interface Session { id: string; @@ -198,51 +188,10 @@ export interface ToolResult { error?: string; } -// v1.8.2: structured reason codes for failed inferences. `error` carries the -// human text; `reason` is the machine-readable discriminator the UI matches -// on (with `error` as fallback when reason is absent or unrecognized). -export type ErrorReason = - | 'llm_provider_error' - | 'tool_execution_failed' - | 'summary_after_cap_failed'; - -// v1.8.2 / v1.11.6: shapes stored in messages.metadata. Discriminated on `kind`. -// cap_hit — system sentinel emitted when tool budget is exhausted -// doom_loop — system sentinel emitted when the model called the same -// tool with the same args DOOM_LOOP_THRESHOLD times in a row -// mistake_recovery — system sentinel emitted when a run of consecutive -// *heterogeneous* tool failures is detected (#12). A nudge -// (escalated:false) injects model-facing recovery guidance -// and continues; an escalate (escalated:true) stops the -// turn after the nudge failed to break the failure run. -// error — attached to a failed assistant message so UI can show reason -export type MessageMetadata = - | { - kind: 'cap_hit'; - used: number; - limit: number; - agent_name: string | null; - can_continue: boolean; - } - | { - kind: 'doom_loop'; - tool_name: string; - args: Record; - threshold: number; - } - | { - // PINNED CONTRACT (#12) — mirrored byte-for-byte in apps/web/src/api/types.ts. - kind: 'mistake_recovery'; - failure_kinds: string[]; - count: number; - escalated: boolean; - can_continue?: boolean; - } - | { - kind: 'error'; - error_reason: ErrorReason; - error_text: string; - }; +// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in +// @boocode/contracts — edit the package, not here. +import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata'; +export type { ErrorReason, MessageMetadata }; export interface Message { id: string; diff --git a/apps/web/package.json b/apps/web/package.json index e434849..ee6654e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@boocode/contracts": "workspace:*", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@xterm/addon-fit": "0.10.0", diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 462f084..e91b70c 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -34,18 +34,8 @@ export interface AvailableProject { export type SessionStatus = 'open' | 'archived'; -// Session-delete work-loss guard. Mirror of WorktreeRiskReport in -// apps/server/src/types/api.ts — edit both copies together. Arrives as the -// `reports` field of the 409 body when a delete is blocked. -export interface WorktreeRiskReport { - worktreePath: string; - branch: string; - dirty: boolean; - unpushed: number; // commits ahead of upstream, or -1 if no upstream - unmerged: number; // commits not in the project default branch - atRisk: boolean; - error?: string; -} +// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here. +export type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk'; export interface Session { id: string; @@ -143,49 +133,10 @@ export interface ToolResult { error?: string; } -// v1.8.2: structured reason codes that flow through error frames / metadata. -// `error` text stays human; `reason` is the discriminator the UI matches on. -export type ErrorReason = - | 'llm_provider_error' - | 'tool_execution_failed' - | 'summary_after_cap_failed'; - -// v1.8.2 / v1.11.6: shapes stored in Message.metadata. Discriminated on `kind`. -// cap_hit — sentinel emitted when the tool budget is hit; carries the -// budget + agent name + whether Continue is still allowed. -// doom_loop — sentinel emitted when the model called the same tool with -// the same arguments threshold times in a row. -// mistake_recovery — sentinel emitted when the model hit repeated *different* -// errors; non-escalated means recovery guidance was injected and -// the turn continues, escalated means the turn was stopped. -// error — attached to a failed assistant message so the bubble can show -// a specific reason on reload (WS error frame is one-shot). -export type MessageMetadata = - | { - kind: 'cap_hit'; - used: number; - limit: number; - agent_name: string | null; - can_continue: boolean; - } - | { - kind: 'doom_loop'; - tool_name: string; - args: Record; - threshold: number; - } - | { - kind: 'mistake_recovery'; - failure_kinds: string[]; - count: number; - escalated: boolean; - can_continue?: boolean; - } - | { - kind: 'error'; - error_reason: ErrorReason; - error_text: string; - }; +// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in +// @boocode/contracts — edit the package, not here. +import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata'; +export type { ErrorReason, MessageMetadata }; export interface Message { id: string; @@ -239,80 +190,23 @@ export interface ModelInfo { [key: string]: unknown; } -export interface ProviderModel { - id: string; - label: string; - description?: string; - isDefault?: boolean; - thinkingOptions?: ThinkingOption[]; - defaultThinkingOptionId?: string; -} +export type { + ProviderModel, + ProviderMode, + ThinkingOption, + ProviderSnapshotStatus, + AgentCommand, + ProviderSnapshotEntry, +} from '@boocode/contracts/provider-snapshot'; -export interface ProviderMode { - id: string; - label: string; - description?: string; - isUnattended?: boolean; -} +export type { + ProviderOverride, + CoderProvidersFile, + ProviderConfigPatch, +} from '@boocode/contracts/provider-config'; -export interface ThinkingOption { - id: string; - label: string; - isDefault?: boolean; -} - -// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'. -export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error'; - -// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry -// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it). -export interface ProviderSnapshotEntry { - name: string; - label: string; - description?: string; - transport: string; - status: ProviderSnapshotStatus; - enabled: boolean; - installed: boolean; - models: ProviderModel[]; - modes: ProviderMode[]; - defaultModeId: string | null; - commands: AgentCommand[]; - error?: string; - fetchedAt?: string; -} - -// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred -// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts -// (web can't cross-import the coder package — TS6307 on the composite project). -export interface ProviderOverride { - extends?: 'acp'; - label?: string; - description?: string; - command?: string[]; - env?: Record; - enabled?: boolean; - order?: number; - models?: Array<{ id: string; label: string }>; - additionalModels?: Array<{ id: string; label: string }>; -} - -export interface CoderProvidersFile { - providers: Record; -} - -// PATCH body: a partial providers map. A `null` value deletes that id's -// override (revert to built-in default); an object replaces it wholesale. -export interface ProviderConfigPatch { - providers: Record; -} - -export interface AgentSessionConfig { - provider: string; - model: string; - modeId: string | null; - thinkingOptionId: string | null; -} +// AgentSessionConfig single-sourced in @boocode/contracts — edit the package, not here. +export type { AgentSessionConfig } from '@boocode/contracts/message-metadata'; export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation'; @@ -324,14 +218,6 @@ export interface PermissionPrompt { options: Array<{ optionId: string; label: string }>; } -export interface AgentCommand { - name: string; - description?: string; - // v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command). - // Drives the icon split in the coder slash menu. Undefined → command. - kind?: 'command' | 'skill'; -} - export interface CoderSendMessageBody { content: string; pane_id: string; diff --git a/apps/web/src/api/ws-frames.ts b/apps/web/src/api/ws-frames.ts deleted file mode 100644 index a156865..0000000 --- a/apps/web/src/api/ws-frames.ts +++ /dev/null @@ -1,408 +0,0 @@ -// v1.13.11-a: Zod schemas for every WebSocket frame published by the server. -// Validation runs both on send (broker.publishFrame / publishUserFrame) and -// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches -// silent protocol drift between publisher and consumer. -// -// IMPORTANT: This file is duplicated byte-identical at -// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and -// no path alias; the duplication is sync-by-hand. A test asserts the two -// files match. If you change one, change the other. -// -// Per-kind payload schemas (tool_call args, message_parts payloads, etc.) -// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal; -// deep payload validation is follow-up work. - -import { z } from 'zod'; - -// ---- shared primitives ----------------------------------------------------- - -const Uuid = z.string().uuid(); -// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs. -const ToolCallId = z.string().min(1); -// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not -// strings. The publish sites pass them through unchanged, so the schema must -// tolerate both. preprocess converts Date → ISO string before string-validation; -// on the web side (where frames arrive via JSON.parse) it's a no-op. Before -// this fix, every message_complete / session_updated / chat_updated frame -// failed validation and got dropped — symptoms: token tracking blank in UI, -// status stuck at 'streaming' tripping the 60s stale-stream banner. -const IsoTimestamp = z.preprocess( - (v) => (v instanceof Date ? v.toISOString() : v), - z.string().min(1), -); - -const ChatStatusValue = z.enum([ - 'streaming', - 'tool_running', - 'waiting_for_input', - 'idle', - 'error', -]); - -// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for -// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from -// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's -// dispatcher + permission flow on the per-session channel. -const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']); - -const ErrorReasonValue = z.enum([ - 'llm_provider_error', - 'doom_loop', - 'doom_loop_summary_failed', - 'cap_hit', - 'cap_hit_summary_failed', -]); - -const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']); - -const ToolCallShape = z.object({ - id: ToolCallId, - name: z.string().min(1), - args: z.record(z.string(), z.unknown()), -}); - -// Free-form bags: opaque to the frame schema; deep validation is out of -// scope for v1.13.11 (frame-level drift detection is the goal; per-kind -// payload narrowing is follow-up work). z.unknown() means the consumer -// must narrow before reading — TypeScript-side this is fine because every -// consumer already operates on the hand-maintained Project / Chat / Session -// / WorkspacePane types (the brief's "Don't strip existing types yet" -// rule), and the Zod-typed shape is only used at the publishFrame boundary. -const OpaqueObject = z.unknown(); - -// ---- per-session channel frames -------------------------------------------- - -export const SnapshotFrame = z.object({ - type: z.literal('snapshot'), - messages: z.array(OpaqueObject), -}); - -export const MessageStartedFrame = z.object({ - type: z.literal('message_started'), - message_id: Uuid, - chat_id: Uuid.optional(), - role: MessageRoleValue, -}); - -export const DeltaFrame = z.object({ - type: z.literal('delta'), - message_id: Uuid, - chat_id: Uuid.optional(), - content: z.string(), -}); - -export const ReasoningDeltaFrame = z.object({ - type: z.literal('reasoning_delta'), - message_id: Uuid, - chat_id: Uuid.optional(), - content: z.string(), -}); - -export const ToolCallFrame = z.object({ - type: z.literal('tool_call'), - message_id: Uuid, - chat_id: Uuid.optional(), - tool_call: ToolCallShape, -}); - -export const ToolResultFrame = z.object({ - type: z.literal('tool_result'), - tool_message_id: Uuid, - chat_id: Uuid.optional(), - tool_call_id: ToolCallId, - output: z.unknown(), - truncated: z.boolean(), - error: z.string().optional(), -}); - -export const MessageCompleteFrame = z.object({ - type: z.literal('message_complete'), - message_id: Uuid, - chat_id: Uuid.optional(), - tokens_used: z.number().int().nonnegative().nullable().optional(), - ctx_used: z.number().int().nonnegative().nullable().optional(), - ctx_max: z.number().int().positive().nullable().optional(), - started_at: IsoTimestamp.nullable().optional(), - finished_at: IsoTimestamp.nullable().optional(), - // nullable: external-coder turns carry task.model, which is null when no - // model was selected. This frame is published through the same fail-closed - // publishFrame, so null MUST validate or the entire frame (incl. the - // status:'complete' transition) is dropped. - model: z.string().nullable().optional(), - metadata: OpaqueObject.nullable().optional(), -}); - -export const UsageFrame = z.object({ - type: z.literal('usage'), - message_id: Uuid, - chat_id: Uuid.optional(), - completion_tokens: z.number().int().nonnegative().nullable(), - ctx_used: z.number().int().nonnegative().nullable(), - ctx_max: z.number().int().positive().nullable(), -}); - -export const MessagesDeletedFrame = z.object({ - type: z.literal('messages_deleted'), - message_ids: z.array(Uuid), - chat_id: Uuid.optional(), -}); - -export const ChatRenamedFrame = z.object({ - type: z.literal('chat_renamed'), - chat_id: Uuid, - name: z.string(), -}); - -export const CompactedFrame = z.object({ - type: z.literal('compacted'), - session_id: Uuid, - chat_id: Uuid, - summary_message_id: Uuid, -}); - -export const ErrorFrame = z.object({ - type: z.literal('error'), - message_id: Uuid.optional(), - chat_id: Uuid.optional(), - error: z.string(), - reason: ErrorReasonValue.optional(), -}); - -// ---- per-user channel frames (sidebar refresh) ----------------------------- - -export const ChatStatusFrame = z.object({ - type: z.literal('chat_status'), - chat_id: Uuid, - status: ChatStatusValue, - at: IsoTimestamp, - reason: ErrorReasonValue.optional(), -}); - -export const SessionUpdatedFrame = z.object({ - type: z.literal('session_updated'), - session_id: Uuid, - project_id: Uuid, - name: z.string(), - updated_at: IsoTimestamp, -}); - -export const SessionRenamedFrame = z.object({ - type: z.literal('session_renamed'), - session_id: Uuid, - name: z.string(), -}); - -export const SessionCreatedFrame = z.object({ - type: z.literal('session_created'), - session: OpaqueObject, - project_id: Uuid, -}); - -export const SessionArchivedFrame = z.object({ - type: z.literal('session_archived'), - session_id: Uuid, - project_id: Uuid, -}); - -export const SessionDeletedFrame = z.object({ - type: z.literal('session_deleted'), - session_id: Uuid, - project_id: Uuid, -}); - -export const SessionWorkspaceUpdatedFrame = z.object({ - type: z.literal('session_workspace_updated'), - session_id: Uuid, - // v2.6.x: widened from z.array — the payload is now either the legacy bare - // WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers + - // nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop - // every envelope frame at validation. MUST be mirrored in the server's - // byte-identical copy (parity test). - workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]), -}); - -export const ChatCreatedFrame = z.object({ - type: z.literal('chat_created'), - chat: OpaqueObject, - session_id: Uuid, -}); - -export const ChatUpdatedFrame = z.object({ - type: z.literal('chat_updated'), - chat_id: Uuid, - session_id: Uuid, - name: z.string().nullable(), - updated_at: IsoTimestamp, -}); - -export const ChatArchivedFrame = z.object({ - type: z.literal('chat_archived'), - chat_id: Uuid, - session_id: Uuid, -}); - -export const ChatUnarchivedFrame = z.object({ - type: z.literal('chat_unarchived'), - chat: OpaqueObject, -}); - -export const ChatDeletedFrame = z.object({ - type: z.literal('chat_deleted'), - chat_id: Uuid, - session_id: Uuid, -}); - -export const ProjectCreatedFrame = z.object({ - type: z.literal('project_created'), - project: OpaqueObject, -}); - -export const ProjectArchivedFrame = z.object({ - type: z.literal('project_archived'), - project_id: Uuid, -}); - -export const ProjectUnarchivedFrame = z.object({ - type: z.literal('project_unarchived'), - project: OpaqueObject, -}); - -export const ProjectUpdatedFrame = z.object({ - type: z.literal('project_updated'), - project_id: Uuid, - name: z.string(), -}); - -export const ProjectDeletedFrame = z.object({ - type: z.literal('project_deleted'), - project_id: Uuid, -}); - -const PermissionOptionShape = z.object({ - option_id: z.string(), - label: z.string(), -}); - -export const PermissionRequestedFrame = z.object({ - type: z.literal('permission_requested'), - task_id: Uuid, - session_id: Uuid, - kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(), - tool_title: z.string().optional(), - input: z.record(z.unknown()).optional(), - options: z.array(PermissionOptionShape), -}); - -export const PermissionResolvedFrame = z.object({ - type: z.literal('permission_resolved'), - task_id: Uuid, - session_id: Uuid, -}); - -const AgentCommandShape = z.object({ - name: z.string(), - description: z.string().optional(), -}); - -export const AgentCommandsFrame = z.object({ - type: z.literal('agent_commands'), - task_id: Uuid, - session_id: Uuid, - commands: z.array(AgentCommandShape), -}); - -// agent-status-normalize (#10): published by BooCoder on the per-session channel -// when an external agent's normalized status changes (turn start/end, permission -// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per -// pair and resets on chat switch. `reason` is a free-form discriminator -// (turn_start / turn_complete / failed / crashed / permission_request / -// permission_resolved). -export const AgentStatusUpdatedFrame = z.object({ - type: z.literal('agent_status_updated'), - chat_id: Uuid, - agent: z.string().min(1), - status: AgentStatusValue, - reason: z.string().optional(), - at: IsoTimestamp, -}); - -// ---- discriminated union --------------------------------------------------- - -export const WsFrameSchema = z.discriminatedUnion('type', [ - // per-session - SnapshotFrame, - MessageStartedFrame, - DeltaFrame, - ReasoningDeltaFrame, - ToolCallFrame, - ToolResultFrame, - MessageCompleteFrame, - UsageFrame, - MessagesDeletedFrame, - ChatRenamedFrame, - CompactedFrame, - ErrorFrame, - PermissionRequestedFrame, - PermissionResolvedFrame, - AgentCommandsFrame, - AgentStatusUpdatedFrame, - // per-user - ChatStatusFrame, - SessionUpdatedFrame, - SessionRenamedFrame, - SessionCreatedFrame, - SessionArchivedFrame, - SessionDeletedFrame, - SessionWorkspaceUpdatedFrame, - ChatCreatedFrame, - ChatUpdatedFrame, - ChatArchivedFrame, - ChatUnarchivedFrame, - ChatDeletedFrame, - ProjectCreatedFrame, - ProjectArchivedFrame, - ProjectUnarchivedFrame, - ProjectUpdatedFrame, - ProjectDeletedFrame, -]); - -export type WsFrame = z.infer; - -// Convenience: the set of known frame types. Useful for the publishFrame -// helper to log the offending type name when validation fails. Kept in sync -// by hand with the discriminated union above. -export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ - 'snapshot', - 'message_started', - 'delta', - 'reasoning_delta', - 'tool_call', - 'tool_result', - 'message_complete', - 'usage', - 'messages_deleted', - 'chat_renamed', - 'compacted', - 'error', - 'permission_requested', - 'permission_resolved', - 'agent_commands', - 'agent_status_updated', - 'chat_status', - 'session_updated', - 'session_renamed', - 'session_created', - 'session_archived', - 'session_deleted', - 'session_workspace_updated', - 'chat_created', - 'chat_updated', - 'chat_archived', - 'chat_unarchived', - 'chat_deleted', - 'project_created', - 'project_archived', - 'project_unarchived', - 'project_updated', - 'project_deleted', -] as const; diff --git a/apps/web/src/hooks/useSessionStream.ts b/apps/web/src/hooks/useSessionStream.ts index 4e1d3ec..60153ce 100644 --- a/apps/web/src/hooks/useSessionStream.ts +++ b/apps/web/src/hooks/useSessionStream.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import type { Message, WsFrame } from '@/api/types'; -import { WsFrameSchema } from '@/api/ws-frames'; +import { WsFrameSchema } from '@boocode/contracts/ws-frames'; import { api } from '@/api/client'; import { sessionEvents } from './sessionEvents'; import { recordUsage } from './useChatThroughput'; diff --git a/apps/web/src/hooks/useUserEvents.ts b/apps/web/src/hooks/useUserEvents.ts index 670aed2..d4e76a2 100644 --- a/apps/web/src/hooks/useUserEvents.ts +++ b/apps/web/src/hooks/useUserEvents.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { WsFrameSchema } from '@/api/ws-frames'; +import { WsFrameSchema } from '@boocode/contracts/ws-frames'; import { sessionEvents } from './sessionEvents'; import { createWsReconnectToast } from './wsReconnectToast'; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9ac8403..957fd58 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,7 +7,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './styles/globals.css'; - ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0cd11d..176fe4f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,5 +120,5 @@ See [coder-backends.md](./coder-backends.md) for the full dispatch-backend refer ## Deploy topology - **BooChat + BooTerm + Postgres + codecontext:** `docker compose up --build -d` from `/opt/boocode` -- **BooCoder:** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` +- **BooCoder:** `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` - **Ports bind to Tailscale IP** `100.114.205.53`, not `0.0.0.0` — use that IP for host smoke curls diff --git a/docs/DEFERRED-WORK.md b/docs/DEFERRED-WORK.md index b549cf9..dbad639 100644 --- a/docs/DEFERRED-WORK.md +++ b/docs/DEFERRED-WORK.md @@ -152,16 +152,18 @@ These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open: - **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1). - **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2). -- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below). +- ~~**Single-source-of-truth shared types package**~~ — **shipped as `@boocode/contracts`** (branch `contracts-ssot-pkg`): all duplicated cross-app contracts (ws-frames schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`) are now single-sourced in `packages/contracts/`; `provider-types-parity.test.ts` and the byte-parity test were deleted. See §3 below (now historical). - **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11). --- -## 3. Unified `packages/types` for provider snapshot JSON +## 3. ~~Unified `packages/types` for provider snapshot JSON~~ (resolved — shipped as `@boocode/contracts`) -### Current behavior +> **Status: resolved.** All contracts described below are now single-sourced in `packages/contracts/` (`@boocode/contracts`), shipped on branch `contracts-ssot-pkg`. The rest of this section is historical. -Provider snapshot shapes are **duplicated** (not byte-identical exports): +### Former behavior (pre-`@boocode/contracts`) + +Provider snapshot shapes were **duplicated** (not byte-identical exports): | Location | Types | |----------|-------| diff --git a/docs/STALE-DEPRECATED.md b/docs/STALE-DEPRECATED.md index 7f5827c..b686e31 100644 --- a/docs/STALE-DEPRECATED.md +++ b/docs/STALE-DEPRECATED.md @@ -74,4 +74,4 @@ _No open stale items from the 2026-05-26 review._ - **Task cancel → abort external ACP/PTY child** — `AbortController` in dispatcher not wired to cancel route - **Skip ACP cold probe when DB models fresh** — perf; changes snapshot semantics -- **Unified `packages/types`** for provider snapshot JSON — Zod parity test may suffice +- ~~**Unified `packages/types`** for provider snapshot JSON~~ — **shipped as `@boocode/contracts`** (`packages/contracts/`); all duplicated contracts are now single-sourced there diff --git a/docs/coder-backends.md b/docs/coder-backends.md index 0220e58..3401742 100644 --- a/docs/coder-backends.md +++ b/docs/coder-backends.md @@ -140,7 +140,7 @@ No matter which backend runs, the turn streams the same way. Each backend emits | `apps/coder/.env.host` | Production env (DATABASE_URL, LLAMA_SWAP_URL, CODER_PROVIDERS_PATH, CLAUDE_SDK_BACKEND, …) | | `data/coder-providers.json` | Live runtime provider overrides (gitignored); template is `data/coder-providers.example.json` | -**Build & deploy.** `apps/coder` imports the server's compiled `dist/` (`createInferenceRunner`, `createBroker`, `ALL_TOOLS`), so **`apps/server` must build first**: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. The server's `package.json` `exports` map needs both `types` and `default` conditions per subpath (and `declaration: true` in its tsconfig) or NodeNext can't find the `.d.ts` and tsc fails "Cannot find module" here. Agent dispatch spawns binaries **directly** — `spawn(fullBinaryPath, argsArray, { cwd })` using `install_path` — never `spawn('sh', ['-c', ...])`, which fails under systemd. +**Build & deploy.** `apps/coder` imports from both `@boocode/contracts` and the server's compiled `dist/` (`createInferenceRunner`, `createBroker`, `ALL_TOOLS`), so **`packages/contracts` and `apps/server` must build first**: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. The server's `package.json` `exports` map needs both `types` and `default` conditions per subpath (and `declaration: true` in its tsconfig) or NodeNext can't find the `.d.ts` and tsc fails "Cannot find module" here. Agent dispatch spawns binaries **directly** — `spawn(fullBinaryPath, argsArray, { cwd })` using `install_path` — never `spawn('sh', ['-c', ...])`, which fails under systemd. ## Configuration @@ -267,7 +267,7 @@ interface AgentBackend { `AcpToolSnapshot` (`apps/coder/src/services/acp-tool-snapshot.ts`) is the accumulating shape for a tool call — `{ toolCallId, title, kind?, status?, rawInput?, rawOutput? }` — merged incrementally and rendered via `snapshotToWireToolCall`. -The provider picker is driven by `ProviderSnapshotEntry` / `AgentCommand` in `apps/coder/src/services/provider-types.ts`, which must stay byte-identical to the web copy in `apps/web/src/api/types.ts` (see Testing). +The provider picker is driven by `ProviderSnapshotEntry` / `AgentCommand` single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`). `apps/coder/src/services/provider-types.ts` re-exports them; the web imports them directly. There is no hand-synced copy to keep in sync. ### Constants @@ -332,7 +332,7 @@ The picker is built by a four-stage pipeline: `provider-config.ts` (never-throws - `services/backends/__tests__/turn-guard.test.ts` — abort orphan-terminal suppression - `services/backends/__tests__/lifecycle-decisions.test.ts` — idle/LRU/restart eviction - `services/__tests__/acp-event-map.test.ts` / `acp-tool-snapshot.test.ts` — ACP normalization + snapshot merge -- `services/__tests__/provider-types-parity.test.ts` — text-identity parity between `provider-types.ts` and the web `api/types.ts` copy (compile-time cross-import is blocked by TS6307 on web's composite tsconfig) +- `services/__tests__/provider-types-parity.test.ts` — **deleted**: provider snapshot types are now single-sourced in `@boocode/contracts/provider-snapshot`; parity is enforced by the single definition - `services/__tests__/write_guard.test.ts` (+ `_fuzz`) — path escape + secret-file blocking ### Adding a new backend diff --git a/docs/coding-standards/cross-app-contract-parity.md b/docs/coding-standards/cross-app-contract-parity.md index 7736d4c..a4a8cf2 100644 --- a/docs/coding-standards/cross-app-contract-parity.md +++ b/docs/coding-standards/cross-app-contract-parity.md @@ -1,7 +1,8 @@ --- paths: - - "apps/server/src/types/ws-frames.ts" - - "apps/web/src/api/ws-frames.ts" + - "packages/contracts/src/ws-frames.ts" + - "packages/contracts/src/provider-snapshot.ts" + - "packages/contracts/src/message-metadata.ts" - "apps/server/src/types/api.ts" - "apps/web/src/api/types.ts" - "apps/coder/src/services/provider-types.ts" @@ -11,7 +12,7 @@ paths: # Cross-App Contract Parity -*Reach for this when a parity test goes red (`ws-frames.test.ts`, `provider-types-parity.test.ts`), a reviewer flags a "half-synced" type, or a frame/sentinel "does nothing" at runtime — i.e. one copy of a duplicated cross-app contract drifted from the other. The fix-it path is [When to Apply](#when-to-apply) + its Verification step.* +*Reach for this when adding a new WS frame type, a new sentinel `kind`, or touching the server's `InferenceFrame` loose union or the web's strict `WsFrame` union — contracts whose primary definition now lives in `@boocode/contracts` but that still have split secondary representations in the apps. The fix-it path is [When to Apply](#when-to-apply) + its Verification step.* - **Status:** proposed - **Date Created:** 2026-06-02 00:00 @@ -20,19 +21,19 @@ paths: - indifferentketchup (samkintop@gmail.com) - **Reviewers:** - **Applies To:** - - Every hand-synced type/schema contract that crosses the `apps/server` ↔ `apps/web` ↔ `apps/coder` boundary in the files under `paths:`. The primary examples are the WS-frame Zod schema, the provider-snapshot types, and the sentinel `MessageMetadata` union plus its `MessageBubble` render arm — but the same rule governs the other duplicated pairs in these files (`WorktreeRiskReport`, the provider-config wire types, and the interface-typed `WsFrame` union that mirrors the Zod schema). + - The remaining split contracts that cross app boundaries: the server's `InferenceFrame` loose publish union (`services/inference/turn.ts`), the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`), and the `MessageBubble` sentinel render arm (`apps/web/src/components/MessageBubble.tsx`). The primary contracts (WS-frame Zod schema, provider-snapshot types, `MessageMetadata`, `WorktreeRiskReport`, provider-config schemas) are now **single-sourced in `@boocode/contracts`** — those are governed by editing the package, not by this sync-both-copies rule. ## Introduction -Several wire contracts in BooCode exist as **two or three hand-synced copies** in different apps, because the apps have separate `tsconfig`s with no shared path alias and a composite-project restriction (TS6307) that structurally blocks importing one app's types from another. There is no shared workspace package for these types yet. This standard governs what you must do when you touch one of those copies: **change every copy in the same commit** — and, where the contract has no compile-time consumer guarantee (the sentinel render arm), the consumer too. +The primary wire contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`, `AgentSessionConfig`) are now **single-sourced in `@boocode/contracts`** — edit `packages/contracts/src/.ts` and rebuild the package. There is no second copy to sync for those contracts. -The three families in [Coding Standard](#coding-standard) are the primary examples, but the rule applies to **every** hand-synced pair in the files under `paths:`, each of which carries its own in-code `edit both copies` / `Mirror of …` / `KEEP IN SYNC` marker. Beyond the three: `WorktreeRiskReport` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`), the provider-config wire types (`ProviderOverride` / `CoderProvidersFile`, web mirror of the coder's Zod-inferred shapes), and — note this one — a **second** representation of the WS wire shape: the interface-typed `WsFrame` union in `apps/web/src/api/types.ts` plus the `*Frame` interfaces in `apps/server/src/types/api.ts`, which is distinct from the byte-identical Zod `ws-frames.ts` pair and is **not** covered by the byte-parity test. A WS frame's shape therefore lives in more than one place; treat all of them as one contract. +What this standard still governs: adding a new WS frame type or a new sentinel `kind` touches contracts whose primary definition is in the package but that also have split secondary representations in the apps. The server's `InferenceFrame` loose publish union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) both still exist separately from the canonical `WsFrameSchema` in the package, and must be updated together with it. The sentinel `MessageBubble` render arm (`apps/web/src/components/MessageBubble.tsx`) has no compile-time guard and still needs updating when a new `MessageMetadata` kind is added to the package. ### Purpose - **Primary:** prevent *silent runtime* contract breakage. Nothing at compile time links the copies — each app type-checks against its own copy, so `tsc` stays green when they drift. The failure surfaces only at runtime, and silently: a WS frame whose `type` exists on one side but not the other is **dropped at JSON-parse** with no error; a sentinel `kind` added without a render arm shows nothing. Editing every copy in lockstep is the only thing that keeps the contract whole. -- **Secondary:** two of the three contracts have runtime parity tests (`ws-frames.test.ts`, `provider-types-parity.test.ts`) that catch drift in the test run — but they are a backstop, not the mechanism, and the sentinel triple has no test at all. -- **Side effect:** keeping the copies byte- or text-identical makes a contract change reviewable as a matched diff across files. +- **Secondary:** the `@boocode/contracts` package's `ws-frames.test.ts` tests schema correctness (accept/reject behavior) and the `KNOWN_FRAME_TYPES` drift probe. `provider-types-parity.test.ts` was deleted when the provider-snapshot types moved to the package. The sentinel render arm still has no automated test. +- **Side effect:** for the remaining split representations (server `InferenceFrame`, web `WsFrame`), updating them together with the package source in a single commit makes the change reviewable as a matched diff across files. ### Scope @@ -43,33 +44,33 @@ The specific duplicated contracts listed in `paths:` above, inside the `apps/ser Walk this before editing a type, schema, enum, or metadata union: 1. **Does this shape exist as a copy in another app?** — Check: `grep -rn "" apps/*/src`. If it appears under two or more of `apps/server`, `apps/web`, `apps/coder` → continue. If it lives in exactly one app → see "When NOT to Apply". -2. **Are you changing its wire shape?** — adding, removing, renaming, or re-typing a field; adding/removing a frame `type`; adding an enum value or a sentinel `kind`. If yes → apply this standard: edit **every** copy, plus every consumer that switches on the shape, in the **same commit**. If no (a comment or formatting change that the contract's parity test normalizes away) → see "When NOT to Apply". +2. **Are you changing its wire shape?** — adding, removing, renaming, or re-typing a field; adding/removing a frame `type`; adding an enum value or a sentinel `kind`. If yes → apply this standard: update the `@boocode/contracts` package source, rebuild, and also update every secondary app-side representation (the server `InferenceFrame` loose union, the web `WsFrame` strict union, and the `MessageBubble` render arm for sentinel kinds), in the **same commit**. If no (a comment or formatting change) → see "When NOT to Apply". -**Exception — the sentinel/consumer triple:** `MessageMetadata` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`) has **no parity test**, and a new `kind` is inert until it gets a render branch in `apps/web/src/components/MessageBubble.tsx`. When the shape you are editing is `MessageMetadata`, "every copy" includes that render arm — there is no test to remind you. +**Exception — the sentinel render arm:** `MessageMetadata` is single-sourced in `@boocode/contracts/message-metadata` (one definition, no second copy). However, a new `kind` is inert until it gets a render branch in `apps/web/src/components/MessageBubble.tsx`. There is no test to catch a missing render arm — when adding a `kind`, the render branch in `MessageBubble.tsx` is the one consumer you must update manually. **Verification step:** run the guards that exist *now*, before you commit: ```bash -# The trailing arg is a FILE-PATH substring filter for `vitest run` (not a test -# name). A typo matches zero files and still exits 0 — a false green — so confirm -# the run actually executed the file (look for "1 passed" on the named file). -pnpm -C apps/server test ws-frames.test # WS-frame byte-parity + KNOWN_FRAME_TYPES drift -pnpm -C apps/coder test provider-types-parity # provider-snapshot text-parity (incl. nested blocks) -# Sentinel triple has no test — grep all copies for a NEW rendering kind: -grep -rn "" apps/server/src/types/api.ts apps/web/src/api/types.ts apps/web/src/components/MessageBubble.tsx +# Build the contracts package first, then run its tests. +pnpm -C packages/contracts build +pnpm -C packages/contracts test ws-frames.test # WS-frame schema correctness + KNOWN_FRAME_TYPES drift +# provider-types-parity.test.ts was deleted — provider snapshot types are in @boocode/contracts + +# Sentinel render arm has no test — grep for the new kind in the package definition and MessageBubble: +grep -rn "" packages/contracts/src/message-metadata.ts apps/web/src/components/MessageBubble.tsx ``` -For a **rendering** sentinel kind (`cap_hit` / `doom_loop` / `mistake_recovery`) the new `kind` must appear in all three files. The non-rendering `error` arm of `MessageMetadata` lives in the two type copies only — it has no `MessageBubble` branch — so for it the grep should match the two `api.ts`/`types.ts` copies, not `MessageBubble.tsx`. +For a **rendering** sentinel kind (`cap_hit` / `doom_loop` / `mistake_recovery`) the new `kind` must appear in `packages/contracts/src/message-metadata.ts` and have a render branch in `MessageBubble.tsx`. The non-rendering `error` arm has no `MessageBubble` branch — for it, only the package definition needs updating. ## When NOT to Apply - **The type lives in a single app.** Internal server types, web-only view models, coder-only helpers — there is no second copy, so there is nothing to sync. Edit the one definition directly; do **not** manufacture a duplicate in another app "for symmetry." A new cross-app contract should prefer the eventual shared package or, at minimum, ship with its own parity test — not a third hand-synced copy. -- **A comment- or whitespace-only edit to a *text-parity* file.** `provider-types-parity.test.ts` strips comments and blank lines before comparing, so a comment-only change to one provider-types copy is tolerated and you needn't chase the other. (This relief does **not** apply to `ws-frames.ts`, which is compared **byte-for-byte** — every character, including comments, must match.) -- **The shared workspace package lands.** This standard exists *only* because the single source of truth was deferred (a Tier-2 follow-up noted in `provider-types-parity.test.ts`). Once these types move into one shared package, delete the hand-syncing rule rather than keep paying it — the SSOT supersedes this standard. +- **A comment- or whitespace-only edit to a `@boocode/contracts` source file.** The package's `ws-frames.test.ts` tests behavior and structure, not formatting, so a comment-only change does not require secondary app-side updates. +- **The type lives in `@boocode/contracts` and has no split secondary representations.** For the migrated contracts (WS-frame Zod schema, provider-snapshot, provider-config, `MessageMetadata`, `WorktreeRiskReport`) the package IS the single source of truth. Edit the package source; there is nothing to sync in the apps. The split representations that still require multi-file changes are the server `InferenceFrame` loose union, the web `WsFrame` strict union, and the `MessageBubble` render arm — all documented in [When to Apply](#when-to-apply). ## Background -The duplication is deliberate, not accidental. A compile-time bidirectional-assignability check was attempted first — a web-side file importing the coder's import-free `provider-types.ts` — but `apps/web/tsconfig.app.json` is a composite project and rejects out-of-include files with **TS6307**, so cross-project type import is structurally blocked. The team chose hand-synced copies guarded by runtime tests over a premature shared package. The WS-frame copies go further and are kept **byte-identical** so a single `readFileSync` equality test can guard them; the provider-snapshot copies are kept **text-identical per named type block** (comments normalized away) because they sit among unrelated types. The cost of this choice is exactly what this standard manages: a copy can drift, and because each app compiles independently, only a runtime test — or a runtime bug — reveals it. +*The history below explains why the duplication existed, not the current state.* The primary contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`) were formerly hand-synced copies guarded by runtime parity tests — because `apps/web/tsconfig.app.json` is a composite project and rejects out-of-include files with **TS6307**, blocking cross-project type import. The WS-frame copies were kept **byte-identical** so a single `readFileSync` equality test could guard them; provider-snapshot copies were kept **text-identical per named block**. These contracts have since moved to `@boocode/contracts` (the `packages/contracts` workspace package), the hand-sync discipline and the byte-parity test are retired, and drift is prevented by there being exactly one definition. The split representations that remain (server `InferenceFrame` loose union, web `WsFrame` strict union, `MessageBubble` render arm) still require lockstep edits when frame types or sentinel kinds are added — that is what this standard now governs. ## Coding Standard @@ -77,41 +78,35 @@ The duplication is deliberate, not accidental. A compile-time bidirectional-assi When you change one copy of a duplicated contract, change the others in the same commit. Each contract family has its own home files and its own (or no) guard. -**WS frame schema — `apps/server/src/types/ws-frames.ts` ↔ `apps/web/src/api/ws-frames.ts` (byte-identical):** +**WS frame schema — single-sourced at `packages/contracts/src/ws-frames.ts` (imported as `@boocode/contracts/ws-frames`):** ```typescript -// PRIMARY: no compile-time link exists across apps (separate tsconfigs, TS6307 -// blocks cross-import). A frame type added to one copy but not the other breaks -// silently at runtime — the frontend drops the frame at JSON-parse. So this file -// and apps/web/src/api/ws-frames.ts MUST stay byte-identical, in the same commit. -// -// IMPORTANT: This file is duplicated byte-identical at -// apps/web/src/api/ws-frames.ts. ... If you change one, change the other. -// -// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES (a drift test -// probes every entry for a discriminated branch). +// Single source of truth: WsFrameSchema (Zod), WsFrame (z.infer), KNOWN_FRAME_TYPES. +// No second copy to sync. When adding a frame type: +// 1. Add it here (the canonical schema definition) and rebuild the package. +// 2. Also add to the server's InferenceFrame loose union (services/inference/turn.ts). +// 3. Also add to the web's strict WsFrame discriminated union (apps/web/src/api/types.ts). +// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES — the package +// test probes every entry for a discriminated branch. ``` -**Provider snapshot types — `apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`, text-identical per block.** By convention you author on the coder side and mirror to web (the in-code `KEEP IN SYNC` markers point that way), but the parity test is **symmetric** — it fails on drift in *either* file and names no authoritative copy, so "fix the red test" means re-sync the two, not edit one in particular: +**Provider snapshot types — single-sourced at `packages/contracts/src/provider-snapshot.ts` (imported as `@boocode/contracts/provider-snapshot`):** ```typescript -// PRIMARY: nothing links these two copies at compile time — a field added here -// but not in apps/web/src/api/types.ts breaks silently at runtime (the web side -// drops or mis-reads the snapshot). The in-file marker, with its test backstop: -// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity -// is enforced by __tests__/provider-types-parity.test.ts (fails on field drift). -// Applies to the nested ProviderModel / ProviderMode / ThinkingOption / -// AgentCommand / ProviderSnapshotStatus blocks the entry references, too. +// Single source of truth for ProviderSnapshotEntry, ProviderModel, ProviderMode, +// ThinkingOption, AgentCommand, ProviderSnapshotStatus. No second copy to sync. +// apps/coder/src/services/provider-types.ts re-exports from this package. +// provider-types-parity.test.ts was deleted — drift is prevented by the single definition. export interface ProviderSnapshotEntry { /* ...fields... */ } ``` -**Sentinel metadata — `apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`, plus the render arm in `apps/web/src/components/MessageBubble.tsx` (no parity test):** +**Sentinel metadata — `packages/contracts/src/message-metadata.ts` (single source) plus the render arm in `apps/web/src/components/MessageBubble.tsx` (no automated test):** ```typescript -// A new *rendering* sentinel kind is a THREE-file change with NO test to catch a miss: -// 1. apps/server/src/types/api.ts — add the arm to MessageMetadata -// 2. apps/web/src/api/types.ts — add the identical arm -// 3. MessageBubble.tsx — add the render branch, else it shows nothing +// MessageMetadata is single-sourced in @boocode/contracts/message-metadata. +// A new *rendering* sentinel kind is a TWO-step change with NO test to catch a miss: +// 1. packages/contracts/src/message-metadata.ts — add the arm to MessageMetadata, rebuild +// 2. MessageBubble.tsx — add the render branch, else it shows nothing // The real union has FOUR arms; show it whole so nobody reads two as the full set: export type MessageMetadata = | { kind: 'cap_hit'; /* used, limit, agent_name, can_continue */ } @@ -129,37 +124,37 @@ export type MessageMetadata = **What to avoid:** ```typescript -// ANTI-PATTERN: editing one copy only. -// Add a new frame type to apps/web/src/api/ws-frames.ts but not the server copy -// (or vice versa): tsc stays green — they're separate projects — but the parity -// test fails, and had it not existed, the server would publish a frame the -// frontend silently discards at JSON-parse. A half-edited contract is invisible -// to the type-checker; never land one. +// ANTI-PATTERN: editing only the package schema without updating the app-side representations. +// Add a new frame type to packages/contracts/src/ws-frames.ts but not to the +// web's strict WsFrame union (apps/web/src/api/types.ts): tsc stays green because +// they're separate projects, but the frontend silently discards the frame at +// JSON-parse. A half-edited contract is invisible to the type-checker; never land one. ``` **Project references:** -- `apps/server/src/types/ws-frames.ts` — the byte-identical sync comment (top of file) and `KNOWN_FRAME_TYPES`. -- `apps/web/src/api/ws-frames.ts` — the web copy that must match it byte-for-byte. -- `apps/coder/src/services/provider-types.ts` — the `KEEP IN SYNC` comment above `ProviderSnapshotEntry`. -- `apps/web/src/api/types.ts` — the provider-snapshot wire copy and the `MessageMetadata` copy. +- `packages/contracts/src/ws-frames.ts` — `WsFrameSchema`, `WsFrame`, `KNOWN_FRAME_TYPES` (via `@boocode/contracts/ws-frames`). The two former app-level `ws-frames.ts` copies are deleted. +- `packages/contracts/src/provider-snapshot.ts` — provider snapshot types (via `@boocode/contracts/provider-snapshot`). The former web mirror block in `apps/web/src/api/types.ts` and the former coder copy in `provider-types.ts` are deleted; `provider-types.ts` now re-exports from the package. +- `packages/contracts/src/message-metadata.ts` — `MessageMetadata`, `ErrorReason` (via `@boocode/contracts/message-metadata`). The former copies in `apps/server/src/types/api.ts` and `apps/web/src/api/types.ts` are deleted. +- `apps/web/src/api/types.ts` — the web-local strict `WsFrame` discriminated union (still maintained separately from the canonical schema in the package). - `apps/web/src/components/MessageBubble.tsx` — the sentinel render arms (`metadata?.kind` branches). ### A wire-shape change passes through the gate, then a consumer A frame is published by the server's permissive `InferenceFrame` union (`apps/server/src/services/inference/turn.ts`) but only reaches the UI if the strict schema/union accepts it — permissive publish, strict receive. Keep the **type/schema copies** (this standard's scope) in lockstep so the frame survives validation; then make sure something consumes it. -> **Where consumer-wiring fits.** This standard governs the duplicated *type/schema* copies and the one consumer with no compile-time guard — the sentinel `MessageBubble` render arm. A new WS frame additionally needs a runtime handler to *do* anything: `applyFrame` in `apps/web/src/hooks/useSessionStream.ts` (per-session frames) and `useUserEvents` (user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed by `apps/web/CLAUDE.md`, not by this parity standard. A frame that passes the byte-parity test but has no reducer `case` validates and is then silently ignored. +> **Where consumer-wiring fits.** This standard governs the duplicated *type/schema* copies and the one consumer with no compile-time guard — the sentinel `MessageBubble` render arm. A new WS frame additionally needs a runtime handler to *do* anything: `applyFrame` in `apps/web/src/hooks/useSessionStream.ts` (per-session frames) and `useUserEvents` (user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed by `apps/web/CLAUDE.md`, not by this parity standard. A frame that passes `WsFrameSchema` validation but has no reducer `case` is then silently ignored. **Correct usage:** ```typescript // Adding a WS frame type, all in one commit: +// - packages/contracts/src/ws-frames.ts — WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES (rebuild package) // - apps/server/src/services/inference/turn.ts — loose InferenceFrame publish union (+ optional fields) -// - apps/server/src/types/ws-frames.ts — strict WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES -// - apps/web/src/api/ws-frames.ts — byte-identical copy of the strict gate -// The strict web-side type is the wire-format gate: a frame whose type isn't in +// - apps/web/src/api/types.ts — strict WsFrame discriminated union (the web gate) +// The web strict WsFrame is the wire-format gate: a frame whose type isn't in // it is dropped at JSON-parse. The loose publish union and the strict gate are -// BOTH required — permissive publish, strict receive. +// BOTH required — permissive publish, strict receive. The canonical schema in the +// package is validated by the broker fail-closed (Zod) on every publish. ``` **What to avoid:** @@ -172,25 +167,26 @@ A frame is published by the server's permissive `InferenceFrame` union (`apps/se ``` **Project references:** +- `packages/contracts/src/ws-frames.ts` — `WsFrameSchema` (the broker's fail-closed validation gate) + `WsFrame` + `KNOWN_FRAME_TYPES`. - `apps/server/src/services/inference/turn.ts` — the loose `InferenceFrame` publish union. -- `apps/server/src/types/ws-frames.ts` — `WsFrameSchema` (the broker's fail-closed validation gate) + `KNOWN_FRAME_TYPES`. +- `apps/web/src/api/types.ts` — the web-local strict `WsFrame` discriminated union. - `apps/web/src/components/MessageBubble.tsx` — the consumer for sentinel `MessageMetadata` kinds. -### Sync the copies; never weaken the parity test +### Keep the package tests; never weaken them -When a parity test fails, the fix is to make the copies match — not to make the test stop checking. The corollary also holds: when you add a **new** nested type that `ProviderSnapshotEntry` references, add its name to the `names` array in `provider-types-parity.test.ts`, or the new type is hand-synced but **unguarded**. +When a package test fails, the fix is to update the package source — not to delete the assertion or skip the test. When adding a **new** nested type to `ProviderSnapshotEntry`, add it to `packages/contracts/src/provider-snapshot.ts` — there is no `names` array to update since `provider-types-parity.test.ts` was deleted; the single definition is the guard. **What to avoid:** ```typescript -// ANTI-PATTERN: a red parity test "fixed" by deleting the assertion, skipping -// the it(), or trimming a type out of the compared `names` list. That converts a -// caught drift into a shipped, silent contract break. Re-sync the copies instead. +// ANTI-PATTERN: "fixing" a red package test by deleting the assertion, skipping +// the it(), or removing a frame type from the tested set. That converts caught +// drift into a shipped, silent contract break. Fix the package source instead. ``` **Project references:** -- `apps/server/src/services/__tests__/ws-frames.test.ts` — `ws-frames.ts file mirror parity` (byte-identical) and the `KNOWN_FRAME_TYPES` drift probe. -- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` — text-identity of each shared block across the coder ↔ web copies. +- `packages/contracts/src/__tests__/ws-frames.test.ts` — schema correctness (accept/reject) and the `KNOWN_FRAME_TYPES` drift probe. (The former server-side `ws-frames.ts file mirror parity` byte-identical test was deleted when the schema moved to the package.) +- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` — **deleted**: provider snapshot types moved to `@boocode/contracts/provider-snapshot`. ## Additional Resources @@ -198,8 +194,8 @@ When a parity test fails, the fix is to make the copies match — not to make th - [BooCoder Dispatch Backends](../coder-backends.md) — the provider-snapshot contract and the WS-frame mapping in their runtime context (see "Core Types" and the parity notes). - [Architecture overview](../ARCHITECTURE.md) — the three surfaces and the shared database the contracts cross. -- Root `CLAUDE.md` → "Conventions" — the cross-app contract rules (WS frame, sentinels, provider-type parity, JSONB) this standard formalizes. -- `apps/server/CLAUDE.md` (`services/broker.ts`) and `apps/coder/CLAUDE.md` — per-app notes on the broker validation and the provider-type mirror. +- Root `CLAUDE.md` → "Conventions" — the cross-app contract rules (WS frame schema, sentinels, provider snapshot types, JSONB) this standard formalizes; updated to reflect `@boocode/contracts` SSOT. +- `apps/server/CLAUDE.md` (`services/broker.ts`) — broker validation against the `@boocode/contracts` schema. `apps/coder/CLAUDE.md` — `provider-types.ts` re-exports from the package. ### External Resources diff --git a/docs/project-discovery.md b/docs/project-discovery.md index bee91fc..985b9e4 100644 --- a/docs/project-discovery.md +++ b/docs/project-discovery.md @@ -100,7 +100,7 @@ - cli: `pnpm -C apps/coder cli` (`tsx src/cli.ts`) - typecheck: `pnpm -C apps/coder typecheck` - test: `pnpm -C apps/coder test` (vitest run) -- deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` +- deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` - tsconfig: `apps/coder/tsconfig.json` (NodeNext, `declaration: false`) - test config: `apps/coder/vitest.config.ts` (vitest ^3.0.0, env=node, `globals: false`, `fileParallelism: false`) - test pattern: `src/**/__tests__/**/*.test.ts` diff --git a/openspec/changes/contracts-ssot/proposal.md b/openspec/changes/contracts-ssot/proposal.md new file mode 100644 index 0000000..480d761 --- /dev/null +++ b/openspec/changes/contracts-ssot/proposal.md @@ -0,0 +1,102 @@ +# @boocode/contracts — cross-app wire contract SSOT + +**Status:** shipped (2026-06-02) + +Eliminate BooCode's hand-synced, duplicated cross-app TypeScript wire contracts by +creating one workspace package, `@boocode/contracts`, that every consumer imports. +Each contract is defined exactly once; the two Zod-backed contracts (`ws-frames`, +`provider-config`) use `z.infer` so validator and type derive from the same definition +and cannot drift independently. + +## Why + +BooCode maintained hand-synced copies of cross-app contracts across up to four +locations (`apps/server`, `apps/web`, `apps/coder`, `apps/coder/web`), verified only +by byte-parity tests — `provider-types-parity.test.ts` and the ws-frames byte-parity +assertion in `ws-frames.test.ts`. A live drift had appeared: `AgentSessionConfig` +existed in two incompatible shapes — `apps/coder` held an all-optional dead copy +(zero live importers) while `apps/web` held the real required/nullable shape — making +the parity-test regime insufficient for type-only contracts. + +`v2.5.12-provider-lifecycle-phase4` had explicitly deferred a shared types package as +"not worth the Docker/build-order risk at solo scale"; the observed drift made the +investment worth taking. + +## What shipped + +**Package:** `packages/contracts` (`@boocode/contracts`), `declaration:true`, zod +pinned `^3.23.8`, per-subpath exports map with `types`-then-`default` conditions. +`packages/*` added to `pnpm-workspace.yaml`; `pnpm-lock.yaml` regenerated. + +**Six contracts single-sourced:** + +- `./ws-frames` — `WsFrameSchema` (Zod runtime), `KNOWN_FRAME_TYPES`, `WsFrame` + (`z.infer` from the schema). +- `./provider-snapshot` — `ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, + `ThinkingOption`, `AgentCommand`, `ProviderSnapshotStatus` (plain TS; the coder's + `provider-types.ts` re-exports them so internal importers are unchanged). +- `./provider-config` — `ProviderOverrideSchema`, `CoderProvidersFileSchema`, + `ProviderConfigPatchSchema` and their `z.infer` types. +- `./message-metadata` — `MessageMetadata`, `ErrorReason`, `AgentSessionConfig`. +- `./worktree-risk` — `WorktreeRiskReport` (unified from three copies that differed + only in name; the coder called it `RiskReport`). + +**Four consumers** import via `workspace:*` through the exports map: `apps/server`, +`apps/web`, `apps/coder`, and the fallback SPA `apps/coder/web`. No tsconfig project +references; built dist only. + +**Deleted:** +- `apps/server/src/types/ws-frames.ts` (server `./ws-frames` exports subpath dropped) +- `apps/web/src/api/ws-frames.ts` +- Provider-snapshot mirror block in web (`apps/web/src/api/types.ts`) +- Provider-config 17-line hand-mirror in web +- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` (6 parity tests) +- ws-frames byte-parity assertion (server test suite) +- All duplicate `MessageMetadata`, `ErrorReason`, `AgentSessionConfig` copies +- `WorktreeRiskReport` / `RiskReport` duplicates (3 copies) +- `apps/coder/web` dead `pending_change_added`/`pending_change_updated` reducer arms + and associated WS plumbing (`DiffPane` prop, `Session.tsx` listener) + +**Preserved:** +- KNOWN_FRAME_TYPES drift test — moved into the package (11/11) +- Broker fail-closed tests — kept in `apps/server`, importing from the package (4/4) +- Web strict `SessionFrame`/`UserFrame` discriminated union — web-local, untouched + +## Key decisions + +**F1 (ws-frames):** repoint all 8 server/coder importers + 2 web validators to the +package; drop the server `./ws-frames` re-export subpath — one path per contract, no +shim. + +**F2 (web strict union):** the package exports the runtime schema only (`WsFrameSchema`, +`KNOWN_FRAME_TYPES`, loose `WsFrame`). The web's rich discriminated union stays +web-local — it references entity types (`Message`, `ToolCall`, etc.) that are +intentionally web-local and not cross-app duplicated. Zero entity-type scope expansion. + +**AgentSessionConfig drift:** unified to the web required/nullable shape; the coder's +all-optional copy confirmed dead (zero live importers) and deleted. + +**`apps/coder/web` (fallback SPA):** its hand-copied 9-arm `WsFrame` union replaced by +the canonical import; dead `pending_change_*` arms removed (no publisher exists for +these frames anywhere in the codebase — they were HTTP-delivered, not WS); field +conflicts reconciled per-field (`tool_result.error` boolean→string, `tokens_used` +number→number|null, `snapshot.messages` cast). + +**Two `ErrorReason` concepts (intentional, not duplication):** +`message-metadata`'s `ErrorReason` is the DB-persisted 3-value set; the ws-frames +frame-level `reason` is the wire 5-value set. Different value sets, different +semantics, confirmed during audit. + +## Build-order inversion + +Contracts builds before all consumers in: +- Root `package.json` build script +- `Dockerfile` — new `COPY packages/*` block + contracts build step before web/server +- Coder deploy command — updated in all 7 doc sites (CLAUDE.md, apps/coder/CLAUDE.md, + BOOCODER.md, docs/ARCHITECTURE.md, docs/project-discovery.md, README.md, + docs/coder-backends.md) + +## Test counts at ship + +Server 543 / coder 293 / contracts 11. Clean `docker compose build --no-cache boocode` +green. Human smoke verified 2026-06-02. diff --git a/openspec/changes/contracts-ssot/tasks.md b/openspec/changes/contracts-ssot/tasks.md new file mode 100644 index 0000000..738abb1 --- /dev/null +++ b/openspec/changes/contracts-ssot/tasks.md @@ -0,0 +1,109 @@ +# Tasks — @boocode/contracts SSOT + +Nine phases, each independently verifiable. Phases 1–7 are the migration units (one +contract group each after a proven tracer); Phase 8 is the audit gate; Phase 9 is the +human smoke test. All shipped 2026-06-02. + +## Phase 1 — Tracer: scaffold package + build-order inversion + web proof ✅ SHIPPED + +- [x] Create `packages/contracts` (`@boocode/contracts`): `declaration:true`, per-subpath + exports map, zod `^3.23.8`. Placeholder `src/index.ts`. +- [x] Add `packages/*` to `pnpm-workspace.yaml`. +- [x] Invert build order everywhere: root `package.json` build script, `Dockerfile` + (`COPY packages/*` + contracts build before web/server), coder deploy command. +- [x] Regenerate `pnpm-lock.yaml` (Dockerfile uses `--frozen-lockfile`). +- [x] Prove the web consumption path end-to-end: `tsc -b` (composite+Bundler), `vite dev` + HMR, and `vite build` all resolve the built `.d.ts`+`.js` via the exports map. +- [x] Verify all consumer builds + `docker compose build boocode` green. +- [x] Remove Phase 1 probe artifacts before Phase 2. + +## Phase 2a — Single-source the ws-frames runtime schema ✅ SHIPPED + +- [x] Move ws-frames schema to `packages/contracts/src/ws-frames.ts` (`./ws-frames` subpath). +- [x] Repoint 8 server/coder importers + 2 web validators to `@boocode/contracts/ws-frames`. +- [x] Delete `apps/server/src/types/ws-frames.ts`; drop server `./ws-frames` exports subpath. +- [x] Delete `apps/web/src/api/ws-frames.ts`. +- [x] Move KNOWN_FRAME_TYPES drift + accept/reject tests into the package (11/11); delete + byte-parity test; keep broker fail-closed tests in server importing from the package. +- [x] Container smoke: `docker compose up`, `/api/health` 200, broker imports from package. + +## Phase 3 — Single-source provider snapshot types ✅ SHIPPED + +- [x] Move `ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, `ThinkingOption`, + `AgentCommand`, `ProviderSnapshotStatus` to `packages/contracts/src/provider-snapshot.ts` + (`./provider-snapshot` subpath). +- [x] `apps/coder/src/services/provider-types.ts` re-exports them (importers unchanged). +- [x] Delete web mirror block; delete `provider-types-parity.test.ts` (coder −6 tests). +- [x] All builds and typechecks green; server 543 / coder 293 unchanged. + +## Phase 4 — Single-source the Zod provider-config schemas ✅ SHIPPED + +- [x] Move `ProviderOverrideSchema`, `CoderProvidersFileSchema`, `ProviderConfigPatchSchema` + + `z.infer` types to `packages/contracts/src/provider-config.ts` (`./provider-config` + subpath). +- [x] `apps/coder/src/services/provider-config.ts` imports + re-exports (importers unchanged). +- [x] Delete 17-line web hand-mirror. +- [x] Coder provider-config tests 13/13; all builds green. + +## Phase 5 — Single-source type-only contracts (MessageMetadata + AgentSessionConfig) ✅ SHIPPED + +- [x] Move `MessageMetadata`, `ErrorReason`, `AgentSessionConfig` to + `packages/contracts/src/message-metadata.ts` (`./message-metadata` subpath). +- [x] Unify `AgentSessionConfig` to the web required/nullable shape; delete the coder's + dead all-optional copy (zero live importers confirmed). +- [x] Delete all duplicate `MessageMetadata` + `ErrorReason` copies (confirmed byte-identical). +- [x] Repoint server `api.ts`, web types, and `MessageBubble.tsx` to the package. +- [x] Server 543 / coder 293 unchanged; all builds green. + +## Phase 6 — Single-source WorktreeRiskReport ✅ SHIPPED + +- [x] Move `WorktreeRiskReport` to `packages/contracts/src/worktree-risk.ts` + (`./worktree-risk` subpath); unify name (coder `RiskReport` → `WorktreeRiskReport`). +- [x] Delete all three copies (shapes identical, names differed). +- [x] Repoint `sessions.ts`, `ProjectSidebar.tsx`, `orphan-worktree-reaper.ts`, + `worktree-safety.ts` via re-exports; `checkWorktreeWorkAtRisk` returns shared type. +- [x] Server 543 / coder 293 unchanged; all builds green. + +## Phase 7 — Migrate apps/coder/web (fallback SPA) ✅ SHIPPED + +- [x] Add `@boocode/contracts` `workspace:*` dep to `apps/coder/web/package.json`. +- [x] Delete hand-copied 9-arm `WsFrame` union; import canonical `WsFrame` from the package. +- [x] Reconcile field conflicts: `tool_result.error` boolean→string; `tokens_used` + number→number|null; `snapshot.messages` cast `as Message[]`; `chat_id ?? ''`. +- [x] Delete dead `pending_change_added`/`pending_change_updated` reducer arms + the entire + dead `onPendingChange` WS plumbing (`DiffPane` prop, `Session.tsx` listener). +- [x] Confirm HTTP pending-change apply/reject path untouched. +- [x] `apps/coder/web` Vite build green; root build + server 543 / coder 293 green. + +## Phase 8 — Audit every requirement bullet ✅ PASSED (12/12) + +- [x] Verify one definition per contract (file + line evidence for each). +- [x] Verify `z.infer` for both Zod contracts (ws-frames, provider-config). +- [x] Verify all four consumers wired via exports map (no project refs, no src imports). +- [x] Verify all hand-copies + parity tests deleted; drift/broker tests preserved. +- [x] Verify single zod version; build-order inverted in root + Dockerfile + deploy docs. +- [x] Verify `packages/*` in workspace; dead `pending_change_*` arms gone. +- [x] Verify web strict union preserved + coherent post-migration. +- [x] Close gap G: update coder deploy command in all 7 doc sites (CLAUDE.md, + apps/coder/CLAUDE.md, BOOCODER.md, docs/ARCHITECTURE.md, docs/project-discovery.md, + README.md, docs/coder-backends.md). +- [x] Correct now-false byte-parity/duplication claims in CLAUDE.md conventions, + apps/server/CLAUDE.md broker note, docs/coder-backends.md, and + docs/coding-standards/cross-app-contract-parity.md (rewritten to describe the SSOT). +- [x] Mark DEFERRED-WORK §3 + STALE-DEPRECATED item as shipped. +- [x] Clean `docker compose build --no-cache boocode` green; server 543 / coder 293 / + contracts 11 at exact baselines; nothing staged (HEAD e5ce01a). + +## Phase 9 — Human smoke test ✅ PASSED (Sam, 2026-06-02) + +- [x] Web dev HMR, web prod build at :9500, live WS stream rendering. +- [x] Coder restart + a turn; fallback SPA; pending-change apply. + +## Verify (all runs green) + +- `pnpm -C packages/contracts build` +- `pnpm -C apps/server test` (543) +- `pnpm -C apps/coder test` (293) +- `pnpm -C apps/server build && pnpm -C apps/coder build` +- `npx tsc -p apps/web/tsconfig.app.json --noEmit` +- `docker compose build --no-cache boocode` diff --git a/package.json b/package.json index 0422256..cccbe85 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev:server": "pnpm --filter ./apps/server dev", "dev:web": "pnpm --filter ./apps/web dev", - "build": "pnpm --filter ./apps/web build && pnpm --filter ./apps/server build", + "build": "pnpm --filter ./packages/contracts build && pnpm --filter ./apps/web build && pnpm --filter ./apps/server build", "start": "node apps/server/dist/index.js" }, "devDependencies": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..0ab63ea --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,46 @@ +{ + "name": "@boocode/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./ws-frames": { + "types": "./dist/ws-frames.d.ts", + "default": "./dist/ws-frames.js" + }, + "./provider-snapshot": { + "types": "./dist/provider-snapshot.d.ts", + "default": "./dist/provider-snapshot.js" + }, + "./provider-config": { + "types": "./dist/provider-config.d.ts", + "default": "./dist/provider-config.js" + }, + "./message-metadata": { + "types": "./dist/message-metadata.d.ts", + "default": "./dist/message-metadata.js" + }, + "./worktree-risk": { + "types": "./dist/worktree-risk.d.ts", + "default": "./dist/worktree-risk.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vitest": "^3.2.4" + }, + "license": "MIT" +} diff --git a/packages/contracts/src/__tests__/ws-frames.test.ts b/packages/contracts/src/__tests__/ws-frames.test.ts new file mode 100644 index 0000000..3999a38 --- /dev/null +++ b/packages/contracts/src/__tests__/ws-frames.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { WsFrameSchema, KNOWN_FRAME_TYPES } from '../ws-frames.js'; + +const VALID_UUID_A = '00000000-0000-0000-0000-000000000001'; +const VALID_UUID_B = '00000000-0000-0000-0000-000000000002'; +const VALID_UUID_C = '00000000-0000-0000-0000-000000000003'; +const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z'; + +describe('WsFrameSchema (v1.13.11-a)', () => { + it('accepts a well-formed chat_status frame', () => { + const result = WsFrameSchema.safeParse({ + type: 'chat_status', + chat_id: VALID_UUID_A, + status: 'streaming', + at: VALID_TIMESTAMP, + }); + expect(result.success).toBe(true); + }); + + it('rejects an unknown frame type', () => { + const result = WsFrameSchema.safeParse({ + type: 'cosmic_ray_strike', + chat_id: VALID_UUID_A, + }); + expect(result.success).toBe(false); + }); + + it('rejects a chat_status frame with invalid status enum', () => { + // v1.12.1 dropped the legacy 'working' status. Any frame still emitting it + // should fail validation — that's a drift catcher. + const result = WsFrameSchema.safeParse({ + type: 'chat_status', + chat_id: VALID_UUID_A, + status: 'working', + at: VALID_TIMESTAMP, + }); + expect(result.success).toBe(false); + }); + + it('rejects a UUID field with a non-UUID string', () => { + const result = WsFrameSchema.safeParse({ + type: 'chat_status', + chat_id: 'not-a-uuid', + status: 'idle', + at: VALID_TIMESTAMP, + }); + expect(result.success).toBe(false); + }); + + it('rejects negative token counts in usage frame', () => { + const result = WsFrameSchema.safeParse({ + type: 'usage', + message_id: VALID_UUID_A, + chat_id: VALID_UUID_B, + completion_tokens: -1, + ctx_used: 100, + ctx_max: 1000, + }); + expect(result.success).toBe(false); + }); + + it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => { + const result = WsFrameSchema.safeParse({ + type: 'usage', + message_id: VALID_UUID_A, + chat_id: VALID_UUID_B, + completion_tokens: null, + ctx_used: null, + ctx_max: null, + }); + expect(result.success).toBe(true); + }); + + it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => { + // Model-emitted tool_call_ids look like "call_abc123", not UUIDs. + const result = WsFrameSchema.safeParse({ + type: 'tool_result', + tool_message_id: VALID_UUID_A, + chat_id: VALID_UUID_B, + tool_call_id: 'call_abc123', + output: { whatever: true }, + truncated: false, + }); + expect(result.success).toBe(true); + }); + + it('accepts a compacted frame', () => { + const result = WsFrameSchema.safeParse({ + type: 'compacted', + session_id: VALID_UUID_A, + chat_id: VALID_UUID_B, + summary_message_id: VALID_UUID_C, + }); + expect(result.success).toBe(true); + }); + + it('accepts a session_workspace_updated frame', () => { + const result = WsFrameSchema.safeParse({ + type: 'session_workspace_updated', + session_id: VALID_UUID_A, + workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }], + }); + expect(result.success).toBe(true); + }); + + it('accepts a message_complete frame with a null model (external coder, no model selected)', () => { + // Regression guard: the dispatcher publishes `model: task.model` (string | + // null). When null, this MUST validate or publishFrame fail-closes and drops + // the whole frame, incl. the status:'complete' transition. + const result = WsFrameSchema.safeParse({ + type: 'message_complete', + message_id: VALID_UUID_A, + chat_id: VALID_UUID_B, + model: null, + }); + expect(result.success).toBe(true); + }); + + it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => { + // Probe each known type by attempting a minimal valid construction. + // Failure here means the union and the KNOWN_FRAME_TYPES list drifted. + for (const type of KNOWN_FRAME_TYPES) { + const probe = WsFrameSchema.safeParse({ type, __dummy__: true }); + // We expect FAILURE on every type because we're missing required fields, + // but the failure must be ABOUT the missing fields, not about an unknown + // type. A "Invalid discriminator value" error means the type isn't in + // the union — that's a drift. + if (probe.success) continue; + const issues = probe.error.issues; + const hasInvalidDiscriminator = issues.some( + (i) => i.code === 'invalid_union_discriminator', + ); + expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false); + } + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..ad14497 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,5 @@ +// @boocode/contracts — single source of truth for cross-app wire contracts. +// Each contract is exported from its own subpath (e.g. @boocode/contracts/ws-frames). +// This root module is intentionally empty; import from the subpath directly. + +export {}; diff --git a/packages/contracts/src/message-metadata.ts b/packages/contracts/src/message-metadata.ts new file mode 100644 index 0000000..5c767ed --- /dev/null +++ b/packages/contracts/src/message-metadata.ts @@ -0,0 +1,45 @@ +// Single source of truth for cross-app message metadata contracts. +// ErrorReason + MessageMetadata: sentinel shapes stored in messages.metadata +// and carried on WS frames. AgentSessionConfig: the required/nullable shape +// used by CoderPane/AgentComposerBar for provider dispatch. + +export type ErrorReason = + | 'llm_provider_error' + | 'tool_execution_failed' + | 'summary_after_cap_failed'; + +export type MessageMetadata = + | { + kind: 'cap_hit'; + used: number; + limit: number; + agent_name: string | null; + can_continue: boolean; + } + | { + kind: 'doom_loop'; + tool_name: string; + args: Record; + threshold: number; + } + | { + kind: 'mistake_recovery'; + failure_kinds: string[]; + count: number; + escalated: boolean; + can_continue?: boolean; + } + | { + kind: 'error'; + error_reason: ErrorReason; + error_text: string; + }; + +// Unified definition is the web required/nullable shape (the coder's all-optional +// copy was dead — zero importers in apps/coder/src). +export interface AgentSessionConfig { + provider: string; + model: string; + modeId: string | null; + thinkingOptionId: string | null; +} diff --git a/packages/contracts/src/provider-config.ts b/packages/contracts/src/provider-config.ts new file mode 100644 index 0000000..c99b75e --- /dev/null +++ b/packages/contracts/src/provider-config.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const ProviderOverrideSchema = z.object({ + extends: z.enum(['acp']).optional(), + label: z.string().min(1).optional(), + description: z.string().optional(), + command: z.array(z.string().min(1)).min(1).optional(), + env: z.record(z.string()).optional(), + enabled: z.boolean().optional(), + order: z.number().int().optional(), + models: z.array(z.object({ id: z.string(), label: z.string() })).optional(), + additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), +}); + +export const CoderProvidersFileSchema = z.object({ + providers: z.record(ProviderOverrideSchema).default({}), +}); + +export const ProviderConfigPatchSchema = z.object({ + providers: z.record(ProviderOverrideSchema.nullable()).default({}), +}); + +export type ProviderOverride = z.infer; +export type CoderProvidersFile = z.infer; +export type ProviderConfigPatch = z.infer; diff --git a/packages/contracts/src/provider-snapshot.ts b/packages/contracts/src/provider-snapshot.ts new file mode 100644 index 0000000..36d3e7b --- /dev/null +++ b/packages/contracts/src/provider-snapshot.ts @@ -0,0 +1,52 @@ +/** Provider snapshot types — single source of truth. Plain TS, no runtime. */ + +export interface ProviderMode { + id: string; + label: string; + description?: string; + /** Auto-approve tool permissions when this mode is selected. */ + isUnattended?: boolean; +} + +export interface ThinkingOption { + id: string; + label: string; + isDefault?: boolean; +} + +export interface ProviderModel { + id: string; + label: string; + description?: string; + isDefault?: boolean; + thinkingOptions?: ThinkingOption[]; + defaultThinkingOptionId?: string; +} + +// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable' +// (disabled or not installed) restored alongside the terminal 'ready' | 'error'. +export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error'; + +export interface AgentCommand { + name: string; + description?: string; + // v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command). + // Drives the icon split in the coder slash menu. Undefined → command. + kind?: 'command' | 'skill'; +} + +export interface ProviderSnapshotEntry { + name: string; + label: string; + description?: string; + transport: string; + status: ProviderSnapshotStatus; + enabled: boolean; + installed: boolean; + models: ProviderModel[]; + modes: ProviderMode[]; + defaultModeId: string | null; + commands: AgentCommand[]; + error?: string; + fetchedAt?: string; +} diff --git a/packages/contracts/src/worktree-risk.ts b/packages/contracts/src/worktree-risk.ts new file mode 100644 index 0000000..1df3c2b --- /dev/null +++ b/packages/contracts/src/worktree-risk.ts @@ -0,0 +1,13 @@ +// Single source of truth for the worktree work-loss guard type. +// WorktreeRiskReport: returned by BooCoder's checkWorktreeWorkAtRisk, passed +// through the server, and consumed by the web dialog. Three-way contract. + +export interface WorktreeRiskReport { + worktreePath: string; + branch: string; + dirty: boolean; + unpushed: number; // commits ahead of upstream, or -1 if no upstream is set + unmerged: number; // commits on this branch not in the project default branch + atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error + error?: string; // populated on a git failure; presence forces atRisk +} diff --git a/apps/server/src/types/ws-frames.ts b/packages/contracts/src/ws-frames.ts similarity index 91% rename from apps/server/src/types/ws-frames.ts rename to packages/contracts/src/ws-frames.ts index a156865..72acc73 100644 --- a/apps/server/src/types/ws-frames.ts +++ b/packages/contracts/src/ws-frames.ts @@ -1,16 +1,10 @@ -// v1.13.11-a: Zod schemas for every WebSocket frame published by the server. -// Validation runs both on send (broker.publishFrame / publishUserFrame) and -// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches +// Single source of truth for the WebSocket frame Zod runtime schema. +// Validation runs on send (broker.publishFrame / publishUserFrame) and +// on receive (apps/web hooks useSessionStream + useUserEvents). Catches // silent protocol drift between publisher and consumer. // -// IMPORTANT: This file is duplicated byte-identical at -// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and -// no path alias; the duplication is sync-by-hand. A test asserts the two -// files match. If you change one, change the other. -// -// Per-kind payload schemas (tool_call args, message_parts payloads, etc.) -// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal; -// deep payload validation is follow-up work. +// Per-kind payload schemas stay z.unknown() — frame-level drift detection +// is the goal; deep payload validation is follow-up work. import { z } from 'zod'; @@ -66,8 +60,8 @@ const ToolCallShape = z.object({ // payload narrowing is follow-up work). z.unknown() means the consumer // must narrow before reading — TypeScript-side this is fine because every // consumer already operates on the hand-maintained Project / Chat / Session -// / WorkspacePane types (the brief's "Don't strip existing types yet" -// rule), and the Zod-typed shape is only used at the publishFrame boundary. +// / WorkspacePane types, and the Zod-typed shape is only used at the +// publishFrame boundary. const OpaqueObject = z.unknown(); // ---- per-session channel frames -------------------------------------------- @@ -216,8 +210,7 @@ export const SessionWorkspaceUpdatedFrame = z.object({ // v2.6.x: widened from z.array — the payload is now either the legacy bare // WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers + // nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop - // every envelope frame at validation. MUST be mirrored in the server's - // byte-identical copy (parity test). + // every envelope frame at validation. workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]), }); @@ -370,7 +363,7 @@ export type WsFrame = z.infer; // Convenience: the set of known frame types. Useful for the publishFrame // helper to log the offending type name when validation fails. Kept in sync -// by hand with the discriminated union above. +// by the drift test in src/__tests__/ws-frames.test.ts. export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'snapshot', 'message_started', diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..4196c99 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "types": [], + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "**/*.test.ts"] +} diff --git a/packages/contracts/vitest.config.ts b/packages/contracts/vitest.config.ts new file mode 100644 index 0000000..5802658 --- /dev/null +++ b/packages/contracts/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false, + include: ['src/**/__tests__/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e08718a..777b535 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.159 version: 0.3.159(@anthropic-ai/sdk@0.100.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) + '@boocode/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@boocode/server': specifier: workspace:* version: link:../server @@ -100,6 +103,9 @@ importers: apps/coder/web: dependencies: + '@boocode/contracts': + specifier: workspace:* + version: link:../../../packages/contracts lucide-react: specifier: ^1.16.0 version: 1.16.0(react@18.3.1) @@ -146,6 +152,9 @@ importers: '@ai-sdk/openai-compatible': specifier: ^2.0.47 version: 2.0.47(zod@3.25.76) + '@boocode/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@fastify/static': specifier: ^7.0.4 version: 7.0.4 @@ -192,6 +201,9 @@ importers: apps/web: dependencies: + '@boocode/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 @@ -287,6 +299,19 @@ importers: specifier: ^5.3.4 version: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0) + packages/contracts: + dependencies: + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3)) + packages: '@agentclientprotocol/sdk@0.22.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index af4ada7..74555b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: + - "packages/*" - "apps/*" - "apps/coder/web"