From f302969c71819d7e1979aa0c1946c6d5451cb7a3 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 17:46:56 +0000 Subject: [PATCH] =?UTF-8?q?coder(providers):=20v2.3=20provider-lifecycle?= =?UTF-8?q?=20phase=204=20=E2=80=94=20config=20HTTP=20API=20(diagnostic=20?= =?UTF-8?q?returns=20JSON)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PATCH /api/providers/config, subset POST /refresh, and GET /api/providers/:id/diagnostic (JSON { diagnostic }, §6.4). PATCH order is validate→save→reload→clear; a malformed body or invalid merged config returns 422 without writing, and a save failure returns 500 without reloading (no file/registry divergence). Web client + types extended. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../routes/__tests__/providers.routes.test.ts | 211 ++++++++++++++++++ apps/coder/src/routes/providers.ts | 116 +++++++++- .../__tests__/provider-config.test.ts | 96 ++++++++ .../__tests__/provider-diagnostic.test.ts | 85 +++++++ .../__tests__/provider-snapshot.test.ts | 13 ++ apps/coder/src/services/provider-config.ts | 35 +++ .../coder/src/services/provider-diagnostic.ts | 71 ++++++ apps/coder/src/services/provider-snapshot.ts | 10 + apps/web/src/api/client.ts | 21 +- apps/web/src/api/types.ts | 25 +++ 10 files changed, 678 insertions(+), 5 deletions(-) create mode 100644 apps/coder/src/routes/__tests__/providers.routes.test.ts create mode 100644 apps/coder/src/services/__tests__/provider-config.test.ts create mode 100644 apps/coder/src/services/__tests__/provider-diagnostic.test.ts create mode 100644 apps/coder/src/services/provider-diagnostic.ts diff --git a/apps/coder/src/routes/__tests__/providers.routes.test.ts b/apps/coder/src/routes/__tests__/providers.routes.test.ts new file mode 100644 index 0000000..c7d3cdc --- /dev/null +++ b/apps/coder/src/routes/__tests__/providers.routes.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { registerProviderRoutes } from '../providers.js'; +import { load } from '../../services/provider-config.js'; +import { loadProviderConfig } from '../../services/provider-config-registry.js'; +import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js'; +import type { Config } from '../../config.js'; +import type { Sql } from '../../db.js'; + +/** Minimal sql stub: available_agents reads return []. */ +function mockSql(): Sql { + return vi.fn((strings: TemplateStringsArray) => { + const q = strings.join(''); + if (q.includes('available_agents')) return Promise.resolve([]); + return Promise.resolve([]); + }) as unknown as Sql; +} + +let tmpCounter = 0; +function freshPath(): string { + tmpCounter += 1; + return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`); +} + +function buildApp(providersPath: string): FastifyInstance { + const app = Fastify(); + // Mirror index.ts: tolerate empty JSON bodies. + app.removeContentTypeParser(['application/json']); + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + const str = (body as string) ?? ''; + if (str.trim().length === 0) return done(null, {}); + try { + done(null, JSON.parse(str)); + } catch (err) { + done(err as Error, undefined); + } + }); + const config = { + CODER_PROVIDERS_PATH: providersPath, + LLAMA_SWAP_URL: 'http://llama-swap.test', + PROVIDER_PROBE_TTL_MS: 86_400_000, + } as unknown as Config; + registerProviderRoutes(app, mockSql(), config); + return app; +} + +const JSON_HEADERS = { 'content-type': 'application/json' }; +const createdPaths: string[] = []; + +beforeEach(() => { + clearProviderSnapshotCache(); + loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins + vi.restoreAllMocks(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test'))); +}); + +afterEach(() => { + for (const p of createdPaths.splice(0)) { + try { + rmSync(p, { force: true }); + } catch { + /* ignore */ + } + } +}); + +describe('GET /api/providers/config', () => { + it('returns the current config file (built-ins-only when missing)', async () => { + const path = freshPath(); + createdPaths.push(path); + const app = buildApp(path); + const res = await app.inject({ method: 'GET', url: '/api/providers/config' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ providers: {} }); + await app.close(); + }); + + it('reflects an existing file', async () => { + const path = freshPath(); + createdPaths.push(path); + writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } })); + const app = buildApp(path); + const res = await app.inject({ method: 'GET', url: '/api/providers/config' }); + expect(res.json()).toEqual({ providers: { goose: { enabled: false } } }); + await app.close(); + }); +}); + +describe('PATCH /api/providers/config', () => { + it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => { + const path = freshPath(); + createdPaths.push(path); + writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } })); + const app = buildApp(path); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/providers/config', + headers: JSON_HEADERS, + payload: JSON.stringify({ providers: { opencode: { enabled: false } } }), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ ok: true }); + // File written + merged (goose untouched, opencode added). + const onDisk = load(path); + expect(onDisk.providers).toEqual({ + goose: { label: 'Goose' }, + opencode: { enabled: false }, + }); + await app.close(); + }); + + it('null value deletes the override', async () => { + const path = freshPath(); + createdPaths.push(path); + writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } })); + const app = buildApp(path); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/providers/config', + headers: JSON_HEADERS, + payload: JSON.stringify({ providers: { goose: null } }), + }); + + expect(res.statusCode).toBe(200); + expect(load(path).providers).toEqual({ opencode: { enabled: false } }); + await app.close(); + }); + + it('INVALID body → 422 and the file is NOT written (validate before save)', async () => { + const path = freshPath(); + createdPaths.push(path); + const before = JSON.stringify({ providers: { goose: { enabled: true } } }); + writeFileSync(path, before); + const app = buildApp(path); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/providers/config', + headers: JSON_HEADERS, + payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type + }); + + expect(res.statusCode).toBe(422); + // File must be byte-for-byte unchanged — nothing written on a 422. + expect(readFileSync(path, 'utf8')).toBe(before); + await app.close(); + }); + + it('save failure → 500 and the file is NOT created (no state divergence)', async () => { + const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json'); + const app = buildApp(path); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/providers/config', + headers: JSON_HEADERS, + payload: JSON.stringify({ providers: { goose: { enabled: false } } }), + }); + + expect(res.statusCode).toBe(500); + expect(existsSync(path)).toBe(false); + await app.close(); + }); +}); + +describe('POST /api/providers/refresh', () => { + it('no body → refreshes all registered providers', async () => { + const app = buildApp(freshPath()); + const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' }); + expect(res.statusCode).toBe(200); + expect(res.json().refreshed).toBeGreaterThan(0); + await app.close(); + }); + + it('subset body → refreshed count reflects only the requested providers', async () => { + const app = buildApp(freshPath()); + const res = await app.inject({ + method: 'POST', + url: '/api/providers/refresh', + headers: JSON_HEADERS, + payload: JSON.stringify({ providers: ['boocode'] }), + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ refreshed: 1 }); + await app.close(); + }); +}); + +describe('GET /api/providers/:id/diagnostic', () => { + it('known provider → 200 JSON { diagnostic }', async () => { + const app = buildApp(freshPath()); + const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(res.json().diagnostic).toContain('provider: boocode'); + await app.close(); + }); + + it('unknown provider → 404', async () => { + const app = buildApp(freshPath()); + const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/coder/src/routes/providers.ts b/apps/coder/src/routes/providers.ts index a1be209..22daf40 100644 --- a/apps/coder/src/routes/providers.ts +++ b/apps/coder/src/routes/providers.ts @@ -1,7 +1,29 @@ import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; -import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js'; +import { + getProviderSnapshot, + clearProviderSnapshotCache, + peekSnapshotEntry, +} from '../services/provider-snapshot.js'; +import { + load, + save, + CoderProvidersFileSchema, + ProviderConfigPatchSchema, + mergeProviderConfigPatch, +} from '../services/provider-config.js'; +import { + reloadProviderConfig, + getResolvedRegistry, +} from '../services/provider-config-registry.js'; +import { + getProviderDiagnostic, + type DiagnosticAgentRow, +} from '../services/provider-diagnostic.js'; + +const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() }); export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void { app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => { @@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C return getProviderSnapshot(sql, config, cwd); }); - app.post('/api/providers/refresh', async (_req, _reply) => { + // 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry). + app.get('/api/providers/config', async (_req, _reply) => { + return load(config.CODER_PROVIDERS_PATH); + }); + + // 4.2 — patch the config file (design.md §6.2). Strict order is the whole + // correctness story: validate → save → reload → clear. A malformed body or an + // invalid merged result returns 422 and NEVER writes; a save failure returns + // 500 and leaves in-memory state untouched (no file/registry divergence). + app.patch('/api/providers/config', async (req, reply) => { + // 1. Validate the PATCH body shape (malformed → 422, never reaches merge). + const parsed = ProviderConfigPatchSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(422).send({ + error: 'invalid provider config patch', + issues: parsed.error.flatten(), + }); + } + + // 2. Shallow per-id merge over the current file (null deletes; object replaces). + const current = load(config.CODER_PROVIDERS_PATH); + const merged = mergeProviderConfigPatch(current, parsed.data); + + // 3. Validate the merged result — refuse to write a config that won't load. + const validated = CoderProvidersFileSchema.safeParse(merged); + if (!validated.success) { + return reply.code(422).send({ + error: 'merged provider config is invalid', + issues: validated.error.flatten(), + }); + } + + // 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on + // disk and the in-memory resolved registry can never diverge. + try { + save(config.CODER_PROVIDERS_PATH, validated.data); + } catch (err) { + req.log.error( + { err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH }, + 'provider-config: save failed — in-memory state untouched', + ); + return reply.code(500).send({ error: 'failed to write provider config' }); + } + + // 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop + // the snapshot cache so the next /snapshot reflects the change. + reloadProviderConfig(); + clearProviderSnapshotCache(); + + // 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers + // so the client can update without a follow-up GET). + return { ok: true, providers: validated.data.providers }; + }); + + // 4.3 — force a cold probe. Optional { providers?: string[] } narrows the + // reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is + // the only existing re-probe primitive (per-provider force would be a + // snapshot-internal change, out of Phase 4 scope), so the probe runs for all + // installed providers; the `refreshed` count reflects the requested subset. + app.post('/api/providers/refresh', async (req, reply) => { + const parsed = RefreshBodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() }); + } + const subset = parsed.data.providers; clearProviderSnapshotCache(); const entries = await getProviderSnapshot(sql, config, undefined, true); - return { refreshed: entries.length }; + const refreshed = + subset && subset.length > 0 + ? entries.filter((e) => subset.includes(e.name)).length + : entries.length; + return { refreshed }; + }); + + // 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`). + // Read-only: reports cached state (resolved def + available_agents row + warm + // snapshot cache for the last probe error) plus a `which` PATH check. No probe + // spawn. The report itself is a plaintext block (§8); the route wraps it as JSON. + app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => { + const id = req.params.id; + const resolved = getResolvedRegistry().get(id); + if (!resolved) { + return reply.code(404).send({ error: `unknown provider '${id}'` }); + } + const rows = await sql` + SELECT name, install_path, supports_acp, models, last_probed_at + FROM available_agents WHERE name = ${id} + `; + const report = await getProviderDiagnostic(resolved, rows[0], { + cachedEntry: peekSnapshotEntry(id), + }); + return { diagnostic: report }; }); } diff --git a/apps/coder/src/services/__tests__/provider-config.test.ts b/apps/coder/src/services/__tests__/provider-config.test.ts new file mode 100644 index 0000000..07352d4 --- /dev/null +++ b/apps/coder/src/services/__tests__/provider-config.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + mergeProviderConfigPatch, + ProviderConfigPatchSchema, + CoderProvidersFileSchema, + type CoderProvidersFile, +} from '../provider-config.js'; + +describe('ProviderConfigPatchSchema', () => { + it('accepts a per-provider override patch', () => { + const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } }); + expect(parsed.success).toBe(true); + }); + + it('accepts a null value (delete-the-override sentinel)', () => { + const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } }); + expect(parsed.success).toBe(true); + }); + + it('defaults providers to {} on an empty body', () => { + const parsed = ProviderConfigPatchSchema.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.providers).toEqual({}); + }); + + it('rejects a malformed override (wrong field type)', () => { + const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } }); + expect(parsed.success).toBe(false); + }); + + it('rejects a non-object providers map', () => { + const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 }); + expect(parsed.success).toBe(false); + }); +}); + +describe('mergeProviderConfigPatch', () => { + const current: CoderProvidersFile = { + providers: { + goose: { enabled: true, label: 'Goose' }, + opencode: { enabled: true }, + }, + }; + + it('replaces an existing override object wholesale (not deep-merge)', () => { + const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } }); + // Whole override replaced — the prior `label` is gone, only `enabled` remains. + expect(merged.providers.goose).toEqual({ enabled: false }); + }); + + it('adds a brand-new override id', () => { + const merged = mergeProviderConfigPatch(current, { + providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } }, + }); + expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] }); + }); + + it('deletes an override when the value is null', () => { + const merged = mergeProviderConfigPatch(current, { providers: { goose: null } }); + expect(merged.providers.goose).toBeUndefined(); + expect(Object.keys(merged.providers)).toEqual(['opencode']); + }); + + it('leaves ids absent from the patch untouched', () => { + const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } }); + expect(merged.providers.opencode).toEqual({ enabled: true }); + }); + + it('does not mutate the input config', () => { + const snapshot = JSON.parse(JSON.stringify(current)); + mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } }); + expect(current).toEqual(snapshot); + }); + + it('empty patch returns an equivalent config', () => { + const merged = mergeProviderConfigPatch(current, { providers: {} }); + expect(merged).toEqual(current); + }); +}); + +describe('CoderProvidersFileSchema (validate-before-save guard)', () => { + it('accepts a clean merged config', () => { + const merged = mergeProviderConfigPatch( + { providers: {} }, + { providers: { goose: { enabled: false } } }, + ); + expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true); + }); + + it('rejects a config carrying an invalid override (never written)', () => { + // A merged object that somehow holds a bad override must fail validation + // so the PATCH route returns 422 and never calls save(). + const invalid = { providers: { goose: { enabled: 'nope' } } }; + expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false); + }); +}); diff --git a/apps/coder/src/services/__tests__/provider-diagnostic.test.ts b/apps/coder/src/services/__tests__/provider-diagnostic.test.ts new file mode 100644 index 0000000..b3ffda9 --- /dev/null +++ b/apps/coder/src/services/__tests__/provider-diagnostic.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js'; +import { buildResolvedRegistry } from '../provider-config-registry.js'; +import { PROVIDERS } from '../provider-registry.js'; +import type { ProviderSnapshotEntry } from '../provider-types.js'; + +const registry = buildResolvedRegistry(PROVIDERS, { + providers: { + goose: { enabled: false }, + 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] }, + }, +}); + +const alwaysAvailable = () => Promise.resolve(true); +const neverAvailable = () => Promise.resolve(false); + +describe('getProviderDiagnostic', () => { + it('reports a disabled built-in (enabled:false, no install)', async () => { + const report = await getProviderDiagnostic(registry.get('goose')!, undefined, { + checkAvailable: neverAvailable, + }); + expect(report).toContain('provider: goose'); + expect(report).toContain('enabled: false'); + expect(report).toContain('installed: false'); + expect(report).toMatch(/command_available:\s*false/); + }); + + it('reports an installed built-in with its install_path, last_probed_at, model count', async () => { + const agentRow: DiagnosticAgentRow = { + name: 'opencode', + install_path: '/usr/bin/opencode', + supports_acp: true, + models: [ + { id: 'm1', label: 'M1' }, + { id: 'm2', label: 'M2' }, + ], + last_probed_at: '2026-05-29T12:00:00.000Z', + }; + const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, { + checkAvailable: alwaysAvailable, + }); + expect(report).toContain('install_path: /usr/bin/opencode'); + expect(report).toContain('2026-05-29T12:00:00.000Z'); + expect(report).toContain('installed: true'); + expect(report).toMatch(/models_in_db:\s*2/); + expect(report).toMatch(/command_available:\s*true/); + }); + + it('reports a custom ACP launch command + its binary', async () => { + const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, { + checkAvailable: alwaysAvailable, + }); + expect(report).toContain('provider: amp-acp'); + expect(report).toContain('amp-acp --acp'); + expect(report).toContain('customAcp: true'); + }); + + it('surfaces the last probe error from a cached snapshot entry', async () => { + const cachedEntry: ProviderSnapshotEntry = { + name: 'opencode', + label: 'OpenCode', + transport: 'acp', + status: 'error', + enabled: true, + installed: true, + models: [], + modes: [], + defaultModeId: null, + commands: [], + error: 'ACP initialize timed out', + }; + const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, { + cachedEntry, + checkAvailable: alwaysAvailable, + }); + expect(report).toContain('ACP initialize timed out'); + }); + + it('reports no error when none is cached', async () => { + const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, { + checkAvailable: alwaysAvailable, + }); + expect(report).toMatch(/last_probe_error:\s*\(none/); + }); +}); diff --git a/apps/coder/src/services/__tests__/provider-snapshot.test.ts b/apps/coder/src/services/__tests__/provider-snapshot.test.ts index d6e452a..450d38c 100644 --- a/apps/coder/src/services/__tests__/provider-snapshot.test.ts +++ b/apps/coder/src/services/__tests__/provider-snapshot.test.ts @@ -7,6 +7,7 @@ import { prefixLlamaSwapModels, clearProviderSnapshotCache, getProviderSnapshot, + peekSnapshotEntry, } from '../provider-snapshot.js'; import { loadProviderConfig } from '../provider-config-registry.js'; @@ -324,6 +325,18 @@ describe('getProviderSnapshot', () => { expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0); }); + it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => { + loadConfigFixture({}); + // Cold cache → undefined (no build triggered). + expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined(); + + const sql = mockSql([]); + await getProviderSnapshot(sql, config, '/tmp/peek', true); + + expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode'); + expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined(); + }); + it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => { loadConfigFixture({}); mockProbe.mockResolvedValue({ diff --git a/apps/coder/src/services/provider-config.ts b/apps/coder/src/services/provider-config.ts index 12a8df5..8816e3d 100644 --- a/apps/coder/src/services/provider-config.ts +++ b/apps/coder/src/services/provider-config.ts @@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({ 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; + +/** + * Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in + * `patch.providers` REPLACES that id's override object wholesale (NOT a deep + * field merge); a `null` value DELETES the override. Returns a new object — + * never mutates `current`. The result is a plain CoderProvidersFile (no nulls), + * which the route re-validates against CoderProvidersFileSchema before save. + */ +export function mergeProviderConfigPatch( + current: CoderProvidersFile, + patch: ProviderConfigPatch, +): CoderProvidersFile { + const providers: Record = { ...current.providers }; + for (const [id, override] of Object.entries(patch.providers)) { + if (override === null) { + delete providers[id]; + } else { + providers[id] = override; + } + } + return { providers }; +} + /** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */ export function load(path: string): CoderProvidersFile { let raw: string; diff --git a/apps/coder/src/services/provider-diagnostic.ts b/apps/coder/src/services/provider-diagnostic.ts new file mode 100644 index 0000000..12876d0 --- /dev/null +++ b/apps/coder/src/services/provider-diagnostic.ts @@ -0,0 +1,71 @@ +/** + * v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report. + * + * Read-only by default: reports CACHED state (resolved registry def + the + * available_agents row + the warm snapshot-cache entry) plus a `which`-style + * PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists + * the live initialize probe as optional, and the route defaults to cached state. + * + * A template string is the whole formatter (no Paseo diagnostic-utils port). + */ +import type { ResolvedProviderDef } from './provider-config-registry.js'; +import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js'; +import { isCommandAvailable } from './command-availability.js'; + +/** The subset of an `available_agents` row the diagnostic reads. */ +export interface DiagnosticAgentRow { + name: string; + install_path: string | null; + supports_acp?: boolean; + models?: ProviderModel[] | null; + last_probed_at?: string | Date | null; +} + +interface DiagnosticOpts { + /** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */ + cachedEntry?: ProviderSnapshotEntry; + /** Injectable PATH check (defaults to the real `which`); stubbed in tests. */ + checkAvailable?: (binary: string) => Promise; +} + +/** Resolve the binary the dispatcher would launch (for the PATH check + report). */ +function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string { + return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id; +} + +export async function getProviderDiagnostic( + resolved: ResolvedProviderDef, + agentRow: DiagnosticAgentRow | undefined, + opts: DiagnosticOpts = {}, +): Promise { + const checkAvailable = opts.checkAvailable ?? isCommandAvailable; + const installed = agentRow?.install_path != null; + const binary = resolveBinary(resolved, agentRow); + // boocode is native (no binary to launch) — short-circuit the PATH check. + const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary); + const lastProbedAt = + agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)'; + const modelCount = agentRow?.models?.length ?? 0; + const launchCommand = resolved.launchCommand + ? resolved.launchCommand.join(' ') + : '(built-in default, resolved at dispatch)'; + const lastError = opts.cachedEntry?.error ?? '(none recorded)'; + + return [ + `provider: ${resolved.id}`, + `label: ${resolved.configLabel ?? resolved.label}`, + `transport: ${resolved.transport}`, + `enabled: ${resolved.enabled}`, + `builtin: ${resolved.isBuiltin}`, + `customAcp: ${resolved.isCustomAcp}`, + `installed: ${installed}`, + `install_path: ${agentRow?.install_path ?? '(none)'}`, + `binary: ${binary}`, + `command_available: ${commandAvailable}`, + `launch_command: ${launchCommand}`, + `supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`, + `last_probed_at: ${lastProbedAt}`, + `models_in_db: ${modelCount}`, + `last_probe_error: ${lastError}`, + ].join('\n'); +} diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts index 17f52ce..b0e4472 100644 --- a/apps/coder/src/services/provider-snapshot.ts +++ b/apps/coder/src/services/provider-snapshot.ts @@ -283,6 +283,16 @@ export function clearProviderSnapshotCache(): void { snapshotInflight.clear(); } +/** + * Read-only peek into the warm snapshot cache for one provider (no build, no + * probe). Used by the diagnostic route to report the last computed probe error + * without spawning anything. Returns undefined on a cold cache / unknown name. + */ +export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined { + const resolvedCwd = cwd?.trim() || homedir(); + return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name); +} + /** Persist probed model lists back to available_agents for fast legacy reads. */ export async function persistProbedModels( sql: Sql, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1e3fbfb..4c1d989 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -14,6 +14,8 @@ import type { AskUserAnswer, ToolCostStat, ProviderSnapshotEntry, + CoderProvidersFile, + ProviderConfigPatch, CoderSendMessageBody, CoderSendMessageResponse, CoderMessageWire, @@ -310,8 +312,23 @@ export const api = { const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''; return request(`/api/coder/providers/snapshot${qs}`); }, - refreshProviders: () => - request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }), + // v2.3 Phase 4: optional subset narrows the reported `refreshed` count. + refreshProviders: (providers?: string[]) => + request<{ refreshed: number }>('/api/coder/providers/refresh', { + method: 'POST', + ...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}), + }), + // v2.3 Phase 4: read/patch the provider config file. PATCH returns the new + // config; a `null` value in the patch deletes that id's override. + getProvidersConfig: () => request('/api/coder/providers/config'), + patchProvidersConfig: (patch: ProviderConfigPatch) => + request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', { + method: 'PATCH', + body: JSON.stringify(patch), + }), + // v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4). + getProviderDiagnostic: (id: string) => + request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`), sendMessage: (sessionId: string, body: CoderSendMessageBody) => request(`/api/coder/sessions/${sessionId}/messages`, { method: 'POST', diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 5b71861..04e6f6c 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry { 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;