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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:00:00 +00:00
parent 2a05d2f9fe
commit 649ce71eff
55 changed files with 804 additions and 1108 deletions

View File

@@ -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 ~1520s 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 })`.

View File

@@ -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:*",

View File

@@ -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';

View File

@@ -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({

View File

@@ -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,

View File

@@ -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));
});
}
});

View File

@@ -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';

View File

@@ -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

View File

@@ -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';

View File

@@ -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<typeof ProviderOverrideSchema>;
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
/**
* 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<typeof ProviderConfigPatchSchema>;
export {
ProviderOverrideSchema,
CoderProvidersFileSchema,
ProviderConfigPatchSchema,
type ProviderOverride,
type CoderProvidersFile,
type ProviderConfigPatch,
};
/**
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in

View File

@@ -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';

View File

@@ -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<RiskReport> {
): Promise<WorktreeRiskReport> {
// 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`,

View File

@@ -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",

View File

@@ -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<Extract<WsFrame, { type: 'error' }>['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 };

View File

@@ -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<PendingChange[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(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');

View File

@@ -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<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const pendingListenersRef = useRef<Set<(change: PendingChange) => 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,
};
}

View File

@@ -14,8 +14,7 @@ export function Session() {
const [chat, setChat] = useState<Chat | null>(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 sessionId={sessionId} onPendingChange={onPendingChange} />
}
diffPane={<DiffPane sessionId={sessionId} />}
/>
);
}