From 35a0aba2113e6d93313c97e6ccd77652976e5ec7 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 11:47:48 +0000 Subject: [PATCH] =?UTF-8?q?coder(providers):=20v2.3=20provider-lifecycle?= =?UTF-8?q?=20phase=202=20=E2=80=94=20snapshot=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider-snapshot no longer returns null for uninstalled/disabled providers: it emits one entry per registered provider with a lifecycle status (loading|ready|unavailable|error), an enabled flag, and a two-tier probe. Tier-1 is a fast which-style check (command-availability.ts, execFile/no-shell); tier-2 (cold ACP probe) is skipped unless forced, last_probed_at is older than PROVIDER_PROBE_TTL_MS (24h), or DB models are empty — the snapshot-latency win. Cache miss returns status:'loading' synchronously while the build settles via the existing inflight promise. ProviderSnapshotStatus/Entry regain loading/unavailable + gain enabled/description?/fetchedAt? in both coder and web copies, guarded by a runtime parity test (provider-types-parity.test.ts; compile-time cross-project check was blocked by TS6307). Also tracks the data/coder-providers.json seed via a .gitignore exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. 13 snapshot tests (+6) + 6 parity tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + CHANGELOG.md | 4 + apps/coder/src/config.ts | 4 + .../__tests__/provider-snapshot.test.ts | 158 +++++++++++++ .../__tests__/provider-types-parity.test.ts | 64 ++++++ .../src/services/command-availability.ts | 22 ++ apps/coder/src/services/provider-snapshot.ts | 209 +++++++++++------- apps/coder/src/services/provider-types.ts | 9 +- apps/web/src/api/types.ts | 8 +- data/coder-providers.json | 3 + 10 files changed, 404 insertions(+), 78 deletions(-) create mode 100644 apps/coder/src/services/__tests__/provider-types-parity.test.ts create mode 100644 apps/coder/src/services/command-availability.ts create mode 100644 data/coder-providers.json diff --git a/.gitignore b/.gitignore index bf40f12..5f8ebd3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ data/* !data/AGENTS.md !data/skills/ !data/mcp.json +!data/coder-providers.json codecontext/fork.tar.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index 8716b8c..9d8b194 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.5.5-provider-lifecycle-phase2 — 2026-05-29 + +Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 5–30s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`. + ## v2.5.4-provider-lifecycle-phase1 — 2026-05-29 Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`. diff --git a/apps/coder/src/config.ts b/apps/coder/src/config.ts index 6ce7e27..009f851 100644 --- a/apps/coder/src/config.ts +++ b/apps/coder/src/config.ts @@ -26,6 +26,10 @@ const ConfigSchema = z.object({ // v2.3: config-backed provider overrides/custom-ACP entries merged over the // hardcoded built-ins. Missing file = built-ins only (see provider-config.ts). CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'), + // v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was + // probed more recently than this. 24h default — stale model lists self-heal + // on the next snapshot; an explicit /refresh always re-probes. + PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000), // v2.0.5: cheaper model for titles, summaries, labeling. FAST_MODEL: z.string().optional(), // SSH access to the host for external agent dispatch (Phase 5) diff --git a/apps/coder/src/services/__tests__/provider-snapshot.test.ts b/apps/coder/src/services/__tests__/provider-snapshot.test.ts index dded8cf..940167a 100644 --- a/apps/coder/src/services/__tests__/provider-snapshot.test.ts +++ b/apps/coder/src/services/__tests__/provider-snapshot.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { mergeModels, prefixLlamaSwapModels, clearProviderSnapshotCache, getProviderSnapshot, } from '../provider-snapshot.js'; +import { loadProviderConfig } from '../provider-config-registry.js'; vi.mock('../acp-probe.js', () => ({ probeAcpProvider: vi.fn(), @@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js'; const mockProbe = vi.mocked(probeAcpProvider); +/** Write a temp coder-providers.json and point the resolved registry at it. */ +function loadConfigFixture(providers: Record): void { + const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`); + writeFileSync(path, JSON.stringify({ providers }), 'utf8'); + loadProviderConfig(path); +} + function mockSql(agents: Array<{ name: string; install_path: string | null; @@ -21,6 +32,7 @@ function mockSql(agents: Array<{ models: Array<{ id: string; label: string }> | null; label: string | null; transport: string | null; + last_probed_at?: string | null; }>) { return vi.fn((strings: TemplateStringsArray) => { const query = strings.join(''); @@ -36,6 +48,7 @@ function mockSql(agents: Array<{ const config = { LLAMA_SWAP_URL: 'http://llama-swap.test', + PROVIDER_PROBE_TTL_MS: 86_400_000, } as import('../config.js').Config; describe('prefixLlamaSwapModels', () => { @@ -68,6 +81,8 @@ describe('mergeModels', () => { describe('getProviderSnapshot', () => { beforeEach(() => { clearProviderSnapshotCache(); + // Reset the resolved registry to built-ins-only (missing path → {} config). + loadProviderConfig('/nonexistent-coder-providers.json'); vi.restoreAllMocks(); vi.stubGlobal( 'fetch', @@ -165,4 +180,147 @@ describe('getProviderSnapshot', () => { expect(claude?.modes.length).toBeGreaterThan(0); expect(claude?.commands.some((c) => c.name === 'help')).toBe(true); }); + + it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => { + loadConfigFixture({ goose: { enabled: false } }); + mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] }); + + const sql = mockSql([ + { + name: 'goose', + install_path: '/usr/bin/goose', + supports_acp: true, + models: [{ id: 'g1', label: 'G1' }], + label: 'Goose', + transport: 'acp', + last_probed_at: new Date().toISOString(), + }, + ]); + + const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); + const goose = entries.find((e) => e.name === 'goose'); + + expect(goose?.status).toBe('unavailable'); + expect(goose?.enabled).toBe(false); + expect(goose?.installed).toBe(false); + expect(mockProbe).not.toHaveBeenCalled(); + }); + + it('uninstalled provider → unavailable + enabled:true + installed:false', async () => { + loadConfigFixture({}); + mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] }); + + const sql = mockSql([]); // nothing probed/installed + const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); + const opencode = entries.find((e) => e.name === 'opencode'); + + expect(opencode?.status).toBe('unavailable'); + expect(opencode?.enabled).toBe(true); + expect(opencode?.installed).toBe(false); + expect(mockProbe).not.toHaveBeenCalled(); + }); + + it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => { + loadConfigFixture({}); + // If this were wrongly called, cached-goose would be replaced and the + // not.toHaveBeenCalled assertion would fail. + mockProbe.mockResolvedValue({ + ok: true, + models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }], + modes: [], + defaultModeId: null, + commands: [], + }); + + const sql = mockSql([ + { + name: 'goose', + install_path: '/usr/bin/goose', + supports_acp: true, + models: [{ id: 'cached-goose', label: 'Cached Goose' }], + label: 'Goose', + transport: 'acp', + last_probed_at: new Date().toISOString(), // fresh + }, + ]); + + // force=false → cache-miss returns loading; second call joins the build / cache. + await getProviderSnapshot(sql, config, '/tmp/cwd', false); + const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false); + const goose = entries.find((e) => e.name === 'goose'); + + expect(goose?.status).toBe('ready'); + expect(goose?.installed).toBe(true); + expect(goose?.models.map((m) => m.id)).toContain('cached-goose'); + expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR'); + expect(mockProbe).not.toHaveBeenCalled(); + }); + + it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => { + loadConfigFixture({}); + mockProbe.mockResolvedValue({ + ok: true, + models: [{ id: 'fresh-probe', label: 'Fresh' }], + modes: [], + defaultModeId: null, + commands: [], + }); + + const sql = mockSql([ + { + name: 'goose', + install_path: '/usr/bin/goose', + supports_acp: true, + models: [{ id: 'cached-goose', label: 'Cached' }], + label: 'Goose', + transport: 'acp', + last_probed_at: new Date().toISOString(), // fresh, but force overrides + }, + ]); + + await getProviderSnapshot(sql, config, '/tmp/cwd', true); + expect(mockProbe).toHaveBeenCalled(); + }); + + it('native boocode → ready, enabled, installed', async () => { + loadConfigFixture({}); + const sql = mockSql([]); + const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); + const boocode = entries.find((e) => e.name === 'boocode'); + + expect(boocode?.status).toBe('ready'); + expect(boocode?.enabled).toBe(true); + expect(boocode?.installed).toBe(true); + }); + + it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => { + loadConfigFixture({}); + mockProbe.mockResolvedValue({ + ok: true, + models: [{ id: 'm1', label: 'M1' }], + modes: [], + defaultModeId: null, + commands: [], + }); + + const sql = mockSql([ + { + name: 'goose', + install_path: '/usr/bin/goose', + supports_acp: true, + models: null, + label: 'Goose', + transport: 'acp', + last_probed_at: null, + }, + ]); + + await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate + const probeCallsAfterFirst = mockProbe.mock.calls.length; + await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read + const probeCallsAfterSecond = mockProbe.mock.calls.length; + + // Success criterion: second snapshot is served from cache with no ACP spawns. + expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0); + }); }); diff --git a/apps/coder/src/services/__tests__/provider-types-parity.test.ts b/apps/coder/src/services/__tests__/provider-types-parity.test.ts new file mode 100644 index 0000000..b47b0bd --- /dev/null +++ b/apps/coder/src/services/__tests__/provider-types-parity.test.ts @@ -0,0 +1,64 @@ +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/command-availability.ts b/apps/coder/src/services/command-availability.ts new file mode 100644 index 0000000..89579db --- /dev/null +++ b/apps/coder/src/services/command-availability.ts @@ -0,0 +1,22 @@ +/** + * v2.3 phase 2: tier-1 fast availability check — is a binary on PATH? + * + * Uses execFile (NO shell) because the binary name can come from the provider + * config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening. + * Note: agent-probe's `whichBinary` returns the resolved path (it needs it for + * `install_path`); this returns a boolean. Kept separate rather than over- + * refactored into one helper — different return contracts, two short call sites. + */ +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCb); + +export async function isCommandAvailable(binary: string): Promise { + try { + const { stdout } = await execFile('which', [binary], { timeout: 10_000 }); + return stdout.trim().length > 0; + } catch { + return false; + } +} diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts index c8378f6..9a9af61 100644 --- a/apps/coder/src/services/provider-snapshot.ts +++ b/apps/coder/src/services/provider-snapshot.ts @@ -5,7 +5,6 @@ import { homedir } from 'node:os'; import type { FastifyBaseLogger } from 'fastify'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; -import { PROVIDERS, type ProviderDef } from './provider-registry.js'; import { getManifestDefaultModeId, getManifestModes, @@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js'; import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { readQwenSettingsModels } from './qwen-settings.js'; +import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; +import { isCommandAvailable } from './command-availability.js'; interface AgentRow { name: string; @@ -23,6 +24,7 @@ interface AgentRow { models: ProviderModel[] | null; label: string | null; transport: string | null; + last_probed_at: string | Date | null; } async function fetchLlamaSwapModels(config: Config): Promise { @@ -68,113 +70,160 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] { } async function buildProviderEntry( - provider: ProviderDef, + resolved: ResolvedProviderDef, agentRow: AgentRow | undefined, llamaModels: ProviderModel[], cwd: string, -): Promise { - const isNative = provider.name === 'boocode'; - const installed = isNative || !!agentRow; - if (!installed) return null; + ttlMs: number, + force: boolean, +): Promise { + const name = resolved.id; + const isNative = resolved.transport === 'native'; + const fallbackModes = getManifestModes(name); + const defaultModeId = getManifestDefaultModeId(name); + const manifestCommands = getManifestCommands(name); + const label = agentRow?.label ?? resolved.configLabel ?? resolved.label; + const descr = resolved.configDescription ? { description: resolved.configDescription } : {}; - let transport = provider.transport; - if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) { + // ACP built-ins fall back to PTY transport when the installed binary lacks ACP. + let transport = resolved.transport; + if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) { transport = 'pty'; } - const fallbackModes = getManifestModes(provider.name); - const defaultModeId = getManifestDefaultModeId(provider.name); - - if (isNative) { + // 1. Disabled → unavailable, no probe. + if (!resolved.enabled) { return { - name: provider.name, - label: provider.label, - transport, - status: 'ready', - installed: true, - models: llamaModels, - modes: [], - defaultModeId: null, - commands: getManifestCommands(provider.name), + name, label, ...descr, transport, status: 'unavailable', + enabled: false, installed: false, models: [], modes: fallbackModes, + defaultModeId, commands: manifestCommands, }; } + // 2. Native boocode → always ready (llama-swap models). + if (isNative) { + return { + name, label: resolved.label, transport, status: 'ready', + enabled: true, installed: true, models: llamaModels, modes: [], + defaultModeId: null, commands: manifestCommands, + }; + } + + // 3. Tier-1 fast availability: installed iff a probed install_path exists or + // the launch binary is on PATH. No spawn beyond a `which` for custom entries. + const fast = + agentRow?.install_path != null || + (resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false); + + if (!fast) { + return { + name, label, ...descr, transport, status: 'unavailable', + enabled: true, installed: false, models: [], modes: fallbackModes, + defaultModeId, commands: manifestCommands, + }; + } + + // Baseline model precedence (used by claude + non-probe fallbacks). let models: ProviderModel[] = []; - if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) { + if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) { models = llamaModels; } else if (agentRow?.models?.length) { models = agentRow.models; - } else if (provider.staticModels) { - models = provider.staticModels.map((m) => ({ id: m.id, label: m.label })); + } else if (resolved.staticModels) { + models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label })); } - if (provider.name === 'claude') { - models = attachClaudeThinking(models); + // claude: static models + thinking options, no ACP probe (unchanged from v2.2). + if (name === 'claude') { return { - name: provider.name, - label: agentRow?.label ?? provider.label, - transport, - status: 'ready', - installed: true, - models, - modes: fallbackModes, - defaultModeId, - commands: getManifestCommands(provider.name), + name, label, transport, status: 'ready', enabled: true, installed: true, + models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId, + commands: manifestCommands, }; } - if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) { - const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd); - if (probe.models.length > 0) { - models = probe.models; - } else if (provider.modelSource === 'llama-swap') { - models = llamaModels; + const canProbeAcp = + transport === 'acp' && + ((agentRow?.install_path != null && agentRow.supports_acp) || + (resolved.isCustomAcp && resolved.launchCommand != null)); + + if (canProbeAcp) { + // Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB + // models. Otherwise serve DB models + manifest modes/commands — no spawn. + const lastProbedMs = + agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN; + const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs; + const dbEmpty = !(agentRow?.models && agentRow.models.length > 0); + const runTier2 = force || stale || dbEmpty; + + if (!runTier2) { + let skipModels = agentRow?.models ?? []; + if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') { + skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels)); + } else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) { + skipModels = llamaModels; + } + return { + name, label, transport, status: 'ready', enabled: true, installed: true, + models: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands, + }; } - if (provider.name === 'qwen') { - const settingsModels = await readQwenSettingsModels(); - models = mergeModels(models, settingsModels); - } + const probeTarget = + resolved.isCustomAcp && resolved.launchCommand + ? resolved.launchCommand[0] + : agentRow!.install_path!; + const probe = await probeAcpProvider(name, probeTarget, cwd); - if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') { - const nativeModels = probe.models.length > 0 ? probe.models : models; - models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); + let probeModels = probe.models.length > 0 ? probe.models : models; + if (name === 'qwen') { + probeModels = mergeModels(probeModels, await readQwenSettingsModels()); + } + if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') { + const nativeModels = probe.models.length > 0 ? probe.models : probeModels; + probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); } return { - name: provider.name, - label: agentRow.label ?? provider.label, - transport, + name, label, transport, status: probe.ok ? 'ready' : 'error', - installed: true, - models, + enabled: true, installed: true, + models: probeModels, modes: probe.modes.length > 0 ? probe.modes : fallbackModes, defaultModeId: probe.defaultModeId ?? defaultModeId, - commands: mergeCommands(getManifestCommands(provider.name), probe.commands), - error: probe.error, + commands: mergeCommands(manifestCommands, probe.commands), + ...(probe.error ? { error: probe.error } : {}), + fetchedAt: new Date().toISOString(), }; } - // PTY-only providers (qwen fallback when ACP unavailable) - if (provider.name === 'qwen') { - if (models.length === 0) { - models = await readQwenSettingsModels(); - } + // PTY-only fallback (e.g. qwen without ACP) — installed + ready. + if (name === 'qwen' && models.length === 0) { + models = await readQwenSettingsModels(); } - return { - name: provider.name, - label: agentRow?.label ?? provider.label, - transport, - status: 'ready', - installed: true, - models, - modes: fallbackModes, - defaultModeId, - commands: getManifestCommands(provider.name), + name, label, transport, status: 'ready', enabled: true, installed: true, + models, modes: fallbackModes, defaultModeId, commands: manifestCommands, }; } +/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */ +function loadingEntries(): ProviderSnapshotEntry[] { + return [...getResolvedRegistry().values()].map((r) => ({ + name: r.id, + label: r.configLabel ?? r.label, + ...(r.configDescription ? { description: r.configDescription } : {}), + transport: r.transport, + status: r.enabled ? ('loading' as const) : ('unavailable' as const), + enabled: r.enabled, + installed: false, + models: [], + modes: getManifestModes(r.id), + defaultModeId: getManifestDefaultModeId(r.id), + commands: getManifestCommands(r.id), + })); +} + const snapshotCache = new Map(); const snapshotInflight = new Map>(); const CACHE_TTL_MS = 5 * 60_000; @@ -200,16 +249,16 @@ export async function getProviderSnapshot( const build = async (): Promise => { const llamaModels = await fetchLlamaSwapModels(config); const agents = await sql` - SELECT name, install_path, supports_acp, models, label, transport FROM available_agents + SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents `; const agentMap = new Map(agents.map((a) => [a.name, a])); + const ttlMs = config.PROVIDER_PROBE_TTL_MS; - const built = await Promise.all( - PROVIDERS.map((provider) => - buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd), + const entries = await Promise.all( + [...getResolvedRegistry().values()].map((resolved) => + buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force), ), ); - const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null); snapshotCache.set(cacheKey, { at: Date.now(), entries }); return entries; @@ -219,7 +268,15 @@ export async function getProviderSnapshot( snapshotInflight.delete(cacheKey); }); snapshotInflight.set(cacheKey, promise); - return promise; + + // force → await the full build (cold probes included). Non-force cache miss → + // return loading entries synchronously; the build settles in the background + // and the next call returns it via cache / inflight (§4.4; client polls). + if (force) return promise; + promise.catch(() => { + /* settled errors surface on the next call that awaits inflight/rebuilds */ + }); + return loadingEntries(); } export function clearProviderSnapshotCache(): void { diff --git a/apps/coder/src/services/provider-types.ts b/apps/coder/src/services/provider-types.ts index 87c10c4..5e73d0e 100644 --- a/apps/coder/src/services/provider-types.ts +++ b/apps/coder/src/services/provider-types.ts @@ -23,24 +23,31 @@ export interface ProviderModel { defaultThinkingOptionId?: string; } -export type ProviderSnapshotStatus = 'ready' | 'error'; +// 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; } +// 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 { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 88d7a67..2d0b983 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -232,19 +232,25 @@ export interface ThinkingOption { isDefault?: boolean; } -export type ProviderSnapshotStatus = 'ready' | 'error'; +// 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; } export interface AgentSessionConfig { diff --git a/data/coder-providers.json b/data/coder-providers.json new file mode 100644 index 0000000..87b07bc --- /dev/null +++ b/data/coder-providers.json @@ -0,0 +1,3 @@ +{ + "providers": {} +}