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:
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user