import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Config } from '../config.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) => { const cwd = req.query.cwd; return getProviderSnapshot(sql, config, cwd); }); // 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); 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 }; }); }