From 5e31bd1c062f7e9f2a6f82de350a395157418ef0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 20:22:27 +0300 Subject: [PATCH] feat(opencode): add team runtime integration --- .../src/internal/runtime.js | 97 ++ agent-teams-controller/src/mcpToolCatalog.js | 9 +- .../test/controller.test.js | 71 + mcp-server/src/agent-teams-controller.d.ts | 4 + mcp-server/src/tools/runtimeTools.ts | 180 +++ mcp-server/test/tools.test.ts | 63 + src/main/http/teams.ts | 134 +- src/main/index.ts | 100 +- src/main/ipc/teams.ts | 26 +- .../infrastructure/CliInstallerService.ts | 22 +- .../services/infrastructure/FileWatcher.ts | 11 + .../runtime/ClaudeMultimodelBridgeService.ts | 371 +++++- .../runtime/ProviderConnectionService.ts | 11 +- .../services/runtime/buildRuntimeBaseEnv.ts | 10 +- .../services/runtime/providerRuntimeEnv.ts | 25 +- src/main/services/team/TeamDataService.ts | 3 +- .../services/team/TeamMcpConfigBuilder.ts | 6 +- src/main/services/team/TeamMemberResolver.ts | 26 +- src/main/services/team/TeamMetaStore.ts | 2 +- .../services/team/TeamProvisioningService.ts | 1069 ++++++++++++++- src/main/services/team/index.ts | 55 + .../team/memberUpdateNotifications.ts | 8 +- .../bridge/OpenCodeBridgeCommandClient.ts | 267 ++++ .../bridge/OpenCodeBridgeCommandContract.ts | 813 ++++++++++++ .../OpenCodeBridgeCommandLedgerStore.ts | 438 +++++++ .../bridge/OpenCodeBridgeHandshakeClient.ts | 112 ++ .../bridge/OpenCodeReadinessBridge.ts | 413 ++++++ ...enCodeStateChangingBridgeCommandService.ts | 283 ++++ .../capabilities/OpenCodeApiCapabilities.ts | 555 ++++++++ .../opencode/config/OpenCodeManagedOverlay.ts | 401 ++++++ .../delivery/RuntimeDeliveryJournal.ts | 482 +++++++ .../delivery/RuntimeDeliveryService.ts | 306 +++++ .../e2e/OpenCodeProductionE2EEvidence.ts | 527 ++++++++ .../e2e/OpenCodeProductionE2EEvidenceStore.ts | 73 ++ .../events/OpenCodeEventNormalizer.ts | 413 ++++++ .../mcp/OpenCodeMcpToolAvailability.ts | 421 ++++++ .../opencode/permissions/RuntimePermission.ts | 936 ++++++++++++++ .../readiness/OpenCodeTeamLaunchReadiness.ts | 378 ++++++ .../store/OpenCodeLaunchTransactionStore.ts | 399 ++++++ .../OpenCodeRuntimeManifestEvidenceReader.ts | 48 + .../store/RuntimeRunTombstoneStore.ts | 313 +++++ .../opencode/store/RuntimeStoreManifest.ts | 1144 +++++++++++++++++ .../team/opencode/store/VersionedJsonStore.ts | 292 +++++ .../opencode/version/OpenCodeVersionPolicy.ts | 284 ++++ .../runtime/OpenCodeTeamRuntimeAdapter.ts | 499 +++++++ .../team/runtime/TeamRuntimeAdapter.ts | 184 +++ src/main/services/team/runtime/index.ts | 29 + .../stream/BoardTaskLogStreamService.ts | 17 +- .../OpenCodeTaskLogAttributionService.ts | 293 +++++ .../stream/OpenCodeTaskLogAttributionStore.ts | 481 +++++++ .../stream/OpenCodeTaskLogStreamSource.ts | 1122 ++++++++++++++++ .../components/dashboard/CliStatusBanner.tsx | 12 +- .../extensions/ExtensionStoreView.tsx | 2 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 24 + .../settings/sections/CliStatusSection.tsx | 8 +- .../sidebar/DateGroupedSessions.tsx | 1 + .../sidebar/SessionFiltersPopover.tsx | 1 + .../ProvisioningProviderStatusList.tsx | 15 +- .../team/dialogs/TeamModelSelector.tsx | 86 +- .../team/dialogs/editTeamRuntimeChanges.ts | 11 +- .../team/taskLogs/TaskLogStreamSection.tsx | 39 +- .../services/createTeamDraftStorage.ts | 10 +- .../store/slices/cliInstallerSlice.ts | 10 +- src/renderer/store/slices/teamSlice.ts | 2 +- .../utils/bootstrapPromptSanitizer.ts | 11 +- src/renderer/utils/memberHelpers.ts | 2 + src/renderer/utils/providerBackendIdentity.ts | 4 +- src/renderer/utils/teamModelAvailability.ts | 5 + src/renderer/utils/teamModelCatalog.ts | 37 +- src/renderer/utils/teamModelContext.ts | 6 +- .../constants/opencodeTaskLogAttribution.ts | 1 + src/shared/types/cliInstaller.ts | 3 +- src/shared/types/team.ts | 23 +- .../utils/__tests__/teamProvider.test.ts | 14 +- src/shared/utils/opencodeModelRef.ts | 78 ++ src/shared/utils/teamProvider.ts | 18 +- src/shared/utils/toolSummary.ts | 2 + src/types/agent-teams-controller.d.ts | 4 + .../CliInstallerService.test.ts | 182 +++ .../infrastructure/FileWatcher.test.ts | 22 + .../ClaudeMultimodelBridgeService.test.ts | 323 ++++- .../runtime/providerRuntimeEnv.test.ts | 1 + .../team/BoardTaskLogStreamService.test.ts | 55 + .../team/OpenCodeApiCapabilities.test.ts | 223 ++++ .../team/OpenCodeBridgeCommandClient.test.ts | 269 ++++ .../OpenCodeBridgeCommandContract.test.ts | 303 +++++ .../OpenCodeBridgeCommandLedgerStore.test.ts | 234 ++++ .../team/OpenCodeEventNormalizer.test.ts | 240 ++++ .../OpenCodeLaunchTransactionStore.test.ts | 258 ++++ .../team/OpenCodeManagedOverlay.test.ts | 158 +++ .../team/OpenCodeMcpToolAvailability.test.ts | 224 ++++ .../OpenCodeProductionE2EEvidence.test.ts | 209 +++ .../team/OpenCodeProductionGate.live.test.ts | 427 ++++++ .../team/OpenCodeReadinessBridge.test.ts | 405 ++++++ ...eStateChangingBridgeCommandService.test.ts | 368 ++++++ .../OpenCodeTaskLogAttributionService.test.ts | 219 ++++ .../OpenCodeTaskLogAttributionStore.test.ts | 313 +++++ .../team/OpenCodeTaskLogStreamSource.test.ts | 1110 ++++++++++++++++ .../team/OpenCodeTeamLaunchReadiness.test.ts | 432 +++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 255 ++++ .../team/OpenCodeVersionPolicy.test.ts | 375 ++++++ .../team/RuntimeDeliveryService.test.ts | 356 +++++ .../services/team/RuntimePermission.test.ts | 510 ++++++++ .../team/RuntimeRunTombstoneStore.test.ts | 159 +++ .../team/RuntimeStoreManifest.test.ts | 361 ++++++ .../TeamProvisioningServicePrepare.test.ts | 34 + .../team/TeamRuntimeAdapterRegistry.test.ts | 128 ++ .../services/team/VersionedJsonStore.test.ts | 128 ++ .../cli/CliStatusVisibility.test.ts | 2 +- .../extensions/skills/SkillsPanel.test.ts | 2 +- .../ProviderRuntimeSettingsDialog.test.ts | 90 ++ .../components/team/TeamModelSelector.test.ts | 9 + .../TeamModelSelectorDisabledState.test.ts | 122 +- .../providerPrepareDiagnostics.test.ts | 34 +- .../taskLogs/TaskLogStreamSection.test.ts | 109 ++ test/renderer/store/cliInstallerSlice.test.ts | 22 +- .../utils/teamModelAvailability.test.ts | 62 + 117 files changed, 23210 insertions(+), 178 deletions(-) create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts create mode 100644 src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts create mode 100644 src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts create mode 100644 src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts create mode 100644 src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts create mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts create mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts create mode 100644 src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts create mode 100644 src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts create mode 100644 src/main/services/team/opencode/permissions/RuntimePermission.ts create mode 100644 src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts create mode 100644 src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts create mode 100644 src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts create mode 100644 src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts create mode 100644 src/main/services/team/opencode/store/RuntimeStoreManifest.ts create mode 100644 src/main/services/team/opencode/store/VersionedJsonStore.ts create mode 100644 src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts create mode 100644 src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts create mode 100644 src/main/services/team/runtime/TeamRuntimeAdapter.ts create mode 100644 src/main/services/team/runtime/index.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts create mode 100644 src/shared/constants/opencodeTaskLogAttribution.ts create mode 100644 src/shared/utils/opencodeModelRef.ts create mode 100644 test/main/services/team/OpenCodeApiCapabilities.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandClient.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandContract.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandLedgerStore.test.ts create mode 100644 test/main/services/team/OpenCodeEventNormalizer.test.ts create mode 100644 test/main/services/team/OpenCodeLaunchTransactionStore.test.ts create mode 100644 test/main/services/team/OpenCodeManagedOverlay.test.ts create mode 100644 test/main/services/team/OpenCodeMcpToolAvailability.test.ts create mode 100644 test/main/services/team/OpenCodeProductionE2EEvidence.test.ts create mode 100644 test/main/services/team/OpenCodeProductionGate.live.test.ts create mode 100644 test/main/services/team/OpenCodeReadinessBridge.test.ts create mode 100644 test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogAttributionService.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogAttributionStore.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogStreamSource.test.ts create mode 100644 test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts create mode 100644 test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts create mode 100644 test/main/services/team/OpenCodeVersionPolicy.test.ts create mode 100644 test/main/services/team/RuntimeDeliveryService.test.ts create mode 100644 test/main/services/team/RuntimePermission.test.ts create mode 100644 test/main/services/team/RuntimeRunTombstoneStore.test.ts create mode 100644 test/main/services/team/RuntimeStoreManifest.test.ts create mode 100644 test/main/services/team/TeamRuntimeAdapterRegistry.test.ts create mode 100644 test/main/services/team/VersionedJsonStore.test.ts diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js index 821a87c4..6cb7f1f0 100644 --- a/agent-teams-controller/src/internal/runtime.js +++ b/agent-teams-controller/src/internal/runtime.js @@ -218,6 +218,29 @@ function shouldWaitForStop(flags = {}) { return true; } +function compactRuntimeToolBody(context, flags = {}, fields) { + const body = { teamName: context.teamName }; + for (const field of fields) { + if (flags[field] !== undefined) { + body[field] = flags[field]; + } + } + return body; +} + +async function postRuntimeTool(context, flags = {}, toolPath, body) { + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/${toolPath}`, + { + method: 'POST', + body, + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000), + } + ); +} + async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) { const startedAt = Date.now(); let lastProgress = null; @@ -331,8 +354,82 @@ async function getRuntimeState(context, flags = {}) { return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`); } +async function runtimeBootstrapCheckin(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'bootstrap-checkin', + compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'diagnostics', + 'metadata', + ]) + ); +} + +async function runtimeDeliverMessage(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'deliver-message', + compactRuntimeToolBody(context, flags, [ + 'idempotencyKey', + 'runId', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + 'createdAt', + 'summary', + 'taskRefs', + ]) + ); +} + +async function runtimeTaskEvent(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'task-event', + compactRuntimeToolBody(context, flags, [ + 'idempotencyKey', + 'runId', + 'memberName', + 'runtimeSessionId', + 'taskId', + 'event', + 'createdAt', + 'summary', + 'metadata', + ]) + ); +} + +async function runtimeHeartbeat(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'heartbeat', + compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'status', + 'metadata', + ]) + ); +} + module.exports = { launchTeam, stopTeam, getRuntimeState, + runtimeBootstrapCheckin, + runtimeDeliverMessage, + runtimeTaskEvent, + runtimeHeartbeat, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index 211b1485..bcdeacf3 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -51,7 +51,14 @@ const AGENT_TEAMS_KANBAN_TOOL_NAMES = [ 'kanban_set_column', ]; -const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop']; +const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [ + 'team_launch', + 'team_stop', + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +]; const AGENT_TEAMS_MCP_TOOL_GROUPS = [ { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 843b0a89..967b28a0 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1069,6 +1069,77 @@ describe('agent-teams-controller API', () => { } }); + it('forwards OpenCode runtime MCP calls to the app control API', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/bootstrap-checkin') { + return { body: { ok: true, state: 'accepted' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/deliver-message') { + return { body: { ok: true, state: 'delivered' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/task-event') { + return { body: { ok: true, state: 'recorded' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/heartbeat') { + return { body: { ok: true, state: 'accepted' } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + await controller.runtime.runtimeDeliverMessage({ + controlUrl: server.baseUrl, + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'bob', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + await controller.runtime.runtimeTaskEvent({ + controlUrl: server.baseUrl, + idempotencyKey: 'idem-task-1', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + taskId: 'task-1', + event: 'started', + }); + await controller.runtime.runtimeHeartbeat({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(calls.map((call) => call.url)).toEqual([ + '/api/teams/my-team/opencode/runtime/bootstrap-checkin', + '/api/teams/my-team/opencode/runtime/deliver-message', + '/api/teams/my-team/opencode/runtime/task-event', + '/api/teams/my-team/opencode/runtime/heartbeat', + ]); + expect(calls[0].body).toEqual({ + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index e2c03985..bdc6f469 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -76,6 +76,10 @@ declare module 'agent-teams-controller' { launchTeam(flags: Record): Promise; stopTeam(flags?: Record): Promise; getRuntimeState(flags?: Record): Promise; + runtimeBootstrapCheckin(flags: Record): Promise; + runtimeDeliverMessage(flags: Record): Promise; + runtimeTaskEvent(flags: Record): Promise; + runtimeHeartbeat(flags: Record): Promise; } export interface AgentTeamsController { diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts index cb53fcdc..6dd065e9 100644 --- a/mcp-server/src/tools/runtimeTools.ts +++ b/mcp-server/src/tools/runtimeTools.ts @@ -11,6 +11,22 @@ const toolContextSchema = { waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), }; +const runtimeMetadataSchema = z.record(z.string(), z.unknown()).optional(); +const runtimeDiagnosticsSchema = z.array(z.string().min(1)).optional(); +const runtimeIdentitySchema = { + ...toolContextSchema, + runId: z.string().min(1), + memberName: z.string().min(1), + runtimeSessionId: z.string().min(1), +}; +const runtimeDeliveryTargetSchema = z.union([ + z.literal('user'), + z.object({ + memberName: z.string().min(1), + teamName: z.string().min(1).optional(), + }), +]); + export function registerRuntimeTools(server: Pick) { server.addTool({ name: 'team_launch', @@ -75,4 +91,168 @@ export function registerRuntimeTools(server: Pick) { }) ), }); + + server.addTool({ + name: 'runtime_bootstrap_checkin', + description: 'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary', + parameters: z.object({ + ...runtimeIdentitySchema, + observedAt: z.string().min(1).optional(), + diagnostics: runtimeDiagnosticsSchema, + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({ + runId, + memberName, + runtimeSessionId, + ...(observedAt ? { observedAt } : {}), + ...(diagnostics ? { diagnostics } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_deliver_message', + description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination', + parameters: z.object({ + ...toolContextSchema, + idempotencyKey: z.string().min(1), + runId: z.string().min(1), + fromMemberName: z.string().min(1), + runtimeSessionId: z.string().min(1), + to: runtimeDeliveryTargetSchema, + text: z.string().min(1), + createdAt: z.string().min(1).optional(), + summary: z.string().optional(), + taskRefs: z.array(z.unknown()).optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + idempotencyKey, + runId, + fromMemberName, + runtimeSessionId, + to, + text, + createdAt, + summary, + taskRefs, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({ + idempotencyKey, + runId, + fromMemberName, + runtimeSessionId, + to, + text, + ...(createdAt ? { createdAt } : {}), + ...(summary ? { summary } : {}), + ...(taskRefs ? { taskRefs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_task_event', + description: 'Record an idempotent OpenCode runtime task event for app-side attribution', + parameters: z.object({ + ...toolContextSchema, + idempotencyKey: z.string().min(1), + runId: z.string().min(1), + memberName: z.string().min(1), + runtimeSessionId: z.string().min(1).optional(), + taskId: z.string().min(1), + event: z.string().min(1), + createdAt: z.string().min(1).optional(), + summary: z.string().optional(), + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + idempotencyKey, + runId, + memberName, + runtimeSessionId, + taskId, + event, + createdAt, + summary, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeTaskEvent({ + idempotencyKey, + runId, + memberName, + ...(runtimeSessionId ? { runtimeSessionId } : {}), + taskId, + event, + ...(createdAt ? { createdAt } : {}), + ...(summary ? { summary } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_heartbeat', + description: 'Refresh OpenCode member runtime liveness in the app-owned launch state', + parameters: z.object({ + ...runtimeIdentitySchema, + observedAt: z.string().min(1).optional(), + status: z.enum(['alive', 'idle', 'busy']).optional(), + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + runId, + memberName, + runtimeSessionId, + observedAt, + status, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeHeartbeat({ + runId, + memberName, + runtimeSessionId, + ...(observedAt ? { observedAt } : {}), + ...(status ? { status } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 56b3de14..b5bbc643 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -215,6 +215,69 @@ describe('agent-teams-mcp tools', () => { } }); + it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + return { body: { ok: true, state: 'accepted' } }; + }); + + try { + await getTool('runtime_bootstrap_checkin').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + }); + await getTool('runtime_deliver_message').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'alice', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + await getTool('runtime_task_event').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + idempotencyKey: 'idem-task-1', + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + taskId: 'task-1', + event: 'started', + }); + await getTool('runtime_heartbeat').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + }); + + expect(calls.map((call) => call.url)).toEqual([ + '/api/teams/alpha/opencode/runtime/bootstrap-checkin', + '/api/teams/alpha/opencode/runtime/deliver-message', + '/api/teams/alpha/opencode/runtime/task-event', + '/api/teams/alpha/opencode/runtime/heartbeat', + ]); + expect(calls[1].body).toEqual({ + teamName: 'alpha', + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'alice', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + } finally { + await server.close(); + } + }); + it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); const statePath = path.join(claudeDir, 'team-control-api.json'); diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index c02e5ccc..321e8102 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -6,6 +6,7 @@ import { } from '@shared/utils/effortLevels'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { isAbsolute } from 'path'; import type { HttpServices } from './index'; @@ -33,6 +34,9 @@ function getStatusCode(error: unknown, fallback: number = 500): number { if (error instanceof HttpFeatureUnavailableError) { return 501; } + if (error instanceof Error && error.name === 'RuntimeStaleEvidenceError') { + return 409; + } return fallback; } @@ -110,15 +114,15 @@ function assertOptionalFastMode(value: unknown): TeamFastMode | undefined { function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; const providerId = - payload.providerId === 'codex' - ? 'codex' - : payload.providerId === 'gemini' - ? 'gemini' - : payload.providerId == null || payload.providerId === 'anthropic' - ? 'anthropic' - : (() => { - throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini'); - })(); + payload.providerId == null + ? 'anthropic' + : isTeamProviderId(payload.providerId) + ? payload.providerId + : (() => { + throw new HttpBadRequestError( + 'providerId must be anthropic, codex, gemini, or opencode' + ); + })(); const prompt = assertOptionalString(payload.prompt, 'prompt'); const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId'); const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId); @@ -169,6 +173,18 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest }; } +function withRuntimeTeamName(teamName: string, body: unknown): Record { + const payload = + body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : {}; + const bodyTeamName = typeof payload.teamName === 'string' ? payload.teamName.trim() : ''; + if (bodyTeamName && bodyTeamName !== teamName) { + throw new HttpBadRequestError('runtime body teamName must match route teamName'); + } + return { ...payload, teamName }; +} + export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { app.post<{ Params: { teamName: string }; Body: LaunchBody }>( '/api/teams/:teamName/launch', @@ -283,4 +299,104 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); } }); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/bootstrap-checkin', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeBootstrapCheckin( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/bootstrap-checkin:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/deliver-message', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).deliverOpenCodeRuntimeMessage( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/deliver-message:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/task-event', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeTaskEvent( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/task-event:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/heartbeat', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeHeartbeat( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/heartbeat:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); } diff --git a/src/main/index.ts b/src/main/index.ts index 7a6c373f..8fa27bdb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,7 +47,10 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + resolveAgentTeamsMcpLaunchSpec, + TeamMcpConfigBuilder, +} from '@main/services/team/TeamMcpConfigBuilder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { @@ -112,6 +115,18 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; +import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; +import { + createOpenCodeBridgeCommandLeaseStore, + createOpenCodeBridgeCommandLedgerStore, +} from './services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; +import { + createOpenCodeBridgeClientIdentity, + OpenCodeBridgeCommandHandshakePort, +} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; +import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; +import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -130,11 +145,14 @@ import { BoardTaskExactLogsService, BoardTaskLogStreamService, BranchStatusService, + ClaudeBinaryResolver, CliInstallerService, configManager, LocalFileSystemProvider, MemberStatsComputer, NotificationManager, + OpenCodeReadinessBridge, + OpenCodeTeamRuntimeAdapter, PtyTerminalService, ServiceContext, ServiceContextRegistry, @@ -142,6 +160,7 @@ import { TaskBoundaryParser, TeamDataService, TeamLogSourceTracker, + TeamRuntimeAdapterRegistry, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, @@ -155,6 +174,7 @@ import { import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; +import type { OpenCodeTeamLaunchMode } from './services/team'; const logger = createLogger('App'); startEventLoopLagMonitor(); @@ -179,6 +199,83 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) — suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); +function resolveOpenCodeTeamLaunchModeFromEnv(): OpenCodeTeamLaunchMode { + const raw = process.env.CLAUDE_TEAM_OPENCODE_LAUNCH_MODE?.trim().toLowerCase(); + if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { + return raw; + } + if (process.env.CLAUDE_TEAM_OPENCODE_DOGFOOD === '1') { + return 'dogfood'; + } + return 'disabled'; +} + +async function createOpenCodeRuntimeAdapterRegistry(): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved'); + return new TeamRuntimeAdapterRegistry(); + } + + const bridgeEnv = { ...process.env }; + try { + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const mcpEntry = mcpLaunchSpec.args[0]; + if (mcpEntry) { + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath, + tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), + env: bridgeEnv, + }); + const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); + const clientIdentity = createOpenCodeBridgeClientIdentity({ + appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0', + gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null, + buildId: process.env.VITE_BUILD_ID ?? process.env.BUILD_ID ?? null, + }); + const stateChangingCommands = new OpenCodeStateChangingBridgeCommandService({ + expectedClientIdentity: clientIdentity, + handshakePort: new OpenCodeBridgeCommandHandshakePort({ + bridge: bridgeClient, + clientIdentity, + }), + leaseStore: createOpenCodeBridgeCommandLeaseStore({ + filePath: join(bridgeControlDir, 'command-leases.json'), + }), + ledger: createOpenCodeBridgeCommandLedgerStore({ + filePath: join(bridgeControlDir, 'command-ledger.json'), + }), + bridge: bridgeClient, + manifestReader: new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), + }), + }); + return new TeamRuntimeAdapterRegistry([ + new OpenCodeTeamRuntimeAdapter( + new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({ + filePath: join(bridgeControlDir, 'production-e2e-evidence.json'), + }), + }), + { + launchMode: resolveOpenCodeTeamLaunchModeFromEnv(), + } + ), + ]); +} + // --- Team display name cache (avoid listTeams() on every notification) --- const TEAM_DISPLAY_NAME_TTL_MS = 30_000; const teamDisplayNameCache = new Map(); @@ -838,6 +935,7 @@ async function initializeServices(): Promise { teamDataService = new TeamDataService(); teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); + teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()); // Startup GC: remove stale MCP config files from previous sessions (best-effort) void new TeamMcpConfigBuilder().gcStaleConfigs(); void teamDataService diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 3d86f5aa..e1b551d4 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -97,6 +97,7 @@ import { } from '@shared/utils/effortLevels'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, @@ -1124,16 +1125,14 @@ function isValidEffort(value: unknown, providerId?: TeamProviderId | null): valu function parseOptionalMemberProviderId( value: unknown -): - | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined } - | { valid: false; error: string } { +): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } { if (value === undefined || value === null || value === '') { return { valid: true, value: undefined }; } - if (value === 'anthropic' || value === 'codex' || value === 'gemini') { + if (isTeamProviderId(value)) { return { valid: true, value }; } - return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; + return { valid: false, error: 'member providerId must be anthropic, codex, gemini, or opencode' }; } function parseOptionalProviderBackendId( @@ -1701,7 +1700,7 @@ async function handlePrepareProvisioning( ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; - let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; + let validatedProviderIds: TeamProviderId[] | undefined; let validatedSelectedModels: string[] | undefined; let validatedLimitContext: boolean | undefined; if (cwd !== undefined) { @@ -1714,8 +1713,8 @@ async function handlePrepareProvisioning( } } if (providerId !== undefined) { - if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') { - return { success: false, error: 'providerId must be anthropic, codex, or gemini' }; + if (!isTeamProviderId(providerId)) { + return { success: false, error: 'providerId must be anthropic, codex, gemini, or opencode' }; } validatedProviderId = providerId; } @@ -1723,10 +1722,13 @@ async function handlePrepareProvisioning( if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } - const normalized: ('anthropic' | 'codex' | 'gemini')[] = []; + const normalized: TeamProviderId[] = []; for (const entry of providerIds) { - if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { - return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; + if (!isTeamProviderId(entry)) { + return { + success: false, + error: 'providerIds entries must be anthropic, codex, gemini, or opencode', + }; } if (!normalized.includes(entry)) { normalized.push(entry); @@ -3283,7 +3285,7 @@ async function handleReplaceMembers( name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; }[] = []; diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index d5fc7beb..9d78e8aa 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -153,6 +153,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat ...provider, modelVerificationState: provider.modelVerificationState ?? 'idle', modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null, + detailMessage: provider.detailMessage ?? null, modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], runtimeCapabilities: provider.runtimeCapabilities ? structuredClone(provider.runtimeCapabilities) @@ -763,15 +764,18 @@ export class CliInstallerService { return null; } - const providerStatus = await this.multimodelBridgeService.getProviderStatus( - binaryPath, - providerId - ); - const nextProviderStatus = this.applyProviderModelAvailabilityToProvider( - binaryPath, - versionProbe.version, - providerStatus - ); + const providerStatus = + providerId === 'opencode' + ? await this.multimodelBridgeService.verifyProviderStatus(binaryPath, providerId) + : await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId); + const nextProviderStatus = + providerId === 'opencode' + ? await this.multimodelBridgeService.verifyOpenCodeModels(binaryPath, providerStatus) + : this.applyProviderModelAvailabilityToProvider( + binaryPath, + versionProbe.version, + providerStatus + ); this.updateLatestProviderStatus(nextProviderStatus); if (this.latestStatusSnapshot) { this.publishStatusSnapshot(this.latestStatusSnapshot); diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index fe90e437..30dcd444 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -18,6 +18,7 @@ import { getTeamsBasePath, getTodosBasePath, } from '@main/utils/pathDecoder'; +import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution'; import { createLogger } from '@shared/utils/logger'; import { EventEmitter } from 'events'; import * as fs from 'fs'; @@ -1007,6 +1008,16 @@ export class FileWatcher extends EventEmitter { detail: relative, }; this.emit('team-change', event); + return; + } + + if (relative === OPENCODE_TASK_LOG_ATTRIBUTION_FILE) { + const event: TeamChangeEvent = { + type: 'log-source-change', + teamName, + detail: relative, + }; + this.emit('team-change', event); } } diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index c4bd3ab1..0c6f572e 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,6 +1,7 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; import { createDefaultCliExtensionCapabilities, createLegacyRuntimeFallbackCliExtensionCapabilities, @@ -10,12 +11,18 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types'; +import type { + CliProviderId, + CliProviderModelAvailability, + CliProviderReasoningEffort, + CliProviderStatus, +} from '@shared/types'; const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +const OPENCODE_MODEL_VERIFY_TIMEOUT_MS = 60_000; interface RuntimeExtensionCapabilityResponse { status?: 'supported' | 'read-only' | 'unsupported'; @@ -94,6 +101,7 @@ interface ProviderStatusCommandResponse { verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; canLoginFromUi?: boolean; statusMessage?: string | null; + detailMessage?: string | null; capabilities?: { teamLaunch?: boolean; oneShot?: boolean; @@ -179,7 +187,141 @@ interface UnifiedRuntimeStatusResponse { >; } -const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; +interface OpenCodeRuntimeVerifyResponse { + schemaVersion?: number; + providerId?: 'opencode'; + snapshot?: { + detected?: boolean; + hostHealthy?: boolean; + probeError?: string | null; + diagnostics?: string[]; + host?: { + version?: string | null; + resolvedConfigFingerprint?: string | null; + } | null; + profile?: { + profileRootKey?: string; + projectBehaviorFingerprint?: string; + managedConfigFingerprint?: string; + } | null; + config?: { + default_agent?: string; + share?: string | null; + snapshot?: boolean; + autoupdate?: boolean | string; + } | null; + } | null; +} + +export interface OpenCodeRuntimeTranscriptResponse { + schemaVersion?: number; + providerId?: 'opencode'; + transcript?: { + sessionId?: string; + durableState?: string; + staleReason?: string | null; + messageCount?: number; + toolCallCount?: number; + errorCount?: number; + latestAssistantText?: string | null; + latestAssistantPreview?: string | null; + messages?: unknown[]; + diagnostics?: string[]; + logProjection?: { + sessionId?: string; + durableState?: string; + sourceMessageCount?: number; + projectedMessageCount?: number; + syntheticMessageCount?: number; + toolCallCount?: number; + errorCount?: number; + diagnostics?: string[]; + messages?: OpenCodeRuntimeTranscriptLogMessage[]; + } | null; + } | null; +} + +export type OpenCodeRuntimeTranscriptLogContentBlock = + | { + type: 'text'; + text: string; + } + | { + type: 'thinking'; + thinking: string; + signature: string; + } + | { + type: 'tool_use'; + id: string; + name: string; + input: Record; + } + | { + type: 'tool_result'; + tool_use_id: string; + content: string | OpenCodeRuntimeTranscriptLogContentBlock[]; + is_error?: boolean; + }; + +export interface OpenCodeRuntimeTranscriptLogToolCall { + id: string; + name: string; + input: Record; + isTask: boolean; + taskDescription?: string; + taskSubagentType?: string; +} + +export interface OpenCodeRuntimeTranscriptLogToolResult { + toolUseId: string; + content: string | OpenCodeRuntimeTranscriptLogContentBlock[]; + isError: boolean; +} + +export interface OpenCodeRuntimeTranscriptLogMessage { + uuid: string; + parentUuid: string | null; + type: 'assistant' | 'user' | 'system'; + timestamp: string; + role?: string; + content: OpenCodeRuntimeTranscriptLogContentBlock[] | string; + model?: string; + agentName?: string; + isMeta: boolean; + sessionId: string; + toolCalls: OpenCodeRuntimeTranscriptLogToolCall[]; + toolResults: OpenCodeRuntimeTranscriptLogToolResult[]; + sourceToolUseID?: string; + sourceToolAssistantUUID?: string; + subtype?: string; + level?: string; +} + +interface OpenCodeRuntimeVerifyModelResponse { + schemaVersion?: number; + providerId?: 'opencode'; + result?: { + modelId?: string; + outcome?: 'available' | 'unavailable' | 'unknown'; + reason?: string | null; + } | null; +} + +const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode']; + +function getProviderDisplayName(providerId: CliProviderId): string { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'opencode': + return 'OpenCode'; + } +} function extractJsonObject(raw: string): T { const trimmed = raw.trim(); @@ -198,17 +340,17 @@ function extractJsonObject(raw: string): T { function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus { return { providerId, - displayName: - providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + displayName: getProviderDisplayName(providerId), supported: false, authenticated: false, authMethod: null, verificationState: 'unknown', modelVerificationState: 'idle', statusMessage: null, + detailMessage: null, models: [], modelAvailability: [], - canLoginFromUi: true, + canLoginFromUi: providerId !== 'opencode', capabilities: { teamLaunch: false, oneShot: false, @@ -428,6 +570,7 @@ export class ClaudeMultimodelBridgeService { authMethod: runtimeStatus.authMethod ?? null, verificationState: runtimeStatus.verificationState ?? 'unknown', statusMessage: runtimeStatus.statusMessage ?? null, + detailMessage: runtimeStatus.detailMessage ?? null, canLoginFromUi: runtimeStatus.canLoginFromUi !== false, capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, @@ -514,6 +657,7 @@ export class ClaudeMultimodelBridgeService { authMethod: null, verificationState: 'error', statusMessage: issue, + detailMessage: null, backend: null, }; } @@ -525,6 +669,94 @@ export class ClaudeMultimodelBridgeService { return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); } + private async getOpenCodeVerifySnapshot( + binaryPath: string + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + const { stdout } = await execCli( + binaryPath, + ['runtime', 'verify', '--json', '--provider', 'opencode'], + { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + return parsed.providerId === 'opencode' ? (parsed.snapshot ?? null) : null; + } + + private mergeOpenCodeVerification( + provider: CliProviderStatus, + snapshot: OpenCodeRuntimeVerifyResponse['snapshot'] + ): CliProviderStatus { + if (!snapshot) { + return provider; + } + + const diagnostics = snapshot.diagnostics ?? []; + const diagnosticsSummary = diagnostics.slice(0, 2).join(' - '); + const liveIssuesPresent = + snapshot.detected === false || + snapshot.hostHealthy !== true || + Boolean(snapshot.probeError) || + diagnostics.length > 0; + + const detailParts = [ + provider.detailMessage ?? null, + snapshot.host?.resolvedConfigFingerprint + ? `live ${snapshot.host.resolvedConfigFingerprint.slice(0, 12)}` + : null, + snapshot.profile?.managedConfigFingerprint + ? `managed ${snapshot.profile.managedConfigFingerprint.slice(0, 12)}` + : null, + snapshot.profile?.projectBehaviorFingerprint + ? `behavior ${snapshot.profile.projectBehaviorFingerprint.slice(0, 12)}` + : null, + diagnosticsSummary || null, + ].filter((value): value is string => Boolean(value)); + + const nextDiagnostics = [ + ...(provider.externalRuntimeDiagnostics ?? []), + { + id: 'opencode-live-host', + label: 'OpenCode live host', + detected: snapshot.hostHealthy === true, + statusMessage: snapshot.hostHealthy === true ? 'Healthy' : 'Unavailable', + detailMessage: snapshot.probeError ?? null, + }, + { + id: 'opencode-managed-runtime', + label: 'OpenCode managed runtime', + detected: !liveIssuesPresent, + statusMessage: liveIssuesPresent + ? 'Live verification found runtime drift' + : 'Managed runtime verified', + detailMessage: diagnosticsSummary || null, + }, + ]; + + return { + ...provider, + verificationState: liveIssuesPresent ? 'error' : 'verified', + statusMessage: liveIssuesPresent + ? (snapshot.probeError ?? + diagnostics[0] ?? + 'OpenCode live verification found runtime drift') + : provider.statusMessage, + detailMessage: detailParts.length > 0 ? detailParts.join(' - ') : provider.detailMessage, + externalRuntimeDiagnostics: nextDiagnostics, + backend: provider.backend + ? { + ...provider.backend, + authMethodDetail: + snapshot.config?.default_agent === 'teammate' + ? 'managed teammate agent' + : (provider.backend.authMethodDetail ?? null), + } + : provider.backend, + }; + } + async getProviderStatus( binaryPath: string, providerId: CliProviderId @@ -565,6 +797,134 @@ export class ClaudeMultimodelBridgeService { ); } + async verifyProviderStatus( + binaryPath: string, + providerId: CliProviderId + ): Promise { + const provider = await this.getProviderStatus(binaryPath, providerId); + if (providerId !== 'opencode') { + return provider; + } + + try { + const snapshot = await this.getOpenCodeVerifySnapshot(binaryPath); + return this.mergeOpenCodeVerification(provider, snapshot); + } catch (error) { + logger.warn( + `OpenCode live verification unavailable: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { + ...provider, + verificationState: 'error', + statusMessage: 'OpenCode live verification failed', + detailMessage: error instanceof Error ? error.message : String(error), + }; + } + } + + async getOpenCodeTranscript( + binaryPath: string, + params: { + teamId: string; + memberName: string; + limit?: number; + } + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + const args = [ + 'runtime', + 'transcript', + '--json', + '--provider', + 'opencode', + '--team', + params.teamId, + '--member', + params.memberName, + ]; + if (typeof params.limit === 'number') { + args.push('--limit', String(params.limit)); + } + + const { stdout } = await execCli(binaryPath, args, { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + }); + const parsed = extractJsonObject(stdout); + return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null; + } + + private async verifyOpenCodeModel( + binaryPath: string, + modelId: string + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + try { + const { stdout } = await execCli( + binaryPath, + ['runtime', 'verify-model', '--json', '--provider', 'opencode', '--model', modelId], + { + timeout: OPENCODE_MODEL_VERIFY_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + const outcome = parsed.providerId === 'opencode' ? parsed.result?.outcome : undefined; + const reason = parsed.providerId === 'opencode' ? (parsed.result?.reason ?? null) : null; + + return { + modelId, + status: + outcome === 'available' + ? 'available' + : outcome === 'unavailable' + ? 'unavailable' + : 'unknown', + reason, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + return { + modelId, + status: 'unknown', + reason: error instanceof Error ? error.message : String(error), + checkedAt: new Date().toISOString(), + }; + } + } + + async verifyOpenCodeModels( + binaryPath: string, + provider: CliProviderStatus + ): Promise { + const visibleModels = filterVisibleProviderRuntimeModels(provider.providerId, provider.models); + if ( + provider.providerId !== 'opencode' || + provider.supported !== true || + provider.authenticated !== true || + visibleModels.length === 0 + ) { + return { + ...provider, + modelVerificationState: 'idle', + modelAvailability: [], + }; + } + + const modelAvailability: CliProviderModelAvailability[] = []; + for (const modelId of visibleModels) { + modelAvailability.push(await this.verifyOpenCodeModel(binaryPath, modelId)); + } + + return { + ...provider, + modelVerificationState: 'verified', + modelAvailability, + }; + } + private async buildGeminiStatus(binaryPath: string): Promise { const provider = createDefaultProviderStatus('gemini'); const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini'); @@ -686,6 +1046,7 @@ export class ClaudeMultimodelBridgeService { authMethod: runtimeStatus.authMethod ?? null, verificationState: runtimeStatus.verificationState ?? 'unknown', statusMessage: runtimeStatus.statusMessage ?? null, + detailMessage: runtimeStatus.detailMessage ?? null, canLoginFromUi: runtimeStatus.canLoginFromUi !== false, capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 05133652..13c98862 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -44,6 +44,11 @@ const PROVIDER_CAPABILITIES: Record< supportsApiKey: true, configurableAuthModes: [], }, + opencode: { + supportsOAuth: false, + supportsApiKey: false, + configurableAuthModes: [], + }, }; const PROVIDER_API_KEY_ENV_VARS: Partial> = { @@ -184,7 +189,7 @@ export class ProviderConnectionService { async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId); } return nextEnv; @@ -238,7 +243,7 @@ export class ProviderConnectionService { async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId); } return nextEnv; @@ -308,7 +313,7 @@ export class ProviderConnectionService { async getConfiguredConnectionIssues( env: NodeJS.ProcessEnv, - providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'], + providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'], runtimeBackendOverrides?: Partial> ): Promise>> { const issues: Partial> = {}; diff --git a/src/main/services/runtime/buildRuntimeBaseEnv.ts b/src/main/services/runtime/buildRuntimeBaseEnv.ts index 568d0fa3..0d11060d 100644 --- a/src/main/services/runtime/buildRuntimeBaseEnv.ts +++ b/src/main/services/runtime/buildRuntimeBaseEnv.ts @@ -6,7 +6,7 @@ import { configManager } from '../infrastructure/ConfigManager'; import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv, - resolveTeamProviderId, + resolveRuntimeProviderId, } from './providerRuntimeEnv'; import type { CliProviderId, TeamProviderId } from '@shared/types'; @@ -68,19 +68,19 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): { }; } - const resolvedProviderId = resolveTeamProviderId(options.providerId); + const runtimeProviderId = resolveRuntimeProviderId(options.providerId); applyProviderRuntimeEnv(env, options.providerId); - if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) { + if (runtimeProviderId === 'codex' && options.providerBackendId?.trim()) { env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim(); } - if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) { + if (runtimeProviderId === 'gemini' && options.providerBackendId?.trim()) { env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim(); } return { env, - resolvedProviderId, + resolvedProviderId: runtimeProviderId, }; } diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts index 26ea3771..f16d6559 100644 --- a/src/main/services/runtime/providerRuntimeEnv.ts +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -1,6 +1,8 @@ import { ConfigManager } from '../infrastructure/ConfigManager'; -import type { TeamProviderId } from '@shared/types'; +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type RuntimeEnvProviderId = CliProviderId | TeamProviderId; const PROVIDER_ROUTING_ENV_KEYS = [ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', @@ -32,10 +34,9 @@ export function applyConfiguredRuntimeBackendsEnv( export function applyProviderRuntimeEnv( env: NodeJS.ProcessEnv, - providerId: TeamProviderId | undefined + providerId: RuntimeEnvProviderId | undefined ): NodeJS.ProcessEnv { - const resolvedProvider: TeamProviderId = - providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; + const resolvedProvider = resolveRuntimeProviderId(providerId); for (const key of PROVIDER_ROUTING_ENV_KEYS) { env[key] = undefined; @@ -52,6 +53,18 @@ export function applyProviderRuntimeEnv( return env; } -export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { - return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +export function resolveRuntimeProviderId( + providerId: RuntimeEnvProviderId | undefined +): CliProviderId { + if (providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode') { + return providerId; + } + + return 'anthropic'; +} + +export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { + return providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode' + ? providerId + : 'anthropic'; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 54a652ac..d736bb37 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -78,6 +78,7 @@ import type { TeamMember, TeamMemberActivityMeta, TeamProcess, + TeamProviderId, TeamSummary, TeamTask, TeamTaskStatus, @@ -1315,7 +1316,7 @@ export class TeamDataService { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; }[]; diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 0a15034d..7d088e80 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; -interface McpLaunchSpec { +export interface McpLaunchSpec { command: string; args: string[]; } @@ -202,7 +202,7 @@ async function resolvePackagedServerEntry(): Promise { } } -async function resolveMcpLaunchSpec(): Promise { +export async function resolveAgentTeamsMcpLaunchSpec(): Promise { const checked: string[] = []; // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath @@ -250,7 +250,7 @@ async function resolveMcpLaunchSpec(): Promise { export class TeamMcpConfigBuilder { async writeConfigFile(_projectPath?: string): Promise { - const launchSpec = await resolveMcpLaunchSpec(); + const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); const configDir = getMcpConfigsBasePath(); const configPath = path.join( configDir, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 9e819f66..3f4ab861 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -5,8 +5,15 @@ import { } from '@shared/utils/teamMemberName'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; -import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; +import type { + TeamConfig, + TeamMember, + TeamMemberSnapshot, + TeamProviderId, + TeamTaskWithKanban, +} from '@shared/types'; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ @@ -121,7 +128,7 @@ export class TeamMemberResolver { agentType?: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; color?: string; @@ -131,17 +138,10 @@ export class TeamMemberResolver { if (Array.isArray(config.members)) { for (const m of config.members) { if (typeof m?.name === 'string' && m.name.trim() !== '') { - const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' }; + const configMember = m as TeamMember & { provider?: TeamProviderId }; const providerId = - configMember.providerId === 'anthropic' || - configMember.providerId === 'codex' || - configMember.providerId === 'gemini' - ? configMember.providerId - : configMember.provider === 'anthropic' || - configMember.provider === 'codex' || - configMember.provider === 'gemini' - ? configMember.provider - : undefined; + normalizeOptionalTeamProviderId(configMember.providerId) ?? + normalizeOptionalTeamProviderId(configMember.provider); configMemberMap.set(m.name.trim(), { agentId: configMember.agentId, agentType: configMember.agentType, @@ -164,7 +164,7 @@ export class TeamMemberResolver { agentType?: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; color?: string; diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 57f727a5..a7d3feba 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -21,7 +21,7 @@ export interface TeamMetaFile { color?: string; cwd: string; prompt?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; providerBackendId?: string; model?: string; effort?: string; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2e7e3a5a..733ba278 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -119,6 +119,7 @@ import { } from './TeamBootstrapStateReader'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; +import { TeamInboxWriter } from './TeamInboxWriter'; import { createPersistedLaunchSnapshot, snapshotFromRuntimeMemberStatuses, @@ -129,6 +130,26 @@ import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; +import { + TeamRuntimeAdapterRegistry, + type TeamLaunchRuntimeAdapter, + type TeamRuntimeLaunchInput, + type TeamRuntimeLaunchResult, + type TeamRuntimeMemberLaunchEvidence, +} from './runtime'; +import { + RuntimeDeliveryDestinationRegistry, + RuntimeDeliveryReconciler, + RuntimeDeliveryService, + type RuntimeDeliveryDestinationPort, +} from './opencode/delivery/RuntimeDeliveryService'; +import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; +import { getOpenCodeTeamRuntimeDirectory } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { + createRuntimeRunTombstoneStore, + type RuntimeEvidenceKind, +} from './opencode/store/RuntimeRunTombstoneStore'; +import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; @@ -173,6 +194,20 @@ interface RelayInboxMessageView { isCoarseNoise: boolean; } +interface OpenCodeRuntimeControlAck { + ok: true; + providerId: 'opencode'; + teamName: string; + runId: string; + state: 'accepted' | 'delivered' | 'duplicate' | 'recorded'; + memberName?: string; + runtimeSessionId?: string; + idempotencyKey?: string; + location?: unknown; + diagnostics: string[]; + observedAt: string; +} + import type { CliProviderModelCatalog, ActiveToolCall, @@ -185,9 +220,11 @@ import type { MemberSpawnLivenessSource, MemberSpawnStatus, MemberSpawnStatusEntry, + PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, ProviderModelLaunchIdentity, + PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot, @@ -224,6 +261,63 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; + +function asRuntimeRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('OpenCode runtime payload must be an object'); + } + return value as Record; +} + +function requireRuntimeString(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode runtime payload missing ${fieldName}`); + } + return value.trim(); +} + +function optionalRuntimeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeRuntimeIso(value: unknown, fallback: string = nowIso()): string { + const raw = optionalRuntimeString(value); + if (!raw) { + return fallback; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; +} + +function normalizeRuntimeStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : []; +} + +function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined { + const refs = normalizeRuntimeStringArray(value); + return refs.length > 0 + ? refs.map((ref) => ({ + teamName, + taskId: ref, + displayId: ref, + })) + : undefined; +} + +function mergeRuntimeDiagnostics( + previous: string[] | undefined, + incoming: unknown, + fallback?: string +): string[] | undefined { + const merged = [ + ...(previous ?? []), + ...normalizeRuntimeStringArray(incoming), + ...(fallback ? [fallback] : []), + ].filter((value) => value.trim().length > 0); + return merged.length > 0 ? [...new Set(merged)] : undefined; +} const VERIFY_POLL_MS = 500; const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250; const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000; @@ -516,6 +610,8 @@ function resolveRequestedLaunchModel(params: { function getTeamProviderLabel(providerId: TeamProviderId): string { switch (providerId) { + case 'opencode': + return 'OpenCode'; case 'codex': return 'Codex'; case 'gemini': @@ -555,6 +651,8 @@ function getCanonicalSendMessageToolRule(to: string): string { function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; switch (providerId) { + case 'opencode': + return null; case 'gemini': return runtimeConfig.gemini; case 'codex': @@ -565,6 +663,33 @@ function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null } } +function isOpenCodeLegacyProvisioningRequest(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): boolean { + return ( + normalizeOptionalTeamProviderId(request.providerId) === 'opencode' || + (request.members ?? []).some( + (member) => + normalizeOptionalTeamProviderId(member.providerId) === 'opencode' || + normalizeOptionalTeamProviderId(member.provider) === 'opencode' + ) + ); +} + +function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): void { + if (!isOpenCodeLegacyProvisioningRequest(request)) { + return; + } + throw new Error( + 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' + + 'Use the gated OpenCode runtime adapter once production launch is enabled.' + ); +} + function mergeProvisioningWarnings( existing: string[] | undefined, nextWarning: string | null @@ -1408,9 +1533,7 @@ function formatWorkflowBlock(workflow: string, indent: string): string { type TeamMemberInput = TeamCreateRequest['members'][number]; function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { - return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic' - ? providerId - : undefined; + return normalizeOptionalTeamProviderId(providerId); } function buildEffectiveTeamMemberSpec( @@ -2963,6 +3086,11 @@ export class TeamProvisioningService { private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); + private readonly runtimeAdapterProgressByRunId = new Map(); + private readonly runtimeAdapterRunByTeam = new Map< + string, + { runId: string; providerId: TeamProviderId; cwd?: string } + >(); private readonly retainedClaudeLogsByTeam = new Map(); private readonly persistedTranscriptClaudeLogsCache = new Map< string, @@ -2998,6 +3126,7 @@ export class TeamProvisioningService { private toolApprovalSettingsByTeam = new Map(); private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; private crossTeamSender: | ((request: { @@ -3017,9 +3146,11 @@ export class TeamProvisioningService { private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), - _sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), + private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(), - private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() + private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), + private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(), + private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore() ) { this.memberLogsFinder = new TeamMemberLogsFinder( this.configReader, @@ -3029,6 +3160,10 @@ export class TeamProvisioningService { this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); } + setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { + this.runtimeAdapterRegistry = registry; + } + setCrossTeamSender( sender: | ((request: { @@ -3405,6 +3540,31 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private getOpenCodeRuntimeAdapter(): TeamLaunchRuntimeAdapter | null { + if (!this.runtimeAdapterRegistry?.has('opencode')) { + return null; + } + return this.runtimeAdapterRegistry.get('opencode'); + } + + private shouldRouteOpenCodeToRuntimeAdapter(request: { + providerId?: TeamProviderId; + members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[]; + }): boolean { + return ( + isOpenCodeLegacyProvisioningRequest(request) && this.getOpenCodeRuntimeAdapter() !== null + ); + } + + private setRuntimeAdapterProgress( + progress: TeamProvisioningProgress, + onProgress?: (progress: TeamProvisioningProgress) => void + ): TeamProvisioningProgress { + this.runtimeAdapterProgressByRunId.set(progress.runId, progress); + onProgress?.(progress); + return progress; + } + private async getPersistedTranscriptClaudeLogs( teamName: string ): Promise { @@ -4352,6 +4512,446 @@ export class TeamProvisioningService { return this.getAliveRunId(teamName); } + async recordOpenCodeRuntimeBootstrapCheckin(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); + const observedAt = normalizeRuntimeIso(payload.observedAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'bootstrap_checkin', + }); + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics: payload.diagnostics, + reason: 'OpenCode runtime bootstrap check-in accepted', + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'accepted', + memberName, + runtimeSessionId, + diagnostics: [], + observedAt, + }; + } + + async deliverOpenCodeRuntimeMessage(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'delivery_call', + }); + + const delivery = this.createOpenCodeRuntimeDeliveryService(teamName); + const ack = await delivery.deliver({ + ...payload, + teamName, + runId, + providerId: 'opencode', + createdAt: normalizeRuntimeIso(payload.createdAt), + }); + + if (!ack.ok) { + throw new Error(`OpenCode runtime delivery rejected: ${ack.reason}`); + } + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: ack.delivered ? 'delivered' : 'duplicate', + idempotencyKey: ack.idempotencyKey, + location: ack.location, + diagnostics: ack.reason ? [ack.reason] : [], + observedAt: normalizeRuntimeIso(payload.createdAt), + }; + } + + async recordOpenCodeRuntimeTaskEvent(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const taskId = requireRuntimeString(payload.taskId, 'taskId'); + const event = requireRuntimeString(payload.event, 'event'); + const idempotencyKey = requireRuntimeString(payload.idempotencyKey, 'idempotencyKey'); + const runtimeSessionId = optionalRuntimeString(payload.runtimeSessionId); + const observedAt = normalizeRuntimeIso(payload.createdAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'delivery_call', + }); + + const writeResult = await this.openCodeTaskLogAttributionStore.upsertTaskRecord(teamName, { + taskId, + memberName, + scope: 'member_session_window', + ...(runtimeSessionId ? { sessionId: runtimeSessionId } : {}), + since: observedAt, + source: 'launch_runtime', + }); + this.teamChangeEmitter?.({ + type: 'task-log-change', + teamName, + runId, + taskId, + detail: `opencode-runtime-task-event:${event}`, + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'recorded', + memberName, + ...(runtimeSessionId ? { runtimeSessionId } : {}), + idempotencyKey, + diagnostics: [writeResult], + observedAt, + }; + } + + async recordOpenCodeRuntimeHeartbeat(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); + const observedAt = normalizeRuntimeIso(payload.observedAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'heartbeat', + }); + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics: undefined, + reason: `OpenCode runtime heartbeat accepted${optionalRuntimeString(payload.status) ? ` (${optionalRuntimeString(payload.status)})` : ''}`, + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'accepted', + memberName, + runtimeSessionId, + diagnostics: [], + observedAt, + }; + } + + private async assertOpenCodeRuntimeEvidenceAccepted(input: { + teamName: string; + runId: string; + evidenceKind: RuntimeEvidenceKind; + }): Promise { + const store = createRuntimeRunTombstoneStore({ + filePath: path.join( + getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), input.teamName), + 'opencode-run-tombstones.json' + ), + }); + await store.assertEvidenceAccepted({ + teamName: input.teamName, + runId: input.runId, + currentRunId: this.getTrackedRunId(input.teamName), + evidenceKind: input.evidenceKind, + }); + } + + private async updateOpenCodeRuntimeMemberLiveness(input: { + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + observedAt: string; + diagnostics: unknown; + reason: string; + }): Promise { + const previous = await this.launchStateStore.read(input.teamName); + const expectedMembers = previous?.expectedMembers.length + ? previous.expectedMembers + : this.readPersistedRuntimeMembers(input.teamName) + .map((member) => (typeof member.name === 'string' ? member.name.trim() : '')) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })); + const previousMember = previous?.members[input.memberName]; + const nextMember: PersistedTeamLaunchMemberState = { + name: input.memberName, + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + firstSpawnAcceptedAt: previousMember?.firstSpawnAcceptedAt ?? input.observedAt, + lastHeartbeatAt: input.observedAt, + lastRuntimeAliveAt: input.observedAt, + lastEvaluatedAt: input.observedAt, + sources: { + ...(previousMember?.sources ?? {}), + nativeHeartbeat: true, + processAlive: true, + }, + diagnostics: mergeRuntimeDiagnostics( + previousMember?.diagnostics, + input.diagnostics, + input.reason + ), + }; + const snapshot = createPersistedLaunchSnapshot({ + teamName: input.teamName, + expectedMembers: [...new Set([...expectedMembers, input.memberName])], + leadSessionId: previous?.leadSessionId, + launchPhase: previous?.launchPhase ?? 'active', + members: { + ...(previous?.members ?? {}), + [input.memberName]: nextMember, + }, + updatedAt: input.observedAt, + }); + await this.launchStateStore.write(input.teamName, snapshot); + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + runId: input.runId, + detail: input.memberName, + }); + } + + private createOpenCodeRuntimeDeliveryService(teamName: string): RuntimeDeliveryService { + const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); + const journal = createRuntimeDeliveryJournalStore({ + filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), + }); + return new RuntimeDeliveryService( + { + getCurrentRunId: async (candidateTeamName) => this.getTrackedRunId(candidateTeamName), + }, + journal, + new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), + { + append: async (event) => { + logger.warn(`[${event.teamName}] ${event.message}`); + }, + }, + { + emit: (event) => { + this.teamChangeEmitter?.({ + type: event.type as TeamChangeEvent['type'], + teamName: event.teamName, + detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined, + }); + }, + } + ); + } + + private createOpenCodeRuntimeDeliveryPorts(): RuntimeDeliveryDestinationPort[] { + const userMessagesPort: RuntimeDeliveryDestinationPort = { + kind: 'user_sent_messages', + write: async ({ envelope, destinationMessageId }) => { + await this.sentMessagesStore.appendMessage(envelope.teamName, { + from: envelope.fromMemberName, + to: 'user', + text: envelope.text, + timestamp: envelope.createdAt, + read: true, + summary: envelope.summary ?? undefined, + messageId: destinationMessageId, + source: 'lead_process', + leadSessionId: envelope.runtimeSessionId, + taskRefs: runtimeTaskRefs(envelope.teamName, envelope.taskRefs), + }); + return { + kind: 'user_sent_messages', + teamName: envelope.teamName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'user_sent_messages') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.sentMessagesStore.readMessages(destination.teamName); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'user_sent_messages', + teamName: destination.teamName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName }) => ({ + type: 'lead-message', + teamName, + data: { detail: 'opencode-runtime-delivery' }, + }), + }; + + const memberInboxPort: RuntimeDeliveryDestinationPort = { + kind: 'member_inbox', + write: async ({ envelope, destinationMessageId }) => { + if (typeof envelope.to !== 'object' || !('memberName' in envelope.to)) { + throw new Error('Runtime delivery member destination missing memberName'); + } + const memberName = envelope.to.memberName; + await this.inboxWriter.sendMessage(envelope.teamName, { + member: memberName, + from: envelope.fromMemberName, + to: memberName, + text: envelope.text, + timestamp: envelope.createdAt, + messageId: destinationMessageId, + summary: envelope.summary ?? undefined, + source: 'inbox', + leadSessionId: envelope.runtimeSessionId, + taskRefs: runtimeTaskRefs(envelope.teamName, envelope.taskRefs), + }); + return { + kind: 'member_inbox', + teamName: envelope.teamName, + memberName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'member_inbox') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.inboxReader.getMessagesFor( + destination.teamName, + destination.memberName + ); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'member_inbox', + teamName: destination.teamName, + memberName: destination.memberName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName, location }) => ({ + type: 'inbox', + teamName, + data: { + detail: + location.kind === 'member_inbox' ? `inboxes/${location.memberName}.json` : 'inboxes', + }, + }), + }; + + const crossTeamPort: RuntimeDeliveryDestinationPort = { + kind: 'cross_team_outbox', + write: async ({ envelope, destinationMessageId }) => { + if (typeof envelope.to !== 'object' || !('teamName' in envelope.to)) { + throw new Error('Runtime delivery cross-team destination missing teamName'); + } + if (!this.crossTeamSender) { + throw new Error('Cross-team sender is not configured'); + } + await this.crossTeamSender({ + fromTeam: envelope.teamName, + fromMember: envelope.fromMemberName, + toTeam: envelope.to.teamName, + text: envelope.text, + summary: envelope.summary ?? undefined, + messageId: destinationMessageId, + timestamp: envelope.createdAt, + conversationId: envelope.idempotencyKey, + }); + return { + kind: 'cross_team_outbox', + fromTeamName: envelope.teamName, + toTeamName: envelope.to.teamName, + toMemberName: envelope.to.memberName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'cross_team_outbox') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.sentMessagesStore.readMessages(destination.fromTeamName); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'cross_team_outbox', + fromTeamName: destination.fromTeamName, + toTeamName: destination.toTeamName, + toMemberName: destination.toMemberName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName }) => ({ + type: 'inbox', + teamName, + data: { detail: 'cross-team-outbox' }, + }), + }; + + return [userMessagesPort, memberInboxPort, crossTeamPort]; + } + + async recoverOpenCodeRuntimeDeliveryJournal(teamName: string): Promise<{ recovered: true }> { + const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); + const journal = createRuntimeDeliveryJournalStore({ + filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), + }); + const reconciler = new RuntimeDeliveryReconciler( + journal, + new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), + { + append: async (event) => { + logger.warn(`[${event.teamName}] ${event.message}`); + }, + } + ); + await reconciler.reconcileTeam(teamName); + return { recovered: true }; + } + getLeadActivityState(teamName: string): { state: 'active' | 'idle' | 'offline'; runId: string | null; @@ -5529,6 +6129,33 @@ export class TeamProvisioningService { ); for (const providerId of providerIds) { + if (providerId === 'opencode') { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + blockingMessages.push( + 'OpenCode team launch is not enabled yet. Production launch requires the gated OpenCode runtime adapter.' + ); + continue; + } + + const prepare = await adapter.prepare({ + runId: `prepare-${randomUUID()}`, + teamName: '__prepare_opencode__', + cwd: targetCwd, + providerId: 'opencode', + model: selectedModelIds[0], + skipPermissions: true, + expectedMembers: [], + previousLaunchState: null, + }); + details.push(...prepare.diagnostics); + warnings.push(...prepare.warnings); + if (!prepare.ok) { + blockingMessages.push(`OpenCode: ${prepare.reason}`); + } + continue; + } + const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId)); if (!probeResult?.claudePath) { @@ -6720,6 +7347,10 @@ export class TeamProvisioningService { return { runId: existingProvisioningRunId }; } assertAppDeterministicBootstrapEnabled(); + if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { + return this.createOpenCodeTeamThroughRuntimeAdapter(request, onProgress); + } + assertOpenCodeNotLaunchedThroughLegacyProvisioning(request); // Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock) const pendingKey = `pending-${randomUUID()}`; @@ -7105,6 +7736,316 @@ export class TeamProvisioningService { } } + private async createOpenCodeTeamThroughRuntimeAdapter( + request: TeamCreateRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); + for (const probe of teamsBasePathsToProbe) { + const configPath = path.join(probe.basePath, request.teamName, 'config.json'); + if (await this.pathExists(configPath)) { + const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`; + throw new Error(`Team already exists${suffix}`); + } + } + + await ensureCwdExists(request.cwd); + const effectiveMembers = buildEffectiveTeamMemberSpecs(request.members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + const teamDir = path.join(getTeamsBasePath(), request.teamName); + const tasksDir = path.join(getTasksBasePath(), request.teamName); + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.mkdir(tasksDir, { recursive: true }); + await this.teamMetaStore.writeMeta(request.teamName, { + displayName: request.displayName, + description: request.description, + color: request.color, + cwd: request.cwd, + prompt: request.prompt, + providerId: request.providerId, + providerBackendId: request.providerBackendId, + model: request.model, + effort: request.effort, + skipPermissions: request.skipPermissions, + worktree: request.worktree, + extraCliArgs: request.extraCliArgs, + limitContext: request.limitContext, + createdAt: Date.now(), + }); + const membersToWrite = applyDistinctProvisioningMemberColors( + effectiveMembers.map((member) => ({ + name: member.name.trim(), + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + agentType: 'general-purpose' as const, + joinedAt: Date.now(), + })) + ); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); + await this.writeOpenCodeTeamConfig(request, effectiveMembers); + + return this.runOpenCodeTeamRuntimeAdapterLaunch({ + request, + members: effectiveMembers, + prompt: request.prompt?.trim() ?? '', + sourceWarning: undefined, + onProgress, + }); + } + + private async launchOpenCodeTeamThroughRuntimeAdapter( + request: TeamLaunchRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); + const configRaw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!configRaw) { + throw new Error(`Team "${request.teamName}" not found — config.json does not exist`); + } + await ensureCwdExists(request.cwd); + const { members, warning } = await this.resolveLaunchExpectedMembers( + request.teamName, + configRaw + ); + const effectiveMembers = buildEffectiveTeamMemberSpecs(members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + await this.updateConfigProjectPath(request.teamName, request.cwd); + + let existingTasks: TeamTask[] = []; + try { + existingTasks = await new TeamTaskReader().getTasks(request.teamName); + } catch (error) { + logger.warn( + `[${request.teamName}] Failed to read tasks for OpenCode launch prompt: ${String(error)}` + ); + } + const prompt = buildDeterministicLaunchHydrationPrompt( + request, + effectiveMembers, + existingTasks, + false + ); + + return this.runOpenCodeTeamRuntimeAdapterLaunch({ + request, + members: effectiveMembers, + prompt, + sourceWarning: warning, + onProgress, + }); + } + + private async runOpenCodeTeamRuntimeAdapterLaunch(input: { + request: TeamCreateRequest | TeamLaunchRequest; + members: TeamCreateRequest['members']; + prompt: string; + sourceWarning?: string; + onProgress: (progress: TeamProvisioningProgress) => void; + }): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + throw new Error('OpenCode runtime adapter is not registered'); + } + + const runId = randomUUID(); + const startedAt = nowIso(); + const initialProgress: TeamProvisioningProgress = { + runId, + teamName: input.request.teamName, + state: 'validating', + message: 'Validating OpenCode team launch gate', + startedAt, + updatedAt: startedAt, + warnings: input.sourceWarning ? [input.sourceWarning] : undefined, + }; + this.provisioningRunByTeam.set(input.request.teamName, runId); + this.setRuntimeAdapterProgress(initialProgress, input.onProgress); + this.resetTeamScopedTransientStateForNewRun(input.request.teamName); + const previousLaunchState = await this.launchStateStore.read(input.request.teamName); + await this.clearPersistedLaunchState(input.request.teamName); + const launchInput: TeamRuntimeLaunchInput = { + runId, + teamName: input.request.teamName, + cwd: input.request.cwd, + prompt: input.prompt, + providerId: 'opencode', + model: input.request.model, + effort: input.request.effort, + skipPermissions: input.request.skipPermissions !== false, + expectedMembers: input.members.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + providerId: 'opencode', + model: member.model ?? input.request.model, + effort: member.effort ?? input.request.effort, + cwd: input.request.cwd, + })), + previousLaunchState, + }; + + const launching = this.setRuntimeAdapterProgress( + { + ...initialProgress, + state: 'spawning', + message: 'Starting OpenCode sessions through runtime adapter', + updatedAt: nowIso(), + }, + input.onProgress + ); + + try { + const result = await adapter.launch(launchInput); + await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput); + const success = result.teamLaunchState === 'clean_success'; + const pending = result.teamLaunchState === 'partial_pending'; + const finalProgress = this.setRuntimeAdapterProgress( + { + ...launching, + state: success || pending ? 'ready' : 'failed', + message: success + ? 'OpenCode team launch is ready' + : pending + ? 'OpenCode team launch is waiting for runtime evidence or permissions' + : 'OpenCode team launch failed readiness gate', + messageSeverity: pending + ? 'warning' + : result.teamLaunchState === 'partial_failure' + ? 'error' + : undefined, + updatedAt: nowIso(), + warnings: result.warnings.length > 0 ? result.warnings : launching.warnings, + error: + result.teamLaunchState === 'partial_failure' + ? result.diagnostics.join('\n') || 'OpenCode launch failed' + : undefined, + cliLogsTail: result.diagnostics.join('\n') || undefined, + configReady: true, + }, + input.onProgress + ); + this.runtimeAdapterRunByTeam.set(input.request.teamName, { + runId, + providerId: 'opencode', + cwd: input.request.cwd, + }); + this.aliveRunByTeam.set(input.request.teamName, runId); + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + this.teamChangeEmitter?.({ + type: 'process', + teamName: input.request.teamName, + runId, + detail: finalProgress.state, + }); + return { runId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.setRuntimeAdapterProgress( + { + ...launching, + state: 'failed', + message: 'OpenCode runtime adapter launch failed', + messageSeverity: 'error', + updatedAt: nowIso(), + error: message, + cliLogsTail: message, + }, + input.onProgress + ); + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + throw error; + } + } + + private async writeOpenCodeTeamConfig( + request: TeamCreateRequest, + members: TeamCreateRequest['members'] + ): Promise { + const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); + const config: TeamConfig = { + name: request.displayName?.trim() || request.teamName, + description: request.description, + color: request.color, + projectPath: request.cwd, + members: members.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model, + effort: member.effort, + })), + }; + await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`); + } + + private async persistOpenCodeRuntimeAdapterLaunchResult( + result: TeamRuntimeLaunchResult, + input: TeamRuntimeLaunchInput + ): Promise { + const members: Record = {}; + for (const member of input.expectedMembers) { + const evidence = result.members[member.name]; + members[member.name] = this.toOpenCodePersistedLaunchMember(member.name, evidence); + } + const snapshot = createPersistedLaunchSnapshot({ + teamName: input.teamName, + expectedMembers: input.expectedMembers.map((member) => member.name), + leadSessionId: result.leadSessionId, + launchPhase: result.launchPhase, + members, + }); + await this.launchStateStore.write(input.teamName, snapshot); + return snapshot; + } + + private toOpenCodePersistedLaunchMember( + memberName: string, + evidence: TeamRuntimeMemberLaunchEvidence | undefined + ): PersistedTeamLaunchMemberState { + const now = nowIso(); + const launchState = evidence?.launchState ?? 'failed_to_start'; + return { + name: memberName, + launchState, + agentToolAccepted: evidence?.agentToolAccepted === true, + runtimeAlive: evidence?.runtimeAlive === true, + bootstrapConfirmed: evidence?.bootstrapConfirmed === true, + hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', + hardFailureReason: evidence?.hardFailureReason, + firstSpawnAcceptedAt: evidence?.agentToolAccepted ? now : undefined, + lastHeartbeatAt: evidence?.bootstrapConfirmed ? now : undefined, + lastRuntimeAliveAt: evidence?.runtimeAlive ? now : undefined, + lastEvaluatedAt: now, + sources: { + processAlive: evidence?.runtimeAlive === true, + nativeHeartbeat: evidence?.bootstrapConfirmed === true, + }, + diagnostics: evidence?.diagnostics, + }; + } + async launchTeam( request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void @@ -7123,6 +8064,10 @@ export class TeamProvisioningService { return { runId: existingProvisioningRunId }; } assertAppDeterministicBootstrapEnabled(); + if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { + return this.launchOpenCodeTeamThroughRuntimeAdapter(request, onProgress); + } + assertOpenCodeNotLaunchedThroughLegacyProvisioning(request); // Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock) const pendingKey = `pending-${randomUUID()}`; @@ -7179,6 +8124,10 @@ export class TeamProvisioningService { source, warning, } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw); + assertOpenCodeNotLaunchedThroughLegacyProvisioning({ + providerId: request.providerId, + members: expectedMemberSpecs, + }); const expectedMembers = expectedMemberSpecs.map((m) => m.name); // Extract leadSessionId for session resume on reconnect. @@ -7747,10 +8696,14 @@ export class TeamProvisioningService { async getProvisioningStatus(runId: string): Promise { const run = this.runs.get(runId); - if (!run) { - throw new Error('Unknown runId'); + if (run) { + return run.progress; } - return run.progress; + const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); + if (runtimeProgress) { + return runtimeProgress; + } + throw new Error('Unknown runId'); } async cancelProvisioning(runId: string): Promise { @@ -8593,6 +9546,9 @@ export class TeamProvisioningService { const runId = this.getAliveRunId(teamName); if (!runId) return false; const run = this.runs.get(runId); + if (!run && this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) { + return true; + } return run?.child != null && !run.processKilled && !run.cancelRequested; } @@ -8618,7 +9574,8 @@ export class TeamProvisioningService { teamName, isAlive: this.isTeamAlive(teamName), runId: run?.runId ?? runId ?? null, - progress: run?.progress ?? null, + progress: + run?.progress ?? (runId ? (this.runtimeAdapterProgressByRunId.get(runId) ?? null) : null), }; } @@ -10277,6 +11234,11 @@ export class TeamProvisioningService { } const run = this.runs.get(runId); if (!run) { + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.runId === runId && runtimeRun.providerId === 'opencode') { + void this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); + return; + } this.provisioningRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); return; @@ -10293,6 +11255,83 @@ export class TeamProvisioningService { logger.info(`[${teamName}] Process stopped (SIGKILL)`); } + private async stopOpenCodeRuntimeAdapterTeam(teamName: string, runId: string): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(teamName); + if (!adapter) { + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + this.provisioningRunByTeam.delete(teamName); + return; + } + const startedAt = nowIso(); + const previousProgress = this.runtimeAdapterProgressByRunId.get(runId); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: 'disconnected', + message: 'Stopping OpenCode team through runtime adapter', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: startedAt, + }); + try { + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + const result = await adapter.stop({ + runId, + teamName, + cwd: runtimeRun?.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, + providerId: 'opencode', + reason: 'user_requested', + previousLaunchState, + force: true, + }); + await this.launchStateStore.write( + teamName, + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: previousLaunchState?.expectedMembers ?? [], + leadSessionId: previousLaunchState?.leadSessionId, + launchPhase: 'reconciled', + members: previousLaunchState?.members ?? {}, + }) + ); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: result.stopped ? 'disconnected' : 'failed', + message: result.stopped ? 'OpenCode team stopped' : 'OpenCode team stop failed', + messageSeverity: result.stopped ? undefined : 'error', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: nowIso(), + cliLogsTail: result.diagnostics.join('\n') || undefined, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: 'failed', + message: 'OpenCode team stop failed', + messageSeverity: 'error', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: nowIso(), + error: message, + cliLogsTail: message, + }); + } finally { + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + this.provisioningRunByTeam.delete(teamName); + this.teamChangeEmitter?.({ + type: 'process', + teamName, + runId, + detail: 'stopped', + }); + } + } + private stopPersistentTeamMembers(teamName: string): void { const members = this.readPersistedRuntimeMembers(teamName); if (members.length > 0) { @@ -10301,6 +11340,18 @@ export class TeamProvisioningService { this.killOrphanedTeamAgentProcesses(teamName); } + private readPersistedTeamProjectPath(teamName: string): string | null { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { projectPath?: unknown }; + const projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath.trim() : ''; + return projectPath || null; + } catch { + return null; + } + } + private readPersistedRuntimeMembers(teamName: string): PersistedRuntimeMemberLike[] { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index fda988d3..5db62fbc 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,12 +16,67 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; +export { + isTeamRuntimeProviderId, + OpenCodeTeamRuntimeAdapter, + TeamRuntimeAdapterRegistry, + TEAM_RUNTIME_PROVIDER_IDS, +} from './runtime'; +export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge'; +export type { + OpenCodeTeamLaunchMode, + OpenCodeTeamRuntimeAdapterOptions, + OpenCodeTeamRuntimeBridgePort, + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareFailure, + TeamRuntimePrepareResult, + TeamRuntimePrepareSuccess, + TeamRuntimeProviderId, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileReason, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopReason, + TeamRuntimeStopResult, +} from './runtime'; +export type { + OpenCodeReadinessBridgeCommandBody, + OpenCodeReadinessBridgeCommandExecutor, + OpenCodeReadinessBridgeOptions, +} from './opencode/bridge/OpenCodeReadinessBridge'; export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; +export { OpenCodeTaskLogAttributionService } from './taskLogs/stream/OpenCodeTaskLogAttributionService'; +export type { + OpenCodeTaskLogAttributionBulkWriteOutcome, + OpenCodeTaskLogAttributionMemberWindowInput, + OpenCodeTaskLogAttributionRecordDraft, + OpenCodeTaskLogAttributionRecordWriteOutcome, + OpenCodeTaskLogAttributionReplaceInput, + OpenCodeTaskLogAttributionTaskInput, + OpenCodeTaskLogAttributionTaskSessionInput, + OpenCodeTaskLogAttributionWriter, +} from './taskLogs/stream/OpenCodeTaskLogAttributionService'; +export { + OpenCodeTaskLogAttributionStore, + getOpenCodeTaskLogAttributionPath, +} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; +export type { + OpenCodeTaskLogAttributionReader, + OpenCodeTaskLogAttributionRecord, + OpenCodeTaskLogAttributionScope, + OpenCodeTaskLogAttributionSource, + OpenCodeTaskLogAttributionWriteResult, +} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index bb5acde8..eb0e6e1f 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -1,8 +1,10 @@ +import type { TeamProviderId } from '@shared/types'; + export interface MemberDiffInput { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; removedAt?: number | string | null; } @@ -12,7 +14,7 @@ export interface ReplaceMembersDiff { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; }[]; removed: string[]; @@ -65,7 +67,7 @@ export function buildReplaceMembersDiff( name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; }[] ): ReplaceMembersDiff { diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts new file mode 100644 index 00000000..fd55cca0 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { execCli } from '@main/utils/childProcess'; + +import { + extractRunId, + OPEN_CODE_BRIDGE_SCHEMA_VERSION, + parseSingleBridgeJsonResult, + validateBridgeResultEnvelope, + type OpenCodeBridgeCommandEnvelope, + type OpenCodeBridgeCommandName, + type OpenCodeBridgeDiagnosticEvent, + type OpenCodeBridgeFailure, + type OpenCodeBridgeFailureKind, + type OpenCodeBridgeResult, +} from './OpenCodeBridgeCommandContract'; + +export interface OpenCodeBridgeProcessRunInput { + binaryPath: string; + args: string[]; + cwd: string; + timeoutMs: number; + stdoutLimitBytes: number; + stderrLimitBytes: number; + env: NodeJS.ProcessEnv; +} + +export interface OpenCodeBridgeProcessRunResult { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; +} + +export interface OpenCodeBridgeProcessRunner { + run(input: OpenCodeBridgeProcessRunInput): Promise; +} + +export interface OpenCodeBridgeDiagnosticsSink { + append(event: OpenCodeBridgeDiagnosticEvent): Promise; +} + +export interface OpenCodeBridgeCommandClientOptions { + binaryPath: string; + tempDirectory: string; + processRunner?: OpenCodeBridgeProcessRunner; + diagnostics?: OpenCodeBridgeDiagnosticsSink; + requestIdFactory?: () => string; + diagnosticIdFactory?: () => string; + clock?: () => Date; + env?: NodeJS.ProcessEnv; + keepInputFile?: boolean; +} + +const DEFAULT_STDOUT_LIMIT_BYTES = 1_000_000; +const DEFAULT_STDERR_LIMIT_BYTES = 256_000; + +export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { + async run(input: OpenCodeBridgeProcessRunInput): Promise { + try { + const result = await execCli(input.binaryPath, input.args, { + cwd: input.cwd, + timeout: input.timeoutMs, + maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes, + env: input.env, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: 0, + timedOut: false, + }; + } catch (error) { + const failure = error as NodeJS.ErrnoException & { + stdout?: string | Buffer; + stderr?: string | Buffer; + killed?: boolean; + signal?: string; + }; + const message = failure.message ?? ''; + return { + stdout: bufferToString(failure.stdout), + stderr: bufferToString(failure.stderr) || message, + exitCode: typeof failure.code === 'number' ? failure.code : null, + timedOut: + failure.killed === true || + failure.signal === 'SIGTERM' || + /timed out|timeout/i.test(message), + }; + } + } +} + +export class OpenCodeBridgeCommandClient { + private readonly binaryPath: string; + private readonly tempDirectory: string; + private readonly processRunner: OpenCodeBridgeProcessRunner; + private readonly diagnostics: OpenCodeBridgeDiagnosticsSink | null; + private readonly requestIdFactory: () => string; + private readonly diagnosticIdFactory: () => string; + private readonly clock: () => Date; + private readonly env: NodeJS.ProcessEnv; + private readonly keepInputFile: boolean; + + constructor(options: OpenCodeBridgeCommandClientOptions) { + this.binaryPath = options.binaryPath; + this.tempDirectory = options.tempDirectory; + this.processRunner = options.processRunner ?? new ExecCliOpenCodeBridgeProcessRunner(); + this.diagnostics = options.diagnostics ?? null; + this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`); + this.diagnosticIdFactory = + options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); + this.clock = options.clock ?? (() => new Date()); + this.env = options.env ?? process.env; + this.keepInputFile = options.keepInputFile ?? false; + } + + async execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise> { + const envelope: OpenCodeBridgeCommandEnvelope = { + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: options.requestId ?? this.requestIdFactory(), + command, + cwd: options.cwd, + startedAt: this.clock().toISOString(), + timeoutMs: options.timeoutMs, + body, + }; + const inputPath = await this.writeInputFile(envelope); + + try { + const processResult = await this.processRunner.run({ + binaryPath: this.binaryPath, + args: ['runtime', 'opencode-command', '--json', '--input', inputPath], + cwd: options.cwd, + timeoutMs: options.timeoutMs, + stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, + stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, + env: this.env, + }); + + if (processResult.timedOut) { + return this.contractFailure( + envelope, + 'timeout', + 'OpenCode bridge command timed out', + true, + { + stderr: redactBridgeDiagnosticText(processResult.stderr), + } + ); + } + + if (processResult.exitCode !== 0) { + return this.contractFailure( + envelope, + 'provider_error', + 'OpenCode bridge command failed', + true, + { + exitCode: processResult.exitCode, + stderr: redactBridgeDiagnosticText(processResult.stderr), + } + ); + } + + const parsed = parseSingleBridgeJsonResult(processResult.stdout); + if (!parsed.ok) { + return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { + stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)), + }); + } + + const validation = validateBridgeResultEnvelope(parsed.value, envelope); + if (!validation.ok) { + return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {}); + } + + return parsed.value; + } finally { + if (!this.keepInputFile) { + await fs.unlink(inputPath).catch(() => undefined); + } + } + } + + private async writeInputFile( + envelope: OpenCodeBridgeCommandEnvelope + ): Promise { + await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 }); + const inputPath = path.join(this.tempDirectory, `opencode-command-${envelope.requestId}.json`); + await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + return inputPath; + } + + private async contractFailure( + envelope: OpenCodeBridgeCommandEnvelope, + kind: OpenCodeBridgeFailureKind, + message: string, + retryable: boolean, + details: Record + ): Promise { + const completedAt = this.clock().toISOString(); + const diagnostic: OpenCodeBridgeDiagnosticEvent = { + id: this.diagnosticIdFactory(), + type: + kind === 'timeout' + ? 'opencode_bridge_unknown_outcome' + : 'opencode_bridge_contract_violation', + providerId: 'opencode', + runId: extractRunId(envelope.body) ?? undefined, + severity: retryable ? 'warning' : 'error', + message, + data: details, + createdAt: completedAt, + }; + + await this.diagnostics?.append(diagnostic); + + return { + ok: false, + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: envelope.requestId, + command: envelope.command, + completedAt, + durationMs: Math.max(0, Date.parse(completedAt) - Date.parse(envelope.startedAt)), + error: { + kind, + message, + retryable, + details, + }, + diagnostics: [diagnostic], + }; + } +} + +export function redactBridgeDiagnosticText(value: string): string { + const capped = value.length > 4_000 ? `${value.slice(0, 4_000)}...[truncated]` : value; + return capped + .replace(/(authorization:\s*bearer\s+)[^\s]+/gi, '$1[redacted]') + .replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]'); +} + +function bufferToString(value: string | Buffer | undefined): string { + if (typeof value === 'string') { + return value; + } + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + return ''; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts new file mode 100644 index 00000000..c263abe9 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -0,0 +1,813 @@ +import { createHash } from 'crypto'; + +export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const; + +export type OpenCodeBridgeCommandName = + | 'opencode.handshake' + | 'opencode.commandStatus' + | 'opencode.readiness' + | 'opencode.launchTeam' + | 'opencode.reconcileTeam' + | 'opencode.stopTeam' + | 'opencode.answerPermission' + | 'opencode.listRuntimePermissions' + | 'opencode.getRuntimeTranscript' + | 'opencode.recoverDeliveryJournal'; + +export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production'; + +export type OpenCodeTeamLaunchBridgeState = + | 'blocked' + | 'launching' + | 'ready' + | 'permission_blocked' + | 'failed'; + +export type OpenCodeTeamMemberLaunchBridgeState = + | 'created' + | 'confirmed_alive' + | 'permission_blocked' + | 'failed'; + +export interface OpenCodeTeamBridgeDiagnostic { + code: string; + severity: 'info' | 'warning' | 'error'; + message: string; +} + +export interface OpenCodeTeamBridgeWarning { + code: string; + message: string; +} + +export interface OpenCodeTeamLaunchMemberCommandSpec { + name: string; + role: string; + prompt: string; +} + +export interface OpenCodeLaunchTeamCommandBody { + mode: OpenCodeTeamLaunchMode; + runId: string; + teamId: string; + teamName: string; + projectPath: string; + selectedModel: string; + members: OpenCodeTeamLaunchMemberCommandSpec[]; + leadPrompt: string; + expectedCapabilitySnapshotId: string | null; + manifestHighWatermark: number | null; +} + +export interface OpenCodeTeamMemberLaunchCommandData { + sessionId: string; + launchState: OpenCodeTeamMemberLaunchBridgeState; + model: string; + evidence: Array<{ kind: string; observedAt: string }>; +} + +export interface OpenCodeLaunchTeamCommandData { + runId: string; + teamLaunchState: OpenCodeTeamLaunchBridgeState; + members: Record; + warnings: OpenCodeTeamBridgeWarning[]; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; + idempotencyKey?: string; + manifestHighWatermark?: number | null; + runtimeStoreManifestHighWatermark?: number | null; + durableCheckpoints?: Array<{ name: string; memberName?: string | null; observedAt: string }>; +} + +export interface OpenCodeReconcileTeamCommandBody { + runId: string; + teamId: string; + teamName: string; + projectPath?: string; + expectedCapabilitySnapshotId?: string | null; + manifestHighWatermark?: number | null; + reconcileAttemptId?: string; + expectedMembers: Array<{ name: string; model: string | null }>; + reason: string; +} + +export interface OpenCodeStopTeamCommandBody { + runId: string; + teamId: string; + teamName: string; + projectPath?: string; + expectedCapabilitySnapshotId?: string | null; + manifestHighWatermark?: number | null; + reason: string; + force?: boolean; +} + +export interface OpenCodeStopTeamCommandData { + runId: string; + stopped: boolean; + members: Record; + warnings: OpenCodeTeamBridgeWarning[]; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; + idempotencyKey?: string; + manifestHighWatermark?: number | null; + runtimeStoreManifestHighWatermark?: number | null; +} + +export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator'; + +export type OpenCodeBridgeFailureKind = + | 'unsupported_schema' + | 'unsupported_command' + | 'invalid_input' + | 'runtime_not_ready' + | 'provider_error' + | 'timeout' + | 'contract_violation' + | 'internal_error'; + +export interface OpenCodeBridgeDiagnosticEvent { + id?: string; + type: string; + providerId: 'opencode'; + teamName?: string; + runId?: string; + severity: 'info' | 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface OpenCodeBridgeCommandEnvelope { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + cwd: string; + startedAt: string; + timeoutMs: number; + body: TBody; +} + +export interface OpenCodeBridgeRuntimeSnapshot { + providerId: 'opencode'; + binaryPath: string | null; + binaryFingerprint: string | null; + version: string | null; + capabilitySnapshotId: string | null; +} + +export interface OpenCodeBridgeSuccess { + ok: true; + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + completedAt: string; + durationMs: number; + runtime: OpenCodeBridgeRuntimeSnapshot; + diagnostics: OpenCodeBridgeDiagnosticEvent[]; + data: TData; +} + +export interface OpenCodeBridgeFailure { + ok: false; + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + completedAt: string; + durationMs: number; + error: { + kind: OpenCodeBridgeFailureKind; + message: string; + retryable: boolean; + details?: Record; + }; + diagnostics: OpenCodeBridgeDiagnosticEvent[]; +} + +export type OpenCodeBridgeResult = OpenCodeBridgeSuccess | OpenCodeBridgeFailure; + +export interface OpenCodeBridgePeerIdentity { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + peer: OpenCodeBridgePeerName; + appVersion: string; + gitSha: string | null; + buildId: string | null; + bridgeProtocol: { + minVersion: number; + currentVersion: number; + supportedCommands: OpenCodeBridgeCommandName[]; + }; + runtime: { + providerId: 'opencode'; + binaryPath: string | null; + binaryFingerprint: string | null; + version: string | null; + capabilitySnapshotId: string | null; + runtimeStoreManifestHighWatermark: number | null; + activeRunId: string | null; + }; + featureFlags: { + opencodeTeamLaunch: boolean; + opencodeStateChangingCommands: boolean; + }; +} + +export interface OpenCodeBridgeHandshake { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + client: OpenCodeBridgePeerIdentity; + server: OpenCodeBridgePeerIdentity; + agreedProtocolVersion: number; + acceptedCommands: OpenCodeBridgeCommandName[]; + serverTime: string; + identityHash: string; +} + +export interface OpenCodeBridgeCommandPreconditions { + handshakeIdentityHash: string; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; + expectedManifestHighWatermark: number | null; + commandLeaseId: string | null; + idempotencyKey: string; +} + +export interface OpenCodeStateChangingBridgeEnvelope< + TBody, +> extends OpenCodeBridgeCommandEnvelope { + stateChanging: true; + preconditions: OpenCodeBridgeCommandPreconditions; +} + +export interface RuntimeStoreManifestEvidence { + highWatermark: number; + activeRunId?: string | null; + capabilitySnapshotId?: string | null; +} + +const VALID_COMMANDS: ReadonlySet = new Set([ + 'opencode.handshake', + 'opencode.commandStatus', + 'opencode.readiness', + 'opencode.launchTeam', + 'opencode.reconcileTeam', + 'opencode.stopTeam', + 'opencode.answerPermission', + 'opencode.listRuntimePermissions', + 'opencode.getRuntimeTranscript', + 'opencode.recoverDeliveryJournal', +]); + +const VALID_FAILURE_KINDS: ReadonlySet = new Set([ + 'unsupported_schema', + 'unsupported_command', + 'invalid_input', + 'runtime_not_ready', + 'provider_error', + 'timeout', + 'contract_violation', + 'internal_error', +]); + +export function isOpenCodeBridgeCommandName(value: unknown): value is OpenCodeBridgeCommandName { + return typeof value === 'string' && VALID_COMMANDS.has(value as OpenCodeBridgeCommandName); +} + +export function parseSingleBridgeJsonResult( + stdout: string +): { ok: true; value: OpenCodeBridgeResult } | { ok: false; error: string } { + const trimmed = stdout.trim(); + if (!trimmed) { + return { ok: false, error: 'Bridge stdout was empty' }; + } + + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length !== 1) { + return { + ok: false, + error: `Bridge stdout must contain exactly one JSON line, got ${lines.length}`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(lines[0]); + } catch (error) { + return { ok: false, error: `Bridge stdout JSON parse failed: ${stringifyError(error)}` }; + } + + const validation = validateOpenCodeBridgeResultShape(parsed); + if (!validation.ok) { + return { ok: false, error: validation.reason }; + } + + return { ok: true, value: validation.value as OpenCodeBridgeResult }; +} + +export function validateBridgeResultEnvelope( + result: OpenCodeBridgeResult, + envelope: Pick, 'schemaVersion' | 'requestId' | 'command'> +): { ok: true } | { ok: false; reason: string } { + const shape = validateOpenCodeBridgeResultShape(result); + if (!shape.ok) { + return { ok: false, reason: shape.reason }; + } + + if (result.schemaVersion !== envelope.schemaVersion) { + return { ok: false, reason: 'OpenCode bridge schemaVersion mismatch' }; + } + + if (result.requestId !== envelope.requestId) { + return { ok: false, reason: 'OpenCode bridge requestId mismatch' }; + } + + if (result.command !== envelope.command) { + return { ok: false, reason: 'OpenCode bridge command mismatch' }; + } + + return { ok: true }; +} + +export function assertBridgeResultCanMutateState( + result: OpenCodeBridgeResult, + expected: { + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + } +): asserts result is OpenCodeBridgeSuccess { + if (!result.ok) { + throw new Error( + `OpenCode bridge command failed: ${result.error.kind}: ${result.error.message}` + ); + } + + if (result.requestId !== expected.requestId) { + throw new Error('OpenCode bridge requestId mismatch'); + } + + if (result.command !== expected.command) { + throw new Error('OpenCode bridge command mismatch'); + } + + if (extractRunId(result.data) !== expected.runId) { + throw new Error('OpenCode bridge runId mismatch'); + } + + if ( + expected.capabilitySnapshotId !== null && + result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId + ) { + throw new Error('OpenCode bridge capability snapshot mismatch'); + } +} + +export function validateOpenCodeBridgeHandshake(input: { + handshake: OpenCodeBridgeHandshake; + expectedClient: OpenCodeBridgePeerIdentity; + requiredCommand: OpenCodeBridgeCommandName; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + expectedRunId: string | null; +}): { ok: true } | { ok: false; reason: string } { + const shape = validateOpenCodeBridgeHandshakeShape(input.handshake); + if (!shape.ok) { + return shape; + } + + if (input.handshake.client.peer !== input.expectedClient.peer) { + return { ok: false, reason: 'Bridge handshake client peer mismatch' }; + } + + if (stableHash(input.handshake.client) !== stableHash(input.expectedClient)) { + return { ok: false, reason: 'Bridge handshake client identity mismatch' }; + } + + const minimumProtocol = Math.max( + input.handshake.client.bridgeProtocol.minVersion, + input.handshake.server.bridgeProtocol.minVersion + ); + const maximumProtocol = Math.min( + input.handshake.client.bridgeProtocol.currentVersion, + input.handshake.server.bridgeProtocol.currentVersion + ); + + if ( + input.handshake.agreedProtocolVersion < minimumProtocol || + input.handshake.agreedProtocolVersion > maximumProtocol + ) { + return { ok: false, reason: 'Bridge handshake protocol version mismatch' }; + } + + if (!input.handshake.acceptedCommands.includes(input.requiredCommand)) { + return { ok: false, reason: `Bridge server does not accept command ${input.requiredCommand}` }; + } + + if (!input.handshake.server.bridgeProtocol.supportedCommands.includes(input.requiredCommand)) { + return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` }; + } + + if ( + input.expectedCapabilitySnapshotId && + input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId + ) { + return { ok: false, reason: 'Bridge server capability snapshot mismatch' }; + } + + if ( + input.expectedRunId && + input.handshake.server.runtime.activeRunId && + input.handshake.server.runtime.activeRunId !== input.expectedRunId + ) { + return { ok: false, reason: 'Bridge server active run mismatch' }; + } + + const serverHighWatermark = input.handshake.server.runtime.runtimeStoreManifestHighWatermark; + if ( + input.expectedManifestHighWatermark !== null && + serverHighWatermark !== null && + serverHighWatermark < input.expectedManifestHighWatermark + ) { + return { ok: false, reason: 'Bridge server runtime manifest high watermark is stale' }; + } + + const expectedIdentityHash = createOpenCodeBridgeHandshakeIdentityHash(input.handshake); + if (input.handshake.identityHash !== expectedIdentityHash) { + return { ok: false, reason: 'Bridge handshake identity hash mismatch' }; + } + + return { ok: true }; +} + +export function createOpenCodeBridgeHandshakeIdentityHash( + handshake: Omit | OpenCodeBridgeHandshake +): string { + const { identityHash: _ignored, ...hashable } = handshake as OpenCodeBridgeHandshake; + return stableHash(hashable); +} + +export function assertBridgeEvidenceCanCommitToRuntimeStores(input: { + result: OpenCodeBridgeResult; + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + manifest: RuntimeStoreManifestEvidence; + idempotencyKey: string; +}): asserts input is { + result: OpenCodeBridgeSuccess; + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + manifest: RuntimeStoreManifestEvidence; + idempotencyKey: string; +} { + assertBridgeResultCanMutateState(input.result, { + requestId: input.requestId, + command: input.command, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + }); + + const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data); + if ( + typeof resultManifestHighWatermark === 'number' && + resultManifestHighWatermark < input.manifest.highWatermark + ) { + throw new Error('Bridge result manifest high watermark is stale'); + } + + if (extractIdempotencyKey(input.result.data) !== input.idempotencyKey) { + throw new Error('Bridge result idempotency key mismatch'); + } +} + +export function createOpenCodeBridgeIdempotencyKey(input: { + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + body: unknown; +}): string { + const scope = [ + 'opencode', + sanitizeKeyPart(input.command), + sanitizeKeyPart(input.teamName), + sanitizeKeyPart(input.runId ?? 'no-run'), + ].join(':'); + return `${scope}:${stableHash(input).slice(0, 32)}`; +} + +export function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +export function stableJsonStringify(value: unknown): string { + return JSON.stringify(normalizeStableJson(value)); +} + +export function extractRunId(value: unknown): string | null { + return ( + extractStringByPath(value, ['runId']) ?? + extractStringByPath(value, ['runtimeRunId']) ?? + extractStringByPath(value, ['runtime', 'runId']) ?? + extractStringByPath(value, ['launch', 'runId']) + ); +} + +export function extractIdempotencyKey(value: unknown): string | null { + return ( + extractStringByPath(value, ['idempotencyKey']) ?? + extractStringByPath(value, ['preconditions', 'idempotencyKey']) ?? + extractStringByPath(value, ['command', 'idempotencyKey']) + ); +} + +export function extractManifestHighWatermark(value: unknown): number | null { + return ( + extractNumberByPath(value, ['runtimeStoreManifestHighWatermark']) ?? + extractNumberByPath(value, ['manifestHighWatermark']) ?? + extractNumberByPath(value, ['manifest', 'highWatermark']) + ); +} + +function validateOpenCodeBridgeResultShape( + value: unknown +): { ok: true; value: OpenCodeBridgeResult } | { ok: false; reason: string } { + if (!isRecord(value)) { + return { ok: false, reason: 'Bridge result must be a JSON object' }; + } + + if (value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) { + return { ok: false, reason: 'Bridge result has unsupported schemaVersion' }; + } + + if (typeof value.ok !== 'boolean') { + return { ok: false, reason: 'Bridge result missing ok boolean' }; + } + + if (typeof value.requestId !== 'string' || !value.requestId.trim()) { + return { ok: false, reason: 'Bridge result missing requestId' }; + } + + if (!isOpenCodeBridgeCommandName(value.command)) { + return { ok: false, reason: 'Bridge result has unsupported command' }; + } + + if (typeof value.completedAt !== 'string' || !value.completedAt.trim()) { + return { ok: false, reason: 'Bridge result missing completedAt' }; + } + + if (!isNonNegativeFiniteNumber(value.durationMs)) { + return { ok: false, reason: 'Bridge result has invalid durationMs' }; + } + + if (!Array.isArray(value.diagnostics) || !value.diagnostics.every(isDiagnosticEvent)) { + return { ok: false, reason: 'Bridge result diagnostics are invalid' }; + } + + if (value.ok) { + if (!isRuntimeSnapshot(value.runtime)) { + return { ok: false, reason: 'Bridge success runtime snapshot is invalid' }; + } + + if (!Object.prototype.hasOwnProperty.call(value, 'data')) { + return { ok: false, reason: 'Bridge success missing data' }; + } + + return { ok: true, value: value as unknown as OpenCodeBridgeSuccess }; + } + + if (!isRecord(value.error)) { + return { ok: false, reason: 'Bridge failure missing error object' }; + } + + if (!VALID_FAILURE_KINDS.has(value.error.kind as OpenCodeBridgeFailureKind)) { + return { ok: false, reason: 'Bridge failure has unsupported error kind' }; + } + + if (typeof value.error.message !== 'string' || !value.error.message.trim()) { + return { ok: false, reason: 'Bridge failure missing error message' }; + } + + if (typeof value.error.retryable !== 'boolean') { + return { ok: false, reason: 'Bridge failure missing retryable boolean' }; + } + + if ( + value.error.details !== undefined && + (value.error.details === null || !isRecord(value.error.details)) + ) { + return { ok: false, reason: 'Bridge failure details must be an object' }; + } + + return { ok: true, value: value as unknown as OpenCodeBridgeFailure }; +} + +function validateOpenCodeBridgeHandshakeShape( + handshake: OpenCodeBridgeHandshake +): { ok: true } | { ok: false; reason: string } { + if (!isRecord(handshake)) { + return { ok: false, reason: 'Bridge handshake must be a JSON object' }; + } + + if (handshake.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) { + return { ok: false, reason: 'Bridge handshake has unsupported schemaVersion' }; + } + + if (typeof handshake.requestId !== 'string' || !handshake.requestId.trim()) { + return { ok: false, reason: 'Bridge handshake missing requestId' }; + } + + if (!isPeerIdentity(handshake.client) || !isPeerIdentity(handshake.server)) { + return { ok: false, reason: 'Bridge handshake peer identity is invalid' }; + } + + if (!Number.isInteger(handshake.agreedProtocolVersion) || handshake.agreedProtocolVersion < 1) { + return { ok: false, reason: 'Bridge handshake protocol version is invalid' }; + } + + if ( + !Array.isArray(handshake.acceptedCommands) || + !handshake.acceptedCommands.every(isOpenCodeBridgeCommandName) + ) { + return { ok: false, reason: 'Bridge handshake accepted commands are invalid' }; + } + + if (typeof handshake.serverTime !== 'string' || !handshake.serverTime.trim()) { + return { ok: false, reason: 'Bridge handshake serverTime is invalid' }; + } + + if (typeof handshake.identityHash !== 'string' || !handshake.identityHash.trim()) { + return { ok: false, reason: 'Bridge handshake identityHash is invalid' }; + } + + return { ok: true }; +} + +function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity { + if (!isRecord(value)) { + return false; + } + + if ( + value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION || + (value.peer !== 'claude_team' && value.peer !== 'agent_teams_orchestrator') || + typeof value.appVersion !== 'string' || + !isNullableString(value.gitSha) || + !isNullableString(value.buildId) + ) { + return false; + } + + const bridgeProtocol = value.bridgeProtocol; + if (!isRecord(bridgeProtocol)) { + return false; + } + + if ( + !Number.isInteger(bridgeProtocol.minVersion) || + !Number.isInteger(bridgeProtocol.currentVersion) || + (bridgeProtocol.minVersion as number) < 1 || + (bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) || + !Array.isArray(bridgeProtocol.supportedCommands) || + !bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) + ) { + return false; + } + + const runtime = value.runtime; + if (!isRecord(runtime) || runtime.providerId !== 'opencode') { + return false; + } + + if ( + !isNullableString(runtime.binaryPath) || + !isNullableString(runtime.binaryFingerprint) || + !isNullableString(runtime.version) || + !isNullableString(runtime.capabilitySnapshotId) || + !isNullableInteger(runtime.runtimeStoreManifestHighWatermark) || + !isNullableString(runtime.activeRunId) + ) { + return false; + } + + const featureFlags = value.featureFlags; + if (!isRecord(featureFlags)) { + return false; + } + + return ( + typeof featureFlags.opencodeTeamLaunch === 'boolean' && + typeof featureFlags.opencodeStateChangingCommands === 'boolean' + ); +} + +function isRuntimeSnapshot(value: unknown): value is OpenCodeBridgeRuntimeSnapshot { + return ( + isRecord(value) && + value.providerId === 'opencode' && + isNullableString(value.binaryPath) && + isNullableString(value.binaryFingerprint) && + isNullableString(value.version) && + isNullableString(value.capabilitySnapshotId) + ); +} + +function isDiagnosticEvent(value: unknown): value is OpenCodeBridgeDiagnosticEvent { + return ( + isRecord(value) && + value.providerId === 'opencode' && + typeof value.type === 'string' && + value.type.trim().length > 0 && + (value.severity === 'info' || value.severity === 'warning' || value.severity === 'error') && + typeof value.message === 'string' && + value.message.trim().length > 0 && + typeof value.createdAt === 'string' && + value.createdAt.trim().length > 0 && + (value.data === undefined || isRecord(value.data)) + ); +} + +function extractStringByPath(value: unknown, pathParts: string[]): string | null { + const nested = getByPath(value, pathParts); + return typeof nested === 'string' && nested.trim() ? nested : null; +} + +function extractNumberByPath(value: unknown, pathParts: string[]): number | null { + const nested = getByPath(value, pathParts); + return isNonNegativeFiniteNumber(nested) ? nested : null; +} + +function getByPath(value: unknown, pathParts: string[]): unknown { + let current = value; + for (const part of pathParts) { + if (!isRecord(current)) { + return undefined; + } + current = current[part]; + } + return current; +} + +function sanitizeKeyPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^a-zA-Z0-9_.-]+/g, '_') + .replace(/^_+|_+$/g, ''); + return sanitized.slice(0, 64) || 'unknown'; +} + +function stableJsonComparableNumber(value: number): number | string { + if (Number.isFinite(value)) { + return value; + } + return String(value); +} + +function normalizeStableJson(value: unknown): unknown { + if (value === null) { + return null; + } + + if (typeof value === 'number') { + return stableJsonComparableNumber(value); + } + + if (typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map(normalizeStableJson); + } + + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + const nested = (value as Record)[key]; + if (nested !== undefined) { + output[key] = normalizeStableJson(nested); + } + } + return output; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isNullableInteger(value: unknown): value is number | null { + return value === null || (Number.isInteger(value) && (value as number) >= 0); +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts new file mode 100644 index 00000000..b49fd7a2 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts @@ -0,0 +1,438 @@ +import { + createOpenCodeBridgeIdempotencyKey, + isOpenCodeBridgeCommandName, + stableHash, + type OpenCodeBridgeCommandName, +} from './OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION = 1; +export const OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION = 1; + +export type OpenCodeBridgeCommandLedgerStatus = + | 'started' + | 'completed' + | 'failed' + | 'unknown_after_timeout'; + +export interface OpenCodeBridgeCommandLedgerEntry { + idempotencyKey: string; + requestId: string; + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + requestHash: string; + responseHash: string | null; + status: OpenCodeBridgeCommandLedgerStatus; + retryable: boolean; + startedAt: string; + completedAt: string | null; + lastError: string | null; +} + +export interface OpenCodeBridgeCommandLease { + leaseId: string; + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + holderPeer: 'claude_team'; + acquiredAt: string; + expiresAt: string; + state: 'active' | 'released' | 'expired'; +} + +export type OpenCodeBridgeLedgerBeginResult = 'started' | 'duplicate_same_payload_completed'; + +export class OpenCodeBridgeCommandLedgerError extends Error { + constructor(message: string) { + super(message); + this.name = 'OpenCodeBridgeCommandLedgerError'; + } +} + +export class OpenCodeBridgeCommandLeaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'OpenCodeBridgeCommandLeaseError'; + } +} + +export class OpenCodeBridgeCommandLedger { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async begin(input: { + idempotencyKey: string; + requestId: string; + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + requestHash: string; + }): Promise { + let outcome: OpenCodeBridgeLedgerBeginResult = 'started'; + + await this.store.updateLocked((entries) => { + const existing = entries.find((entry) => entry.idempotencyKey === input.idempotencyKey); + if (existing) { + if (existing.requestHash !== input.requestHash) { + throw new OpenCodeBridgeCommandLedgerError( + 'OpenCode bridge idempotency key reused with different payload' + ); + } + + if (existing.status === 'unknown_after_timeout') { + throw new OpenCodeBridgeCommandLedgerError( + 'OpenCode bridge command outcome must be reconciled before retry' + ); + } + + if (existing.status === 'started') { + throw new OpenCodeBridgeCommandLedgerError('OpenCode bridge command already started'); + } + + if (existing.status === 'completed') { + outcome = 'duplicate_same_payload_completed'; + return entries; + } + + throw new OpenCodeBridgeCommandLedgerError( + `OpenCode bridge command cannot be retried from status ${existing.status}` + ); + } + + const now = this.clock().toISOString(); + return [ + ...entries, + { + idempotencyKey: input.idempotencyKey, + requestId: input.requestId, + command: input.command, + teamName: input.teamName, + runId: input.runId, + requestHash: input.requestHash, + responseHash: null, + status: 'started', + retryable: false, + startedAt: now, + completedAt: null, + lastError: null, + }, + ]; + }); + + return outcome; + } + + async markCompleted(input: { + idempotencyKey: string; + response: unknown; + completedAt?: Date; + }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + responseHash: stableHash(input.response), + status: 'completed', + retryable: false, + completedAt: (input.completedAt ?? this.clock()).toISOString(), + lastError: null, + })); + } + + async markFailed(input: { + idempotencyKey: string; + error: string; + retryable: boolean; + completedAt?: Date; + }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + status: 'failed', + retryable: input.retryable, + completedAt: (input.completedAt ?? this.clock()).toISOString(), + lastError: input.error, + })); + } + + async markUnknownAfterTimeout(input: { idempotencyKey: string; error: string }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + status: 'unknown_after_timeout', + retryable: false, + completedAt: null, + lastError: input.error, + })); + } + + async getByIdempotencyKey( + idempotencyKey: string + ): Promise { + const entries = await this.readRequired(); + return entries.find((entry) => entry.idempotencyKey === idempotencyKey) ?? null; + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + idempotencyKey: string, + updater: (entry: OpenCodeBridgeCommandLedgerEntry) => OpenCodeBridgeCommandLedgerEntry + ): Promise { + let found = false; + await this.store.updateLocked((entries) => + entries.map((entry) => { + if (entry.idempotencyKey !== idempotencyKey) { + return entry; + } + found = true; + return updater(entry); + }) + ); + + if (!found) { + throw new OpenCodeBridgeCommandLedgerError( + `OpenCode bridge command ledger entry not found: ${idempotencyKey}` + ); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export class OpenCodeBridgeCommandLeaseStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly idFactory: () => string, + private readonly clock: () => Date = () => new Date() + ) {} + + async acquire(input: { + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + ttlMs: number; + }): Promise { + let created: OpenCodeBridgeCommandLease | null = null; + + await this.store.updateLocked((leases) => { + const now = this.clock(); + const nowMs = now.getTime(); + const normalized = leases.map((lease) => + lease.state === 'active' && Date.parse(lease.expiresAt) <= nowMs + ? { ...lease, state: 'expired' as const } + : lease + ); + const active = normalized.find( + (lease) => + lease.teamName === input.teamName && + lease.state === 'active' && + Date.parse(lease.expiresAt) > nowMs + ); + + if (active) { + throw new OpenCodeBridgeCommandLeaseError( + `OpenCode bridge command lease already active: ${active.leaseId}` + ); + } + + created = { + leaseId: this.idFactory(), + teamName: input.teamName, + runId: input.runId, + command: input.command, + holderPeer: 'claude_team', + acquiredAt: now.toISOString(), + expiresAt: new Date(nowMs + input.ttlMs).toISOString(), + state: 'active', + }; + + return [...normalized, created]; + }); + + if (!created) { + throw new OpenCodeBridgeCommandLeaseError('OpenCode bridge command lease was not created'); + } + + return created; + } + + async release(leaseId: string): Promise { + let found = false; + await this.store.updateLocked((leases) => + leases.map((lease) => { + if (lease.leaseId !== leaseId) { + return lease; + } + found = true; + return { ...lease, state: 'released' as const }; + }) + ); + + if (!found) { + throw new OpenCodeBridgeCommandLeaseError( + `OpenCode bridge command lease not found: ${leaseId}` + ); + } + } + + async getActive(teamName: string): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + + const nowMs = this.clock().getTime(); + return ( + result.data.find( + (lease) => + lease.teamName === teamName && + lease.state === 'active' && + Date.parse(lease.expiresAt) > nowMs + ) ?? null + ); + } +} + +export function createOpenCodeBridgeCommandLedgerStore(options: { + filePath: string; + clock?: () => Date; +}): OpenCodeBridgeCommandLedger { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeBridgeCommandLedger( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION, + defaultData: () => [], + validate: validateLedgerEntries, + clock, + }), + clock + ); +} + +export function createOpenCodeBridgeCommandLeaseStore(options: { + filePath: string; + idFactory?: () => string; + clock?: () => Date; +}): OpenCodeBridgeCommandLeaseStore { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeBridgeCommandLeaseStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION, + defaultData: () => [], + validate: validateLeases, + clock, + }), + options.idFactory ?? + (() => + createOpenCodeBridgeIdempotencyKey({ + command: 'opencode.commandStatus', + teamName: 'lease', + runId: null, + body: { now: clock().toISOString(), random: Math.random() }, + })), + clock + ); +} + +export function validateLedgerEntries(value: unknown): OpenCodeBridgeCommandLedgerEntry[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode bridge command ledger must be an array'); + } + + const seen = new Set(); + return value.map((entry, index) => { + if (!isLedgerEntry(entry)) { + throw new Error(`Invalid OpenCode bridge command ledger entry at index ${index}`); + } + if (seen.has(entry.idempotencyKey)) { + throw new Error(`Duplicate OpenCode bridge ledger idempotencyKey at index ${index}`); + } + seen.add(entry.idempotencyKey); + return entry; + }); +} + +export function validateLeases(value: unknown): OpenCodeBridgeCommandLease[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode bridge command leases must be an array'); + } + + const seen = new Set(); + return value.map((lease, index) => { + if (!isLease(lease)) { + throw new Error(`Invalid OpenCode bridge command lease at index ${index}`); + } + if (seen.has(lease.leaseId)) { + throw new Error(`Duplicate OpenCode bridge leaseId at index ${index}`); + } + seen.add(lease.leaseId); + return lease; + }); +} + +function isLedgerEntry(value: unknown): value is OpenCodeBridgeCommandLedgerEntry { + return ( + isRecord(value) && + isNonEmptyString(value.idempotencyKey) && + isNonEmptyString(value.requestId) && + isOpenCodeBridgeCommandName(value.command) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNonEmptyString(value.requestHash) && + isNullableString(value.responseHash) && + isLedgerStatus(value.status) && + typeof value.retryable === 'boolean' && + isNonEmptyString(value.startedAt) && + isNullableString(value.completedAt) && + isNullableString(value.lastError) && + Number.isFinite(Date.parse(value.startedAt)) && + (value.completedAt === null || Number.isFinite(Date.parse(value.completedAt))) + ); +} + +function isLease(value: unknown): value is OpenCodeBridgeCommandLease { + return ( + isRecord(value) && + isNonEmptyString(value.leaseId) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isOpenCodeBridgeCommandName(value.command) && + value.holderPeer === 'claude_team' && + isNonEmptyString(value.acquiredAt) && + isNonEmptyString(value.expiresAt) && + Number.isFinite(Date.parse(value.acquiredAt)) && + Number.isFinite(Date.parse(value.expiresAt)) && + (value.state === 'active' || value.state === 'released' || value.state === 'expired') + ); +} + +function isLedgerStatus(value: unknown): value is OpenCodeBridgeCommandLedgerStatus { + return ( + value === 'started' || + value === 'completed' || + value === 'failed' || + value === 'unknown_after_timeout' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts new file mode 100644 index 00000000..e5f7e48a --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts @@ -0,0 +1,112 @@ +import type { + OpenCodeBridgeCommandName, + OpenCodeBridgeHandshake, + OpenCodeBridgePeerIdentity, +} from './OpenCodeBridgeCommandContract'; +import type { + OpenCodeBridgeCommandExecutor, + OpenCodeBridgeHandshakePort, +} from './OpenCodeStateChangingBridgeCommandService'; + +export interface OpenCodeBridgeCommandHandshakePortOptions { + bridge: OpenCodeBridgeCommandExecutor; + clientIdentity: OpenCodeBridgePeerIdentity; + timeoutMs?: number; +} + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 120_000; + +export class OpenCodeBridgeCommandHandshakePort implements OpenCodeBridgeHandshakePort { + private readonly bridge: OpenCodeBridgeCommandExecutor; + private readonly clientIdentity: OpenCodeBridgePeerIdentity; + private readonly timeoutMs: number; + + constructor(options: OpenCodeBridgeCommandHandshakePortOptions) { + this.bridge = options.bridge; + this.clientIdentity = options.clientIdentity; + this.timeoutMs = options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; + } + + async handshake(input: { + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + cwd?: string; + }): Promise { + const result = await this.bridge.execute< + { + client: OpenCodeBridgePeerIdentity; + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + }, + OpenCodeBridgeHandshake + >( + 'opencode.handshake', + { + client: this.clientIdentity, + requiredCommand: input.requiredCommand, + expectedRunId: input.expectedRunId, + expectedCapabilitySnapshotId: input.expectedCapabilitySnapshotId, + expectedManifestHighWatermark: input.expectedManifestHighWatermark, + }, + { + cwd: input.cwd ?? process.cwd(), + timeoutMs: this.timeoutMs, + } + ); + + if (!result.ok) { + throw new Error( + `OpenCode bridge handshake failed: ${result.error.kind}: ${result.error.message}` + ); + } + + return result.data; + } +} + +export function createOpenCodeBridgeClientIdentity(input: { + appVersion: string; + gitSha?: string | null; + buildId?: string | null; +}): OpenCodeBridgePeerIdentity { + return { + schemaVersion: 1, + peer: 'claude_team', + appVersion: input.appVersion, + gitSha: input.gitSha ?? null, + buildId: input.buildId ?? null, + bridgeProtocol: { + minVersion: 1, + currentVersion: 1, + supportedCommands: [ + 'opencode.handshake', + 'opencode.commandStatus', + 'opencode.readiness', + 'opencode.launchTeam', + 'opencode.reconcileTeam', + 'opencode.stopTeam', + 'opencode.answerPermission', + 'opencode.listRuntimePermissions', + 'opencode.getRuntimeTranscript', + 'opencode.recoverDeliveryJournal', + ], + }, + runtime: { + providerId: 'opencode', + binaryPath: null, + binaryFingerprint: null, + version: null, + capabilitySnapshotId: null, + runtimeStoreManifestHighWatermark: null, + activeRunId: null, + }, + featureFlags: { + opencodeTeamLaunch: true, + opencodeStateChangingCommands: true, + }, + }; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts new file mode 100644 index 00000000..33c4a9ba --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -0,0 +1,413 @@ +import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; +import { + buildOpenCodeCanonicalMcpToolId, + REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, +} from '../mcp/OpenCodeMcpToolAvailability'; +import type { + OpenCodeTeamLaunchReadiness, + OpenCodeTeamLaunchReadinessState, +} from '../readiness/OpenCodeTeamLaunchReadiness'; +import { + assertOpenCodeProductionE2EArtifactGate, + type OpenCodeProductionE2EEvidence, +} from '../e2e/OpenCodeProductionE2EEvidence'; +import type { + OpenCodeBridgeCommandName, + OpenCodeBridgeDiagnosticEvent, + OpenCodeBridgeFailureKind, + OpenCodeBridgeResult, + OpenCodeBridgeRuntimeSnapshot, + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData, + OpenCodeReconcileTeamCommandBody, + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData, + OpenCodeTeamLaunchMode, +} from './OpenCodeBridgeCommandContract'; +import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService'; + +export interface OpenCodeReadinessBridgeCommandExecutor { + execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise>; +} + +export interface OpenCodeReadinessBridgeOptions { + timeoutMs?: number; + launchTimeoutMs?: number; + reconcileTimeoutMs?: number; + stopTimeoutMs?: number; + stateChangingCommands?: Pick; + productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort; +} + +export interface OpenCodeProductionE2EEvidenceReadPort { + read(): Promise<{ + ok: boolean; + evidence: OpenCodeProductionE2EEvidence | null; + artifactPath: string; + diagnostics: string[]; + }>; +} + +export interface OpenCodeReadinessBridgeCommandBody { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; +} + +const DEFAULT_READINESS_TIMEOUT_MS = 120_000; +const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000; +const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000; +const DEFAULT_STOP_TIMEOUT_MS = 30_000; + +export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { + private readonly lastRuntimeSnapshotsByProjectPath = new Map< + string, + OpenCodeBridgeRuntimeSnapshot + >(); + + constructor( + private readonly bridge: OpenCodeReadinessBridgeCommandExecutor, + private readonly options: OpenCodeReadinessBridgeOptions = {} + ) {} + + async checkOpenCodeTeamLaunchReadiness( + input: OpenCodeReadinessBridgeCommandBody + ): Promise { + const result = await this.bridge.execute< + OpenCodeReadinessBridgeCommandBody, + OpenCodeTeamLaunchReadiness + >('opencode.readiness', input, { + cwd: input.projectPath, + timeoutMs: this.options.timeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, + }); + + if (result.ok) { + this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime); + return this.applyProductionE2EGate({ + input, + readiness: result.data, + runtime: result.runtime, + }); + } + + this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); + return blockedReadiness({ + state: mapBridgeFailureToReadinessState(result.error.kind), + modelId: input.selectedModel, + diagnostics: [ + `OpenCode readiness bridge failed: ${result.error.kind}: ${result.error.message}`, + ...result.diagnostics.map(formatDiagnosticEvent), + ], + missing: [result.error.message], + }); + } + + private async applyProductionE2EGate(input: { + input: OpenCodeReadinessBridgeCommandBody; + readiness: OpenCodeTeamLaunchReadiness; + runtime: OpenCodeBridgeRuntimeSnapshot; + }): Promise { + const launchMode = input.input.launchMode; + if (launchMode !== 'production' && launchMode !== 'dogfood') { + return input.readiness; + } + if (!input.readiness.launchAllowed) { + return input.readiness; + } + + const evidenceRead = this.options.productionE2eEvidence + ? await this.options.productionE2eEvidence.read() + : { + ok: false, + evidence: null, + artifactPath: '', + diagnostics: ['OpenCode production E2E evidence store is not configured'], + }; + const expectedModel = input.readiness.modelId ?? input.input.selectedModel; + const gate = evidenceRead.ok + ? assertOpenCodeProductionE2EArtifactGate({ + evidence: evidenceRead.evidence, + artifactPath: evidenceRead.artifactPath, + expected: { + opencodeVersion: input.runtime.version, + binaryFingerprint: input.runtime.binaryFingerprint, + capabilitySnapshotId: input.runtime.capabilitySnapshotId, + selectedModel: expectedModel, + requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + ), + }, + }) + : { + ok: false, + diagnostics: evidenceRead.diagnostics, + }; + + if (gate.ok) { + return { + ...input.readiness, + diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]), + supportLevel: 'production_supported', + }; + } + + const diagnostics = dedupe([ + ...input.readiness.diagnostics, + ...evidenceRead.diagnostics, + ...gate.diagnostics, + ]); + if (launchMode === 'dogfood') { + return { + ...input.readiness, + supportLevel: 'supported_e2e_pending', + diagnostics, + }; + } + + return { + ...input.readiness, + state: 'e2e_missing', + launchAllowed: false, + supportLevel: 'supported_e2e_pending', + missing: dedupe([...input.readiness.missing, ...gate.diagnostics]), + diagnostics, + }; + } + + getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null { + return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null; + } + + async launchOpenCodeTeam( + input: OpenCodeLaunchTeamCommandBody + ): Promise { + const result = await this.executeStateChangingCommand< + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData + >('opencode.launchTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId, + cwd: input.projectPath, + timeoutMs: this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS, + }); + return result.ok ? result.data : blockedLaunchData(input.runId, result); + } + + async reconcileOpenCodeTeam( + input: OpenCodeReconcileTeamCommandBody + ): Promise { + const cwd = input.projectPath ?? process.cwd(); + const result = await this.executeStateChangingCommand< + OpenCodeReconcileTeamCommandBody, + OpenCodeLaunchTeamCommandData + >('opencode.reconcileTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, + cwd, + timeoutMs: this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, + }); + return result.ok ? result.data : blockedLaunchData(input.runId, result); + } + + async stopOpenCodeTeam(input: OpenCodeStopTeamCommandBody): Promise { + const cwd = input.projectPath ?? process.cwd(); + const result = await this.executeStateChangingCommand< + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData + >('opencode.stopTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, + cwd, + timeoutMs: this.options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, + }); + if (result.ok) { + return result.data; + } + return { + runId: input.runId, + stopped: false, + members: {}, + warnings: [], + diagnostics: [ + { + code: result.error.kind, + severity: 'error', + message: `OpenCode stop bridge failed: ${result.error.message}`, + }, + ...result.diagnostics.map((event) => ({ + code: event.type, + severity: event.severity, + message: event.message, + })), + ], + }; + } + + private async executeStateChangingCommand( + command: OpenCodeStateChangingTeamCommandName, + body: TBody, + input: { + teamName: string; + runId: string; + capabilitySnapshotId: string | null; + cwd: string; + timeoutMs: number; + } + ): Promise> { + if (this.options.stateChangingCommands) { + try { + return await this.options.stateChangingCommands.execute({ + command, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: null, + body, + cwd: input.cwd, + timeoutMs: input.timeoutMs, + }); + } catch (error) { + return thrownBridgeFailure(command, input.runId, error); + } + } + + return this.bridge.execute(command, body, { + cwd: input.cwd, + timeoutMs: input.timeoutMs, + }); + } +} + +type OpenCodeStateChangingTeamCommandName = Extract< + OpenCodeBridgeCommandName, + 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' +>; + +function blockedLaunchData( + runId: string, + result: OpenCodeBridgeResult +): OpenCodeLaunchTeamCommandData { + if (result.ok) { + throw new Error('blockedLaunchData expects a failed bridge result'); + } + return { + runId, + teamLaunchState: 'failed', + members: {}, + warnings: [], + diagnostics: [ + { + code: result.error.kind, + severity: 'error', + message: `OpenCode bridge failed: ${result.error.message}`, + }, + ...result.diagnostics.map((event) => ({ + code: event.type, + severity: event.severity, + message: event.message, + })), + ], + }; +} + +function blockedReadiness(input: { + state: OpenCodeTeamLaunchReadinessState; + modelId: string | null; + diagnostics: string[]; + missing: string[]; +}): OpenCodeTeamLaunchReadiness { + return { + state: input.state, + launchAllowed: false, + modelId: input.modelId, + opencodeVersion: null, + installMethod: null, + binaryPath: null, + hostHealthy: false, + appMcpConnected: false, + requiredToolsPresent: false, + permissionBridgeReady: false, + runtimeStoresReady: false, + supportLevel: null, + missing: dedupe(input.missing), + diagnostics: dedupe(input.diagnostics), + evidence: { + capabilitiesReady: false, + mcpToolProofRoute: null, + observedMcpTools: [], + runtimeStoreReadinessReason: null, + }, + }; +} + +function mapBridgeFailureToReadinessState( + kind: OpenCodeBridgeFailureKind +): OpenCodeTeamLaunchReadinessState { + switch (kind) { + case 'runtime_not_ready': + return 'adapter_disabled'; + case 'timeout': + case 'contract_violation': + case 'provider_error': + case 'unsupported_schema': + case 'unsupported_command': + case 'invalid_input': + case 'internal_error': + default: + return 'unknown_error'; + } +} + +function formatDiagnosticEvent(event: OpenCodeBridgeDiagnosticEvent): string { + return `${event.type}: ${event.message}`; +} + +function thrownBridgeFailure( + command: OpenCodeBridgeCommandName, + runId: string, + error: unknown +): OpenCodeBridgeResult { + const message = error instanceof Error ? error.message : String(error); + const completedAt = new Date().toISOString(); + return { + ok: false, + schemaVersion: 1, + requestId: 'opencode-state-changing-bridge-exception', + command, + completedAt, + durationMs: 0, + error: { + kind: 'internal_error', + message, + retryable: false, + }, + diagnostics: [ + { + type: 'opencode_state_changing_bridge_exception', + providerId: 'opencode', + runId, + severity: 'error', + message, + createdAt: completedAt, + }, + ], + }; +} + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts new file mode 100644 index 00000000..0dc1fd30 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'crypto'; + +import { + assertBridgeEvidenceCanCommitToRuntimeStores, + createOpenCodeBridgeIdempotencyKey, + extractRunId, + stableHash, + validateOpenCodeBridgeHandshake, + type OpenCodeBridgeCommandName, + type OpenCodeBridgeCommandPreconditions, + type OpenCodeBridgeDiagnosticEvent, + type OpenCodeBridgeHandshake, + type OpenCodeBridgePeerIdentity, + type OpenCodeBridgeResult, + type RuntimeStoreManifestEvidence, +} from './OpenCodeBridgeCommandContract'; +import { + OpenCodeBridgeCommandLedger, + OpenCodeBridgeCommandLeaseStore, +} from './OpenCodeBridgeCommandLedgerStore'; + +export interface OpenCodeBridgeCommandExecutor { + execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise>; +} + +export interface OpenCodeBridgeHandshakePort { + handshake(input: { + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + cwd?: string; + }): Promise; +} + +export interface RuntimeStoreManifestReader { + read(teamName: string): Promise; +} + +export interface OpenCodeStateChangingBridgeDiagnosticsSink { + append(event: OpenCodeBridgeDiagnosticEvent): Promise; +} + +export interface OpenCodeStateChangingBridgeCommandServiceOptions { + expectedClientIdentity: OpenCodeBridgePeerIdentity; + handshakePort: OpenCodeBridgeHandshakePort; + leaseStore: OpenCodeBridgeCommandLeaseStore; + ledger: OpenCodeBridgeCommandLedger; + bridge: OpenCodeBridgeCommandExecutor; + manifestReader: RuntimeStoreManifestReader; + diagnostics?: OpenCodeStateChangingBridgeDiagnosticsSink; + requestIdFactory?: () => string; + diagnosticIdFactory?: () => string; + clock?: () => Date; +} + +export class OpenCodeStateChangingBridgeCommandService { + private readonly expectedClientIdentity: OpenCodeBridgePeerIdentity; + private readonly handshakePort: OpenCodeBridgeHandshakePort; + private readonly leaseStore: OpenCodeBridgeCommandLeaseStore; + private readonly ledger: OpenCodeBridgeCommandLedger; + private readonly bridge: OpenCodeBridgeCommandExecutor; + private readonly manifestReader: RuntimeStoreManifestReader; + private readonly diagnostics: OpenCodeStateChangingBridgeDiagnosticsSink | null; + private readonly requestIdFactory: () => string; + private readonly diagnosticIdFactory: () => string; + private readonly clock: () => Date; + + constructor(options: OpenCodeStateChangingBridgeCommandServiceOptions) { + this.expectedClientIdentity = options.expectedClientIdentity; + this.handshakePort = options.handshakePort; + this.leaseStore = options.leaseStore; + this.ledger = options.ledger; + this.bridge = options.bridge; + this.manifestReader = options.manifestReader; + this.diagnostics = options.diagnostics ?? null; + this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`); + this.diagnosticIdFactory = + options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); + this.clock = options.clock ?? (() => new Date()); + } + + async execute(input: { + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + body: TBody; + cwd: string; + timeoutMs: number; + }): Promise> { + const manifest = await this.manifestReader.read(input.teamName); + const handshake = await this.handshakePort.handshake({ + requiredCommand: input.command, + expectedRunId: input.runId, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedManifestHighWatermark: manifest.highWatermark, + cwd: input.cwd, + }); + const handshakeValidation = validateOpenCodeBridgeHandshake({ + handshake, + expectedClient: this.expectedClientIdentity, + requiredCommand: input.command, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedManifestHighWatermark: manifest.highWatermark, + expectedRunId: input.runId, + }); + + if (!handshakeValidation.ok) { + throw new Error(handshakeValidation.reason); + } + + const idempotencyKey = createOpenCodeBridgeIdempotencyKey({ + command: input.command, + teamName: input.teamName, + runId: input.runId, + body: input.body, + }); + const commandRequestId = this.requestIdFactory(); + const lease = await this.leaseStore.acquire({ + teamName: input.teamName, + runId: input.runId, + command: input.command, + ttlMs: input.timeoutMs + 5_000, + }); + + try { + const bodyWithPreconditions = attachBridgePreconditions(input.body, { + handshakeIdentityHash: handshake.identityHash, + expectedRunId: input.runId, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedBehaviorFingerprint: input.behaviorFingerprint, + expectedManifestHighWatermark: manifest.highWatermark, + commandLeaseId: lease.leaseId, + idempotencyKey, + }); + + const begin = await this.ledger.begin({ + idempotencyKey, + requestId: commandRequestId, + command: input.command, + teamName: input.teamName, + runId: input.runId, + requestHash: stableHash({ + command: input.command, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: input.behaviorFingerprint, + manifestHighWatermark: manifest.highWatermark, + body: input.body, + }), + }); + + if (begin === 'duplicate_same_payload_completed') { + throw new Error('OpenCode bridge command already completed; recover through commandStatus'); + } + + const result = await this.bridge.execute( + input.command, + bodyWithPreconditions, + { + cwd: input.cwd, + timeoutMs: input.timeoutMs, + requestId: commandRequestId, + } + ); + + if (!result.ok) { + if (result.error.kind === 'timeout') { + await this.ledger.markUnknownAfterTimeout({ + idempotencyKey, + error: result.error.message, + }); + await this.appendUnknownOutcomeDiagnostic({ + result, + teamName: input.teamName, + runId: input.runId, + command: input.command, + idempotencyKey, + leaseId: lease.leaseId, + }); + } else { + await this.ledger.markFailed({ + idempotencyKey, + error: result.error.message, + retryable: result.error.retryable, + }); + } + + await this.leaseStore.release(lease.leaseId); + return result; + } + + try { + assertBridgeEvidenceCanCommitToRuntimeStores({ + result, + requestId: commandRequestId, + command: input.command, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + manifest, + idempotencyKey, + }); + } catch (error) { + await this.ledger.markFailed({ + idempotencyKey, + error: stringifyError(error), + retryable: false, + }); + throw error; + } + await this.ledger.markCompleted({ idempotencyKey, response: result }); + await this.leaseStore.release(lease.leaseId); + return result; + } catch (error) { + await this.leaseStore.release(lease.leaseId).catch(() => undefined); + throw error; + } + } + + private async appendUnknownOutcomeDiagnostic(input: { + result: OpenCodeBridgeResult; + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + idempotencyKey: string; + leaseId: string; + }): Promise { + const completedAt = this.clock().toISOString(); + await this.diagnostics?.append({ + id: this.diagnosticIdFactory(), + type: 'opencode_bridge_unknown_outcome', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId ?? extractRunId(input.result) ?? undefined, + severity: 'warning', + message: 'OpenCode bridge command timed out; outcome must be reconciled before retry', + data: { + command: input.command, + idempotencyKey: input.idempotencyKey, + leaseId: input.leaseId, + }, + createdAt: completedAt, + }); + } +} + +export function attachBridgePreconditions( + body: TBody, + preconditions: OpenCodeBridgeCommandPreconditions +): TBody & { preconditions: OpenCodeBridgeCommandPreconditions } { + if (isRecord(body)) { + return { + ...body, + preconditions, + } as TBody & { preconditions: OpenCodeBridgeCommandPreconditions }; + } + + return { + payload: body, + preconditions, + } as unknown as TBody & { preconditions: OpenCodeBridgeCommandPreconditions }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts new file mode 100644 index 00000000..388e5af7 --- /dev/null +++ b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts @@ -0,0 +1,555 @@ +import { createHash } from 'crypto'; + +export interface OpenCodeApiEndpointMap { + health: boolean; + sessionCreate: boolean; + sessionGet: boolean; + sessionMessageList: boolean; + sessionPromptAsync: boolean; + sessionAbort: boolean; + sessionStatus: boolean; + permissionList: boolean; + permissionReply: boolean; + permissionLegacySessionRespond: boolean; + sessionEventStream: boolean; + globalEventStream: boolean; + mcpList: boolean; + mcpCreate: boolean; + experimentalToolIds: boolean; + experimentalToolList: boolean; +} + +export type OpenCodeApiEndpointKey = keyof OpenCodeApiEndpointMap; + +export type OpenCodeEndpointEvidence = + | 'openapi' + | 'direct_probe' + | 'undocumented_direct_probe' + | 'real_e2e' + | 'missing'; + +export type OpenCodeApiCapabilitySource = + | 'openapi_doc' + | 'sdk_probe' + | 'direct_probe' + | 'mixed_openapi_direct_probe'; + +export interface OpenCodeApiCapabilities { + version: string | null; + source: OpenCodeApiCapabilitySource; + endpoints: OpenCodeApiEndpointMap; + requiredForTeamLaunch: { + ready: boolean; + missing: string[]; + }; + evidence: Record; + diagnostics: string[]; +} + +export interface OpenCodeApiDiscoverySnapshot { + checkedAt: string; + opencodeVersion: string | null; + baseUrlRedacted: string; + capabilities: OpenCodeApiCapabilities; + openApiHash: string | null; +} + +export interface OpenCodeApiCapabilityDetectorInput { + baseUrl: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +export interface OpenCodeApiDiscoverySnapshotInput { + baseUrl: string; + checkedAt: string; + capabilities: OpenCodeApiCapabilities; + openApiDocument?: unknown; +} + +interface OpenApiDocument { + openapi?: string; + info?: { + version?: unknown; + }; + paths?: Record>; +} + +interface RequiredOpenCodeEndpoint { + key: OpenCodeApiEndpointKey; + method: 'get' | 'post' | 'delete' | 'patch'; + path: RegExp; + label: string; +} + +interface DirectSafeProbe { + method: 'GET'; + path: string; + accept: 'application/json' | 'text/event-stream'; +} + +const OPENAPI_SPEC_CANDIDATES = ['/doc', '/doc.json', '/openapi.json'] as const; + +export const REQUIRED_OPENCODE_ENDPOINTS: RequiredOpenCodeEndpoint[] = [ + { key: 'health', method: 'get', path: /^\/global\/health\/?$/, label: 'GET /global/health' }, + { key: 'sessionCreate', method: 'post', path: /^\/session\/?$/, label: 'POST /session' }, + { + key: 'sessionGet', + method: 'get', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/?$/, + label: 'GET /session/:id', + }, + { + key: 'sessionMessageList', + method: 'get', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/message\/?$/, + label: 'GET /session/:id/message', + }, + { + key: 'sessionPromptAsync', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt_async\/?$/, + label: 'POST /session/:id/prompt_async', + }, + { + key: 'sessionAbort', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/abort\/?$/, + label: 'POST /session/:id/abort', + }, + { + key: 'sessionStatus', + method: 'get', + path: /^\/session\/status\/?$/, + label: 'GET /session/status', + }, + { key: 'permissionList', method: 'get', path: /^\/permission\/?$/, label: 'GET /permission' }, + { + key: 'permissionReply', + method: 'post', + path: /^\/permission\/(?:\{[^}]+\}|:[^/]+)\/reply\/?$/, + label: 'POST /permission/:requestID/reply', + }, + { + key: 'permissionLegacySessionRespond', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/permissions\/(?:\{[^}]+\}|:[^/]+)\/?$/, + label: 'POST /session/:sessionID/permissions/:permissionID', + }, + { key: 'sessionEventStream', method: 'get', path: /^\/event\/?$/, label: 'GET /event' }, + { + key: 'globalEventStream', + method: 'get', + path: /^\/global\/event\/?$/, + label: 'GET /global/event', + }, + { key: 'mcpList', method: 'get', path: /^\/mcp\/?$/, label: 'GET /mcp' }, + { key: 'mcpCreate', method: 'post', path: /^\/mcp\/?$/, label: 'POST /mcp' }, + { + key: 'experimentalToolIds', + method: 'get', + path: /^\/experimental\/tool\/ids\/?$/, + label: 'GET /experimental/tool/ids', + }, + { + key: 'experimentalToolList', + method: 'get', + path: /^\/experimental\/tool\/?$/, + label: 'GET /experimental/tool', + }, +]; + +const DIRECT_SAFE_PROBES: Partial> = { + health: { method: 'GET', path: '/global/health', accept: 'application/json' }, + sessionStatus: { method: 'GET', path: '/session/status', accept: 'application/json' }, + permissionList: { method: 'GET', path: '/permission/', accept: 'application/json' }, + sessionEventStream: { method: 'GET', path: '/event', accept: 'text/event-stream' }, + globalEventStream: { method: 'GET', path: '/global/event', accept: 'text/event-stream' }, + mcpList: { method: 'GET', path: '/mcp', accept: 'application/json' }, + experimentalToolIds: { + method: 'GET', + path: '/experimental/tool/ids', + accept: 'application/json', + }, + experimentalToolList: { + method: 'GET', + path: '/experimental/tool', + accept: 'application/json', + }, +}; + +export async function detectOpenCodeApiCapabilities( + input: OpenCodeApiCapabilityDetectorInput +): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + const timeoutMs = input.timeoutMs ?? 5_000; + const diagnostics: string[] = []; + const endpoints = createEmptyEndpointMap(); + const evidence = createEmptyEvidenceMap(); + + const openApi = await loadOpenApiDocument({ + baseUrl: input.baseUrl, + fetchImpl, + timeoutMs, + diagnostics, + }); + + if (openApi.document?.paths) { + applyOpenApiEndpointEvidence(openApi.document, endpoints, evidence); + } + + await runDirectSafeProbes({ + baseUrl: input.baseUrl, + fetchImpl, + timeoutMs, + docAvailable: Boolean(openApi.document), + endpoints, + evidence, + diagnostics, + }); + + if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) { + diagnostics.push( + 'OpenCode permission response endpoint was not proven by OpenAPI; require real permission E2E before production launch' + ); + } + + const missing = resolveMissingOpenCodeCapabilities(endpoints); + const version = + extractOpenApiVersion(openApi.document) ?? + (await probeOpenCodeHealthVersion(input.baseUrl, fetchImpl, timeoutMs, diagnostics)); + + return { + version, + source: resolveCapabilitySource(openApi.document, evidence), + endpoints, + requiredForTeamLaunch: { + ready: missing.length === 0, + missing, + }, + evidence, + diagnostics, + }; +} + +export function createOpenCodeApiDiscoverySnapshot( + input: OpenCodeApiDiscoverySnapshotInput +): OpenCodeApiDiscoverySnapshot { + return { + checkedAt: input.checkedAt, + opencodeVersion: input.capabilities.version, + baseUrlRedacted: redactUrl(input.baseUrl), + capabilities: input.capabilities, + openApiHash: input.openApiDocument === undefined ? null : stableHash(input.openApiDocument), + }; +} + +export function applyOpenApiEndpointEvidence( + document: OpenApiDocument, + endpoints: OpenCodeApiEndpointMap, + evidence: Record +): void { + for (const [path, methods] of Object.entries(document.paths ?? {})) { + for (const required of REQUIRED_OPENCODE_ENDPOINTS) { + if (required.path.test(path) && Boolean(methods[required.method])) { + endpoints[required.key] = true; + evidence[required.key] = 'openapi'; + } + } + } +} + +export function resolveMissingOpenCodeCapabilities(endpoints: OpenCodeApiEndpointMap): string[] { + const missing: string[] = []; + + for (const endpoint of REQUIRED_OPENCODE_ENDPOINTS) { + if (endpoint.key === 'permissionLegacySessionRespond') { + continue; + } + + if (endpoint.key === 'experimentalToolList') { + continue; + } + + if (endpoint.key === 'permissionReply') { + if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) { + missing.push('POST permission reply route'); + } + continue; + } + + if (endpoint.key === 'experimentalToolIds') { + if (!endpoints.experimentalToolIds && !endpoints.experimentalToolList) { + missing.push('GET OpenCode tool availability route'); + } + continue; + } + + if (!endpoints[endpoint.key]) { + missing.push(endpoint.label); + } + } + + return missing; +} + +export function createEmptyEndpointMap(): OpenCodeApiEndpointMap { + return { + health: false, + sessionCreate: false, + sessionGet: false, + sessionMessageList: false, + sessionPromptAsync: false, + sessionAbort: false, + sessionStatus: false, + permissionList: false, + permissionReply: false, + permissionLegacySessionRespond: false, + sessionEventStream: false, + globalEventStream: false, + mcpList: false, + mcpCreate: false, + experimentalToolIds: false, + experimentalToolList: false, + }; +} + +function createEmptyEvidenceMap(): Record { + return Object.fromEntries( + (Object.keys(createEmptyEndpointMap()) as OpenCodeApiEndpointKey[]).map((key) => [ + key, + 'missing', + ]) + ) as Record; +} + +async function loadOpenApiDocument(input: { + baseUrl: string; + fetchImpl: typeof fetch; + timeoutMs: number; + diagnostics: string[]; +}): Promise<{ document: OpenApiDocument | null; raw: string | null }> { + for (const candidate of OPENAPI_SPEC_CANDIDATES) { + try { + const response = await fetchWithTimeout(input.fetchImpl, buildUrl(input.baseUrl, candidate), { + timeoutMs: input.timeoutMs, + requestInit: { headers: { accept: 'application/json' } }, + }); + const text = await response.text(); + + if (!response.ok) { + input.diagnostics.push(`OpenCode ${candidate} returned HTTP ${response.status}`); + continue; + } + + if (looksLikeHtml(text)) { + input.diagnostics.push(`OpenCode ${candidate} returned HTML, expected OpenAPI JSON`); + continue; + } + + const parsed = JSON.parse(text) as OpenApiDocument; + if (parsed.paths && Object.keys(parsed.paths).length > 0) { + return { document: parsed, raw: text }; + } + + input.diagnostics.push(`OpenCode ${candidate} did not include OpenAPI paths`); + } catch (error) { + input.diagnostics.push(`OpenCode ${candidate} probe failed: ${stringifyError(error)}`); + } + } + + return { document: null, raw: null }; +} + +async function runDirectSafeProbes(input: { + baseUrl: string; + fetchImpl: typeof fetch; + timeoutMs: number; + docAvailable: boolean; + endpoints: OpenCodeApiEndpointMap; + evidence: Record; + diagnostics: string[]; +}): Promise { + for (const [key, probe] of Object.entries(DIRECT_SAFE_PROBES) as Array< + [OpenCodeApiEndpointKey, DirectSafeProbe] + >) { + if (input.endpoints[key]) { + continue; + } + + try { + const response = await fetchWithTimeout( + input.fetchImpl, + buildUrl(input.baseUrl, probe.path), + { + timeoutMs: input.timeoutMs, + requestInit: { + method: probe.method, + headers: { accept: probe.accept }, + }, + } + ); + await cancelResponseBody(response); + + if (!response.ok) { + input.diagnostics.push( + `OpenCode direct probe ${probe.path} returned HTTP ${response.status}` + ); + continue; + } + + input.endpoints[key] = true; + input.evidence[key] = input.docAvailable ? 'undocumented_direct_probe' : 'direct_probe'; + } catch (error) { + input.diagnostics.push( + `OpenCode direct probe ${probe.path} failed: ${stringifyError(error)}` + ); + } + } +} + +async function probeOpenCodeHealthVersion( + baseUrl: string, + fetchImpl: typeof fetch, + timeoutMs: number, + diagnostics: string[] +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, buildUrl(baseUrl, '/global/health'), { + timeoutMs, + requestInit: { headers: { accept: 'application/json' } }, + }); + const text = await response.text(); + if (!response.ok) { + diagnostics.push(`OpenCode health version probe returned HTTP ${response.status}`); + return null; + } + const parsed = JSON.parse(text) as unknown; + return extractHealthVersion(parsed); + } catch (error) { + diagnostics.push(`OpenCode health version probe failed: ${stringifyError(error)}`); + return null; + } +} + +function extractOpenApiVersion(document: OpenApiDocument | null): string | null { + return typeof document?.info?.version === 'string' && document.info.version.trim().length > 0 + ? document.info.version + : null; +} + +function extractHealthVersion(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + if (typeof value.version === 'string' && value.version.trim().length > 0) { + return value.version; + } + if ( + isRecord(value.build) && + typeof value.build.version === 'string' && + value.build.version.trim().length > 0 + ) { + return value.build.version; + } + if ( + isRecord(value.data) && + typeof value.data.version === 'string' && + value.data.version.trim().length > 0 + ) { + return value.data.version; + } + return null; +} + +function resolveCapabilitySource( + document: OpenApiDocument | null, + evidence: Record +): OpenCodeApiCapabilitySource { + if (!document) { + return 'direct_probe'; + } + return Object.values(evidence).some((item) => item === 'undocumented_direct_probe') + ? 'mixed_openapi_direct_probe' + : 'openapi_doc'; +} + +async function fetchWithTimeout( + fetchImpl: typeof fetch, + url: string, + options: { + timeoutMs: number; + requestInit?: RequestInit; + } +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); + try { + return await fetchImpl(url, { + ...options.requestInit, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Best-effort cleanup for SSE probes after headers are proven. + } +} + +function buildUrl(baseUrl: string, path: string): string { + return new URL(path, normalizeBaseUrl(baseUrl)).toString(); +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} + +function looksLikeHtml(text: string): boolean { + return text.trimStart().startsWith('<'); +} + +function redactUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username) { + parsed.username = 'redacted'; + } + if (parsed.password) { + parsed.password = 'redacted'; + } + return parsed.toString(); + } catch { + return ''; + } +} + +function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts new file mode 100644 index 00000000..3afa8f13 --- /dev/null +++ b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts @@ -0,0 +1,401 @@ +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface OpenCodeMcpServerConfig { + type: 'local' | 'remote'; + command?: string; + args?: string[]; + url?: string; + enabled: boolean; + environment?: Record; + timeout?: number; +} + +export type OpenCodeBehaviorSourceKind = + | 'global_config' + | 'project_config' + | 'global_plugin_dir' + | 'project_plugin_dir' + | 'project_opencode_dir'; + +export interface OpenCodeBehaviorSource { + kind: OpenCodeBehaviorSourceKind; + pathHash: string; + exists: boolean; + fingerprint: string | null; + fileCount: number; +} + +export interface OpenCodeManagedOverlay { + launchMode: 'project_root_with_inline_overlay'; + projectPath: string; + env: { + OPENCODE_CONFIG_CONTENT: string; + OPENCODE_DISABLE_AUTOUPDATE: '1'; + }; + appMcpServerName: string; + appMcpConfig: OpenCodeMcpServerConfig; + preservedSources: OpenCodeBehaviorSource[]; + diagnostics: string[]; +} + +export interface OpenCodeManagedOverlayBuilderInput { + projectPath: string; + preferredMcpName: string; + appMcpCommand: string; + appMcpArgs: string[]; + appMcpEnv: Record; + mcpTimeoutMs?: number; +} + +export interface OpenCodeBehaviorSourceScannerOptions { + homePath?: string; + maxDirectoryFiles?: number; +} + +const FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS = [ + 'plugin', + 'plugins', + 'agent', + 'command', + 'instructions', + 'formatter', + 'lsp', + 'theme', + 'keybinds', + 'model', + 'mode', + 'provider', + 'tools', + 'skills', +] as const; + +export class OpenCodeManagedOverlayBuilder { + constructor( + private readonly behaviorSourceScanner = new OpenCodeBehaviorSourceScanner(), + private readonly clock: () => Date = () => new Date() + ) {} + + async build(input: OpenCodeManagedOverlayBuilderInput): Promise { + const preservedSources = await this.behaviorSourceScanner.scan(input.projectPath); + const existingMcpNames = await this.behaviorSourceScanner.readDeclaredMcpNames( + input.projectPath + ); + const appMcpServerName = pickAppOwnedMcpServerName(input.preferredMcpName, existingMcpNames); + const overlayConfig = buildManagedOverlayConfig({ + serverName: appMcpServerName, + command: input.appMcpCommand, + args: input.appMcpArgs, + environment: input.appMcpEnv, + timeout: input.mcpTimeoutMs ?? 10_000, + }); + + assertManagedOverlayDoesNotShadowUserConfig(overlayConfig); + + return { + launchMode: 'project_root_with_inline_overlay', + projectPath: input.projectPath, + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig), + OPENCODE_DISABLE_AUTOUPDATE: '1', + }, + appMcpServerName, + appMcpConfig: overlayConfig.mcp[appMcpServerName], + preservedSources, + diagnostics: buildOverlayDiagnostics({ + preferredMcpName: input.preferredMcpName, + appMcpServerName, + existingMcpNames, + preservedSources, + checkedAt: this.clock().toISOString(), + }), + }; + } +} + +export class OpenCodeBehaviorSourceScanner { + private readonly homePath: string; + private readonly maxDirectoryFiles: number; + + constructor(options: OpenCodeBehaviorSourceScannerOptions = {}) { + this.homePath = options.homePath ?? os.homedir(); + this.maxDirectoryFiles = options.maxDirectoryFiles ?? 200; + } + + async scan(projectPath: string): Promise { + const sourceSpecs: Array<{ kind: OpenCodeBehaviorSourceKind; targetPath: string }> = [ + { + kind: 'global_config', + targetPath: path.join(this.homePath, '.config/opencode/opencode.json'), + }, + { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') }, + { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') }, + { + kind: 'global_plugin_dir', + targetPath: path.join(this.homePath, '.config/opencode/plugins'), + }, + { kind: 'project_plugin_dir', targetPath: path.join(projectPath, '.opencode/plugins') }, + { kind: 'project_opencode_dir', targetPath: path.join(projectPath, '.opencode') }, + ]; + + return Promise.all(sourceSpecs.map((source) => this.fingerprintSource(source))); + } + + async readDeclaredMcpNames(projectPath: string): Promise> { + const configPaths = [ + path.join(this.homePath, '.config/opencode/opencode.json'), + path.join(projectPath, 'opencode.json'), + path.join(projectPath, 'opencode.jsonc'), + path.join(projectPath, '.opencode/opencode.json'), + path.join(projectPath, '.opencode/opencode.jsonc'), + ]; + const names = new Set(); + + for (const configPath of configPaths) { + const config = await this.readConfig(configPath); + const mcp = asRecord(config?.mcp); + for (const name of Object.keys(mcp ?? {})) { + names.add(name); + } + } + + return names; + } + + private async fingerprintSource(input: { + kind: OpenCodeBehaviorSourceKind; + targetPath: string; + }): Promise { + const pathHash = hashText(input.targetPath); + let stat; + try { + stat = await fs.stat(input.targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + kind: input.kind, + pathHash, + exists: false, + fingerprint: null, + fileCount: 0, + }; + } + throw error; + } + + if (stat.isFile()) { + const content = await fs.readFile(input.targetPath); + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:${hashBuffer(content)}`), + fileCount: 1, + }; + } + + if (stat.isDirectory()) { + const entries = await this.listDirectoryFiles(input.targetPath); + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashJson( + entries.map((entry) => ({ + relativePath: entry.relativePath, + size: entry.size, + mtimeMs: entry.mtimeMs, + contentHash: entry.contentHash, + })) + ), + fileCount: entries.length, + }; + } + + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:unsupported`), + fileCount: 0, + }; + } + + private async listDirectoryFiles(rootPath: string): Promise< + Array<{ + relativePath: string; + size: number; + mtimeMs: number; + contentHash: string; + }> + > { + const results: Array<{ + relativePath: string; + size: number; + mtimeMs: number; + contentHash: string; + }> = []; + + const visit = async (directoryPath: string): Promise => { + if (results.length >= this.maxDirectoryFiles) { + return; + } + + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + if (results.length >= this.maxDirectoryFiles) { + return; + } + + const absolutePath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + await visit(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + + const stat = await fs.stat(absolutePath); + const content = await fs.readFile(absolutePath); + results.push({ + relativePath: path.relative(rootPath, absolutePath), + size: stat.size, + mtimeMs: stat.mtimeMs, + contentHash: hashBuffer(content), + }); + } + }; + + await visit(rootPath); + return results.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + } + + private async readConfig(configPath: string): Promise | null> { + let text: string; + try { + text = await fs.readFile(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } + + try { + const parsed = JSON.parse(stripJsonComments(text)) as unknown; + return asRecord(parsed); + } catch { + return null; + } + } +} + +export function buildManagedOverlayConfig(input: { + serverName: string; + command: string; + args: string[]; + environment: Record; + timeout: number; +}): { mcp: Record } { + return { + mcp: { + [input.serverName]: { + type: 'local', + command: input.command, + args: input.args, + enabled: true, + environment: input.environment, + timeout: input.timeout, + }, + }, + }; +} + +export function assertManagedOverlayDoesNotShadowUserConfig(config: Record): void { + const usedForbiddenKeys = FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS.filter((key) => key in config); + if (usedForbiddenKeys.length > 0) { + throw new Error( + `Managed OpenCode overlay must not set user behavior keys: ${usedForbiddenKeys.join(', ')}` + ); + } +} + +export function pickAppOwnedMcpServerName(preferred: string, existingNames: Set): string { + if (!existingNames.has(preferred)) { + return preferred; + } + + let index = 1; + while (existingNames.has(`${preferred}-runtime-${index}`)) { + index += 1; + } + + return `${preferred}-runtime-${index}`; +} + +function buildOverlayDiagnostics(input: { + preferredMcpName: string; + appMcpServerName: string; + existingMcpNames: Set; + preservedSources: OpenCodeBehaviorSource[]; + checkedAt: string; +}): string[] { + const diagnostics = [ + `OpenCode managed overlay checked at ${input.checkedAt}`, + `OpenCode preserved behavior sources: ${input.preservedSources.filter((source) => source.exists).length}`, + ]; + + if (input.appMcpServerName !== input.preferredMcpName) { + diagnostics.push( + `User OpenCode config already declares MCP server "${input.preferredMcpName}"; managed runtime will use "${input.appMcpServerName}"` + ); + } + + if (input.existingMcpNames.size > 0) { + diagnostics.push( + `OpenCode existing MCP server names observed: ${[...input.existingMcpNames].sort().join(', ')}` + ); + } + + return diagnostics; +} + +function stripJsonComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, (_match, prefix: string) => prefix); +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hashJson(value: unknown): string { + return hashText(stableJsonStringify(value)); +} + +function hashText(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hashBuffer(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} diff --git a/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts b/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts new file mode 100644 index 00000000..c11445d8 --- /dev/null +++ b/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts @@ -0,0 +1,482 @@ +import { stableHash, stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION = 1; + +export type RuntimeDeliveryJournalStatus = + | 'pending' + | 'committed' + | 'failed_retryable' + | 'failed_terminal'; + +export type RuntimeDeliveryDestinationRef = + | { kind: 'user_sent_messages'; teamName: string } + | { kind: 'member_inbox'; teamName: string; memberName: string } + | { + kind: 'cross_team_outbox'; + fromTeamName: string; + toTeamName: string; + toMemberName: string; + }; + +export type RuntimeDeliveryLocation = + | { kind: 'user_sent_messages'; teamName: string; messageId: string } + | { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string } + | { + kind: 'cross_team_outbox'; + fromTeamName: string; + toTeamName: string; + toMemberName: string; + messageId: string; + }; + +export interface RuntimeDeliveryJournalRecord { + idempotencyKey: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + payloadHash: string; + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + committedLocation: RuntimeDeliveryLocation | null; + status: RuntimeDeliveryJournalStatus; + attempts: number; + createdAt: string; + updatedAt: string; + committedAt: string | null; + lastError: string | null; +} + +export interface RuntimeDeliveryJournalBeginInput { + idempotencyKey: string; + payloadHash: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + now: string; +} + +export type RuntimeDeliveryJournalBeginResult = + | { state: 'new'; record: RuntimeDeliveryJournalRecord } + | { state: 'already_committed'; record: RuntimeDeliveryJournalRecord } + | { state: 'resume_pending'; record: RuntimeDeliveryJournalRecord } + | { state: 'payload_conflict'; record: RuntimeDeliveryJournalRecord }; + +export class RuntimeDeliveryJournalStore { + constructor(private readonly store: VersionedJsonStore) {} + + async begin(input: RuntimeDeliveryJournalBeginInput): Promise { + let result: RuntimeDeliveryJournalBeginResult | null = null; + await this.store.updateLocked((records) => { + const existing = records.find((record) => record.idempotencyKey === input.idempotencyKey); + if (existing) { + if (existing.payloadHash !== input.payloadHash) { + result = { state: 'payload_conflict', record: existing }; + return records; + } + + if (existing.status === 'committed') { + result = { state: 'already_committed', record: existing }; + return records; + } + + const resumed = { + ...existing, + attempts: existing.attempts + 1, + status: existing.status === 'failed_terminal' ? existing.status : 'pending', + updatedAt: input.now, + } satisfies RuntimeDeliveryJournalRecord; + result = { state: 'resume_pending', record: resumed }; + return records.map((record) => + record.idempotencyKey === input.idempotencyKey ? resumed : record + ); + } + + const created: RuntimeDeliveryJournalRecord = { + idempotencyKey: input.idempotencyKey, + runId: input.runId, + teamName: input.teamName, + fromMemberName: input.fromMemberName, + providerId: input.providerId, + runtimeSessionId: input.runtimeSessionId, + payloadHash: input.payloadHash, + destination: input.destination, + destinationMessageId: input.destinationMessageId, + committedLocation: null, + status: 'pending', + attempts: 1, + createdAt: input.now, + updatedAt: input.now, + committedAt: null, + lastError: null, + }; + result = { state: 'new', record: created }; + return [...records, created]; + }); + + if (!result) { + throw new Error('Runtime delivery journal begin failed'); + } + return result; + } + + async markCommitted(input: { + idempotencyKey: string; + location: RuntimeDeliveryLocation; + committedAt: string; + }): Promise { + await this.updateExisting(input.idempotencyKey, (record) => ({ + ...record, + committedLocation: input.location, + status: 'committed', + updatedAt: input.committedAt, + committedAt: input.committedAt, + lastError: null, + })); + } + + async markFailed(input: { + idempotencyKey: string; + status: 'failed_retryable' | 'failed_terminal'; + error: string; + updatedAt: string; + }): Promise { + await this.updateExisting(input.idempotencyKey, (record) => ({ + ...record, + status: input.status, + updatedAt: input.updatedAt, + lastError: input.error, + })); + } + + async get(idempotencyKey: string): Promise { + const records = await this.readRequired(); + return records.find((record) => record.idempotencyKey === idempotencyKey) ?? null; + } + + async listRecoverable(teamName: string): Promise { + const records = await this.readRequired(); + return records.filter( + (record) => + record.teamName === teamName && + (record.status === 'pending' || record.status === 'failed_retryable') + ); + } + + async findCommittedByRuntimeSession(input: { + teamName: string; + runId: string; + runtimeSessionId: string; + }): Promise> { + const records = await this.readRequired(); + return new Map( + records + .filter( + (record) => + record.teamName === input.teamName && + record.runId === input.runId && + record.runtimeSessionId === input.runtimeSessionId && + record.status === 'committed' + ) + .map((record) => [record.idempotencyKey, record]) + ); + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + idempotencyKey: string, + updater: (record: RuntimeDeliveryJournalRecord) => RuntimeDeliveryJournalRecord + ): Promise { + let found = false; + await this.store.updateLocked((records) => + records.map((record) => { + if (record.idempotencyKey !== idempotencyKey) { + return record; + } + found = true; + return updater(record); + }) + ); + + if (!found) { + throw new Error(`Runtime delivery journal record not found: ${idempotencyKey}`); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function createRuntimeDeliveryJournalStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimeDeliveryJournalStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeDeliveryJournalStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeDeliveryJournalRecords, + clock, + }) + ); +} + +export function validateRuntimeDeliveryJournalRecords( + value: unknown +): RuntimeDeliveryJournalRecord[] { + if (!Array.isArray(value)) { + throw new Error('Runtime delivery journal must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimeDeliveryJournalRecord(record)) { + throw new Error(`Invalid runtime delivery journal record at index ${index}`); + } + if (seen.has(record.idempotencyKey)) { + throw new Error(`Duplicate runtime delivery idempotency key: ${record.idempotencyKey}`); + } + seen.add(record.idempotencyKey); + return record; + }); +} + +export function hashRuntimeDeliveryEnvelope(envelope: RuntimeDeliveryEnvelope): string { + return `sha256:${stableHash({ + providerId: envelope.providerId, + runId: envelope.runId, + teamName: envelope.teamName, + fromMemberName: envelope.fromMemberName, + runtimeSessionId: envelope.runtimeSessionId, + to: envelope.to, + text: envelope.text, + summary: envelope.summary ?? null, + taskRefs: envelope.taskRefs ?? [], + createdAt: envelope.createdAt, + })}`; +} + +export function buildRuntimeDestinationMessageId(envelope: RuntimeDeliveryEnvelope): string { + return `runtime-delivery-${stableHash({ + idempotencyKey: envelope.idempotencyKey, + runId: envelope.runId, + teamName: envelope.teamName, + }).slice(0, 32)}`; +} + +export type RuntimeDeliveryTarget = + | 'user' + | { memberName: string } + | { teamName: string; memberName: string }; + +export interface RuntimeDeliveryEnvelope { + idempotencyKey: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + to: RuntimeDeliveryTarget; + text: string; + createdAt: string; + summary?: string | null; + taskRefs?: string[]; +} + +export function normalizeRuntimeDeliveryEnvelope(value: unknown): RuntimeDeliveryEnvelope { + if (!isRecord(value)) { + throw new Error('Runtime delivery envelope must be an object'); + } + + const envelope: RuntimeDeliveryEnvelope = { + idempotencyKey: requireNonEmptyString(value.idempotencyKey, 'idempotencyKey'), + runId: requireNonEmptyString(value.runId, 'runId'), + teamName: requireNonEmptyString(value.teamName, 'teamName'), + fromMemberName: requireNonEmptyString(value.fromMemberName, 'fromMemberName'), + providerId: value.providerId === 'opencode' ? 'opencode' : fail('providerId must be opencode'), + runtimeSessionId: requireNonEmptyString(value.runtimeSessionId, 'runtimeSessionId'), + to: normalizeRuntimeDeliveryTarget(value.to), + text: requireNonEmptyString(value.text, 'text'), + createdAt: requireNonEmptyString(value.createdAt, 'createdAt'), + summary: value.summary === undefined || value.summary === null ? null : String(value.summary), + taskRefs: Array.isArray(value.taskRefs) + ? value.taskRefs.filter((item): item is string => typeof item === 'string') + : [], + }; + return envelope; +} + +export function resolveRuntimeDeliveryDestination( + envelope: RuntimeDeliveryEnvelope +): RuntimeDeliveryDestinationRef { + if (envelope.to === 'user') { + return { kind: 'user_sent_messages', teamName: envelope.teamName }; + } + + if ('memberName' in envelope.to && !('teamName' in envelope.to)) { + return { + kind: 'member_inbox', + teamName: envelope.teamName, + memberName: envelope.to.memberName, + }; + } + + return { + kind: 'cross_team_outbox', + fromTeamName: envelope.teamName, + toTeamName: envelope.to.teamName, + toMemberName: envelope.to.memberName, + }; +} + +export function buildLocationFromJournal( + record: RuntimeDeliveryJournalRecord +): RuntimeDeliveryLocation { + if (record.committedLocation) { + return record.committedLocation; + } + + switch (record.destination.kind) { + case 'user_sent_messages': + return { + kind: 'user_sent_messages', + teamName: record.destination.teamName, + messageId: record.destinationMessageId, + }; + case 'member_inbox': + return { + kind: 'member_inbox', + teamName: record.destination.teamName, + memberName: record.destination.memberName, + messageId: record.destinationMessageId, + }; + case 'cross_team_outbox': + return { + kind: 'cross_team_outbox', + fromTeamName: record.destination.fromTeamName, + toTeamName: record.destination.toTeamName, + toMemberName: record.destination.toMemberName, + messageId: record.destinationMessageId, + }; + } +} + +export function runtimeDeliveryEnvelopeStableJson(envelope: RuntimeDeliveryEnvelope): string { + return stableJsonStringify(envelope); +} + +function normalizeRuntimeDeliveryTarget(value: unknown): RuntimeDeliveryTarget { + if (value === 'user') { + return 'user'; + } + if (!isRecord(value)) { + throw new Error('Runtime delivery target must be user or object'); + } + const memberName = requireNonEmptyString(value.memberName, 'to.memberName'); + if (typeof value.teamName === 'string' && value.teamName.trim()) { + return { teamName: value.teamName, memberName }; + } + return { memberName }; +} + +function isRuntimeDeliveryJournalRecord(value: unknown): value is RuntimeDeliveryJournalRecord { + return ( + isRecord(value) && + isNonEmptyString(value.idempotencyKey) && + isNonEmptyString(value.runId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.fromMemberName) && + value.providerId === 'opencode' && + isNonEmptyString(value.runtimeSessionId) && + isNonEmptyString(value.payloadHash) && + isRuntimeDeliveryDestinationRef(value.destination) && + isNonEmptyString(value.destinationMessageId) && + (value.committedLocation === null || isRuntimeDeliveryLocation(value.committedLocation)) && + isRuntimeDeliveryJournalStatus(value.status) && + Number.isInteger(value.attempts) && + (value.attempts as number) >= 1 && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) && + (value.committedAt === null || isNonEmptyString(value.committedAt)) && + (value.lastError === null || typeof value.lastError === 'string') + ); +} + +function isRuntimeDeliveryJournalStatus(value: unknown): value is RuntimeDeliveryJournalStatus { + return ( + value === 'pending' || + value === 'committed' || + value === 'failed_retryable' || + value === 'failed_terminal' + ); +} + +function isRuntimeDeliveryDestinationRef(value: unknown): value is RuntimeDeliveryDestinationRef { + if (!isRecord(value)) { + return false; + } + if (value.kind === 'user_sent_messages') { + return isNonEmptyString(value.teamName); + } + if (value.kind === 'member_inbox') { + return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName); + } + return ( + value.kind === 'cross_team_outbox' && + isNonEmptyString(value.fromTeamName) && + isNonEmptyString(value.toTeamName) && + isNonEmptyString(value.toMemberName) + ); +} + +function isRuntimeDeliveryLocation(value: unknown): value is RuntimeDeliveryLocation { + if (!isRecord(value) || !isNonEmptyString(value.messageId)) { + return false; + } + if (value.kind === 'user_sent_messages') { + return isNonEmptyString(value.teamName); + } + if (value.kind === 'member_inbox') { + return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName); + } + return ( + value.kind === 'cross_team_outbox' && + isNonEmptyString(value.fromTeamName) && + isNonEmptyString(value.toTeamName) && + isNonEmptyString(value.toMemberName) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (!isNonEmptyString(value)) { + throw new Error(`Runtime delivery envelope missing ${field}`); + } + return value; +} + +function fail(message: string): never { + throw new Error(message); +} diff --git a/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts new file mode 100644 index 00000000..583241da --- /dev/null +++ b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts @@ -0,0 +1,306 @@ +import { + buildLocationFromJournal, + buildRuntimeDestinationMessageId, + hashRuntimeDeliveryEnvelope, + normalizeRuntimeDeliveryEnvelope, + resolveRuntimeDeliveryDestination, + type RuntimeDeliveryDestinationRef, + type RuntimeDeliveryEnvelope, + type RuntimeDeliveryJournalRecord, + RuntimeDeliveryJournalStore, + type RuntimeDeliveryLocation, +} from './RuntimeDeliveryJournal'; + +export interface RuntimeDeliveryVerifyResult { + found: boolean; + location: RuntimeDeliveryLocation | null; + diagnostics: string[]; +} + +export interface RuntimeDeliveryDestinationPort { + readonly kind: RuntimeDeliveryDestinationRef['kind']; + + write(input: { + envelope: RuntimeDeliveryEnvelope; + destinationMessageId: string; + }): Promise; + + verify(input: { + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + }): Promise; + + buildChangeEvent(input: { + teamName: string; + location: RuntimeDeliveryLocation; + }): RuntimeDeliveryTeamChangeEvent | null; +} + +export interface RuntimeDeliveryTeamChangeEvent { + type: string; + teamName: string; + data?: Record; +} + +export interface RuntimeDeliveryRunStateReader { + getCurrentRunId(teamName: string): Promise; +} + +export interface RuntimeDeliveryDiagnosticsSink { + append(event: RuntimeDeliveryDiagnosticEvent): Promise; +} + +export interface RuntimeDeliveryDiagnosticEvent { + type: + | 'runtime_delivery_conflict' + | 'runtime_delivery_failed' + | 'runtime_delivery_recovery_needed'; + providerId: 'opencode'; + teamName: string; + runId: string; + severity: 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface RuntimeDeliveryTeamChangeEmitter { + emit(event: RuntimeDeliveryTeamChangeEvent): void; +} + +export type RuntimeDeliveryAck = + | { + ok: true; + delivered: boolean; + reason: null | 'duplicate' | 'duplicate_destination_found'; + idempotencyKey: string; + location: RuntimeDeliveryLocation; + } + | { + ok: false; + delivered: false; + reason: 'stale_run' | 'idempotency_conflict'; + idempotencyKey: string; + }; + +export class RuntimeDeliveryDestinationRegistry { + private readonly ports = new Map< + RuntimeDeliveryDestinationRef['kind'], + RuntimeDeliveryDestinationPort + >(); + + constructor(ports: RuntimeDeliveryDestinationPort[]) { + for (const port of ports) { + if (this.ports.has(port.kind)) { + throw new Error(`Duplicate runtime delivery destination port: ${port.kind}`); + } + this.ports.set(port.kind, port); + } + } + + get(kind: RuntimeDeliveryDestinationRef['kind']): RuntimeDeliveryDestinationPort { + const port = this.ports.get(kind); + if (!port) { + throw new Error(`Runtime delivery destination port not registered: ${kind}`); + } + return port; + } +} + +export class RuntimeDeliveryService { + constructor( + private readonly runState: RuntimeDeliveryRunStateReader, + private readonly journal: RuntimeDeliveryJournalStore, + private readonly destinations: RuntimeDeliveryDestinationRegistry, + private readonly diagnostics: RuntimeDeliveryDiagnosticsSink, + private readonly teamChangeEmitter: RuntimeDeliveryTeamChangeEmitter, + private readonly clock: () => Date = () => new Date() + ) {} + + async deliver(raw: unknown): Promise { + const envelope = normalizeRuntimeDeliveryEnvelope(raw); + const now = this.clock().toISOString(); + const currentRunId = await this.runState.getCurrentRunId(envelope.teamName); + if (currentRunId !== envelope.runId) { + return { + ok: false, + delivered: false, + reason: 'stale_run', + idempotencyKey: envelope.idempotencyKey, + }; + } + + const destination = resolveRuntimeDeliveryDestination(envelope); + const destinationMessageId = buildRuntimeDestinationMessageId(envelope); + const payloadHash = hashRuntimeDeliveryEnvelope(envelope); + const begin = await this.journal.begin({ + idempotencyKey: envelope.idempotencyKey, + payloadHash, + runId: envelope.runId, + teamName: envelope.teamName, + fromMemberName: envelope.fromMemberName, + providerId: envelope.providerId, + runtimeSessionId: envelope.runtimeSessionId, + destination, + destinationMessageId, + now, + }); + + if (begin.state === 'payload_conflict') { + await this.diagnostics.append({ + type: 'runtime_delivery_conflict', + providerId: 'opencode', + teamName: envelope.teamName, + runId: envelope.runId, + severity: 'error', + message: 'Runtime delivery idempotency key was reused with a different payload', + data: { + idempotencyKey: envelope.idempotencyKey, + existingPayloadHash: begin.record.payloadHash, + newPayloadHash: payloadHash, + }, + createdAt: now, + }); + return { + ok: false, + delivered: false, + reason: 'idempotency_conflict', + idempotencyKey: envelope.idempotencyKey, + }; + } + + if (begin.state === 'already_committed') { + return { + ok: true, + delivered: false, + reason: 'duplicate', + idempotencyKey: envelope.idempotencyKey, + location: buildLocationFromJournal(begin.record), + }; + } + + const port = this.destinations.get(destination.kind); + const preExisting = await port.verify({ destination, destinationMessageId }); + if (preExisting.found && preExisting.location) { + await this.journal.markCommitted({ + idempotencyKey: envelope.idempotencyKey, + location: preExisting.location, + committedAt: now, + }); + return { + ok: true, + delivered: false, + reason: 'duplicate_destination_found', + idempotencyKey: envelope.idempotencyKey, + location: preExisting.location, + }; + } + + try { + const location = await port.write({ envelope, destinationMessageId }); + const verified = await port.verify({ destination, destinationMessageId }); + if (!verified.found) { + throw new Error( + `Delivery destination write was not verifiable for ${destinationMessageId}` + ); + } + + const committedLocation = verified.location ?? location; + await this.journal.markCommitted({ + idempotencyKey: envelope.idempotencyKey, + location: committedLocation, + committedAt: this.clock().toISOString(), + }); + + const change = port.buildChangeEvent({ + teamName: envelope.teamName, + location: committedLocation, + }); + if (change) { + this.teamChangeEmitter.emit(change); + } + + return { + ok: true, + delivered: true, + reason: null, + idempotencyKey: envelope.idempotencyKey, + location: committedLocation, + }; + } catch (error) { + await this.journal.markFailed({ + idempotencyKey: envelope.idempotencyKey, + status: 'failed_retryable', + error: stringifyError(error), + updatedAt: this.clock().toISOString(), + }); + await this.diagnostics.append({ + type: 'runtime_delivery_failed', + providerId: 'opencode', + teamName: envelope.teamName, + runId: envelope.runId, + severity: 'warning', + message: 'Runtime delivery failed and remains retryable', + data: { + idempotencyKey: envelope.idempotencyKey, + destination, + error: stringifyError(error), + }, + createdAt: this.clock().toISOString(), + }); + throw error; + } + } +} + +export class RuntimeDeliveryReconciler { + constructor( + private readonly journal: RuntimeDeliveryJournalStore, + private readonly destinations: RuntimeDeliveryDestinationRegistry, + private readonly diagnostics: RuntimeDeliveryDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async reconcileTeam(teamName: string): Promise { + const records = await this.journal.listRecoverable(teamName); + for (const record of records) { + await this.reconcileRecord(record); + } + } + + private async reconcileRecord(record: RuntimeDeliveryJournalRecord): Promise { + const port = this.destinations.get(record.destination.kind); + const verified = await port.verify({ + destination: record.destination, + destinationMessageId: record.destinationMessageId, + }); + + if (verified.found && verified.location) { + await this.journal.markCommitted({ + idempotencyKey: record.idempotencyKey, + location: verified.location, + committedAt: this.clock().toISOString(), + }); + return; + } + + await this.diagnostics.append({ + type: 'runtime_delivery_recovery_needed', + providerId: 'opencode', + teamName: record.teamName, + runId: record.runId, + severity: 'warning', + message: `Runtime delivery ${record.idempotencyKey} is pending and destination write is not visible`, + data: { + destination: record.destination, + attempts: record.attempts, + lastError: record.lastError, + }, + createdAt: this.clock().toISOString(), + }); + } +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts new file mode 100644 index 00000000..c2e76455 --- /dev/null +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -0,0 +1,527 @@ +export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; + +export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [ + 'required_tools_proven', + 'delivery_ready', + 'member_ready', + 'run_ready', +] as const; + +export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [ + 'app_mcp_tools_visible', + 'state_changing_launch_completed', + 'session_records_persisted', + 'bootstrap_confirmed_alive', + 'canonical_log_projection_observed', + 'reconcile_completed', + 'stop_completed', + 'stale_run_rejected', +] as const; + +export type OpenCodeProductionE2ERequiredSignal = + (typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number]; + +export interface OpenCodeProductionE2ECheckpointEvidence { + name: string; + observedAt: string; +} + +export interface OpenCodeProductionE2ESessionEvidence { + memberName: string; + sessionId: string; + launchState: 'confirmed_alive'; +} + +export interface OpenCodeProductionE2EEvidence { + schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION; + evidenceId: string; + createdAt: string; + expiresAt: string; + version: string; + passed: boolean; + artifactPath: string | null; + binaryFingerprint: string; + capabilitySnapshotId: string; + selectedModel: string; + projectPathFingerprint: string | null; + requiredSignals: Record; + mcpTools: { + requiredTools: string[]; + observedTools: string[]; + }; + launch: { + runId: string; + teamId: string; + teamLaunchState: 'ready'; + memberCount: number; + sessions: OpenCodeProductionE2ESessionEvidence[]; + durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[]; + }; + reconcile: { + runId: string; + teamLaunchState: 'ready'; + memberCount: number; + }; + stop: { + runId: string; + stopped: true; + stoppedSessionIds: string[]; + }; + logProjection: { + observed: true; + projectedMessageCount: number; + }; + diagnostics?: string[]; +} + +export interface OpenCodeProductionE2EGateExpectation { + opencodeVersion: string | null; + binaryFingerprint: string | null; + capabilitySnapshotId: string | null; + selectedModel: string | null; + requiredMcpTools?: string[]; +} + +export interface OpenCodeProductionE2EGateResult { + ok: boolean; + diagnostics: string[]; +} + +export function validateOpenCodeProductionE2EEvidence( + value: unknown +): OpenCodeProductionE2EEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence must be an object'); + } + + if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) { + throw new Error('OpenCode production E2E evidence has unsupported schemaVersion'); + } + + const evidence: OpenCodeProductionE2EEvidence = { + schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + evidenceId: requireString(record.evidenceId, 'evidenceId'), + createdAt: requireIsoDate(record.createdAt, 'createdAt'), + expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'), + version: requireString(record.version, 'version'), + passed: requireBoolean(record.passed, 'passed'), + artifactPath: optionalString(record.artifactPath, 'artifactPath'), + binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'), + capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'), + selectedModel: requireString(record.selectedModel, 'selectedModel'), + projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'), + requiredSignals: normalizeRequiredSignals(record.requiredSignals), + mcpTools: normalizeMcpTools(record.mcpTools), + launch: normalizeLaunch(record.launch), + reconcile: normalizeReconcile(record.reconcile), + stop: normalizeStop(record.stop), + logProjection: normalizeLogProjection(record.logProjection), + diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'), + }; + + return evidence; +} + +export function validateNullableOpenCodeProductionE2EEvidence( + value: unknown +): OpenCodeProductionE2EEvidence | null { + if (value === null) { + return null; + } + return validateOpenCodeProductionE2EEvidence(value); +} + +export function assertOpenCodeProductionE2EEvidenceBasics(input: { + evidence: OpenCodeProductionE2EEvidence | null; + testedVersion: string; + now?: Date; + artifactPath?: string | null; +}): OpenCodeProductionE2EGateResult { + const diagnostics: string[] = []; + const now = input.now ?? new Date(); + const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; + + if (!input.evidence) { + return { + ok: false, + diagnostics: [ + 'OpenCode version is capability-compatible but production E2E evidence is missing', + ], + }; + } + + diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); + + if (input.evidence.version !== input.testedVersion) { + diagnostics.push( + `OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}` + ); + } + + return { + ok: diagnostics.length === 0, + diagnostics, + }; +} + +export function assertOpenCodeProductionE2EArtifactGate(input: { + evidence: OpenCodeProductionE2EEvidence | null; + expected: OpenCodeProductionE2EGateExpectation; + now?: Date; + artifactPath?: string | null; +}): OpenCodeProductionE2EGateResult { + const diagnostics: string[] = []; + const now = input.now ?? new Date(); + const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; + + if (!input.evidence) { + return { + ok: false, + diagnostics: [ + 'OpenCode production launch requires a current production E2E evidence artifact', + ], + }; + } + + diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); + diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected)); + + return { + ok: diagnostics.length === 0, + diagnostics, + }; +} + +function collectArtifactShapeDiagnostics( + evidence: OpenCodeProductionE2EEvidence, + now: Date, + artifactPath: string | null +): string[] { + const diagnostics: string[] = []; + const createdAtMs = Date.parse(evidence.createdAt); + const expiresAtMs = Date.parse(evidence.expiresAt); + + if (!evidence.passed) { + diagnostics.push('OpenCode production E2E evidence did not pass'); + } + + if (!artifactPath) { + diagnostics.push('OpenCode production E2E evidence artifact path is missing'); + } + + if (!Number.isFinite(createdAtMs)) { + diagnostics.push('OpenCode production E2E evidence createdAt is invalid'); + } else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) { + diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age'); + } + + if (!Number.isFinite(expiresAtMs)) { + diagnostics.push('OpenCode production E2E evidence expiresAt is invalid'); + } else if (expiresAtMs <= now.getTime()) { + diagnostics.push('OpenCode production E2E evidence is expired'); + } + + const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter( + (signal) => evidence.requiredSignals[signal] !== true + ); + if (missingSignals.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}` + ); + } + + const checkpointNames = new Set( + evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name) + ); + const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter( + (checkpoint) => !checkpointNames.has(checkpoint) + ); + if (missingCheckpoints.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}` + ); + } + + if ( + evidence.launch.memberCount <= 0 || + evidence.launch.sessions.length !== evidence.launch.memberCount + ) { + diagnostics.push( + 'OpenCode production E2E evidence must include confirmed session evidence for every member' + ); + } + + if (evidence.reconcile.runId !== evidence.launch.runId) { + diagnostics.push( + 'OpenCode production E2E reconcile evidence runId does not match launch runId' + ); + } + + if (evidence.reconcile.memberCount !== evidence.launch.memberCount) { + diagnostics.push( + 'OpenCode production E2E reconcile member count does not match launch member count' + ); + } + + if (evidence.stop.runId !== evidence.launch.runId) { + diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId'); + } + + if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) { + diagnostics.push( + 'OpenCode production E2E evidence does not prove every launched session was stopped' + ); + } + + if (evidence.logProjection.projectedMessageCount <= 0) { + diagnostics.push('OpenCode production E2E evidence must include projected log messages'); + } + + const observedTools = new Set(evidence.mcpTools.observedTools); + const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool)); + if (missingTools.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}` + ); + } + + return diagnostics; +} + +function collectExpectedRuntimeDiagnostics( + evidence: OpenCodeProductionE2EEvidence, + expected: OpenCodeProductionE2EGateExpectation +): string[] { + const diagnostics: string[] = []; + + if (!expected.opencodeVersion) { + diagnostics.push('OpenCode production gate cannot verify runtime version'); + } else if (evidence.version !== expected.opencodeVersion) { + diagnostics.push( + `OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}` + ); + } + + if (!expected.binaryFingerprint) { + diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint'); + } else if (evidence.binaryFingerprint !== expected.binaryFingerprint) { + diagnostics.push( + 'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint' + ); + } + + if (!expected.capabilitySnapshotId) { + diagnostics.push('OpenCode production gate cannot verify capability snapshot id'); + } else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) { + diagnostics.push( + 'OpenCode production E2E evidence capability snapshot does not match current runtime' + ); + } + + if (!expected.selectedModel) { + diagnostics.push('OpenCode production gate cannot verify selected raw model id'); + } else if (evidence.selectedModel !== expected.selectedModel) { + diagnostics.push( + `OpenCode production E2E evidence model ${evidence.selectedModel} does not match selected model ${expected.selectedModel}` + ); + } + + const requiredTools = expected.requiredMcpTools ?? []; + if (requiredTools.length > 0) { + const observedTools = new Set(evidence.mcpTools.observedTools); + const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); + if (missingTools.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}` + ); + } + } + + return diagnostics; +} + +function normalizeRequiredSignals( + value: unknown +): Record { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence requiredSignals must be an object'); + } + + return Object.fromEntries( + OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [ + signal, + requireBoolean(record[signal], `requiredSignals.${signal}`), + ]) + ) as Record; +} + +function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence mcpTools must be an object'); + } + return { + requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'), + observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'), + }; +} + +function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence launch must be an object'); + } + if (record.teamLaunchState !== 'ready') { + throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready'); + } + return { + runId: requireString(record.runId, 'launch.runId'), + teamId: requireString(record.teamId, 'launch.teamId'), + teamLaunchState: 'ready', + memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'), + sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession), + durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map( + normalizeCheckpoint + ), + }; +} + +function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence launch session must be an object'); + } + if (record.launchState !== 'confirmed_alive') { + throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive'); + } + return { + memberName: requireString(record.memberName, 'launch.sessions.memberName'), + sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'), + launchState: 'confirmed_alive', + }; +} + +function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence durable checkpoint must be an object'); + } + return { + name: requireString(record.name, 'launch.durableCheckpoints.name'), + observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'), + }; +} + +function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence reconcile must be an object'); + } + if (record.teamLaunchState !== 'ready') { + throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready'); + } + return { + runId: requireString(record.runId, 'reconcile.runId'), + teamLaunchState: 'ready', + memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'), + }; +} + +function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence stop must be an object'); + } + if (record.stopped !== true) { + throw new Error('OpenCode production E2E evidence stop.stopped must be true'); + } + return { + runId: requireString(record.runId, 'stop.runId'), + stopped: true, + stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'), + }; +} + +function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence logProjection must be an object'); + } + if (record.observed !== true) { + throw new Error('OpenCode production E2E evidence logProjection.observed must be true'); + } + return { + observed: true, + projectedMessageCount: requirePositiveInteger( + record.projectedMessageCount, + 'logProjection.projectedMessageCount' + ), + }; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`); + } + return value.trim(); +} + +function optionalString(value: unknown, field: string): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`); + } + return value.trim(); +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`OpenCode production E2E evidence ${field} must be boolean`); + } + return value; +} + +function requirePositiveInteger(value: unknown, field: string): number { + if (!Number.isInteger(value) || (value as number) <= 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`); + } + return value as number; +} + +function requireIsoDate(value: unknown, field: string): string { + const text = requireString(value, field); + if (!Number.isFinite(Date.parse(text))) { + throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`); + } + return text; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`OpenCode production E2E evidence ${field} must be an array`); + } + return value; +} + +function requireStringArray(value: unknown, field: string): string[] { + return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`)); +} + +function optionalStringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + return requireStringArray(value, field); +} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts new file mode 100644 index 00000000..fd1fa414 --- /dev/null +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; + +import { + OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + validateNullableOpenCodeProductionE2EEvidence, + validateOpenCodeProductionE2EEvidence, + type OpenCodeProductionE2EEvidence, +} from './OpenCodeProductionE2EEvidence'; +import { VersionedJsonStore } from '../store/VersionedJsonStore'; + +export interface OpenCodeProductionE2EEvidenceStoreReadResult { + ok: boolean; + evidence: OpenCodeProductionE2EEvidence | null; + artifactPath: string; + diagnostics: string[]; +} + +export interface OpenCodeProductionE2EEvidenceStoreOptions { + filePath: string; + clock?: () => Date; +} + +export class OpenCodeProductionE2EEvidenceStore { + private readonly filePath: string; + private readonly store: VersionedJsonStore; + + constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) { + this.filePath = options.filePath; + this.store = new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + defaultData: () => null, + validate: validateNullableOpenCodeProductionE2EEvidence, + clock: options.clock, + quarantineDir: path.dirname(options.filePath), + }); + } + + async read(): Promise { + const result = await this.store.read(); + if (!result.ok) { + return { + ok: false, + evidence: null, + artifactPath: this.filePath, + diagnostics: [ + `OpenCode production E2E evidence store is unreadable: ${result.message}`, + ...(result.quarantinePath + ? [`Quarantined corrupt evidence at ${result.quarantinePath}`] + : []), + ], + }; + } + + return { + ok: true, + evidence: result.data, + artifactPath: this.filePath, + diagnostics: + result.status === 'missing' + ? ['OpenCode production E2E evidence artifact has not been written yet'] + : [], + }; + } + + async write(evidence: OpenCodeProductionE2EEvidence): Promise { + const validated = validateOpenCodeProductionE2EEvidence(evidence); + await this.store.updateLocked(() => ({ + ...validated, + artifactPath: validated.artifactPath ?? this.filePath, + })); + } +} diff --git a/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts b/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts new file mode 100644 index 00000000..7c7a8ea1 --- /dev/null +++ b/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts @@ -0,0 +1,413 @@ +export type OpenCodeEventScope = 'instance' | 'global'; + +export type OpenCodeNormalizedStatusType = 'idle' | 'busy' | 'retry' | 'error' | 'unknown'; + +export interface OpenCodeNormalizedSessionStatus { + type: OpenCodeNormalizedStatusType; + retryAttempt: number | null; + retryMessage: string | null; + retryNextAt: number | null; + rawShape: 'v1.14' | 'legacy-string' | 'unknown'; + raw: unknown; +} + +export type OpenCodeDurableSessionState = + | 'idle' + | 'running' + | 'retrying' + | 'blocked' + | 'reply_pending' + | 'error' + | 'unknown'; + +export type OpenCodeNormalizedEvent = + | { + kind: 'server_connected' | 'server_heartbeat'; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'session_status'; + sessionId: string; + status: OpenCodeNormalizedSessionStatus; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'session_error'; + sessionId: string | null; + errorName: string | null; + errorMessage: string | null; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_updated'; + sessionId: string; + messageId: string | null; + role: 'assistant' | 'user' | 'system' | 'unknown'; + info: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_updated'; + sessionId: string; + messageId: string | null; + partId: string | null; + partType: string | null; + textSnapshot: string | null; + part: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_delta'; + sessionId: string; + messageId: string; + partId: string; + field: string; + delta: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_removed'; + sessionId: string; + messageId: string; + partId: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'permission_asked' | 'permission_replied'; + sessionId: string | null; + requestId: string | null; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'unknown'; + type: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + }; + +export interface OpenCodeSseEventEnvelope { + type: string; + properties: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; +} + +export interface OpenCodeDurableStateProjection { + hasPendingPermission: boolean; + hasLatestAssistantError: boolean; + replyPendingSinceMessageId: string | null; +} + +export function normalizeOpenCodeSessionStatus(raw: unknown): OpenCodeNormalizedSessionStatus { + if (typeof raw === 'string') { + return { + type: normalizeLegacyStatusType(raw), + retryAttempt: null, + retryMessage: null, + retryNextAt: null, + rawShape: 'legacy-string', + raw, + }; + } + + const record = asRecord(raw); + const statusType = asString(record?.type); + if ( + statusType === 'idle' || + statusType === 'busy' || + statusType === 'retry' || + statusType === 'error' + ) { + return { + type: statusType, + retryAttempt: asNumber(record?.attempt), + retryMessage: asString(record?.message), + retryNextAt: asNumber(record?.next), + rawShape: 'v1.14', + raw, + }; + } + + return { + type: 'unknown', + retryAttempt: null, + retryMessage: null, + retryNextAt: null, + rawShape: 'unknown', + raw, + }; +} + +export function mapOpenCodeStatusToDurableState( + status: OpenCodeNormalizedSessionStatus | null, + projection: OpenCodeDurableStateProjection +): OpenCodeDurableSessionState { + if (projection.hasPendingPermission) { + return 'blocked'; + } + if (projection.hasLatestAssistantError || status?.type === 'error') { + return 'error'; + } + if (status?.type === 'retry') { + return 'retrying'; + } + if (status?.type === 'busy') { + return 'running'; + } + if (projection.replyPendingSinceMessageId) { + return 'reply_pending'; + } + if (status?.type === 'idle') { + return 'idle'; + } + return 'unknown'; +} + +export function unwrapOpenCodeEventEnvelope(raw: unknown): OpenCodeSseEventEnvelope | null { + const record = asRecord(raw); + if (!record) { + return null; + } + + const directType = asString(record.type); + if (directType) { + return { + type: directType, + properties: asRecord(record.properties) ?? {}, + scope: 'instance', + directory: null, + raw, + }; + } + + const payload = asRecord(record.payload); + const payloadType = asString(payload?.type); + if (!payloadType) { + return null; + } + + return { + type: payloadType, + properties: asRecord(payload?.properties) ?? {}, + scope: 'global', + directory: asString(record.directory), + raw, + }; +} + +export function normalizeOpenCodeEvent(raw: unknown): OpenCodeNormalizedEvent | null { + const event = unwrapOpenCodeEventEnvelope(raw); + if (!event) { + return null; + } + + const props = event.properties; + + if (event.type === 'server.connected' || event.type === 'server.heartbeat') { + return { + kind: event.type === 'server.connected' ? 'server_connected' : 'server_heartbeat', + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.status') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'session_status', + sessionId, + status: normalizeOpenCodeSessionStatus(props.status), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.idle') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'session_status', + sessionId, + status: normalizeOpenCodeSessionStatus({ type: 'idle' }), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.error') { + const error = asRecord(props.error); + return { + kind: 'session_error', + sessionId: asString(props.sessionID) ?? asString(props.sessionId), + errorName: asString(error?.name) ?? asString(props.name), + errorMessage: asString(error?.message) ?? asString(props.message), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.updated') { + const info = asRecord(props.info) ?? {}; + const sessionId = + asString(props.sessionID) ?? asString(props.sessionId) ?? asString(info.sessionID); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'message_updated', + sessionId, + messageId: asString(info.id) ?? asString(info.messageID), + role: normalizeMessageRole(asString(info.role)), + info, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.updated') { + const part = asRecord(props.part) ?? {}; + const sessionId = + asString(props.sessionID) ?? asString(props.sessionId) ?? asString(part.sessionID); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'message_part_updated', + sessionId, + messageId: asString(part.messageID) ?? asString(part.messageId), + partId: asString(part.id) ?? asString(part.partID) ?? asString(part.partId), + partType: asString(part.type), + textSnapshot: asStringAllowEmpty(part.text), + part, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.delta') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + const messageId = asString(props.messageID) ?? asString(props.messageId); + const partId = asString(props.partID) ?? asString(props.partId); + const field = asString(props.field); + const delta = asStringAllowEmpty(props.delta); + if (!sessionId || !messageId || !partId || !field || delta === null) { + return unknownEvent(event); + } + return { + kind: 'message_part_delta', + sessionId, + messageId, + partId, + field, + delta, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.removed') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + const messageId = asString(props.messageID) ?? asString(props.messageId); + const partId = asString(props.partID) ?? asString(props.partId); + if (!sessionId || !messageId || !partId) { + return unknownEvent(event); + } + return { + kind: 'message_part_removed', + sessionId, + messageId, + partId, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'permission.asked' || event.type === 'permission.replied') { + return { + kind: event.type === 'permission.asked' ? 'permission_asked' : 'permission_replied', + sessionId: asString(props.sessionID) ?? asString(props.sessionId), + requestId: asString(props.id) ?? asString(props.requestID) ?? asString(props.requestId), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + return unknownEvent(event); +} + +function normalizeLegacyStatusType(raw: string): OpenCodeNormalizedStatusType { + if (raw === 'active') { + return 'busy'; + } + if (raw === 'idle' || raw === 'busy' || raw === 'retry' || raw === 'error') { + return raw; + } + return 'unknown'; +} + +function normalizeMessageRole(role: string | null): 'assistant' | 'user' | 'system' | 'unknown' { + if (role === 'assistant' || role === 'user' || role === 'system') { + return role; + } + return 'unknown'; +} + +function unknownEvent(event: OpenCodeSseEventEnvelope): OpenCodeNormalizedEvent { + return { + kind: 'unknown', + type: event.type, + scope: event.scope, + directory: event.directory, + raw: event.raw, + }; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asStringAllowEmpty(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts new file mode 100644 index 00000000..6c92e8d4 --- /dev/null +++ b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts @@ -0,0 +1,421 @@ +export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +] as const; + +export type RequiredAgentTeamsRuntimeTool = (typeof REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS)[number]; + +export interface OpenCodeToolListItem { + id: string; + description?: string; + parameters?: unknown; +} + +export interface OpenCodeInfrastructureToolClient { + listExperimentalToolIds(): Promise; + listExperimentalTools(input: { + providerId: string; + modelId: string; + }): Promise; +} + +export type OpenCodeMcpToolProofRoute = '/experimental/tool/ids' | '/experimental/tool' | null; + +export interface OpenCodeMcpToolProof { + ok: boolean; + route: OpenCodeMcpToolProofRoute; + canonicalServerName: string; + canonicalExpectedIds: Record; + observedTools: string[]; + missingTools: string[]; + matchedByRequiredTool: Record; + aliasMatchedByRequiredTool: Record; + diagnostics: string[]; +} + +export interface AppMcpRuntimeToolContract { + name: RequiredAgentTeamsRuntimeTool; + requiredInputFields: string[]; + idempotencyField: string | null; + runScoped: boolean; + handlerKind: 'bootstrap' | 'delivery' | 'task_event' | 'heartbeat'; +} + +export interface AppMcpToolDefinition { + name: string; + inputSchema: unknown; +} + +export interface AppMcpRuntimeToolPreflightResult { + ok: boolean; + observedToolNames: string[]; + diagnostics: string[]; +} + +export interface RuntimeDeliverMessageSchemaDiagnostic { + severity: 'error'; + message: string; + missingFields?: string[]; +} + +export const APP_MCP_RUNTIME_TOOL_CONTRACTS: AppMcpRuntimeToolContract[] = [ + { + name: 'runtime_bootstrap_checkin', + requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'], + idempotencyField: null, + runScoped: true, + handlerKind: 'bootstrap', + }, + { + name: 'runtime_deliver_message', + requiredInputFields: [ + 'idempotencyKey', + 'runId', + 'teamName', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + ], + idempotencyField: 'idempotencyKey', + runScoped: true, + handlerKind: 'delivery', + }, + { + name: 'runtime_task_event', + requiredInputFields: ['idempotencyKey', 'runId', 'teamName', 'memberName', 'taskId', 'event'], + idempotencyField: 'idempotencyKey', + runScoped: true, + handlerKind: 'task_event', + }, + { + name: 'runtime_heartbeat', + requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'], + idempotencyField: null, + runScoped: true, + handlerKind: 'heartbeat', + }, +]; + +export class OpenCodeMcpToolAvailabilityProbe { + constructor(private readonly client: OpenCodeInfrastructureToolClient) {} + + async proveRequiredTools(input: { + serverName: string; + requiredTools: string[]; + providerId: string; + modelId: string; + }): Promise { + const idsProof = await this.tryToolIdsProof(input); + if (idsProof.ok) { + return idsProof; + } + + const definitionsProof = await this.tryToolDefinitionsProof(input); + if (definitionsProof.ok) { + return definitionsProof; + } + + return mergeFailedToolProofs({ + serverName: input.serverName, + requiredTools: input.requiredTools, + idsProof, + definitionsProof, + }); + } + + private async tryToolIdsProof(input: { + serverName: string; + requiredTools: string[]; + }): Promise { + try { + const observedTools = await this.client.listExperimentalToolIds(); + return matchRequiredOpenCodeTools({ + route: '/experimental/tool/ids', + serverName: input.serverName, + requiredTools: input.requiredTools, + observedTools, + }); + } catch (error) { + return failedToolProof({ + route: '/experimental/tool/ids', + serverName: input.serverName, + requiredTools: input.requiredTools, + diagnostics: [`OpenCode /experimental/tool/ids unavailable - ${stringifyError(error)}`], + }); + } + } + + private async tryToolDefinitionsProof(input: { + serverName: string; + requiredTools: string[]; + providerId: string; + modelId: string; + }): Promise { + try { + const tools = await this.client.listExperimentalTools({ + providerId: input.providerId, + modelId: input.modelId, + }); + return matchRequiredOpenCodeTools({ + route: '/experimental/tool', + serverName: input.serverName, + requiredTools: input.requiredTools, + observedTools: tools.map((tool) => tool.id), + }); + } catch (error) { + return failedToolProof({ + route: '/experimental/tool', + serverName: input.serverName, + requiredTools: input.requiredTools, + diagnostics: [`OpenCode /experimental/tool unavailable - ${stringifyError(error)}`], + }); + } + } +} + +export function sanitizeOpenCodeMcpToolPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^a-zA-Z0-9_-]/g, '_') + .replace(/_+/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +export function buildOpenCodeCanonicalMcpToolId(serverName: string, toolName: string): string { + return `${sanitizeOpenCodeMcpToolPart(serverName)}_${sanitizeOpenCodeMcpToolPart(toolName)}`; +} + +export function buildOpenCodeToolIdCandidates(serverName: string, toolName: string): string[] { + const dashServerName = serverName.trim(); + const underscoreServerName = sanitizeOpenCodeMcpToolPart(serverName); + const canonical = buildOpenCodeCanonicalMcpToolId(serverName, toolName); + + return unique([ + canonical, + toolName, + `${dashServerName}:${toolName}`, + `${underscoreServerName}:${toolName}`, + `${dashServerName}_${toolName}`, + `${underscoreServerName}_${toolName}`, + `mcp__${dashServerName}__${toolName}`, + `mcp__${underscoreServerName}__${toolName}`, + ]); +} + +export function matchRequiredOpenCodeTools(input: { + route: Exclude; + serverName: string; + requiredTools: string[]; + observedTools: string[]; +}): OpenCodeMcpToolProof { + const observed = new Set(input.observedTools); + const matchedByRequiredTool: Record = {}; + const aliasMatchedByRequiredTool: Record = {}; + const missingTools: string[] = []; + const diagnostics: string[] = []; + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + + for (const requiredTool of input.requiredTools) { + const canonical = canonicalExpectedIds[requiredTool]; + const alias = buildOpenCodeToolIdCandidates(input.serverName, requiredTool).find( + (candidate) => candidate !== canonical && observed.has(candidate) + ); + + matchedByRequiredTool[requiredTool] = observed.has(canonical) ? canonical : null; + aliasMatchedByRequiredTool[requiredTool] = alias ?? null; + + if (!observed.has(canonical)) { + missingTools.push(requiredTool); + diagnostics.push( + alias + ? `OpenCode observed alias ${alias} but missing canonical app MCP tool id ${canonical}` + : `OpenCode missing canonical app MCP tool id ${canonical}` + ); + } + } + + return { + ok: missingTools.length === 0, + route: input.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: unique(input.observedTools).sort(), + missingTools, + matchedByRequiredTool, + aliasMatchedByRequiredTool, + diagnostics, + }; +} + +export function verifyAppMcpRuntimeToolContracts( + tools: AppMcpToolDefinition[] +): AppMcpRuntimeToolPreflightResult { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const diagnostics: string[] = []; + + for (const contract of APP_MCP_RUNTIME_TOOL_CONTRACTS) { + const tool = byName.get(contract.name); + if (!tool) { + diagnostics.push(`App MCP tool missing: ${contract.name}`); + continue; + } + + const schema = asRecord(tool.inputSchema); + const properties = asRecord(schema?.properties); + const required = asStringArray(schema?.required); + + for (const field of contract.requiredInputFields) { + if (!properties?.[field] || !required.includes(field)) { + diagnostics.push(`App MCP tool ${contract.name} missing required field ${field}`); + } + } + + if (contract.idempotencyField && !required.includes(contract.idempotencyField)) { + diagnostics.push( + `App MCP tool ${contract.name} idempotency field ${contract.idempotencyField} is not required` + ); + } + } + + return { + ok: diagnostics.length === 0, + observedToolNames: tools.map((tool) => tool.name).sort(), + diagnostics, + }; +} + +export function assertRuntimeDeliverMessageSchema( + tools: OpenCodeToolListItem[], + serverName = 'agent-teams' +): RuntimeDeliverMessageSchemaDiagnostic[] { + const deliverToolIds = new Set( + buildOpenCodeToolIdCandidates(serverName, 'runtime_deliver_message') + ); + const deliver = tools.find((tool) => deliverToolIds.has(tool.id)); + if (!deliver) { + return [{ severity: 'error', message: 'runtime_deliver_message tool is absent' }]; + } + + const schema = asRecord(deliver.parameters); + const properties = asRecord(schema?.properties); + const required = asStringArray(schema?.required); + const requiredFields = [ + 'idempotencyKey', + 'runId', + 'teamName', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + ]; + const missingFields = requiredFields.filter( + (field) => !properties?.[field] || !required.includes(field) + ); + + return missingFields.length === 0 + ? [] + : [ + { + severity: 'error', + message: `runtime_deliver_message schema missing required fields: ${missingFields.join(', ')}`, + missingFields, + }, + ]; +} + +function mergeFailedToolProofs(input: { + serverName: string; + requiredTools: string[]; + idsProof: OpenCodeMcpToolProof; + definitionsProof: OpenCodeMcpToolProof; +}): OpenCodeMcpToolProof { + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + const matchedByRequiredTool: Record = {}; + const aliasMatchedByRequiredTool: Record = {}; + + for (const tool of input.requiredTools) { + matchedByRequiredTool[tool] = + input.idsProof.matchedByRequiredTool[tool] ?? + input.definitionsProof.matchedByRequiredTool[tool] ?? + null; + aliasMatchedByRequiredTool[tool] = + input.idsProof.aliasMatchedByRequiredTool[tool] ?? + input.definitionsProof.aliasMatchedByRequiredTool[tool] ?? + null; + } + + return { + ok: false, + route: input.definitionsProof.route ?? input.idsProof.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: unique([ + ...input.idsProof.observedTools, + ...input.definitionsProof.observedTools, + ]).sort(), + missingTools: unique([ + ...input.idsProof.missingTools, + ...input.definitionsProof.missingTools, + ]).sort(), + matchedByRequiredTool, + aliasMatchedByRequiredTool, + diagnostics: [ + ...input.idsProof.diagnostics, + ...input.definitionsProof.diagnostics, + 'OpenCode app-owned MCP server is connected but required runtime tools were not proven available', + ], + }; +} + +function failedToolProof(input: { + route: Exclude; + serverName: string; + requiredTools: string[]; + diagnostics: string[]; +}): OpenCodeMcpToolProof { + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + return { + ok: false, + route: input.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: [], + missingTools: [...input.requiredTools], + matchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])), + aliasMatchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])), + diagnostics: input.diagnostics, + }; +} + +function buildCanonicalExpectedIds( + serverName: string, + requiredTools: string[] +): Record { + return Object.fromEntries( + requiredTools.map((tool) => [tool, buildOpenCodeCanonicalMcpToolId(serverName, tool)]) + ); +} + +function unique(items: T[]): T[] { + return [...new Set(items)]; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === 'string'); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/permissions/RuntimePermission.ts b/src/main/services/team/opencode/permissions/RuntimePermission.ts new file mode 100644 index 00000000..a5e03bb9 --- /dev/null +++ b/src/main/services/team/opencode/permissions/RuntimePermission.ts @@ -0,0 +1,936 @@ +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION = 1; + +export type OpenCodePermissionDecision = 'once' | 'always' | 'reject'; + +export type OpenCodeRawPermissionRequest = { + id?: unknown; + requestID?: unknown; + sessionID?: unknown; + permission?: unknown; + patterns?: unknown; + metadata?: unknown; + always?: unknown; + tool?: unknown; + title?: unknown; + kind?: unknown; +}; + +export interface OpenCodeNormalizedPermissionRequest { + requestId: string; + sessionId: string; + permission: string; + patterns: string[]; + alwaysPatterns: string[]; + toolName: string; + toolCallId: string | null; + messageId: string | null; + title: string; + description: string | null; + metadata: Record; + rawShape: 'v1.14' | 'legacy' | 'mixed'; + raw: OpenCodeRawPermissionRequest; +} + +export type RuntimePermissionState = + | 'pending' + | 'answering' + | 'answered' + | 'expired' + | 'stale_run' + | 'provider_missing' + | 'failed_retryable' + | 'failed_terminal'; + +export type RuntimePermissionAnswerOrigin = 'user_click' | 'provider_side_effect_projection'; + +export interface RuntimePermissionRequestRecord { + appRequestId: string; + providerRequestId: string; + runId: string; + teamName: string; + memberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + permission: string; + patterns: string[]; + alwaysPatterns: string[]; + toolName: string; + title: string; + description: string | null; + state: RuntimePermissionState; + rawShape: OpenCodeNormalizedPermissionRequest['rawShape']; + requestedAt: string; + updatedAt: string; + expiresAt: string; + answeredAt: string | null; + decision: OpenCodePermissionDecision | null; + answerOrigin: RuntimePermissionAnswerOrigin | null; + lastError: string | null; +} + +export type OpenCodePermissionReplySideEffect = + | { + kind: 'answered_clicked_request'; + appRequestId: string; + providerRequestId: string; + decision: OpenCodePermissionDecision; + } + | { + kind: 'reject_cancelled_same_session'; + appRequestId: string; + providerRequestId: string; + decision: 'reject'; + } + | { + kind: 'always_auto_allowed_same_session'; + appRequestId: string; + providerRequestId: string; + decision: 'always'; + matchedPatterns: string[]; + }; + +export interface RuntimePermissionAnswerProjectionResult { + affectedAppRequestIds: string[]; + sideEffects: OpenCodePermissionReplySideEffect[]; +} + +export interface RuntimePermissionDiagnosticEvent { + type: + | 'opencode_permission_stale_answer_rejected' + | 'opencode_permission_unmatched_session' + | 'opencode_permission_requests_expired' + | 'opencode_permission_answer_failed'; + providerId: 'opencode'; + teamName: string; + runId: string; + severity: 'info' | 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface RuntimePermissionDiagnosticsSink { + append(event: RuntimePermissionDiagnosticEvent): Promise; +} + +export interface RuntimePermissionLaunchStateStore { + read(teamName: string): Promise<{ runId: string | null } | null>; + updateMember( + teamName: string, + memberName: string, + updater: (member: RuntimePermissionLaunchMemberState) => RuntimePermissionLaunchMemberState + ): Promise; +} + +export interface RuntimePermissionLaunchMemberState { + launchState?: string; + bootstrapConfirmed?: boolean; + pendingPermissionRequestIds?: string[]; + lastRuntimeEventAt?: string; +} + +export interface OpenCodePermissionClientPort { + listPendingPermissions(): Promise; + answerPermission(input: { + requestId: string; + sessionId: string; + decision: OpenCodePermissionDecision; + message?: string; + }): Promise; +} + +export interface OpenCodeSessionPermissionRef { + runId: string; + memberName: string; + runtimeSessionId: string; +} + +export interface OpenCodePermissionAnswerResult { + ok: boolean; + requestId: string; + diagnostics: string[]; +} + +export class RuntimePermissionRequestStore { + constructor(private readonly store: VersionedJsonStore) {} + + async upsertPending( + input: RuntimePermissionRequestRecord + ): Promise<'created' | 'updated' | 'unchanged'> { + let outcome: 'created' | 'updated' | 'unchanged' = 'created'; + await this.store.updateLocked((records) => { + const index = records.findIndex((record) => record.appRequestId === input.appRequestId); + if (index < 0) { + return [...records, input]; + } + + const current = records[index]; + if (current.state === 'answered') { + if (current.answerOrigin !== 'provider_side_effect_projection') { + outcome = 'unchanged'; + return records; + } + + const reopened = { + ...current, + ...input, + requestedAt: current.requestedAt, + answeredAt: null, + decision: null, + answerOrigin: null, + lastError: null, + }; + outcome = + stablePermissionRecordJson(current) === stablePermissionRecordJson(reopened) + ? 'unchanged' + : 'updated'; + return records.map((record, recordIndex) => (recordIndex === index ? reopened : record)); + } + + const next = { + ...current, + ...input, + requestedAt: current.requestedAt, + answeredAt: current.answeredAt, + decision: current.decision, + answerOrigin: current.answerOrigin, + lastError: null, + }; + outcome = + stablePermissionRecordJson(current) === stablePermissionRecordJson(next) + ? 'unchanged' + : 'updated'; + return records.map((record, recordIndex) => (recordIndex === index ? next : record)); + }); + return outcome; + } + + async beginAnswer(input: { + appRequestId: string; + runId: string; + now: string; + }): Promise< + | { state: 'locked'; record: RuntimePermissionRequestRecord } + | { state: 'missing' } + | { state: 'stale_run'; record: RuntimePermissionRequestRecord } + | { state: 'already_answered'; record: RuntimePermissionRequestRecord } + | { state: 'already_answering'; record: RuntimePermissionRequestRecord } + > { + let result: + | { state: 'locked'; record: RuntimePermissionRequestRecord } + | { state: 'missing' } + | { state: 'stale_run'; record: RuntimePermissionRequestRecord } + | { state: 'already_answered'; record: RuntimePermissionRequestRecord } + | { state: 'already_answering'; record: RuntimePermissionRequestRecord } + | null = null; + + await this.store.updateLocked((records) => { + const existing = records.find((record) => record.appRequestId === input.appRequestId); + if (!existing) { + result = { state: 'missing' }; + return records; + } + + if (existing.runId !== input.runId) { + result = { state: 'stale_run', record: existing }; + return records; + } + + if (existing.state === 'answered') { + result = { state: 'already_answered', record: existing }; + return records; + } + + if (existing.state === 'answering') { + result = { state: 'already_answering', record: existing }; + return records; + } + + const locked = { + ...existing, + state: 'answering' as const, + updatedAt: input.now, + lastError: null, + }; + result = { state: 'locked', record: locked }; + return records.map((record) => + record.appRequestId === input.appRequestId ? locked : record + ); + }); + + if (!result) { + throw new Error('Runtime permission begin answer failed'); + } + return result; + } + + async markAnsweredWithSideEffects(input: { + appRequestId: string; + decision: OpenCodePermissionDecision; + answeredAt: string; + }): Promise { + let result: RuntimePermissionAnswerProjectionResult | null = null; + await this.store.updateLocked((records) => { + const clicked = records.find((record) => record.appRequestId === input.appRequestId); + if (!clicked) { + throw new Error(`Runtime permission request not found: ${input.appRequestId}`); + } + + const affectedAppRequestIds = new Set([input.appRequestId]); + const sideEffects: OpenCodePermissionReplySideEffect[] = [ + { + kind: 'answered_clicked_request', + appRequestId: clicked.appRequestId, + providerRequestId: clicked.providerRequestId, + decision: input.decision, + }, + ]; + + const nextRecords = records.map((record) => { + if (record.appRequestId === input.appRequestId) { + return answerPermissionRecord({ + record, + decision: input.decision, + answeredAt: input.answeredAt, + answerOrigin: 'user_click', + }); + } + + if (!isProjectableProviderSideEffectPeer(clicked, record)) { + return record; + } + + if (input.decision === 'reject') { + affectedAppRequestIds.add(record.appRequestId); + sideEffects.push({ + kind: 'reject_cancelled_same_session', + appRequestId: record.appRequestId, + providerRequestId: record.providerRequestId, + decision: 'reject', + }); + return answerPermissionRecord({ + record, + decision: 'reject', + answeredAt: input.answeredAt, + answerOrigin: 'provider_side_effect_projection', + }); + } + + if (input.decision === 'always') { + const matchedPatterns = findAlwaysProjectionMatches(clicked, record); + if (matchedPatterns.length === 0) { + return record; + } + + affectedAppRequestIds.add(record.appRequestId); + sideEffects.push({ + kind: 'always_auto_allowed_same_session', + appRequestId: record.appRequestId, + providerRequestId: record.providerRequestId, + decision: 'always', + matchedPatterns, + }); + return answerPermissionRecord({ + record, + decision: 'always', + answeredAt: input.answeredAt, + answerOrigin: 'provider_side_effect_projection', + }); + } + + return record; + }); + + result = { + affectedAppRequestIds: [...affectedAppRequestIds], + sideEffects, + }; + return nextRecords; + }); + + if (!result) { + throw new Error('Runtime permission answer projection failed'); + } + return result; + } + + async markFailed(input: { + appRequestId: string; + state: 'failed_retryable' | 'failed_terminal' | 'provider_missing'; + error: string; + updatedAt: string; + }): Promise { + await this.updateExisting(input.appRequestId, (record) => ({ + ...record, + state: input.state, + updatedAt: input.updatedAt, + lastError: input.error, + })); + } + + async expireMissingProviderRequests(input: { + runId: string; + teamName: string; + visibleProviderRequestIds: Set; + now: string; + }): Promise { + const expired: string[] = []; + await this.store.updateLocked((records) => + records.map((record) => { + if ( + record.runId !== input.runId || + record.teamName !== input.teamName || + record.state !== 'pending' || + input.visibleProviderRequestIds.has(record.providerRequestId) + ) { + return record; + } + expired.push(record.appRequestId); + return { + ...record, + state: 'provider_missing' as const, + updatedAt: input.now, + lastError: 'Provider no longer lists this permission request', + }; + }) + ); + return expired; + } + + async listPendingForTeam(teamName: string): Promise { + const records = await this.readRequired(); + return records.filter((record) => record.teamName === teamName && record.state === 'pending'); + } + + async get(appRequestId: string): Promise { + const records = await this.readRequired(); + return records.find((record) => record.appRequestId === appRequestId) ?? null; + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + appRequestId: string, + updater: (record: RuntimePermissionRequestRecord) => RuntimePermissionRequestRecord + ): Promise { + let found = false; + await this.store.updateLocked((records) => + records.map((record) => { + if (record.appRequestId !== appRequestId) { + return record; + } + found = true; + return updater(record); + }) + ); + + if (!found) { + throw new Error(`Runtime permission request not found: ${appRequestId}`); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export class RuntimePermissionAnswerService { + constructor( + private readonly store: RuntimePermissionRequestStore, + private readonly launchStateStore: RuntimePermissionLaunchStateStore, + private readonly openCodeClient: OpenCodePermissionClientPort, + private readonly diagnostics: RuntimePermissionDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async answer(input: { + appRequestId: string; + runId: string; + decision: OpenCodePermissionDecision; + message?: string; + }): Promise { + const now = this.clock().toISOString(); + const begin = await this.store.beginAnswer({ + appRequestId: input.appRequestId, + runId: input.runId, + now, + }); + + if (begin.state === 'missing') { + return { + ok: false, + requestId: input.appRequestId, + diagnostics: ['Permission request not found'], + }; + } + if (begin.state === 'stale_run') { + await this.diagnostics.append({ + type: 'opencode_permission_stale_answer_rejected', + providerId: 'opencode', + teamName: begin.record.teamName, + runId: input.runId, + severity: 'warning', + message: 'OpenCode permission answer rejected because request belongs to another run', + data: { appRequestId: input.appRequestId, requestRunId: begin.record.runId }, + createdAt: now, + }); + return { ok: false, requestId: input.appRequestId, diagnostics: ['Stale runId rejected'] }; + } + if (begin.state === 'already_answered') { + return { + ok: true, + requestId: input.appRequestId, + diagnostics: ['Permission already answered'], + }; + } + if (begin.state === 'already_answering') { + return { + ok: false, + requestId: input.appRequestId, + diagnostics: ['Permission answer already in progress'], + }; + } + + const record = begin.record; + const launchState = await this.launchStateStore.read(record.teamName); + if (launchState?.runId !== record.runId) { + await this.store.markFailed({ + appRequestId: record.appRequestId, + state: 'failed_terminal', + error: 'Launch state moved to another run before permission answer', + updatedAt: now, + }); + return { + ok: false, + requestId: record.appRequestId, + diagnostics: ['Launch state moved to another run'], + }; + } + + try { + await this.openCodeClient.answerPermission({ + requestId: record.providerRequestId, + sessionId: record.runtimeSessionId, + decision: input.decision, + message: input.message, + }); + const answeredAt = this.clock().toISOString(); + await this.store.markAnsweredWithSideEffects({ + appRequestId: record.appRequestId, + decision: input.decision, + answeredAt, + }); + const remainingMemberPendingIds = (await this.store.listPendingForTeam(record.teamName)) + .filter( + (pendingRecord) => + pendingRecord.runId === record.runId && pendingRecord.memberName === record.memberName + ) + .map((pendingRecord) => pendingRecord.appRequestId); + await this.launchStateStore.updateMember(record.teamName, record.memberName, (member) => ({ + ...member, + pendingPermissionRequestIds: remainingMemberPendingIds, + lastRuntimeEventAt: answeredAt, + })); + return { ok: true, requestId: record.appRequestId, diagnostics: [] }; + } catch (error) { + await this.store.markFailed({ + appRequestId: record.appRequestId, + state: 'failed_retryable', + error: stringifyError(error), + updatedAt: this.clock().toISOString(), + }); + await this.diagnostics.append({ + type: 'opencode_permission_answer_failed', + providerId: 'opencode', + teamName: record.teamName, + runId: record.runId, + severity: 'warning', + message: 'OpenCode permission answer failed and remains retryable', + data: { appRequestId: record.appRequestId, error: stringifyError(error) }, + createdAt: this.clock().toISOString(), + }); + throw error; + } + } +} + +export class RuntimePermissionReconciler { + constructor( + private readonly client: OpenCodePermissionClientPort, + private readonly store: RuntimePermissionRequestStore, + private readonly launchStateStore: RuntimePermissionLaunchStateStore, + private readonly diagnostics: RuntimePermissionDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async reconcile(input: { + runId: string; + teamName: string; + sessionsByOpenCodeId: Map; + }): Promise { + const now = this.clock().toISOString(); + const pending = await this.client.listPendingPermissions(); + const visibleProviderRequestIds = new Set(); + const pendingByMember = new Map(); + + for (const permission of pending) { + visibleProviderRequestIds.add(permission.requestId); + const session = input.sessionsByOpenCodeId.get(permission.sessionId); + if (!session || session.runId !== input.runId) { + await this.diagnostics.append({ + type: 'opencode_permission_unmatched_session', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId, + severity: 'warning', + message: 'OpenCode permission request did not match a current runtime session', + data: { providerRequestId: permission.requestId, sessionId: permission.sessionId }, + createdAt: now, + }); + continue; + } + + const appRequestId = createOpenCodePermissionAppRequestId(input.runId, permission.requestId); + await this.store.upsertPending({ + appRequestId, + providerRequestId: permission.requestId, + runId: input.runId, + teamName: input.teamName, + memberName: session.memberName, + providerId: 'opencode', + runtimeSessionId: permission.sessionId, + permission: permission.permission, + patterns: permission.patterns, + alwaysPatterns: permission.alwaysPatterns, + toolName: permission.toolName, + title: permission.title, + description: permission.description, + state: 'pending', + rawShape: permission.rawShape, + requestedAt: now, + updatedAt: now, + expiresAt: new Date(Date.parse(now) + 15 * 60_000).toISOString(), + answeredAt: null, + decision: null, + answerOrigin: null, + lastError: null, + }); + pendingByMember.set(session.memberName, [ + ...(pendingByMember.get(session.memberName) ?? []), + appRequestId, + ]); + } + + const expired = await this.store.expireMissingProviderRequests({ + runId: input.runId, + teamName: input.teamName, + visibleProviderRequestIds, + now, + }); + if (expired.length > 0) { + await this.diagnostics.append({ + type: 'opencode_permission_requests_expired', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId, + severity: 'info', + message: 'OpenCode permission requests disappeared from provider and were expired locally', + data: { expiredCount: expired.length }, + createdAt: now, + }); + } + + for (const [memberName, requestIds] of pendingByMember) { + await this.launchStateStore.updateMember(input.teamName, memberName, (member) => ({ + ...member, + launchState: + member.launchState === 'confirmed_alive' + ? member.launchState + : 'runtime_pending_permission', + pendingPermissionRequestIds: [...new Set(requestIds)], + lastRuntimeEventAt: now, + })); + } + } +} + +export function createRuntimePermissionRequestStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimePermissionRequestStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimePermissionRequestStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimePermissionRequestRecords, + clock, + }) + ); +} + +export function normalizeOpenCodePermissionRequest( + raw: OpenCodeRawPermissionRequest +): OpenCodeNormalizedPermissionRequest | null { + const requestId = asString(raw.id) ?? asString(raw.requestID); + const sessionId = asString(raw.sessionID); + if (!requestId || !sessionId) { + return null; + } + + const toolObject = isRecord(raw.tool) ? raw.tool : null; + const legacyToolName = asString(raw.tool); + const permission = asString(raw.permission) ?? asString(raw.kind) ?? legacyToolName ?? 'unknown'; + const patterns = asStringArray(raw.patterns); + const alwaysPatterns = asStringArray(raw.always); + const metadata = asRecord(raw.metadata); + const toolName = + legacyToolName ?? asString(toolObject?.name) ?? asString(metadata.toolName) ?? permission; + const messageId = asString(toolObject?.messageID) ?? asString(metadata.messageID); + const toolCallId = asString(toolObject?.callID) ?? asString(metadata.callID); + + return { + requestId, + sessionId, + permission, + patterns, + alwaysPatterns, + toolName, + toolCallId, + messageId, + title: asString(raw.title) ?? buildOpenCodePermissionTitle({ permission, toolName, patterns }), + description: + asString(raw.kind) ?? + buildOpenCodePermissionDescription({ patterns, alwaysPatterns, metadata }), + metadata, + rawShape: detectPermissionRawShape(raw), + raw, + }; +} + +export function createOpenCodePermissionAppRequestId( + runId: string, + providerRequestId: string +): string { + return `opencode:${runId}:${providerRequestId}`; +} + +export function validateRuntimePermissionRequestRecords( + value: unknown +): RuntimePermissionRequestRecord[] { + if (!Array.isArray(value)) { + throw new Error('Runtime permission requests must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimePermissionRequestRecord(record)) { + throw new Error(`Invalid runtime permission request at index ${index}`); + } + const normalized = normalizeRuntimePermissionRequestRecord(record); + if (seen.has(normalized.appRequestId)) { + throw new Error(`Duplicate runtime permission request id: ${normalized.appRequestId}`); + } + seen.add(normalized.appRequestId); + return normalized; + }); +} + +function detectPermissionRawShape( + raw: OpenCodeRawPermissionRequest +): OpenCodeNormalizedPermissionRequest['rawShape'] { + const hasV114Fields = + typeof raw.id === 'string' || typeof raw.permission === 'string' || Array.isArray(raw.patterns); + const hasLegacyFields = + typeof raw.requestID === 'string' || + typeof raw.title === 'string' || + typeof raw.kind === 'string'; + if (hasV114Fields && hasLegacyFields) { + return 'mixed'; + } + if (hasV114Fields) { + return 'v1.14'; + } + return 'legacy'; +} + +function buildOpenCodePermissionTitle(input: { + permission: string; + toolName: string; + patterns: string[]; +}): string { + if (input.patterns.length > 0) { + return `OpenCode wants ${input.permission} permission for ${input.patterns[0]}`; + } + if (input.toolName !== 'unknown') { + return `OpenCode wants to use ${input.toolName}`; + } + return `OpenCode permission request: ${input.permission}`; +} + +function buildOpenCodePermissionDescription(input: { + patterns: string[]; + alwaysPatterns: string[]; + metadata: Record; +}): string | null { + const parts: string[] = []; + if (input.patterns.length > 0) { + parts.push(`Patterns: ${input.patterns.join(', ')}`); + } + if (input.alwaysPatterns.length > 0) { + parts.push(`Always candidates: ${input.alwaysPatterns.join(', ')}`); + } + const reason = asString(input.metadata.reason); + if (reason) { + parts.push(`Reason: ${reason}`); + } + return parts.length > 0 ? parts.join('\n') : null; +} + +function answerPermissionRecord(input: { + record: RuntimePermissionRequestRecord; + decision: OpenCodePermissionDecision; + answeredAt: string; + answerOrigin: RuntimePermissionAnswerOrigin; +}): RuntimePermissionRequestRecord { + return { + ...input.record, + state: 'answered', + answeredAt: input.answeredAt, + decision: input.decision, + answerOrigin: input.answerOrigin, + updatedAt: input.answeredAt, + lastError: null, + }; +} + +function isProjectableProviderSideEffectPeer( + clicked: RuntimePermissionRequestRecord, + candidate: RuntimePermissionRequestRecord +): boolean { + return ( + candidate.appRequestId !== clicked.appRequestId && + candidate.providerId === 'opencode' && + candidate.runId === clicked.runId && + candidate.teamName === clicked.teamName && + candidate.runtimeSessionId === clicked.runtimeSessionId && + candidate.state === 'pending' + ); +} + +function findAlwaysProjectionMatches( + clicked: RuntimePermissionRequestRecord, + candidate: RuntimePermissionRequestRecord +): string[] { + const allowedPatterns = new Set([...clicked.alwaysPatterns, ...clicked.patterns]); + if (allowedPatterns.size === 0) { + return []; + } + return [...new Set(candidate.patterns.filter((pattern) => allowedPatterns.has(pattern)))]; +} + +function normalizeRuntimePermissionRequestRecord( + record: RuntimePermissionRequestRecord +): RuntimePermissionRequestRecord { + return { + ...record, + permission: isNonEmptyString(record.permission) ? record.permission : record.toolName, + patterns: isStringArray(record.patterns) ? record.patterns : [], + alwaysPatterns: isStringArray(record.alwaysPatterns) ? record.alwaysPatterns : [], + answerOrigin: isRuntimePermissionAnswerOrigin(record.answerOrigin) ? record.answerOrigin : null, + }; +} + +function isRuntimePermissionRequestRecord(value: unknown): value is RuntimePermissionRequestRecord { + return ( + isRecord(value) && + isNonEmptyString(value.appRequestId) && + isNonEmptyString(value.providerRequestId) && + isNonEmptyString(value.runId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.memberName) && + value.providerId === 'opencode' && + isNonEmptyString(value.runtimeSessionId) && + (value.permission === undefined || isNonEmptyString(value.permission)) && + (value.patterns === undefined || isStringArray(value.patterns)) && + (value.alwaysPatterns === undefined || isStringArray(value.alwaysPatterns)) && + isNonEmptyString(value.toolName) && + isNonEmptyString(value.title) && + (value.description === null || typeof value.description === 'string') && + isRuntimePermissionState(value.state) && + (value.rawShape === 'v1.14' || value.rawShape === 'legacy' || value.rawShape === 'mixed') && + isNonEmptyString(value.requestedAt) && + isNonEmptyString(value.updatedAt) && + isNonEmptyString(value.expiresAt) && + (value.answeredAt === null || isNonEmptyString(value.answeredAt)) && + (value.decision === null || isOpenCodePermissionDecision(value.decision)) && + (value.answerOrigin === undefined || + value.answerOrigin === null || + isRuntimePermissionAnswerOrigin(value.answerOrigin)) && + (value.lastError === null || typeof value.lastError === 'string') + ); +} + +function isRuntimePermissionState(value: unknown): value is RuntimePermissionState { + return ( + value === 'pending' || + value === 'answering' || + value === 'answered' || + value === 'expired' || + value === 'stale_run' || + value === 'provider_missing' || + value === 'failed_retryable' || + value === 'failed_terminal' + ); +} + +function isOpenCodePermissionDecision(value: unknown): value is OpenCodePermissionDecision { + return value === 'once' || value === 'always' || value === 'reject'; +} + +function isRuntimePermissionAnswerOrigin(value: unknown): value is RuntimePermissionAnswerOrigin { + return value === 'user_click' || value === 'provider_side_effect_projection'; +} + +function stablePermissionRecordJson(value: RuntimePermissionRequestRecord): string { + return JSON.stringify(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts new file mode 100644 index 00000000..19da9a8c --- /dev/null +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -0,0 +1,378 @@ +import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; +import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; +import { + evaluateOpenCodeSupport, + OPENCODE_TEAM_LAUNCH_VERSION_POLICY, + type OpenCodeInstallMethod, + type OpenCodeProductionE2EEvidence, + type OpenCodeSupportLevel, + type OpenCodeSupportedVersionPolicy, +} from '../version/OpenCodeVersionPolicy'; +import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; + +export type OpenCodeTeamLaunchReadinessState = + | 'ready' + | 'not_installed' + | 'not_authenticated' + | 'unsupported_version' + | 'capabilities_missing' + | 'e2e_missing' + | 'runtime_store_blocked' + | 'mcp_unavailable' + | 'model_unavailable' + | 'adapter_disabled' + | 'unknown_error'; + +export interface OpenCodeRuntimeInventory { + detected: boolean; + binaryPath: string | null; + installMethod: OpenCodeInstallMethod; + version: string | null; + authenticated: boolean; + connectedProviders: string[]; + models: string[]; + diagnostics: string[]; +} + +export interface OpenCodeModelExecutionProbeResult { + outcome: 'available' | 'unavailable' | 'unknown'; + reason: string | null; + diagnostics: string[]; +} + +export interface OpenCodeTeamLaunchReadiness { + state: OpenCodeTeamLaunchReadinessState; + launchAllowed: boolean; + modelId: string | null; + opencodeVersion: string | null; + installMethod: OpenCodeInstallMethod | null; + binaryPath: string | null; + hostHealthy: boolean; + appMcpConnected: boolean; + requiredToolsPresent: boolean; + permissionBridgeReady: boolean; + runtimeStoresReady: boolean; + supportLevel: OpenCodeSupportLevel | null; + missing: string[]; + diagnostics: string[]; + evidence: { + capabilitiesReady: boolean; + mcpToolProofRoute: OpenCodeMcpToolProof['route']; + observedMcpTools: string[]; + runtimeStoreReadinessReason: RuntimeStoreReadinessCheck['reason'] | null; + }; +} + +export interface OpenCodeRuntimeInventoryPort { + probe(input: { projectPath: string }): Promise; +} + +export interface OpenCodeApiCapabilityPort { + detect(input: { + projectPath: string; + inventory: OpenCodeRuntimeInventory; + }): Promise; +} + +export interface OpenCodeMcpToolProofPort { + prove(input: { + projectPath: string; + modelId: string; + inventory: OpenCodeRuntimeInventory; + capabilities: OpenCodeApiCapabilities; + }): Promise; +} + +export interface OpenCodeRuntimeStoreReadinessPort { + check(input: { projectPath: string }): Promise; +} + +export interface OpenCodeModelExecutionProbePort { + verify(input: { + projectPath: string; + modelId: string; + inventory: OpenCodeRuntimeInventory; + }): Promise; +} + +export interface OpenCodeProductionE2EEvidencePort { + read(input: { + projectPath: string; + inventory: OpenCodeRuntimeInventory; + capabilities: OpenCodeApiCapabilities; + }): Promise; +} + +export interface OpenCodeTeamLaunchReadinessServiceOptions { + versionPolicy?: OpenCodeSupportedVersionPolicy; + launchMode?: OpenCodeTeamLaunchMode; + /** + * @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate. + */ + adapterEnabled?: boolean; +} + +export class OpenCodeTeamLaunchReadinessService { + constructor( + private readonly inventory: OpenCodeRuntimeInventoryPort, + private readonly capabilities: OpenCodeApiCapabilityPort, + private readonly mcpTools: OpenCodeMcpToolProofPort, + private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort, + private readonly modelExecution: OpenCodeModelExecutionProbePort, + private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort, + private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {} + ) {} + + async check(input: { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; + }): Promise { + const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options); + const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; + const dogfoodWarnings: string[] = []; + + if (launchMode === 'disabled') { + return readiness({ + state: 'adapter_disabled', + inventory: null, + modelId: input.selectedModel, + missing: ['OpenCode team launch adapter is disabled by feature gate'], + diagnostics: ['OpenCode team launch adapter is disabled by feature gate'], + }); + } + + try { + const inventory = await this.inventory.probe({ projectPath: input.projectPath }); + if (!inventory.detected) { + return readiness({ + state: 'not_installed', + inventory, + modelId: input.selectedModel, + diagnostics: appendDiagnostics(inventory.diagnostics, [ + 'OpenCode CLI not detected on PATH', + ]), + }); + } + + if (!inventory.authenticated || inventory.connectedProviders.length === 0) { + return readiness({ + state: 'not_authenticated', + inventory, + modelId: input.selectedModel, + diagnostics: appendDiagnostics(inventory.diagnostics, [ + 'No connected OpenCode providers found', + ]), + }); + } + + const modelId = input.selectedModel ?? inventory.models[0] ?? null; + if (!modelId) { + return readiness({ + state: 'model_unavailable', + inventory, + modelId: null, + diagnostics: appendDiagnostics(inventory.diagnostics, ['No OpenCode model is available']), + }); + } + + const capabilities = await this.capabilities.detect({ + projectPath: input.projectPath, + inventory, + }); + const evidence = await this.e2eEvidence.read({ + projectPath: input.projectPath, + inventory, + capabilities, + }); + const support = evaluateOpenCodeSupport({ + version: inventory.version ?? '0.0.0', + capabilities, + evidence, + policy, + }); + + if (!support.supported) { + if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') { + dogfoodWarnings.push( + 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.' + ); + } else { + return readiness({ + state: mapSupportLevelToReadinessState(support.supportLevel), + inventory, + modelId, + capabilities, + supportLevel: support.supportLevel, + missing: support.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), + }); + } + } + + const runtimeStoreReadiness = await this.runtimeStores.check({ + projectPath: input.projectPath, + }); + if (!runtimeStoreReadiness.ok) { + return readiness({ + state: 'runtime_store_blocked', + inventory, + modelId, + capabilities, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: runtimeStoreReadiness.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, runtimeStoreReadiness.diagnostics), + }); + } + + const toolProof = await this.mcpTools.prove({ + projectPath: input.projectPath, + modelId, + inventory, + capabilities, + }); + if (!toolProof.ok) { + return readiness({ + state: 'mcp_unavailable', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: toolProof.missingTools, + diagnostics: appendDiagnostics(inventory.diagnostics, toolProof.diagnostics), + }); + } + + if (input.requireExecutionProbe) { + const modelProbe = await this.modelExecution.verify({ + projectPath: input.projectPath, + modelId, + inventory, + }); + if (modelProbe.outcome !== 'available') { + return readiness({ + state: 'model_unavailable', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: [modelProbe.reason ?? 'OpenCode selected model execution is unavailable'], + diagnostics: appendDiagnostics(inventory.diagnostics, modelProbe.diagnostics), + }); + } + } + + return readiness({ + state: 'ready', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + launchAllowed: true, + diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings), + }); + } catch (error) { + return readiness({ + state: 'unknown_error', + inventory: null, + modelId: input.selectedModel, + diagnostics: [`OpenCode readiness check failed: ${stringifyError(error)}`], + }); + } + } +} + +function resolveReadinessLaunchMode( + requested: OpenCodeTeamLaunchMode | undefined, + options: OpenCodeTeamLaunchReadinessServiceOptions +): OpenCodeTeamLaunchMode { + if (requested) { + return requested; + } + if (options.launchMode) { + return options.launchMode; + } + if (options.adapterEnabled === true) { + return 'production'; + } + return 'disabled'; +} + +function readiness(input: { + state: OpenCodeTeamLaunchReadinessState; + inventory: OpenCodeRuntimeInventory | null; + modelId: string | null; + capabilities?: OpenCodeApiCapabilities; + toolProof?: OpenCodeMcpToolProof; + runtimeStoreReadiness?: RuntimeStoreReadinessCheck; + supportLevel?: OpenCodeSupportLevel | null; + launchAllowed?: boolean; + missing?: string[]; + diagnostics: string[]; +}): OpenCodeTeamLaunchReadiness { + const toolProof = input.toolProof ?? null; + const capabilitiesReady = input.capabilities?.requiredForTeamLaunch.ready === true; + + return { + state: input.state, + launchAllowed: input.launchAllowed === true, + modelId: input.modelId, + opencodeVersion: input.inventory?.version ?? null, + installMethod: input.inventory?.installMethod ?? null, + binaryPath: input.inventory?.binaryPath ?? null, + hostHealthy: input.inventory?.detected === true, + appMcpConnected: toolProof !== null, + requiredToolsPresent: toolProof?.ok === true, + permissionBridgeReady: + input.capabilities?.endpoints.permissionList === true && + (input.capabilities.endpoints.permissionReply === true || + input.capabilities.endpoints.permissionLegacySessionRespond === true), + runtimeStoresReady: input.runtimeStoreReadiness?.ok === true, + supportLevel: input.supportLevel ?? null, + missing: dedupe(input.missing ?? []), + diagnostics: dedupe(input.diagnostics), + evidence: { + capabilitiesReady, + mcpToolProofRoute: toolProof?.route ?? null, + observedMcpTools: toolProof?.observedTools ?? [], + runtimeStoreReadinessReason: input.runtimeStoreReadiness?.reason ?? null, + }, + }; +} + +function mapSupportLevelToReadinessState( + supportLevel: OpenCodeSupportLevel +): OpenCodeTeamLaunchReadinessState { + switch (supportLevel) { + case 'unsupported_too_old': + case 'unsupported_prerelease': + return 'unsupported_version'; + case 'supported_capabilities_pending': + return 'capabilities_missing'; + case 'supported_e2e_pending': + return 'e2e_missing'; + case 'production_supported': + return 'ready'; + } +} + +function appendDiagnostics(left: string[], right: string[]): string[] { + return dedupe([...left, ...right]); +} + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts new file mode 100644 index 00000000..44e7783e --- /dev/null +++ b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts @@ -0,0 +1,399 @@ +import { createHash } from 'crypto'; + +import { stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION = 1; + +export type OpenCodeLaunchCheckpointName = + | 'run_created' + | 'host_ready' + | 'lead_session_recorded' + | 'member_session_recorded' + | 'mcp_connected' + | 'required_tools_proven' + | 'prompt_sent' + | 'bootstrap_confirmed' + | 'permission_blocked' + | 'delivery_ready' + | 'member_ready' + | 'run_ready' + | 'run_failed' + | 'run_cancelled'; + +export interface OpenCodeLaunchCheckpoint { + name: OpenCodeLaunchCheckpointName; + teamName: string; + runId: string; + memberName: string | null; + runtimeSessionId: string | null; + hostKey: string | null; + evidenceHash: string; + createdAt: string; + diagnostics: string[]; +} + +export interface OpenCodeLaunchTransaction { + teamName: string; + runId: string; + providerId: 'opencode'; + startedAt: string; + updatedAt: string; + status: 'active' | 'ready' | 'failed' | 'cancelled' | 'reconciled'; + checkpoints: OpenCodeLaunchCheckpoint[]; +} + +export interface OpenCodeRunReadyInput { + members: Array<{ name: string; launchState?: string }>; + transaction: OpenCodeLaunchTransaction; + toolProof: { ok: boolean }; + deliveryReady: boolean; +} + +export class OpenCodeLaunchTransactionStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async beginRun(input: { + teamName: string; + runId: string; + startedAt?: string; + }): Promise< + | { state: 'created'; transaction: OpenCodeLaunchTransaction } + | { state: 'already_active'; transaction: OpenCodeLaunchTransaction } + > { + let result: + | { state: 'created'; transaction: OpenCodeLaunchTransaction } + | { state: 'already_active'; transaction: OpenCodeLaunchTransaction } + | null = null; + const startedAt = input.startedAt ?? this.clock().toISOString(); + + await this.store.updateLocked((transactions) => { + const active = transactions.find( + (transaction) => transaction.teamName === input.teamName && transaction.status === 'active' + ); + if (active) { + result = { state: 'already_active', transaction: active }; + return transactions; + } + + const transaction: OpenCodeLaunchTransaction = { + teamName: input.teamName, + runId: input.runId, + providerId: 'opencode', + startedAt, + updatedAt: startedAt, + status: 'active', + checkpoints: [], + }; + result = { state: 'created', transaction }; + return [...transactions, transaction]; + }); + + if (!result) { + throw new Error('OpenCode launch transaction begin failed'); + } + return result; + } + + async addCheckpoint(input: OpenCodeLaunchCheckpoint): Promise<'created' | 'unchanged'> { + let outcome: 'created' | 'unchanged' = 'created'; + await this.store.updateLocked((transactions) => + transactions.map((transaction) => { + if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) { + return transaction; + } + + if (transaction.status !== 'active') { + throw new Error(`OpenCode launch transaction is not active: ${input.runId}`); + } + + const duplicate = transaction.checkpoints.some( + (checkpoint) => + checkpoint.name === input.name && + checkpoint.memberName === input.memberName && + checkpoint.evidenceHash === input.evidenceHash + ); + if (duplicate) { + outcome = 'unchanged'; + return transaction; + } + + return { + ...transaction, + updatedAt: input.createdAt, + checkpoints: [...transaction.checkpoints, normalizeCheckpoint(input)], + }; + }) + ); + + if (!(await this.hasTransaction(input.teamName, input.runId))) { + throw new Error(`OpenCode launch transaction not found: ${input.runId}`); + } + + return outcome; + } + + async hasCheckpoint(input: { + teamName: string; + runId: string; + memberName: string | null; + name: OpenCodeLaunchCheckpointName; + evidenceHash?: string; + }): Promise { + const transaction = await this.read(input.teamName, input.runId); + return ( + transaction?.checkpoints.some( + (checkpoint) => + checkpoint.name === input.name && + checkpoint.memberName === input.memberName && + (input.evidenceHash === undefined || checkpoint.evidenceHash === input.evidenceHash) + ) ?? false + ); + } + + async readActive(teamName: string): Promise { + const transactions = await this.readRequired(); + return ( + transactions.find( + (transaction) => transaction.teamName === teamName && transaction.status === 'active' + ) ?? null + ); + } + + async read(teamName: string, runId: string): Promise { + const transactions = await this.readRequired(); + return ( + transactions.find( + (transaction) => transaction.teamName === teamName && transaction.runId === runId + ) ?? null + ); + } + + async finish(input: { + teamName: string; + runId: string; + status: 'ready' | 'failed' | 'cancelled' | 'reconciled'; + updatedAt?: string; + }): Promise<'finished' | 'unchanged'> { + let found = false; + let outcome: 'finished' | 'unchanged' = 'finished'; + const updatedAt = input.updatedAt ?? this.clock().toISOString(); + + await this.store.updateLocked((transactions) => + transactions.map((transaction) => { + if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) { + return transaction; + } + found = true; + if (transaction.status !== 'active') { + outcome = 'unchanged'; + return transaction; + } + return { + ...transaction, + status: input.status, + updatedAt, + }; + }) + ); + + if (!found) { + const active = await this.readActive(input.teamName); + if (active) { + throw new Error( + `OpenCode launch transaction ${input.runId} is stale; active run is ${active.runId}` + ); + } + throw new Error(`OpenCode launch transaction not found: ${input.runId}`); + } + + return outcome; + } + + async list(): Promise { + return this.readRequired(); + } + + private async hasTransaction(teamName: string, runId: string): Promise { + return (await this.read(teamName, runId)) !== null; + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function canMarkOpenCodeRunReady(input: OpenCodeRunReadyInput): { + ok: boolean; + missing: string[]; +} { + const missing: string[] = []; + + for (const member of input.members) { + if (!hasMemberCheckpoint(input.transaction, member.name, 'member_session_recorded')) { + missing.push(`${member.name}:member_session_recorded`); + } + if (!hasMemberCheckpoint(input.transaction, member.name, 'required_tools_proven')) { + missing.push(`${member.name}:required_tools_proven`); + } + if (member.launchState !== 'confirmed_alive') { + missing.push(`${member.name}:bootstrap_confirmed`); + } + } + + if (!input.toolProof.ok) { + missing.push('required_runtime_tools'); + } + if (!input.deliveryReady) { + missing.push('runtime_delivery_service'); + } + + return { + ok: missing.length === 0, + missing, + }; +} + +export function hasMemberCheckpoint( + transaction: OpenCodeLaunchTransaction, + memberName: string, + name: OpenCodeLaunchCheckpointName +): boolean { + return transaction.checkpoints.some( + (checkpoint) => checkpoint.memberName === memberName && checkpoint.name === name + ); +} + +export function createOpenCodeLaunchEvidenceHash(evidence: unknown): string { + return `sha256:${createHash('sha256') + .update(stableJsonStringify(redactOpenCodeLaunchEvidence(evidence))) + .digest('hex')}`; +} + +export function redactOpenCodeLaunchEvidence(evidence: unknown): unknown { + if (evidence === null || typeof evidence !== 'object') { + return evidence; + } + + if (Array.isArray(evidence)) { + return evidence.map(redactOpenCodeLaunchEvidence); + } + + const output: Record = {}; + for (const [key, value] of Object.entries(evidence)) { + if (/token|secret|password|api[_-]?key|authorization/i.test(key)) { + output[key] = '[redacted]'; + } else { + output[key] = redactOpenCodeLaunchEvidence(value); + } + } + return output; +} + +export function createOpenCodeLaunchTransactionStore(options: { + filePath: string; + clock?: () => Date; +}): OpenCodeLaunchTransactionStore { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeLaunchTransactionStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION, + defaultData: () => [], + validate: validateOpenCodeLaunchTransactions, + clock, + }), + clock + ); +} + +export function validateOpenCodeLaunchTransactions(value: unknown): OpenCodeLaunchTransaction[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode launch transactions must be an array'); + } + return value.map((transaction, index) => { + if (!isLaunchTransaction(transaction)) { + throw new Error(`Invalid OpenCode launch transaction at index ${index}`); + } + return transaction; + }); +} + +function normalizeCheckpoint(input: OpenCodeLaunchCheckpoint): OpenCodeLaunchCheckpoint { + return { + ...input, + diagnostics: [...input.diagnostics], + }; +} + +function isLaunchTransaction(value: unknown): value is OpenCodeLaunchTransaction { + return ( + isRecord(value) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + value.providerId === 'opencode' && + isNonEmptyString(value.startedAt) && + isNonEmptyString(value.updatedAt) && + (value.status === 'active' || + value.status === 'ready' || + value.status === 'failed' || + value.status === 'cancelled' || + value.status === 'reconciled') && + Array.isArray(value.checkpoints) && + value.checkpoints.every(isLaunchCheckpoint) + ); +} + +function isLaunchCheckpoint(value: unknown): value is OpenCodeLaunchCheckpoint { + return ( + isRecord(value) && + isLaunchCheckpointName(value.name) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + isNullableString(value.memberName) && + isNullableString(value.runtimeSessionId) && + isNullableString(value.hostKey) && + isNonEmptyString(value.evidenceHash) && + isNonEmptyString(value.createdAt) && + Array.isArray(value.diagnostics) && + value.diagnostics.every((item) => typeof item === 'string') + ); +} + +function isLaunchCheckpointName(value: unknown): value is OpenCodeLaunchCheckpointName { + return ( + value === 'run_created' || + value === 'host_ready' || + value === 'lead_session_recorded' || + value === 'member_session_recorded' || + value === 'mcp_connected' || + value === 'required_tools_proven' || + value === 'prompt_sent' || + value === 'bootstrap_confirmed' || + value === 'permission_blocked' || + value === 'delivery_ready' || + value === 'member_ready' || + value === 'run_ready' || + value === 'run_failed' || + value === 'run_cancelled' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts new file mode 100644 index 00000000..ce11bb46 --- /dev/null +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; + +import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; +import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService'; +import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest'; + +export interface OpenCodeRuntimeManifestEvidenceReaderOptions { + teamsBasePath: string; + clock?: () => Date; +} + +const OPENCODE_TEAM_RUNTIME_DIR = '.opencode-runtime'; +const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json'; + +export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader { + private readonly teamsBasePath: string; + private readonly clock: () => Date; + + constructor(options: OpenCodeRuntimeManifestEvidenceReaderOptions) { + this.teamsBasePath = options.teamsBasePath; + this.clock = options.clock ?? (() => new Date()); + } + + async read(teamName: string): Promise { + const manifest = await createRuntimeStoreManifestStore({ + filePath: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName), + teamName, + clock: this.clock, + }).read(); + + return { + highWatermark: manifest.highWatermark, + activeRunId: manifest.activeRunId, + capabilitySnapshotId: manifest.activeCapabilitySnapshotId, + }; + } +} + +export function getOpenCodeTeamRuntimeDirectory(teamsBasePath: string, teamName: string): string { + return path.join(teamsBasePath, teamName, OPENCODE_TEAM_RUNTIME_DIR); +} + +export function getOpenCodeRuntimeManifestPath(teamsBasePath: string, teamName: string): string { + return path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_RUNTIME_MANIFEST_FILE + ); +} diff --git a/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts b/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts new file mode 100644 index 00000000..a31bd4fc --- /dev/null +++ b/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts @@ -0,0 +1,313 @@ +import { randomUUID } from 'crypto'; + +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION = 1; + +export type RuntimeEvidenceKind = + | 'sse_event' + | 'permission_reply' + | 'delivery_call' + | 'prompt_error' + | 'bootstrap_checkin' + | 'launch_checkpoint' + | 'heartbeat' + | 'bridge_result' + | 'recovery_result'; + +export type RuntimeRunTombstoneReason = + | 'stop_requested' + | 'relaunch_started' + | 'run_replaced' + | 'provider_session_aborted' + | 'recovery_rejected'; + +export interface RuntimeRunTombstone { + tombstoneId: string; + teamName: string; + runId: string; + reason: RuntimeRunTombstoneReason; + evidenceKinds: RuntimeEvidenceKind[]; + createdAt: string; + expiresAt: string | null; + diagnostic: string | null; +} + +export interface RuntimeEvidenceAcceptanceInput { + teamName: string; + runId: string | null; + currentRunId: string | null; + evidenceKind: RuntimeEvidenceKind; +} + +export class RuntimeStaleEvidenceError extends Error { + constructor( + message: string, + readonly reason: 'missing_run_id' | 'current_run_missing' | 'run_mismatch' | 'run_tombstoned', + readonly evidenceKind: RuntimeEvidenceKind, + readonly runId: string | null + ) { + super(message); + this.name = 'RuntimeStaleEvidenceError'; + } +} + +export class RuntimeRunTombstoneStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly options: { + idFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async add(input: { + teamName: string; + runId: string; + reason: RuntimeRunTombstoneReason; + evidenceKinds?: RuntimeEvidenceKind[]; + ttlMs?: number; + diagnostic?: string | null; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const now = clock(); + let created: RuntimeRunTombstone | null = null; + + await this.store.updateLocked((records) => { + const compacted = compactRuntimeRunTombstones(records, now); + const existing = compacted.find( + (record) => + record.teamName === input.teamName && + record.runId === input.runId && + record.reason === input.reason + ); + if (existing) { + created = existing; + return compacted; + } + + created = { + tombstoneId: this.options.idFactory?.() ?? `opencode-run-tombstone-${randomUUID()}`, + teamName: input.teamName, + runId: input.runId, + reason: input.reason, + evidenceKinds: normalizeEvidenceKinds(input.evidenceKinds), + createdAt: now.toISOString(), + expiresAt: + typeof input.ttlMs === 'number' + ? new Date(now.getTime() + input.ttlMs).toISOString() + : null, + diagnostic: input.diagnostic ?? null, + }; + return [...compacted, created]; + }); + + if (!created) { + throw new Error('Runtime run tombstone was not created'); + } + return created; + } + + async list(teamName: string): Promise { + const records = await this.readRequired(); + const now = (this.options.clock ?? (() => new Date()))(); + return compactRuntimeRunTombstones(records, now).filter( + (record) => record.teamName === teamName + ); + } + + async find(input: { + teamName: string; + runId: string; + evidenceKind?: RuntimeEvidenceKind; + }): Promise { + const records = await this.list(input.teamName); + return ( + records.find( + (record) => + record.runId === input.runId && + (!input.evidenceKind || record.evidenceKinds.includes(input.evidenceKind)) + ) ?? null + ); + } + + async assertEvidenceAccepted(input: RuntimeEvidenceAcceptanceInput): Promise { + assertRuntimeEvidenceRunMatches(input); + const tombstone = input.runId + ? await this.find({ + teamName: input.teamName, + runId: input.runId, + evidenceKind: input.evidenceKind, + }) + : null; + + if (tombstone) { + throw new RuntimeStaleEvidenceError( + `Rejected stale runtime evidence: ${input.evidenceKind}`, + 'run_tombstoned', + input.evidenceKind, + input.runId + ); + } + } + + async compact(): Promise { + const now = (this.options.clock ?? (() => new Date()))(); + let removed = 0; + await this.store.updateLocked((records) => { + const compacted = compactRuntimeRunTombstones(records, now); + removed = records.length - compacted.length; + return compacted; + }); + return removed; + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function assertRuntimeEvidenceRunMatches(input: RuntimeEvidenceAcceptanceInput): void { + if (!input.runId) { + throw new RuntimeStaleEvidenceError( + `Rejected runtime evidence without run id: ${input.evidenceKind}`, + 'missing_run_id', + input.evidenceKind, + input.runId + ); + } + + if (!input.currentRunId) { + throw new RuntimeStaleEvidenceError( + `Rejected runtime evidence without current run: ${input.evidenceKind}`, + 'current_run_missing', + input.evidenceKind, + input.runId + ); + } + + if (input.runId !== input.currentRunId) { + throw new RuntimeStaleEvidenceError( + `Rejected stale runtime evidence: ${input.evidenceKind}`, + 'run_mismatch', + input.evidenceKind, + input.runId + ); + } +} + +export function createRuntimeRunTombstoneStore(options: { + filePath: string; + idFactory?: () => string; + clock?: () => Date; +}): RuntimeRunTombstoneStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeRunTombstoneStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeRunTombstones, + clock, + }), + { + idFactory: options.idFactory, + clock, + } + ); +} + +export function validateRuntimeRunTombstones(value: unknown): RuntimeRunTombstone[] { + if (!Array.isArray(value)) { + throw new Error('Runtime run tombstones must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimeRunTombstone(record)) { + throw new Error(`Invalid runtime run tombstone at index ${index}`); + } + if (seen.has(record.tombstoneId)) { + throw new Error(`Duplicate runtime run tombstone id: ${record.tombstoneId}`); + } + seen.add(record.tombstoneId); + return record; + }); +} + +export function compactRuntimeRunTombstones( + records: RuntimeRunTombstone[], + now: Date +): RuntimeRunTombstone[] { + const nowMs = now.getTime(); + return records.filter( + (record) => record.expiresAt === null || Date.parse(record.expiresAt) > nowMs + ); +} + +function normalizeEvidenceKinds(input: RuntimeEvidenceKind[] | undefined): RuntimeEvidenceKind[] { + const all: RuntimeEvidenceKind[] = [ + 'sse_event', + 'permission_reply', + 'delivery_call', + 'prompt_error', + 'bootstrap_checkin', + 'launch_checkpoint', + 'heartbeat', + 'bridge_result', + 'recovery_result', + ]; + const source = input && input.length > 0 ? input : all; + return [...new Set(source)].sort(); +} + +function isRuntimeRunTombstone(value: unknown): value is RuntimeRunTombstone { + return ( + isRecord(value) && + isNonEmptyString(value.tombstoneId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + isRuntimeRunTombstoneReason(value.reason) && + Array.isArray(value.evidenceKinds) && + value.evidenceKinds.length > 0 && + value.evidenceKinds.every(isRuntimeEvidenceKind) && + isNonEmptyString(value.createdAt) && + (value.expiresAt === null || isNonEmptyString(value.expiresAt)) && + (value.diagnostic === null || typeof value.diagnostic === 'string') + ); +} + +function isRuntimeRunTombstoneReason(value: unknown): value is RuntimeRunTombstoneReason { + return ( + value === 'stop_requested' || + value === 'relaunch_started' || + value === 'run_replaced' || + value === 'provider_session_aborted' || + value === 'recovery_rejected' + ); +} + +function isRuntimeEvidenceKind(value: unknown): value is RuntimeEvidenceKind { + return ( + value === 'sse_event' || + value === 'permission_reply' || + value === 'delivery_call' || + value === 'prompt_error' || + value === 'bootstrap_checkin' || + value === 'launch_checkpoint' || + value === 'heartbeat' || + value === 'bridge_result' || + value === 'recovery_result' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts new file mode 100644 index 00000000..339c2ba9 --- /dev/null +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -0,0 +1,1144 @@ +import { createHash, randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1; +export const OPENCODE_RUNTIME_STORE_RECEIPT_SCHEMA_VERSION = 1; + +export type RuntimeStoreSchemaName = + | 'opencode.launchState' + | 'opencode.sessionStore' + | 'opencode.launchTransaction' + | 'opencode.deliveryJournal' + | 'opencode.permissionRequests' + | 'opencode.hostLeases' + | 'opencode.compatibilitySnapshot' + | 'opencode.runtimeRevision' + | 'opencode.runtimeDiagnostics' + | 'opencode.e2eEvidence'; + +export type RuntimeStoreCriticality = + | 'readiness_blocking' + | 'rebuildable_from_provider' + | 'rebuildable_from_canonical_destination' + | 'diagnostic_only'; + +export type RuntimeStoreOwner = + | 'launch' + | 'session' + | 'delivery' + | 'permission' + | 'host' + | 'compatibility' + | 'ui' + | 'diagnostics' + | 'e2e'; + +export type RuntimeStoreRebuildStrategy = + | 'none' + | 'poll_opencode_provider' + | 'verify_canonical_destinations' + | 'rerun_capability_discovery' + | 'rebuild_from_launch_state' + | 'drop_after_quarantine'; + +export interface RuntimeStoreDescriptor { + schemaName: RuntimeStoreSchemaName; + schemaVersion: number; + relativePath: string; + criticality: RuntimeStoreCriticality; + owner: RuntimeStoreOwner; + rebuildStrategy: RuntimeStoreRebuildStrategy; +} + +export type RuntimeStoreManifestEntryState = + | 'healthy' + | 'missing' + | 'quarantined' + | 'future_schema' + | 'hash_mismatch' + | 'stale_run' + | 'rebuild_required' + | 'uncommitted_write'; + +export interface RuntimeStoreManifestEntry { + schemaName: RuntimeStoreSchemaName; + schemaVersion: number; + relativePath: string; + contentHash: string | null; + fileSize: number | null; + mtimeMs: number | null; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + lastWriteReceiptId: string | null; + state: RuntimeStoreManifestEntryState; +} + +export interface RuntimeStoreManifest { + schemaVersion: typeof OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION; + teamName: string; + activeRunId: string | null; + activeCapabilitySnapshotId: string | null; + activeBehaviorFingerprint: string | null; + highWatermark: number; + lastCommittedBatchId: string | null; + lastPreparingBatchId: string | null; + entries: RuntimeStoreManifestEntry[]; + lastRecoveryPlanId: string | null; + updatedAt: string; +} + +export type RuntimeStoreWriteBatchReason = + | 'launch_checkpoint' + | 'permission_reconcile' + | 'delivery_commit' + | 'host_lease_update' + | 'compatibility_discovery' + | 'stop_tombstone' + | 'migration' + | 'recovery'; + +export interface RuntimeStoreWriteReceipt { + receiptId: string; + batchId: string; + schemaName: RuntimeStoreSchemaName; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + schemaVersion: number; + relativePath: string; + contentHash: string; + fileSize: number; + mtimeMs: number; + writtenAt: string; +} + +export interface RuntimeStoreWriteBatch { + batchId: string; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + reason: RuntimeStoreWriteBatchReason; + startedAt: string; + completedAt: string | null; + state: 'preparing' | 'committing' | 'committed' | 'failed'; + receipts: RuntimeStoreWriteReceipt[]; + lastError: string | null; +} + +export interface RuntimeStoreFileInspection { + descriptor: RuntimeStoreDescriptor; + state: RuntimeStoreManifestEntryState; + entry: RuntimeStoreManifestEntry | null; + manifestEntry: RuntimeStoreManifestEntry | null; + message: string | null; +} + +export type RuntimeStoreRecoveryAction = + | { kind: 'none'; schemaName: RuntimeStoreSchemaName } + | { kind: 'quarantine'; schemaName: RuntimeStoreSchemaName; reason: string } + | { kind: 'rebuild_from_provider'; schemaName: RuntimeStoreSchemaName; reason: string } + | { + kind: 'rebuild_from_canonical_destination'; + schemaName: RuntimeStoreSchemaName; + reason: string; + } + | { kind: 'rerun_capability_discovery'; schemaName: RuntimeStoreSchemaName; reason: string } + | { kind: 'block_readiness'; schemaName: RuntimeStoreSchemaName; reason: string }; + +export interface RuntimeStoreRecoveryPlan { + planId: string; + teamName: string; + runId: string | null; + createdAt: string; + manifestHealthy: boolean; + readinessImpact: 'none' | 'degraded' | 'blocked'; + actions: RuntimeStoreRecoveryAction[]; + diagnostics: string[]; +} + +export interface RuntimeStoreReadinessCheck { + ok: boolean; + reason: + | 'runtime_store_manifest_valid' + | 'runtime_store_recovery_required' + | 'runtime_store_rebuild_in_progress'; + diagnostics: string[]; +} + +export const OPENCODE_RUNTIME_STORE_DESCRIPTORS: RuntimeStoreDescriptor[] = [ + { + schemaName: 'opencode.launchState', + schemaVersion: 1, + relativePath: 'launch-state.json', + criticality: 'readiness_blocking', + owner: 'launch', + rebuildStrategy: 'none', + }, + { + schemaName: 'opencode.sessionStore', + schemaVersion: 1, + relativePath: 'opencode-sessions.json', + criticality: 'rebuildable_from_provider', + owner: 'session', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.launchTransaction', + schemaVersion: 1, + relativePath: 'opencode-launch-transaction.json', + criticality: 'readiness_blocking', + owner: 'launch', + rebuildStrategy: 'none', + }, + { + schemaName: 'opencode.deliveryJournal', + schemaVersion: 1, + relativePath: 'opencode-delivery-journal.json', + criticality: 'rebuildable_from_canonical_destination', + owner: 'delivery', + rebuildStrategy: 'verify_canonical_destinations', + }, + { + schemaName: 'opencode.permissionRequests', + schemaVersion: 1, + relativePath: 'opencode-permissions.json', + criticality: 'rebuildable_from_provider', + owner: 'permission', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.hostLeases', + schemaVersion: 1, + relativePath: 'opencode-host-leases.json', + criticality: 'rebuildable_from_provider', + owner: 'host', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.compatibilitySnapshot', + schemaVersion: 1, + relativePath: 'opencode-compatibility.json', + criticality: 'readiness_blocking', + owner: 'compatibility', + rebuildStrategy: 'rerun_capability_discovery', + }, + { + schemaName: 'opencode.runtimeRevision', + schemaVersion: 1, + relativePath: 'opencode-runtime-revision.json', + criticality: 'readiness_blocking', + owner: 'ui', + rebuildStrategy: 'rebuild_from_launch_state', + }, + { + schemaName: 'opencode.runtimeDiagnostics', + schemaVersion: 1, + relativePath: 'opencode-diagnostics.json', + criticality: 'diagnostic_only', + owner: 'diagnostics', + rebuildStrategy: 'drop_after_quarantine', + }, +]; + +export class RuntimeStoreManifestStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async read(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } + + async markBatchPreparing(batch: RuntimeStoreWriteBatch): Promise { + await this.store.updateLocked((manifest) => ({ + ...manifest, + lastPreparingBatchId: batch.batchId, + updatedAt: this.clock().toISOString(), + })); + } + + async applyCommittedBatch(batch: RuntimeStoreWriteBatch): Promise { + const result = await this.store.updateLocked((manifest) => { + const entries = new Map(manifest.entries.map((entry) => [entry.schemaName, entry])); + for (const receipt of batch.receipts) { + entries.set(receipt.schemaName, { + schemaName: receipt.schemaName, + schemaVersion: receipt.schemaVersion, + relativePath: receipt.relativePath, + contentHash: receipt.contentHash, + fileSize: receipt.fileSize, + mtimeMs: receipt.mtimeMs, + runId: receipt.runId, + capabilitySnapshotId: receipt.capabilitySnapshotId, + behaviorFingerprint: receipt.behaviorFingerprint, + lastWriteReceiptId: receipt.receiptId, + state: 'healthy', + }); + } + + return { + ...manifest, + activeRunId: batch.runId, + activeCapabilitySnapshotId: batch.capabilitySnapshotId, + activeBehaviorFingerprint: batch.behaviorFingerprint, + highWatermark: manifest.highWatermark + 1, + lastCommittedBatchId: batch.batchId, + lastPreparingBatchId: + manifest.lastPreparingBatchId === batch.batchId ? null : manifest.lastPreparingBatchId, + entries: [...entries.values()].sort((a, b) => a.schemaName.localeCompare(b.schemaName)), + updatedAt: this.clock().toISOString(), + }; + }); + return result.data; + } + + async markRecoveryPlan(plan: RuntimeStoreRecoveryPlan): Promise { + await this.store.updateLocked((manifest) => ({ + ...manifest, + lastRecoveryPlanId: plan.planId, + updatedAt: this.clock().toISOString(), + })); + } +} + +export class RuntimeStoreReceiptStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async begin(batch: RuntimeStoreWriteBatch): Promise { + await this.store.updateLocked((batches) => { + if (batches.some((item) => item.batchId === batch.batchId)) { + throw new Error(`Runtime store batch already exists: ${batch.batchId}`); + } + return [...batches, batch]; + }); + } + + async commit(batch: RuntimeStoreWriteBatch): Promise { + await this.replace(batch.batchId, { + ...batch, + state: 'committed', + completedAt: this.clock().toISOString(), + lastError: null, + }); + } + + async fail(batch: RuntimeStoreWriteBatch, error: unknown): Promise { + await this.replace(batch.batchId, { + ...batch, + state: 'failed', + completedAt: this.clock().toISOString(), + lastError: stringifyError(error), + }); + } + + async list(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } + + async listUncommitted(teamName: string): Promise { + const batches = await this.list(); + return batches.filter((batch) => batch.teamName === teamName && batch.state !== 'committed'); + } + + private async replace(batchId: string, batch: RuntimeStoreWriteBatch): Promise { + let found = false; + await this.store.updateLocked((batches) => + batches.map((item) => { + if (item.batchId !== batchId) { + return item; + } + found = true; + return batch; + }) + ); + if (!found) { + throw new Error(`Runtime store batch not found: ${batchId}`); + } + } +} + +export class RuntimeStoreBatchWriter { + constructor( + private readonly teamRuntimeDirectory: string, + private readonly manifestStore: RuntimeStoreManifestStore, + private readonly receiptStore: RuntimeStoreReceiptStore, + private readonly options: { + batchIdFactory?: () => string; + receiptIdFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async writeBatch(input: { + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + reason: RuntimeStoreWriteBatchReason; + writes: Array<{ + descriptor: RuntimeStoreDescriptor; + data: unknown; + }>; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const batch: RuntimeStoreWriteBatch = { + batchId: this.options.batchIdFactory?.() ?? `opencode-store-batch-${randomUUID()}`, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: input.behaviorFingerprint, + reason: input.reason, + startedAt: clock().toISOString(), + completedAt: null, + state: 'preparing', + receipts: [], + lastError: null, + }; + + await this.receiptStore.begin(batch); + await this.manifestStore.markBatchPreparing(batch); + + try { + const receipts: RuntimeStoreWriteReceipt[] = []; + for (const write of input.writes) { + const receipt = await this.writeStoreFile({ + batch, + descriptor: write.descriptor, + data: write.data, + now: clock().toISOString(), + }); + receipts.push(receipt); + } + + const committed: RuntimeStoreWriteBatch = { + ...batch, + state: 'committed', + completedAt: clock().toISOString(), + receipts, + }; + await this.receiptStore.commit(committed); + await this.manifestStore.applyCommittedBatch(committed); + return committed; + } catch (error) { + await this.receiptStore.fail(batch, error); + throw error; + } + } + + private async writeStoreFile(input: { + batch: RuntimeStoreWriteBatch; + descriptor: RuntimeStoreDescriptor; + data: unknown; + now: string; + }): Promise { + const filePath = path.join(this.teamRuntimeDirectory, input.descriptor.relativePath); + const raw = `${JSON.stringify( + { + schemaVersion: input.descriptor.schemaVersion, + updatedAt: input.now, + data: input.data, + }, + null, + 2 + )}\n`; + await atomicWriteAsync(filePath, raw); + const stat = await fs.stat(filePath); + return { + receiptId: this.options.receiptIdFactory?.() ?? `opencode-store-receipt-${randomUUID()}`, + batchId: input.batch.batchId, + schemaName: input.descriptor.schemaName, + teamName: input.batch.teamName, + runId: input.batch.runId, + capabilitySnapshotId: input.batch.capabilitySnapshotId, + behaviorFingerprint: input.batch.behaviorFingerprint, + schemaVersion: input.descriptor.schemaVersion, + relativePath: input.descriptor.relativePath, + contentHash: computeRuntimeStoreContentHash(raw), + fileSize: stat.size, + mtimeMs: stat.mtimeMs, + writtenAt: input.now, + }; + } +} + +export class RuntimeStoreFileInspector { + constructor(private readonly teamRuntimeDirectory: string) {} + + async inspect(input: { + descriptor: RuntimeStoreDescriptor; + manifest: RuntimeStoreManifest; + }): Promise { + const filePath = path.join(this.teamRuntimeDirectory, input.descriptor.relativePath); + const manifestEntry = + input.manifest.entries.find((entry) => entry.schemaName === input.descriptor.schemaName) ?? + null; + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + descriptor: input.descriptor, + state: 'missing', + entry: manifestEntry ? { ...manifestEntry, state: 'missing' } : null, + manifestEntry, + message: `Runtime store missing: ${input.descriptor.schemaName}`, + }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return this.failedInspection(input.descriptor, manifestEntry, 'quarantined', 'invalid_json'); + } + + if (!isRecord(parsed) || !Number.isInteger(parsed.schemaVersion)) { + return this.failedInspection( + input.descriptor, + manifestEntry, + 'quarantined', + 'invalid_envelope' + ); + } + + const schemaVersion = parsed.schemaVersion as number; + if (schemaVersion > input.descriptor.schemaVersion) { + return this.failedInspection( + input.descriptor, + manifestEntry, + 'future_schema', + 'future_schema' + ); + } + + const stat = await fs.stat(filePath); + const contentHash = computeRuntimeStoreContentHash(raw); + const actualEntry: RuntimeStoreManifestEntry = { + schemaName: input.descriptor.schemaName, + schemaVersion, + relativePath: input.descriptor.relativePath, + contentHash, + fileSize: stat.size, + mtimeMs: stat.mtimeMs, + runId: manifestEntry?.runId ?? null, + capabilitySnapshotId: manifestEntry?.capabilitySnapshotId ?? null, + behaviorFingerprint: manifestEntry?.behaviorFingerprint ?? null, + lastWriteReceiptId: manifestEntry?.lastWriteReceiptId ?? null, + state: 'healthy', + }; + + if (!manifestEntry) { + return { + descriptor: input.descriptor, + state: 'uncommitted_write', + entry: { ...actualEntry, state: 'uncommitted_write' }, + manifestEntry, + message: `Runtime store has no manifest entry: ${input.descriptor.schemaName}`, + }; + } + + if (manifestEntry.contentHash !== contentHash) { + return { + descriptor: input.descriptor, + state: 'hash_mismatch', + entry: { ...actualEntry, state: 'hash_mismatch' }, + manifestEntry, + message: `Runtime store hash mismatch: ${input.descriptor.schemaName}`, + }; + } + + return { + descriptor: input.descriptor, + state: 'healthy', + entry: actualEntry, + manifestEntry, + message: null, + }; + } + + private failedInspection( + descriptor: RuntimeStoreDescriptor, + manifestEntry: RuntimeStoreManifestEntry | null, + state: RuntimeStoreManifestEntryState, + message: string + ): RuntimeStoreFileInspection { + return { + descriptor, + state, + entry: manifestEntry ? { ...manifestEntry, state } : null, + manifestEntry, + message: `Runtime store ${descriptor.schemaName} failed inspection: ${message}`, + }; + } +} + +export class RuntimeStoreRecoveryPlanner { + constructor( + private readonly descriptors: RuntimeStoreDescriptor[], + private readonly manifestStore: RuntimeStoreManifestStore, + private readonly receiptStore: RuntimeStoreReceiptStore, + private readonly inspector: RuntimeStoreFileInspector, + private readonly options: { + planIdFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async buildPlan(input: { + teamName: string; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const manifest = await this.manifestStore.read(); + const diagnostics: string[] = []; + const actions: RuntimeStoreRecoveryAction[] = []; + const descriptorBySchemaName = new Map( + this.descriptors.map((descriptor) => [descriptor.schemaName, descriptor]) + ); + + if (manifest.teamName !== input.teamName) { + diagnostics.push(`Runtime store manifest team mismatch: ${manifest.teamName}`); + actions.push({ + kind: 'block_readiness', + schemaName: 'opencode.launchState', + reason: 'manifest_team_mismatch', + }); + } + + for (const descriptor of this.descriptors) { + const inspected = await this.inspector.inspect({ descriptor, manifest }); + if (inspected.message) { + diagnostics.push(inspected.message); + } + + if (inspected.state === 'healthy' && inspected.entry) { + const identityFailure = validateManifestEntryIdentity({ + descriptor, + entry: inspected.entry, + expectedRunId: input.expectedRunId, + expectedCapabilitySnapshotId: input.expectedCapabilitySnapshotId, + expectedBehaviorFingerprint: input.expectedBehaviorFingerprint, + }); + if (!identityFailure) { + actions.push({ kind: 'none', schemaName: descriptor.schemaName }); + continue; + } + diagnostics.push(identityFailure); + actions.push({ + kind: 'block_readiness', + schemaName: descriptor.schemaName, + reason: identityFailure, + }); + continue; + } + + actions.push(buildRecoveryAction({ descriptor, inspected })); + } + + for (const batch of await this.receiptStore.listUncommitted(input.teamName)) { + diagnostics.push(`Uncommitted runtime store batch detected: ${batch.batchId}`); + actions.push({ + kind: 'block_readiness', + schemaName: 'opencode.launchTransaction', + reason: `uncommitted_batch:${batch.batchId}`, + }); + } + + const readinessImpact = computeRecoveryReadinessImpact(actions, descriptorBySchemaName); + return { + planId: this.options.planIdFactory?.() ?? `opencode-recovery-plan-${randomUUID()}`, + teamName: input.teamName, + runId: input.expectedRunId, + createdAt: clock().toISOString(), + manifestHealthy: actions.every((action) => action.kind === 'none'), + readinessImpact, + actions, + diagnostics, + }; + } +} + +export function buildRecoveryAction(input: { + descriptor: RuntimeStoreDescriptor; + inspected: RuntimeStoreFileInspection; +}): RuntimeStoreRecoveryAction { + if (input.inspected.state === 'future_schema' || input.inspected.state === 'hash_mismatch') { + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + + if (input.inspected.state === 'quarantined') { + if (input.descriptor.rebuildStrategy === 'poll_opencode_provider') { + return { + kind: 'rebuild_from_provider', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + if (input.descriptor.rebuildStrategy === 'verify_canonical_destinations') { + return { + kind: 'rebuild_from_canonical_destination', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + + switch (input.descriptor.rebuildStrategy) { + case 'poll_opencode_provider': + return { + kind: 'rebuild_from_provider', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'verify_canonical_destinations': + return { + kind: 'rebuild_from_canonical_destination', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'rerun_capability_discovery': + return { + kind: 'rerun_capability_discovery', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'drop_after_quarantine': + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'rebuild_from_launch_state': + case 'none': + default: + return { + kind: 'block_readiness', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } +} + +export function buildRuntimeStoreReadiness(input: { + recoveryPlan: RuntimeStoreRecoveryPlan; + invariantFailures: string[]; +}): RuntimeStoreReadinessCheck { + const diagnostics = [...input.recoveryPlan.diagnostics, ...input.invariantFailures]; + if (input.recoveryPlan.readinessImpact === 'blocked' || input.invariantFailures.length > 0) { + return { + ok: false, + reason: 'runtime_store_recovery_required', + diagnostics, + }; + } + + if (input.recoveryPlan.readinessImpact === 'degraded') { + return { + ok: false, + reason: 'runtime_store_rebuild_in_progress', + diagnostics, + }; + } + + return { + ok: true, + reason: 'runtime_store_manifest_valid', + diagnostics: [], + }; +} + +export interface RuntimeStoreCrossStoreInvariantInput { + launchState: { + providerId?: string; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + aggregateState?: string; + members?: Array<{ name: string; launchState?: string }>; + }; + sessionStore: { + sessions?: Array<{ teamName: string; memberName: string; runId: string | null }>; + }; + transaction: { status?: string } | null; + deliveryJournal: { + records?: Array<{ idempotencyKey: string; runId: string | null; status: string }>; + }; + permissionStore: { + requests?: Array<{ appRequestId: string; runId: string | null; status: string }>; + }; + compatibilitySnapshot: { snapshotId: string | null }; + manifest: RuntimeStoreManifest; +} + +export function validateOpenCodeRuntimeStoreInvariants( + input: RuntimeStoreCrossStoreInvariantInput +): string[] { + const failures: string[] = []; + if (input.launchState.providerId !== 'opencode') { + return failures; + } + + if (input.launchState.runId !== input.manifest.activeRunId) { + failures.push('launch-state runId does not match runtime store manifest activeRunId'); + } + + if (input.launchState.capabilitySnapshotId !== input.compatibilitySnapshot.snapshotId) { + failures.push('launch-state capability snapshot does not match compatibility snapshot'); + } + + for (const member of input.launchState.members ?? []) { + const session = (input.sessionStore.sessions ?? []).find( + (item) => + item.teamName === input.launchState.teamName && + item.memberName === member.name && + item.runId === input.launchState.runId + ); + + if (!session && member.launchState === 'confirmed_alive') { + failures.push(`confirmed member ${member.name} has no matching OpenCode session record`); + } + } + + for (const record of input.deliveryJournal.records ?? []) { + if (record.runId !== input.launchState.runId && record.status !== 'committed') { + failures.push( + `non-committed delivery journal record belongs to stale run: ${record.idempotencyKey}` + ); + } + } + + for (const request of input.permissionStore.requests ?? []) { + if (request.runId !== input.launchState.runId && request.status === 'pending') { + failures.push(`pending permission belongs to stale run: ${request.appRequestId}`); + } + } + + if (input.transaction?.status === 'active' && input.launchState.aggregateState === 'ready') { + failures.push('active launch transaction conflicts with ready launch-state'); + } + + return failures; +} + +export function createRuntimeStoreManifestStore(options: { + filePath: string; + teamName: string; + clock?: () => Date; +}): RuntimeStoreManifestStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeStoreManifestStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()), + validate: validateRuntimeStoreManifest, + clock, + }), + clock + ); +} + +export function createRuntimeStoreReceiptStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimeStoreReceiptStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeStoreReceiptStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_STORE_RECEIPT_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeStoreWriteBatches, + clock, + }), + clock + ); +} + +export function createDefaultRuntimeStoreManifest( + teamName: string, + updatedAt: string +): RuntimeStoreManifest { + return { + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + teamName, + activeRunId: null, + activeCapabilitySnapshotId: null, + activeBehaviorFingerprint: null, + highWatermark: 0, + lastCommittedBatchId: null, + lastPreparingBatchId: null, + entries: [], + lastRecoveryPlanId: null, + updatedAt, + }; +} + +export function computeRuntimeStoreContentHash(raw: string): string { + return `sha256:${createHash('sha256').update(raw).digest('hex')}`; +} + +export function validateRuntimeStoreManifest(value: unknown): RuntimeStoreManifest { + if (!isRecord(value)) { + throw new Error('Runtime store manifest must be an object'); + } + if (value.schemaVersion !== OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION) { + throw new Error('Runtime store manifest has unsupported schemaVersion'); + } + if ( + !isNonEmptyString(value.teamName) || + !isNullableString(value.activeRunId) || + !isNullableString(value.activeCapabilitySnapshotId) || + !isNullableString(value.activeBehaviorFingerprint) || + !Number.isInteger(value.highWatermark) || + (value.highWatermark as number) < 0 || + !isNullableString(value.lastCommittedBatchId) || + !isNullableString(value.lastPreparingBatchId) || + !Array.isArray(value.entries) || + !isNullableString(value.lastRecoveryPlanId) || + !isNonEmptyString(value.updatedAt) + ) { + throw new Error('Runtime store manifest envelope is invalid'); + } + + const seen = new Set(); + const entries = value.entries.map((entry, index) => { + if (!isManifestEntry(entry)) { + throw new Error(`Invalid runtime store manifest entry at index ${index}`); + } + if (seen.has(entry.schemaName)) { + throw new Error(`Duplicate runtime store manifest entry: ${entry.schemaName}`); + } + seen.add(entry.schemaName); + return entry; + }); + + return { + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + teamName: value.teamName, + activeRunId: value.activeRunId, + activeCapabilitySnapshotId: value.activeCapabilitySnapshotId, + activeBehaviorFingerprint: value.activeBehaviorFingerprint, + highWatermark: value.highWatermark as number, + lastCommittedBatchId: value.lastCommittedBatchId, + lastPreparingBatchId: value.lastPreparingBatchId, + entries, + lastRecoveryPlanId: value.lastRecoveryPlanId, + updatedAt: value.updatedAt, + }; +} + +export function validateRuntimeStoreWriteBatches(value: unknown): RuntimeStoreWriteBatch[] { + if (!Array.isArray(value)) { + throw new Error('Runtime store write batches must be an array'); + } + const seen = new Set(); + return value.map((batch, index) => { + if (!isWriteBatch(batch)) { + throw new Error(`Invalid runtime store write batch at index ${index}`); + } + if (seen.has(batch.batchId)) { + throw new Error(`Duplicate runtime store write batch: ${batch.batchId}`); + } + seen.add(batch.batchId); + return batch; + }); +} + +function validateManifestEntryIdentity(input: { + descriptor: RuntimeStoreDescriptor; + entry: RuntimeStoreManifestEntry; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; +}): string | null { + if (input.descriptor.criticality === 'diagnostic_only') { + return null; + } + if (input.entry.runId !== input.expectedRunId) { + return `Runtime store ${input.descriptor.schemaName} has stale run id`; + } + if (input.entry.capabilitySnapshotId !== input.expectedCapabilitySnapshotId) { + return `Runtime store ${input.descriptor.schemaName} has stale capability snapshot`; + } + if (input.entry.behaviorFingerprint !== input.expectedBehaviorFingerprint) { + return `Runtime store ${input.descriptor.schemaName} has stale behavior fingerprint`; + } + return null; +} + +function computeRecoveryReadinessImpact( + actions: RuntimeStoreRecoveryAction[], + descriptors: Map +): RuntimeStoreRecoveryPlan['readinessImpact'] { + let degraded = false; + for (const action of actions) { + if (action.kind === 'none') { + continue; + } + const descriptor = descriptors.get(action.schemaName); + if (action.kind === 'block_readiness' || descriptor?.criticality === 'readiness_blocking') { + return 'blocked'; + } + if (descriptor?.criticality !== 'diagnostic_only') { + degraded = true; + } + } + return degraded ? 'degraded' : 'none'; +} + +function isManifestEntry(value: unknown): value is RuntimeStoreManifestEntry { + return ( + isRecord(value) && + isRuntimeStoreSchemaName(value.schemaName) && + Number.isInteger(value.schemaVersion) && + (value.schemaVersion as number) >= 1 && + isNonEmptyString(value.relativePath) && + isNullableString(value.contentHash) && + isNullableNumber(value.fileSize) && + isNullableNumber(value.mtimeMs) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + isNullableString(value.lastWriteReceiptId) && + isManifestEntryState(value.state) + ); +} + +function isWriteBatch(value: unknown): value is RuntimeStoreWriteBatch { + return ( + isRecord(value) && + isNonEmptyString(value.batchId) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + isWriteBatchReason(value.reason) && + isNonEmptyString(value.startedAt) && + isNullableString(value.completedAt) && + (value.state === 'preparing' || + value.state === 'committing' || + value.state === 'committed' || + value.state === 'failed') && + Array.isArray(value.receipts) && + value.receipts.every(isWriteReceipt) && + isNullableString(value.lastError) + ); +} + +function isWriteReceipt(value: unknown): value is RuntimeStoreWriteReceipt { + return ( + isRecord(value) && + isNonEmptyString(value.receiptId) && + isNonEmptyString(value.batchId) && + isRuntimeStoreSchemaName(value.schemaName) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + Number.isInteger(value.schemaVersion) && + (value.schemaVersion as number) >= 1 && + isNonEmptyString(value.relativePath) && + isNonEmptyString(value.contentHash) && + typeof value.fileSize === 'number' && + Number.isFinite(value.fileSize) && + typeof value.mtimeMs === 'number' && + Number.isFinite(value.mtimeMs) && + isNonEmptyString(value.writtenAt) + ); +} + +function isRuntimeStoreSchemaName(value: unknown): value is RuntimeStoreSchemaName { + return ( + value === 'opencode.launchState' || + value === 'opencode.sessionStore' || + value === 'opencode.launchTransaction' || + value === 'opencode.deliveryJournal' || + value === 'opencode.permissionRequests' || + value === 'opencode.hostLeases' || + value === 'opencode.compatibilitySnapshot' || + value === 'opencode.runtimeRevision' || + value === 'opencode.runtimeDiagnostics' || + value === 'opencode.e2eEvidence' + ); +} + +function isManifestEntryState(value: unknown): value is RuntimeStoreManifestEntryState { + return ( + value === 'healthy' || + value === 'missing' || + value === 'quarantined' || + value === 'future_schema' || + value === 'hash_mismatch' || + value === 'stale_run' || + value === 'rebuild_required' || + value === 'uncommitted_write' + ); +} + +function isWriteBatchReason(value: unknown): value is RuntimeStoreWriteBatchReason { + return ( + value === 'launch_checkpoint' || + value === 'permission_reconcile' || + value === 'delivery_commit' || + value === 'host_lease_update' || + value === 'compatibility_discovery' || + value === 'stop_tombstone' || + value === 'migration' || + value === 'recovery' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || (typeof value === 'number' && Number.isFinite(value)); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/store/VersionedJsonStore.ts b/src/main/services/team/opencode/store/VersionedJsonStore.ts new file mode 100644 index 00000000..425d1bf4 --- /dev/null +++ b/src/main/services/team/opencode/store/VersionedJsonStore.ts @@ -0,0 +1,292 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import { withFileLock } from '../../fileLock'; + +export interface VersionedJsonStoreEnvelope { + schemaVersion: number; + updatedAt: string; + data: TData; +} + +export type VersionedJsonStoreReadStatus = 'missing' | 'loaded'; + +export type VersionedJsonStoreFailureReason = + | 'invalid_json' + | 'invalid_envelope' + | 'invalid_data' + | 'future_schema'; + +export type VersionedJsonStoreReadResult = + | { + ok: true; + status: VersionedJsonStoreReadStatus; + data: TData; + envelope: VersionedJsonStoreEnvelope | null; + } + | { + ok: false; + reason: VersionedJsonStoreFailureReason; + message: string; + quarantinePath: string | null; + }; + +export interface VersionedJsonStoreUpdateResult { + changed: boolean; + data: TData; + envelope: VersionedJsonStoreEnvelope; +} + +export interface VersionedJsonStoreOptions { + filePath: string; + schemaVersion: number; + defaultData: () => TData; + validate: (value: unknown) => TData; + clock?: () => Date; + quarantineDir?: string; +} + +export class VersionedJsonStoreError extends Error { + constructor( + message: string, + readonly reason: VersionedJsonStoreFailureReason, + readonly quarantinePath: string | null + ) { + super(message); + this.name = 'VersionedJsonStoreError'; + } +} + +export class VersionedJsonStore { + private readonly filePath: string; + private readonly schemaVersion: number; + private readonly defaultData: () => TData; + private readonly validate: (value: unknown) => TData; + private readonly clock: () => Date; + private readonly quarantineDir: string | null; + + constructor(options: VersionedJsonStoreOptions) { + this.filePath = options.filePath; + this.schemaVersion = options.schemaVersion; + this.defaultData = options.defaultData; + this.validate = options.validate; + this.clock = options.clock ?? (() => new Date()); + this.quarantineDir = options.quarantineDir ?? null; + } + + async read(): Promise> { + return this.readUnlocked(); + } + + async updateLocked( + updater: (current: TData) => TData | Promise + ): Promise> { + return withFileLock(this.filePath, async () => { + const current = await this.readUnlocked(); + if (!current.ok) { + throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath); + } + + const nextData = await updater(cloneJson(current.data)); + const validatedNextData = this.validate(nextData); + const currentJson = stableJsonStringify(current.data); + const nextJson = stableJsonStringify(validatedNextData); + const changed = current.status === 'missing' || currentJson !== nextJson; + const envelope: VersionedJsonStoreEnvelope = { + schemaVersion: this.schemaVersion, + updatedAt: changed + ? this.clock().toISOString() + : (current.envelope?.updatedAt ?? this.clock().toISOString()), + data: changed ? validatedNextData : current.data, + }; + + if (changed) { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); + } + + return { + changed, + data: envelope.data, + envelope, + }; + }); + } + + private async readUnlocked(): Promise> { + let raw: string; + try { + raw = await fs.readFile(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + const data = this.validate(this.defaultData()); + return { + ok: true, + status: 'missing', + data, + envelope: null, + }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const quarantinePath = await this.quarantine(raw, 'invalid_json'); + return { + ok: false, + reason: 'invalid_json', + message: `Invalid JSON in versioned store ${this.filePath}: ${stringifyError(error)}`, + quarantinePath, + }; + } + + const envelopeResult = this.normalizeEnvelope(parsed); + if (!envelopeResult.ok) { + const quarantinePath = await this.quarantine(raw, envelopeResult.reason); + return { + ok: false, + reason: envelopeResult.reason, + message: envelopeResult.message, + quarantinePath, + }; + } + + if (envelopeResult.envelope.schemaVersion > this.schemaVersion) { + const quarantinePath = await this.quarantine(raw, 'future_schema'); + return { + ok: false, + reason: 'future_schema', + message: `Future schema ${envelopeResult.envelope.schemaVersion} in ${this.filePath}; supported ${this.schemaVersion}`, + quarantinePath, + }; + } + + try { + const data = this.validate(envelopeResult.envelope.data); + return { + ok: true, + status: 'loaded', + data, + envelope: { + schemaVersion: envelopeResult.envelope.schemaVersion, + updatedAt: envelopeResult.envelope.updatedAt, + data, + }, + }; + } catch (error) { + const quarantinePath = await this.quarantine(raw, 'invalid_data'); + return { + ok: false, + reason: 'invalid_data', + message: `Invalid data in versioned store ${this.filePath}: ${stringifyError(error)}`, + quarantinePath, + }; + } + } + + private normalizeEnvelope( + value: unknown + ): + | { ok: true; envelope: VersionedJsonStoreEnvelope } + | { ok: false; reason: VersionedJsonStoreFailureReason; message: string } { + if (!isRecord(value)) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} must contain a JSON object`, + }; + } + + const schemaVersion = value.schemaVersion; + if (!Number.isInteger(schemaVersion) || (schemaVersion as number) < 1) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} has invalid schemaVersion`, + }; + } + + if (typeof value.updatedAt !== 'string' || !value.updatedAt.trim()) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} has invalid updatedAt`, + }; + } + + if (!Object.prototype.hasOwnProperty.call(value, 'data')) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} is missing data`, + }; + } + + return { + ok: true, + envelope: { + schemaVersion: schemaVersion as number, + updatedAt: value.updatedAt, + data: value.data, + }, + }; + } + + private async quarantine( + raw: string, + reason: VersionedJsonStoreFailureReason + ): Promise { + const dir = this.quarantineDir ?? path.dirname(this.filePath); + const baseName = path.basename(this.filePath); + const stamp = this.clock().toISOString().replace(/[:.]/g, '-'); + const quarantinePath = path.join(dir, `${baseName}.${reason}.${stamp}.quarantine`); + + try { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(quarantinePath, raw, 'utf8'); + return quarantinePath; + } catch { + return null; + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function stableJsonStringify(value: unknown): string { + return JSON.stringify(normalizeStableJson(value)); +} + +function normalizeStableJson(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map(normalizeStableJson); + } + + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + const nested = (value as Record)[key]; + if (nested !== undefined) { + output[key] = normalizeStableJson(nested); + } + } + return output; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts new file mode 100644 index 00000000..2f7545a0 --- /dev/null +++ b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts @@ -0,0 +1,284 @@ +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; + +import type { + OpenCodeApiCapabilities, + OpenCodeApiEndpointKey, + OpenCodeEndpointEvidence, +} from '../capabilities/OpenCodeApiCapabilities'; +import { + assertOpenCodeProductionE2EEvidenceBasics, + type OpenCodeProductionE2EEvidence, +} from '../e2e/OpenCodeProductionE2EEvidence'; + +export interface OpenCodeSupportedVersionPolicy { + minimumVersion: string; + testedVersion: string; + allowedPrerelease: boolean; + requireCapabilities: boolean; + requireE2EArtifactsForTestedVersion: boolean; +} + +export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = { + minimumVersion: '1.14.19', + testedVersion: '1.14.19', + allowedPrerelease: false, + requireCapabilities: true, + requireE2EArtifactsForTestedVersion: true, +}; + +export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown'; + +export interface OpenCodeSemver { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +export type OpenCodeSupportLevel = + | 'unsupported_too_old' + | 'unsupported_prerelease' + | 'supported_capabilities_pending' + | 'supported_e2e_pending' + | 'production_supported'; + +export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence'; + +export interface OpenCodeCompatibilitySnapshot { + schemaVersion: 1; + createdAt: string; + binaryPath: string; + binaryFingerprint: string; + installMethod: OpenCodeInstallMethod; + version: string; + semver: OpenCodeSemver; + supported: boolean; + supportLevel: OpenCodeSupportLevel; + apiCapabilities: OpenCodeApiCapabilities; + testedEvidencePath: string | null; + diagnostics: string[]; +} + +export interface OpenCodeSupportDecision { + supported: boolean; + supportLevel: OpenCodeSupportLevel; + semver: OpenCodeSemver | null; + diagnostics: string[]; +} + +export interface OpenCodeRouteCompatibilityCache { + binaryFingerprint: string; + version: string; + routes: Record< + OpenCodeApiEndpointKey, + { + available: boolean; + evidence: OpenCodeEndpointEvidence; + lastVerifiedAt: string; + } + >; +} + +export type OpenCodePermissionReplyRoute = + | { + kind: 'primary_permission_reply'; + method: 'POST'; + pathTemplate: '/permission/:requestID/reply'; + bodyShape: { reply: 'once' }; + } + | { + kind: 'deprecated_session_permission'; + method: 'POST'; + pathTemplate: '/session/:sessionID/permissions/:permissionID'; + bodyShape: { response: 'once' }; + }; + +export async function buildOpenCodeBinaryFingerprint(binaryPath: string): Promise { + const stat = await fs.stat(binaryPath); + return stableHash({ + binaryPath, + realPath: await fs.realpath(binaryPath), + size: stat.size, + mtimeMs: stat.mtimeMs, + }); +} + +export function shouldReuseCompatibilitySnapshot(input: { + cached: OpenCodeCompatibilitySnapshot | null; + binaryPath: string; + binaryFingerprint: string; + version: string; +}): boolean { + return Boolean( + input.cached && + input.cached.binaryPath === input.binaryPath && + input.cached.binaryFingerprint === input.binaryFingerprint && + input.cached.version === input.version + ); +} + +export function evaluateOpenCodeSupport(input: { + version: string; + capabilities: OpenCodeApiCapabilities; + evidence: OpenCodeProductionE2EEvidence | null; + policy?: OpenCodeSupportedVersionPolicy; +}): OpenCodeSupportDecision { + const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; + const parsed = parseOpenCodeSemver(input.version); + if (!parsed || semverCoreLt(parsed, policy.minimumVersion)) { + return { + supported: false, + supportLevel: 'unsupported_too_old', + semver: parsed, + diagnostics: [ + `OpenCode ${input.version} is below supported minimum ${policy.minimumVersion}`, + ], + }; + } + + if (parsed.prerelease.length > 0 && !policy.allowedPrerelease) { + return { + supported: false, + supportLevel: 'unsupported_prerelease', + semver: parsed, + diagnostics: [ + `OpenCode prerelease ${input.version} is not enabled for production team launch`, + ], + }; + } + + if (policy.requireCapabilities && !input.capabilities.requiredForTeamLaunch.ready) { + return { + supported: false, + supportLevel: 'supported_capabilities_pending', + semver: parsed, + diagnostics: input.capabilities.requiredForTeamLaunch.missing, + }; + } + + if (policy.requireE2EArtifactsForTestedVersion) { + const evidenceDecision = assertOpenCodeProductionE2EGate({ + evidence: input.evidence, + testedVersion: policy.testedVersion, + }); + if (!evidenceDecision.ok) { + return { + supported: false, + supportLevel: 'supported_e2e_pending', + semver: parsed, + diagnostics: evidenceDecision.diagnostics, + }; + } + } + + return { + supported: true, + supportLevel: 'production_supported', + semver: parsed, + diagnostics: [], + }; +} + +export function assertOpenCodeProductionE2EGate(input: { + evidence: OpenCodeProductionE2EEvidence | null; + testedVersion: string; + now?: Date; +}): { ok: boolean; diagnostics: string[] } { + return assertOpenCodeProductionE2EEvidenceBasics(input); +} + +export function selectPermissionReplyRouteFromCache( + cache: OpenCodeRouteCompatibilityCache +): OpenCodePermissionReplyRoute | null { + if (cache.routes.permissionReply?.available) { + return { + kind: 'primary_permission_reply', + method: 'POST', + pathTemplate: '/permission/:requestID/reply', + bodyShape: { reply: 'once' }, + }; + } + + if (cache.routes.permissionLegacySessionRespond?.available) { + return { + kind: 'deprecated_session_permission', + method: 'POST', + pathTemplate: '/session/:sessionID/permissions/:permissionID', + bodyShape: { response: 'once' }, + }; + } + + return null; +} + +export function parseOpenCodeSemver(version: string): OpenCodeSemver | null { + const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4]?.split('.').filter(Boolean) ?? [], + }; +} + +export function semverLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean { + const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right; + if (!parsedRight) { + return true; + } + + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] < parsedRight[key]) { + return true; + } + if (left[key] > parsedRight[key]) { + return false; + } + } + + if (left.prerelease.length > 0 && parsedRight.prerelease.length === 0) { + return true; + } + + return false; +} + +function semverCoreLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean { + const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right; + if (!parsedRight) { + return true; + } + + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] < parsedRight[key]) { + return true; + } + if (left[key] > parsedRight[key]) { + return false; + } + } + + return false; +} + +function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts new file mode 100644 index 00000000..6bf9d983 --- /dev/null +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -0,0 +1,499 @@ +import { randomUUID } from 'crypto'; + +import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; +import type { + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData, + OpenCodeBridgeRuntimeSnapshot, + OpenCodeReconcileTeamCommandBody, + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData, + OpenCodeTeamLaunchMode, + OpenCodeTeamMemberLaunchBridgeState, +} from '../opencode/bridge/OpenCodeBridgeCommandContract'; +import type { + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareResult, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopResult, +} from './TeamRuntimeAdapter'; + +export interface OpenCodeTeamRuntimeBridgePort { + checkOpenCodeTeamLaunchReadiness(input: { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; + }): Promise; + getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null; + launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise; + reconcileOpenCodeTeam?( + input: OpenCodeReconcileTeamCommandBody + ): Promise; + stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise; +} + +export interface OpenCodeTeamRuntimeAdapterOptions { + launchMode?: OpenCodeTeamLaunchMode; + /** + * @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired. + */ + launchEnabled?: boolean; +} + +export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract'; + +const REQUIRED_READY_CHECKPOINTS = new Set([ + 'required_tools_proven', + 'delivery_ready', + 'member_ready', + 'run_ready', +]); + +export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { + readonly providerId = 'opencode' as const; + private readonly lastProjectPathByTeamName = new Map(); + + constructor( + private readonly bridge: OpenCodeTeamRuntimeBridgePort, + private readonly options: OpenCodeTeamRuntimeAdapterOptions = {} + ) {} + + async prepare(input: TeamRuntimeLaunchInput): Promise { + const launchMode = resolveOpenCodeTeamLaunchMode(this.options); + if (launchMode === 'disabled') { + return { + ok: false, + providerId: this.providerId, + reason: 'opencode_team_launch_disabled', + retryable: false, + diagnostics: [ + 'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.', + ], + warnings: [], + }; + } + + const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ + projectPath: input.cwd, + selectedModel: input.model ?? null, + requireExecutionProbe: true, + launchMode, + }); + + if (!readiness.launchAllowed) { + return { + ok: false, + providerId: this.providerId, + reason: readiness.state, + retryable: isRetryableReadinessState(readiness.state), + diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing), + warnings: [], + }; + } + + const warnings = + launchMode === 'dogfood' + ? [ + 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', + ] + : []; + + if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') { + return { + ok: false, + providerId: this.providerId, + reason: 'opencode_production_e2e_evidence_missing', + retryable: false, + diagnostics: [ + 'OpenCode production launch requires strict production E2E evidence before enabling team launch.', + ], + warnings, + }; + } + + return { + ok: true, + providerId: this.providerId, + modelId: readiness.modelId, + diagnostics: readiness.diagnostics, + warnings, + }; + } + + async launch(input: TeamRuntimeLaunchInput): Promise { + const prepared = await this.prepare(input); + if (!prepared.ok) { + return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); + } + + if (!this.bridge.launchOpenCodeTeam) { + return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [ + 'OpenCode readiness passed, but the state-changing launch bridge is not registered.', + ]); + } + + const selectedModel = prepared.modelId ?? input.model?.trim() ?? ''; + if (!selectedModel) { + return blockedLaunchResult(input, 'opencode_model_unavailable', [ + 'OpenCode launch requires a selected raw model id.', + ]); + } + + const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; + this.lastProjectPathByTeamName.set(input.teamName, input.cwd); + const data = await this.bridge.launchOpenCodeTeam({ + mode: resolveOpenCodeTeamLaunchMode(this.options), + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath: input.cwd, + selectedModel, + members: input.expectedMembers.map((member) => ({ + name: member.name, + role: member.role?.trim() || member.workflow?.trim() || 'teammate', + prompt: buildMemberBootstrapPrompt(input, member.name), + })), + leadPrompt: input.prompt?.trim() ?? '', + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + }); + + return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings); + } + + async reconcile(input: TeamRuntimeReconcileInput): Promise { + if (this.bridge.reconcileOpenCodeTeam) { + const projectPath = + input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); + const runtimeSnapshot = projectPath + ? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null) + : null; + const data = await this.bridge.reconcileOpenCodeTeam({ + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath, + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + reconcileAttemptId: `opencode-reconcile-${randomUUID()}`, + expectedMembers: input.expectedMembers.map((member) => ({ + name: member.name, + model: member.model ?? null, + })), + reason: input.reason, + }); + const mapped = mapOpenCodeLaunchDataToRuntimeResult( + { + runId: input.runId, + teamName: input.teamName, + cwd: input.expectedMembers[0]?.cwd ?? '', + providerId: this.providerId, + skipPermissions: false, + expectedMembers: input.expectedMembers, + previousLaunchState: input.previousLaunchState, + }, + data, + [] + ); + return { + ...mapped, + snapshot: input.previousLaunchState, + }; + } + + const snapshot = input.previousLaunchState; + if (!snapshot) { + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'reconciled', + teamLaunchState: 'partial_pending', + members: {}, + snapshot: null, + warnings: [], + diagnostics: ['No previous OpenCode launch snapshot was available for reconciliation.'], + }; + } + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: snapshot.launchPhase, + teamLaunchState: snapshot.teamLaunchState, + members: Object.fromEntries( + Object.entries(snapshot.members).map(([memberName, member]) => [ + memberName, + { + memberName, + providerId: this.providerId, + launchState: member.launchState, + agentToolAccepted: member.agentToolAccepted, + runtimeAlive: member.runtimeAlive, + bootstrapConfirmed: member.bootstrapConfirmed, + hardFailure: member.hardFailure, + hardFailureReason: member.hardFailureReason, + diagnostics: member.diagnostics ?? [], + } satisfies TeamRuntimeMemberLaunchEvidence, + ]) + ), + snapshot, + warnings: [], + diagnostics: [`OpenCode launch snapshot reconciled from ${input.reason}.`], + }; + } + + async stop(input: TeamRuntimeStopInput): Promise { + if (this.bridge.stopOpenCodeTeam) { + const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); + const runtimeSnapshot = projectPath + ? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null) + : null; + const data = await this.bridge.stopOpenCodeTeam({ + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath, + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + reason: input.reason, + force: input.force, + }); + if (data.stopped) { + this.lastProjectPathByTeamName.delete(input.teamName); + } + return { + runId: input.runId, + teamName: input.teamName, + stopped: data.stopped, + members: Object.fromEntries( + Object.entries(data.members).map(([memberName, member]) => [ + memberName, + { + memberName, + providerId: this.providerId, + stopped: member.stopped, + sessionId: member.sessionId, + diagnostics: member.diagnostics, + } satisfies TeamRuntimeMemberStopEvidence, + ]) + ), + warnings: data.warnings.map((warning) => warning.message), + diagnostics: data.diagnostics.map(formatOpenCodeBridgeDiagnostic), + }; + } + + const members = input.previousLaunchState + ? Object.fromEntries( + Object.keys(input.previousLaunchState.members).map((memberName) => [ + memberName, + { + memberName, + providerId: this.providerId, + stopped: true, + diagnostics: [ + 'No live OpenCode session stop command is wired in this adapter shell.', + ], + } satisfies TeamRuntimeMemberStopEvidence, + ]) + ) + : {}; + + return { + runId: input.runId, + teamName: input.teamName, + stopped: true, + members, + warnings: [], + diagnostics: input.previousLaunchState + ? ['OpenCode stop was acknowledged without live session ownership changes.'] + : ['No previous OpenCode launch snapshot was available to stop.'], + }; + } +} + +export function resolveOpenCodeTeamLaunchMode( + options: OpenCodeTeamRuntimeAdapterOptions = {} +): OpenCodeTeamLaunchMode { + if (options.launchMode) { + return options.launchMode; + } + if (options.launchEnabled === true) { + return 'production'; + } + return 'disabled'; +} + +function mapOpenCodeLaunchDataToRuntimeResult( + input: TeamRuntimeLaunchInput, + data: OpenCodeLaunchTeamCommandData, + prepareWarnings: string[] +): TeamRuntimeLaunchResult { + const checkpointNames = extractCheckpointNames(data); + const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) => + checkpointNames.has(name) + ); + const bridgeReady = data.teamLaunchState === 'ready'; + const success = bridgeReady && readyCheckpointsPresent; + const checkpointDiagnostic = success + ? [] + : bridgeReady + ? [ + `OpenCode bridge reported ready without all required durable checkpoints: missing ${[ + ...REQUIRED_READY_CHECKPOINTS, + ] + .filter((name) => !checkpointNames.has(name)) + .join(', ')}`, + ] + : []; + + const members = Object.fromEntries( + input.expectedMembers.map((member) => { + const bridgeMember = data.members[member.name]; + return [ + member.name, + mapBridgeMemberToRuntimeEvidence( + member.name, + bridgeMember?.launchState ?? 'failed', + bridgeMember?.sessionId, + [ + ...(bridgeMember?.evidence ?? []).map( + (evidence) => `${evidence.kind} at ${evidence.observedAt}` + ), + ...checkpointDiagnostic, + ] + ), + ]; + }) + ); + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: success + ? 'finished' + : data.teamLaunchState === 'launching' + ? 'active' + : 'finished', + teamLaunchState: success + ? 'clean_success' + : data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked' + ? 'partial_pending' + : 'partial_failure', + members, + warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)], + diagnostics: [...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), ...checkpointDiagnostic], + }; +} + +function mapBridgeMemberToRuntimeEvidence( + memberName: string, + launchState: OpenCodeTeamMemberLaunchBridgeState, + sessionId: string | undefined, + diagnostics: string[] +): TeamRuntimeMemberLaunchEvidence { + const confirmed = launchState === 'confirmed_alive'; + const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked'; + const failed = launchState === 'failed'; + return { + memberName, + providerId: 'opencode', + launchState: failed + ? 'failed_to_start' + : confirmed + ? 'confirmed_alive' + : 'runtime_pending_bootstrap', + agentToolAccepted: confirmed || createdOrBlocked, + runtimeAlive: confirmed || createdOrBlocked, + bootstrapConfirmed: confirmed, + hardFailure: failed, + hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, + sessionId, + diagnostics, + }; +} + +function extractCheckpointNames(data: OpenCodeLaunchTeamCommandData): Set { + const names = new Set(); + for (const checkpoint of data.durableCheckpoints ?? []) { + if (checkpoint.name.trim()) names.add(checkpoint.name); + } + for (const member of Object.values(data.members)) { + for (const evidence of member.evidence) { + if (evidence.kind.trim()) names.add(evidence.kind); + } + } + return names; +} + +function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: string): string { + const shared = input.prompt?.trim(); + if (shared) { + return shared; + } + return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`; +} + +function formatOpenCodeBridgeDiagnostic(diagnostic: { + code: string; + severity: 'info' | 'warning' | 'error'; + message: string; +}): string { + return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`; +} + +function blockedLaunchResult( + input: TeamRuntimeLaunchInput, + reason: string, + diagnostics: string[], + warnings: string[] = [] +): TeamRuntimeLaunchResult { + const members = Object.fromEntries( + input.expectedMembers.map((member) => [ + member.name, + { + memberName: member.name, + providerId: 'opencode' as const, + launchState: 'failed_to_start' as const, + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + diagnostics, + }, + ]) + ); + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members, + warnings, + diagnostics, + }; +} + +function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']): boolean { + return ( + state === 'not_installed' || + state === 'not_authenticated' || + state === 'e2e_missing' || + state === 'runtime_store_blocked' || + state === 'mcp_unavailable' || + state === 'model_unavailable' || + state === 'unknown_error' + ); +} + +function mergeDiagnostics(left: string[], right: string[]): string[] { + return [...new Set([...left, ...right].filter((value) => value.trim().length > 0))]; +} diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts new file mode 100644 index 00000000..e2a13b4c --- /dev/null +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -0,0 +1,184 @@ +import type { + EffortLevel, + MemberLaunchState, + PersistedTeamLaunchPhase, + PersistedTeamLaunchSnapshot, + TeamAgentRuntimeBackendType, + TeamLaunchAggregateState, +} from '@shared/types'; + +export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; + +export type TeamRuntimeProviderId = (typeof TEAM_RUNTIME_PROVIDER_IDS)[number]; + +export interface TeamRuntimeMemberSpec { + name: string; + role?: string; + workflow?: string; + providerId: TeamRuntimeProviderId; + model?: string; + effort?: EffortLevel; + cwd: string; +} + +export interface TeamRuntimeLaunchInput { + runId: string; + teamName: string; + cwd: string; + prompt?: string; + providerId: TeamRuntimeProviderId; + model?: string; + effort?: EffortLevel; + skipPermissions: boolean; + expectedMembers: TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; +} + +export interface TeamRuntimePrepareSuccess { + ok: true; + providerId: TeamRuntimeProviderId; + modelId: string | null; + diagnostics: string[]; + warnings: string[]; +} + +export interface TeamRuntimePrepareFailure { + ok: false; + providerId: TeamRuntimeProviderId; + reason: string; + diagnostics: string[]; + warnings: string[]; + retryable: boolean; +} + +export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure; + +export interface TeamRuntimeMemberLaunchEvidence { + memberName: string; + providerId: TeamRuntimeProviderId; + launchState: MemberLaunchState; + agentToolAccepted: boolean; + runtimeAlive: boolean; + bootstrapConfirmed: boolean; + hardFailure: boolean; + hardFailureReason?: string; + sessionId?: string; + backendType?: TeamAgentRuntimeBackendType; + diagnostics: string[]; +} + +export interface TeamRuntimeLaunchResult { + runId: string; + teamName: string; + leadSessionId?: string; + launchPhase: PersistedTeamLaunchPhase; + teamLaunchState: TeamLaunchAggregateState; + members: Record; + warnings: string[]; + diagnostics: string[]; +} + +export type TeamRuntimeReconcileReason = + | 'startup_recovery' + | 'manual_refresh' + | 'launch_progress' + | 'provider_event' + | 'watcher_event' + | 'stop'; + +export interface TeamRuntimeReconcileInput { + runId: string; + teamName: string; + providerId: TeamRuntimeProviderId; + expectedMembers: TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + reason: TeamRuntimeReconcileReason; +} + +export interface TeamRuntimeReconcileResult { + runId: string; + teamName: string; + launchPhase: PersistedTeamLaunchPhase; + teamLaunchState: TeamLaunchAggregateState; + members: Record; + snapshot: PersistedTeamLaunchSnapshot | null; + warnings: string[]; + diagnostics: string[]; +} + +export type TeamRuntimeStopReason = 'user_requested' | 'relaunch' | 'cleanup' | 'app_shutdown'; + +export interface TeamRuntimeStopInput { + runId: string; + teamName: string; + cwd?: string; + providerId: TeamRuntimeProviderId; + reason: TeamRuntimeStopReason; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + force?: boolean; +} + +export interface TeamRuntimeMemberStopEvidence { + memberName: string; + providerId: TeamRuntimeProviderId; + stopped: boolean; + sessionId?: string; + diagnostics: string[]; +} + +export interface TeamRuntimeStopResult { + runId: string; + teamName: string; + stopped: boolean; + members: Record; + warnings: string[]; + diagnostics: string[]; +} + +export interface TeamLaunchRuntimeAdapter { + readonly providerId: TeamRuntimeProviderId; + prepare(input: TeamRuntimeLaunchInput): Promise; + launch(input: TeamRuntimeLaunchInput): Promise; + reconcile(input: TeamRuntimeReconcileInput): Promise; + stop(input: TeamRuntimeStopInput): Promise; +} + +export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { + return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode'; +} + +export class TeamRuntimeAdapterRegistry { + private readonly adapters = new Map(); + + constructor(adapters: readonly TeamLaunchRuntimeAdapter[] = []) { + for (const adapter of adapters) { + this.register(adapter); + } + } + + register(adapter: TeamLaunchRuntimeAdapter): void { + if (!isTeamRuntimeProviderId(adapter.providerId)) { + throw new Error(`Invalid runtime adapter provider: ${String(adapter.providerId)}`); + } + if (this.adapters.has(adapter.providerId)) { + throw new Error(`Runtime adapter already registered: ${adapter.providerId}`); + } + this.adapters.set(adapter.providerId, adapter); + } + + get(providerId: TeamRuntimeProviderId): TeamLaunchRuntimeAdapter { + const adapter = this.adapters.get(providerId); + if (!adapter) { + throw new Error(`Runtime adapter is not available for provider ${providerId}`); + } + return adapter; + } + + has(providerId: TeamRuntimeProviderId): boolean { + return this.adapters.has(providerId); + } + + providers(): TeamRuntimeProviderId[] { + return Array.from(this.adapters.keys()); + } +} diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts new file mode 100644 index 00000000..fa3ed5fe --- /dev/null +++ b/src/main/services/team/runtime/index.ts @@ -0,0 +1,29 @@ +export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; +export type { + OpenCodeTeamLaunchMode, + OpenCodeTeamRuntimeAdapterOptions, + OpenCodeTeamRuntimeBridgePort, +} from './OpenCodeTeamRuntimeAdapter'; +export { + isTeamRuntimeProviderId, + TeamRuntimeAdapterRegistry, + TEAM_RUNTIME_PROVIDER_IDS, +} from './TeamRuntimeAdapter'; +export type { + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareFailure, + TeamRuntimePrepareResult, + TeamRuntimePrepareSuccess, + TeamRuntimeProviderId, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileReason, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopReason, + TeamRuntimeStopResult, +} from './TeamRuntimeAdapter'; diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 3e0a3cec..bb47fbe4 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -10,6 +10,7 @@ import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictP import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; +import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; @@ -1078,7 +1079,8 @@ export class BoardTaskLogStreamService { private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), - private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource() ) {} private async buildInferredExecutionSlices( @@ -1294,6 +1296,10 @@ export class BoardTaskLogStreamService { teamName: string, taskId: string ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptySummary(); + } + const layout = await this.buildStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { return emptySummary(); @@ -1305,9 +1311,15 @@ export class BoardTaskLogStreamService { } async getTaskLogStream(teamName: string, taskId: string): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptyResponse(); + } + const layout = await this.buildStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - return emptyResponse(); + return ( + (await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId)) ?? emptyResponse() + ); } const segments: BoardTaskLogSegment[] = []; @@ -1360,6 +1372,7 @@ export class BoardTaskLogStreamService { participants: layout.participants, defaultFilter, segments, + source: 'transcript', }; } } diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts new file mode 100644 index 00000000..e51c1fa8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts @@ -0,0 +1,293 @@ +import { + OpenCodeTaskLogAttributionStore, + OpenCodeTaskLogAttributionRecord, + OpenCodeTaskLogAttributionScope, + OpenCodeTaskLogAttributionSource, + OpenCodeTaskLogAttributionWriteResult, +} from './OpenCodeTaskLogAttributionStore'; + +export interface OpenCodeTaskLogAttributionWriter { + upsertTaskRecord( + teamName: string, + record: OpenCodeTaskLogAttributionRecord, + options?: { now?: Date } + ): Promise; + replaceTaskRecords( + teamName: string, + taskId: string, + records: OpenCodeTaskLogAttributionRecord[], + options?: { now?: Date } + ): Promise; + clearTaskRecords( + teamName: string, + taskId: string + ): Promise; +} + +export interface OpenCodeTaskLogAttributionRecordDraft { + memberName: string; + scope?: OpenCodeTaskLogAttributionScope; + sessionId?: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionTaskSessionInput { + teamName: string; + taskId: string; + memberName: string; + sessionId: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionMemberWindowInput { + teamName: string; + taskId: string; + memberName: string; + sessionId?: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionReplaceInput { + teamName: string; + taskId: string; + records: OpenCodeTaskLogAttributionRecordDraft[]; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionTaskInput { + teamName: string; + taskId: string; +} + +export interface OpenCodeTaskLogAttributionRecordWriteOutcome { + result: OpenCodeTaskLogAttributionWriteResult; + record: OpenCodeTaskLogAttributionRecord; +} + +export interface OpenCodeTaskLogAttributionBulkWriteOutcome { + result: OpenCodeTaskLogAttributionWriteResult; + recordCount: number; +} + +const VALID_SOURCES = new Set([ + 'manual', + 'launch_runtime', + 'reconcile', +]); +const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/; +const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const MAX_RUNTIME_ID_LENGTH = 256; + +function trimOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function requireString(field: string, value: unknown): string { + const trimmed = trimOptionalString(value); + if (!trimmed) { + throw new Error(`OpenCode task-log attribution ${field} is required`); + } + return trimmed; +} + +function requirePatternString(field: string, value: unknown, pattern: RegExp): string { + const trimmed = requireString(field, value); + if (!pattern.test(trimmed)) { + throw new Error(`OpenCode task-log attribution ${field} contains invalid characters`); + } + return trimmed; +} + +function trimRuntimeId(field: string, value: unknown): string | undefined { + const trimmed = trimOptionalString(value); + if (!trimmed) { + return undefined; + } + if (trimmed.length > MAX_RUNTIME_ID_LENGTH) { + throw new Error( + `OpenCode task-log attribution ${field} exceeds max length (${MAX_RUNTIME_ID_LENGTH})` + ); + } + return trimmed; +} + +function normalizeIso(field: string, value: string | Date | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + const timestamp = + value instanceof Date ? value.getTime() : Date.parse(requireString(field, value)); + if (!Number.isFinite(timestamp)) { + throw new Error(`OpenCode task-log attribution ${field} must be a valid timestamp`); + } + + return new Date(timestamp).toISOString(); +} + +function normalizeScope( + value: OpenCodeTaskLogAttributionScope | undefined +): OpenCodeTaskLogAttributionScope { + if (value === undefined) { + return 'member_session_window'; + } + if (value === 'task_session' || value === 'member_session_window') { + return value; + } + throw new Error('OpenCode task-log attribution scope is invalid'); +} + +function normalizeSource( + value: OpenCodeTaskLogAttributionSource | undefined, + fallback: OpenCodeTaskLogAttributionSource +): OpenCodeTaskLogAttributionSource { + const source = value ?? fallback; + if (!VALID_SOURCES.has(source)) { + throw new Error('OpenCode task-log attribution source is invalid'); + } + return source; +} + +function assertRecordPolicy(record: OpenCodeTaskLogAttributionRecord): void { + if (record.since && record.until && Date.parse(record.since) > Date.parse(record.until)) { + throw new Error('OpenCode task-log attribution since must be before or equal to until'); + } + + if (record.scope === 'task_session') { + if (!record.sessionId) { + throw new Error('OpenCode task-log attribution task_session requires sessionId'); + } + return; + } + + if (!record.since && !record.startMessageUuid) { + throw new Error( + 'OpenCode task-log attribution member_session_window requires since or startMessageUuid' + ); + } +} + +function buildRecord( + taskId: string, + draft: OpenCodeTaskLogAttributionRecordDraft, + fallbackSource: OpenCodeTaskLogAttributionSource +): OpenCodeTaskLogAttributionRecord { + const sessionId = trimRuntimeId('sessionId', draft.sessionId); + const since = normalizeIso('since', draft.since); + const until = normalizeIso('until', draft.until); + const startMessageUuid = trimRuntimeId('startMessageUuid', draft.startMessageUuid); + const endMessageUuid = trimRuntimeId('endMessageUuid', draft.endMessageUuid); + const record: OpenCodeTaskLogAttributionRecord = { + taskId: requirePatternString('taskId', taskId, TASK_ID_PATTERN), + memberName: requirePatternString('memberName', draft.memberName, MEMBER_NAME_PATTERN), + scope: normalizeScope(draft.scope), + ...(sessionId ? { sessionId } : {}), + ...(since ? { since } : {}), + ...(until ? { until } : {}), + ...(startMessageUuid ? { startMessageUuid } : {}), + ...(endMessageUuid ? { endMessageUuid } : {}), + source: normalizeSource(draft.source, fallbackSource), + }; + assertRecordPolicy(record); + return record; +} + +export class OpenCodeTaskLogAttributionService { + constructor( + private readonly writer: OpenCodeTaskLogAttributionWriter = new OpenCodeTaskLogAttributionStore(), + private readonly now: () => Date = () => new Date() + ) {} + + async recordTaskSession( + input: OpenCodeTaskLogAttributionTaskSessionInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const record = buildRecord( + requireString('taskId', input.taskId), + { + memberName: input.memberName, + scope: 'task_session', + sessionId: input.sessionId, + since: input.since, + until: input.until, + startMessageUuid: input.startMessageUuid, + endMessageUuid: input.endMessageUuid, + source: input.source, + }, + 'launch_runtime' + ); + + return { + result: await this.writer.upsertTaskRecord(teamName, record, { now: this.now() }), + record, + }; + } + + async recordMemberSessionWindow( + input: OpenCodeTaskLogAttributionMemberWindowInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const record = buildRecord( + requireString('taskId', input.taskId), + { + memberName: input.memberName, + scope: 'member_session_window', + sessionId: input.sessionId, + since: input.since, + until: input.until, + startMessageUuid: input.startMessageUuid, + endMessageUuid: input.endMessageUuid, + source: input.source, + }, + 'reconcile' + ); + + return { + result: await this.writer.upsertTaskRecord(teamName, record, { now: this.now() }), + record, + }; + } + + async replaceTaskAttribution( + input: OpenCodeTaskLogAttributionReplaceInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const taskId = requirePatternString('taskId', input.taskId, TASK_ID_PATTERN); + const fallbackSource = normalizeSource(input.source, 'reconcile'); + const records = input.records.map((record) => buildRecord(taskId, record, fallbackSource)); + + return { + result: await this.writer.replaceTaskRecords(teamName, taskId, records, { now: this.now() }), + recordCount: records.length, + }; + } + + async clearTaskAttribution( + input: OpenCodeTaskLogAttributionTaskInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const taskId = requirePatternString('taskId', input.taskId, TASK_ID_PATTERN); + + return { + result: await this.writer.clearTaskRecords(teamName, taskId), + recordCount: 0, + }; + } +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts new file mode 100644 index 00000000..0240999e --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts @@ -0,0 +1,481 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '../../atomicWrite'; +import { withFileLock } from '../../fileLock'; + +const logger = createLogger('OpenCodeTaskLogAttributionStore'); + +const MAX_ATTRIBUTION_FILE_BYTES = 512 * 1024; + +export type OpenCodeTaskLogAttributionScope = 'task_session' | 'member_session_window'; +export type OpenCodeTaskLogAttributionSource = 'manual' | 'launch_runtime' | 'reconcile'; + +export interface OpenCodeTaskLogAttributionRecord { + taskId: string; + memberName: string; + scope: OpenCodeTaskLogAttributionScope; + sessionId?: string; + since?: string; + until?: string; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; + createdAt?: string; + updatedAt?: string; +} + +interface RawAttributionRecord extends Record { + taskId?: unknown; +} + +interface OpenCodeTaskLogAttributionFile { + schemaVersion: 1; + tasks: Record; +} + +export type OpenCodeTaskLogAttributionWriteResult = 'created' | 'updated' | 'unchanged' | 'deleted'; + +export interface OpenCodeTaskLogAttributionReader { + readTaskRecords(teamName: string, taskId: string): Promise; +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeIso(value: unknown): string | undefined { + const trimmed = trimString(value); + if (!trimmed) { + return undefined; + } + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; +} + +function normalizeScope(value: unknown): OpenCodeTaskLogAttributionScope { + return value === 'task_session' ? 'task_session' : 'member_session_window'; +} + +function normalizeSource(value: unknown): OpenCodeTaskLogAttributionSource | undefined { + return value === 'manual' || value === 'launch_runtime' || value === 'reconcile' + ? value + : undefined; +} + +function normalizeRecord( + taskId: string, + raw: RawAttributionRecord +): OpenCodeTaskLogAttributionRecord | null { + const memberName = trimString(raw.memberName); + if (!memberName) { + return null; + } + + const since = normalizeIso(raw.since); + const until = normalizeIso(raw.until); + if (since && until && Date.parse(since) > Date.parse(until)) { + return null; + } + const sessionId = trimString(raw.sessionId); + const startMessageUuid = trimString(raw.startMessageUuid); + const endMessageUuid = trimString(raw.endMessageUuid); + const source = normalizeSource(raw.source); + const createdAt = normalizeIso(raw.createdAt); + const updatedAt = normalizeIso(raw.updatedAt); + + return { + taskId, + memberName, + scope: normalizeScope(raw.scope), + ...(sessionId ? { sessionId } : {}), + ...(since ? { since } : {}), + ...(until ? { until } : {}), + ...(startMessageUuid ? { startMessageUuid } : {}), + ...(endMessageUuid ? { endMessageUuid } : {}), + ...(source ? { source } : {}), + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +function extractRawRecords(parsed: unknown, taskId: string): RawAttributionRecord[] { + if (!parsed || typeof parsed !== 'object') { + return []; + } + + const file = parsed as Record; + if (file.schemaVersion !== 1) { + return []; + } + + const rawRecords: RawAttributionRecord[] = []; + if (file.tasks && typeof file.tasks === 'object' && !Array.isArray(file.tasks)) { + const taskRecords = (file.tasks as Record)[taskId]; + if (Array.isArray(taskRecords)) { + for (const record of taskRecords) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push(record as RawAttributionRecord); + } + } + } + } + + if (Array.isArray(file.records)) { + for (const record of file.records) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + continue; + } + const raw = record as RawAttributionRecord; + if (trimString(raw.taskId) === taskId) { + rawRecords.push(raw); + } + } + } + + return rawRecords; +} + +function extractAllRawRecords(parsed: unknown): RawAttributionRecord[] { + if (!parsed || typeof parsed !== 'object') { + return []; + } + + const file = parsed as Record; + if (file.schemaVersion !== 1) { + return []; + } + + const rawRecords: RawAttributionRecord[] = []; + if (file.tasks && typeof file.tasks === 'object' && !Array.isArray(file.tasks)) { + for (const [taskId, taskRecords] of Object.entries(file.tasks as Record)) { + if (!Array.isArray(taskRecords)) { + continue; + } + for (const record of taskRecords) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push({ + ...(record as RawAttributionRecord), + taskId, + }); + } + } + } + } + + if (Array.isArray(file.records)) { + for (const record of file.records) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push(record as RawAttributionRecord); + } + } + } + + return rawRecords; +} + +function dedupeRecords( + records: OpenCodeTaskLogAttributionRecord[] +): OpenCodeTaskLogAttributionRecord[] { + const deduped = new Map(); + for (const record of records) { + deduped.set( + [ + record.taskId, + record.memberName.trim().toLowerCase(), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.until ?? '', + record.startMessageUuid ?? '', + record.endMessageUuid ?? '', + ].join('\0'), + record + ); + } + return Array.from(deduped.values()).sort((left, right) => { + const leftStart = left.since ?? left.createdAt ?? ''; + const rightStart = right.since ?? right.createdAt ?? ''; + if (leftStart !== rightStart) { + return leftStart.localeCompare(rightStart); + } + return left.memberName.localeCompare(right.memberName); + }); +} + +function buildUpsertKey(record: OpenCodeTaskLogAttributionRecord): string { + return JSON.stringify([ + record.taskId, + record.memberName.trim().toLowerCase(), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.startMessageUuid ?? '', + ]); +} + +function canonicalizeFile( + records: OpenCodeTaskLogAttributionRecord[] +): OpenCodeTaskLogAttributionFile { + const byTask = new Map(); + for (const record of records) { + const existing = byTask.get(record.taskId) ?? []; + existing.push(record); + byTask.set(record.taskId, existing); + } + + const tasks: Record = {}; + for (const [taskId, taskRecords] of [...byTask.entries()].sort(([left], [right]) => + left.localeCompare(right) + )) { + const normalized = dedupeRecords(taskRecords); + if (normalized.length > 0) { + tasks[taskId] = normalized; + } + } + + return { + schemaVersion: 1, + tasks, + }; +} + +function normalizeRecordForWrite( + record: OpenCodeTaskLogAttributionRecord +): OpenCodeTaskLogAttributionRecord | null { + return normalizeRecord(record.taskId, record as unknown as RawAttributionRecord); +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function stripAuditFields( + record: OpenCodeTaskLogAttributionRecord +): Omit { + const { createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = record; + return rest; +} + +export function getOpenCodeTaskLogAttributionPath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, OPENCODE_TASK_LOG_ATTRIBUTION_FILE); +} + +export class OpenCodeTaskLogAttributionStore implements OpenCodeTaskLogAttributionReader { + private async readFileForWrite(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + throw new Error(`OpenCode task-log attribution path is not a file: ${filePath}`); + } + if (stat.size > MAX_ATTRIBUTION_FILE_BYTES) { + throw new Error(`OpenCode task-log attribution file is too large: ${filePath}`); + } + + const raw = await readFileUtf8WithTimeout(filePath, 5_000); + const parsed = JSON.parse(raw) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as { schemaVersion?: unknown }).schemaVersion !== 1 + ) { + throw new Error(`Unsupported OpenCode task-log attribution schema: ${filePath}`); + } + + return canonicalizeFile( + extractAllRawRecords(parsed) + .map((record) => { + const taskId = trimString(record.taskId); + return taskId ? normalizeRecord(taskId, record) : null; + }) + .filter((record): record is OpenCodeTaskLogAttributionRecord => record !== null) + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { schemaVersion: 1, tasks: {} }; + } + if (error instanceof SyntaxError) { + throw new Error(`Invalid OpenCode task-log attribution JSON: ${filePath}`); + } + throw error; + } + } + + private async writeFileIfChanged( + filePath: string, + previous: OpenCodeTaskLogAttributionFile, + next: OpenCodeTaskLogAttributionFile + ): Promise { + if (sameJson(previous, next)) { + return false; + } + + await atomicWriteAsync(filePath, `${JSON.stringify(next, null, 2)}\n`); + return true; + } + + async readTaskRecords( + teamName: string, + taskId: string + ): Promise { + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size > MAX_ATTRIBUTION_FILE_BYTES) { + return []; + } + + const raw = await readFileUtf8WithTimeout(filePath, 5_000); + const parsed = JSON.parse(raw) as unknown; + return dedupeRecords( + extractRawRecords(parsed, taskId) + .map((record) => normalizeRecord(taskId, record)) + .filter((record): record is OpenCodeTaskLogAttributionRecord => record !== null) + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + if (error instanceof SyntaxError) { + logger.warn(`[${teamName}/${taskId}] invalid OpenCode task-log attribution JSON`); + return []; + } + if (error instanceof FileReadTimeoutError) { + logger.warn(`[${teamName}/${taskId}] OpenCode task-log attribution read timed out`); + return []; + } + logger.warn( + `[${teamName}/${taskId}] failed to read OpenCode task-log attribution: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return []; + } + } + + async upsertTaskRecord( + teamName: string, + record: OpenCodeTaskLogAttributionRecord, + options?: { now?: Date } + ): Promise { + const normalized = normalizeRecordForWrite(record); + if (!normalized) { + throw new Error('Invalid OpenCode task-log attribution record'); + } + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + const now = (options?.now ?? new Date()).toISOString(); + const taskRecords = previous.tasks[normalized.taskId] ?? []; + const targetKey = buildUpsertKey(normalized); + const existingIndex = taskRecords.findIndex( + (candidate) => buildUpsertKey(candidate) === targetKey + ); + const existingRecord = existingIndex >= 0 ? taskRecords[existingIndex] : undefined; + if ( + existingRecord && + sameJson(stripAuditFields(existingRecord), stripAuditFields(normalized)) + ) { + return 'unchanged'; + } + + const nextRecord: OpenCodeTaskLogAttributionRecord = { + ...normalized, + createdAt: existingRecord?.createdAt ?? normalized.createdAt ?? now, + updatedAt: now, + }; + + const nextTaskRecords = + existingIndex >= 0 + ? taskRecords.map((candidate, index) => + index === existingIndex ? nextRecord : candidate + ) + : [...taskRecords, nextRecord]; + const next = canonicalizeFile([ + ...Object.entries(previous.tasks).flatMap(([taskId, records]) => + taskId === normalized.taskId ? [] : records + ), + ...nextTaskRecords, + ]); + const changed = await this.writeFileIfChanged(filePath, previous, next); + if (!changed) { + return 'unchanged'; + } + return existingIndex >= 0 ? 'updated' : 'created'; + }); + } + + async replaceTaskRecords( + teamName: string, + taskId: string, + records: OpenCodeTaskLogAttributionRecord[], + options?: { now?: Date } + ): Promise { + const normalizedTaskId = trimString(taskId); + if (!normalizedTaskId) { + throw new Error('Invalid OpenCode task-log attribution task id'); + } + + const now = (options?.now ?? new Date()).toISOString(); + const normalizedRecords = records.map((record) => + normalizeRecordForWrite({ + ...record, + taskId: normalizedTaskId, + createdAt: record.createdAt ?? now, + updatedAt: record.updatedAt ?? now, + }) + ); + if (normalizedRecords.some((record) => record === null)) { + throw new Error('Invalid OpenCode task-log attribution record'); + } + const validRecords = normalizedRecords as OpenCodeTaskLogAttributionRecord[]; + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + const next = canonicalizeFile([ + ...Object.entries(previous.tasks).flatMap(([candidateTaskId, taskRecords]) => + candidateTaskId === normalizedTaskId ? [] : taskRecords + ), + ...validRecords, + ]); + const changed = await this.writeFileIfChanged(filePath, previous, next); + return changed ? 'updated' : 'unchanged'; + }); + } + + async clearTaskRecords( + teamName: string, + taskId: string + ): Promise { + const normalizedTaskId = trimString(taskId); + if (!normalizedTaskId) { + throw new Error('Invalid OpenCode task-log attribution task id'); + } + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + if (!previous.tasks[normalizedTaskId]) { + return 'unchanged'; + } + + const next = canonicalizeFile( + Object.entries(previous.tasks).flatMap(([candidateTaskId, taskRecords]) => + candidateTaskId === normalizedTaskId ? [] : taskRecords + ) + ); + await this.writeFileIfChanged(filePath, previous, next); + return 'deleted'; + }); + } +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts new file mode 100644 index 00000000..b69a9109 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -0,0 +1,1122 @@ +import { createLogger } from '@shared/utils/logger'; + +import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; +import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; +import { TeamTaskReader } from '../../TeamTaskReader'; +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; + +import type { + OpenCodeRuntimeTranscriptLogContentBlock, + OpenCodeRuntimeTranscriptLogMessage, +} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { + OpenCodeTaskLogAttributionReader, + OpenCodeTaskLogAttributionRecord, +} from './OpenCodeTaskLogAttributionStore'; +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, + TeamTask, +} from '@shared/types'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; + +const logger = createLogger('OpenCodeTaskLogStreamSource'); + +const CACHE_TTL_MS = 1_500; +const HEURISTIC_TRANSCRIPT_LIMIT = 200; +const ATTRIBUTED_TRANSCRIPT_LIMIT = 500; +const WINDOW_GRACE_BEFORE_MS = 30_000; +const WINDOW_GRACE_AFTER_MS = 15_000; +const ATTRIBUTION_WINDOW_GRACE_MS = 1_000; +const TASK_MARKER_CONTEXT_BEFORE_MESSAGES = 1; +const TASK_MARKER_CONTEXT_MAX_MS = 5 * 60_000; + +const TASK_LOG_MARKER_TOOL_NAMES = new Set([ + 'task_start', + 'task_complete', + 'task_set_status', + 'task_set_owner', + 'task_add_comment', + 'task_attach_file', + 'task_attach_comment_file', + 'task_set_clarification', + 'review_start', + 'review_request', + 'review_approve', + 'review_request_changes', +]); + +const TERMINAL_TASK_MARKER_TOOL_NAMES = new Set([ + 'task_complete', + 'review_approve', + 'review_request_changes', +]); + +const TERMINAL_TASK_SET_STATUS_VALUES = new Set(['completed', 'pending', 'deleted']); + +const TASK_REFERENCE_KEYS = new Set([ + 'taskid', + 'task_id', + 'targetid', + 'targettaskid', + 'target_task_id', + 'canonicalid', + 'canonical_id', + 'displayid', + 'display_id', +]); + +const TEAM_REFERENCE_KEYS = new Set(['team', 'teamid', 'team_id', 'teamname', 'team_name']); + +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +interface TaskMarkerCall { + toolName: string; + input: unknown; +} + +interface TaskMarkerMatch { + index: number; + markerCalls: TaskMarkerCall[]; + windowIndex: number | null; +} + +interface BinaryResolverLike { + resolve(): Promise; +} + +interface MemberProjectedMessages { + memberName: string; + sessionId?: string; + messages: ParsedMessage[]; +} + +interface TaskMarkerProjection { + messages: ParsedMessage[]; + markerMatchCount: number; + markerSpanCount: number; +} + +type HeuristicFallbackReason = + | 'no_attribution_records' + | 'attribution_no_projected_messages' + | 'task_tool_markers'; + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function buildParticipantKey(memberName: string): string { + return `member:${normalizeMemberName(memberName)}`; +} + +function buildParticipant(memberName: string): BoardTaskLogParticipant { + return { + key: buildParticipantKey(memberName), + label: memberName, + role: 'member', + isLead: false, + isSidechain: true, + }; +} + +function buildActor(memberName: string, sessionId: string | undefined): BoardTaskLogActor { + return { + memberName, + role: 'member', + sessionId: sessionId?.trim() || `opencode:${normalizeMemberName(memberName)}`, + isSidechain: true, + }; +} + +function stableTaskWindowKey(task: TeamTask): string { + const intervals = (task.workIntervals ?? []) + .map((interval) => `${interval.startedAt}:${interval.completedAt ?? ''}`) + .join('|'); + return [task.id, task.owner ?? '', task.createdAt ?? '', task.updatedAt ?? '', intervals].join( + '::' + ); +} + +function stableAttributionKey(records: OpenCodeTaskLogAttributionRecord[]): string { + if (records.length === 0) { + return 'no-attribution'; + } + + return records + .map((record) => + JSON.stringify([ + normalizeMemberName(record.memberName), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.until ?? '', + record.startMessageUuid ?? '', + record.endMessageUuid ?? '', + ]) + ) + .sort() + .join('|'); +} + +function normalizeTaskRef(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') { + return null; + } + + const normalized = String(value).trim().replace(/^#/, '').toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function buildTaskRefSet(task: TeamTask): Set { + return new Set( + [task.id, task.displayId] + .map(normalizeTaskRef) + .filter((value): value is string => value !== null) + ); +} + +function valueReferencesTask(value: unknown, taskRefs: Set, depth = 0): boolean { + if (depth > 4 || value === null || value === undefined || taskRefs.size === 0) { + return false; + } + + const normalized = normalizeTaskRef(value); + if (normalized && taskRefs.has(normalized)) { + return true; + } + + if (Array.isArray(value)) { + return value.some((item) => valueReferencesTask(item, taskRefs, depth + 1)); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).some(([key, nestedValue]) => { + const normalizedKey = key.toLowerCase(); + if (TASK_REFERENCE_KEYS.has(normalizedKey)) { + return valueReferencesTask(nestedValue, taskRefs, depth + 1); + } + return depth < 2 && valueReferencesTask(nestedValue, taskRefs, depth + 1); + }); + } + + return false; +} + +function collectNormalizedRefs(value: unknown, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + const normalized = normalizeTaskRef(value); + if (normalized) { + refs.add(normalized); + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectNormalizedRefs(item, depth + 1)) { + refs.add(ref); + } + } + } else if (typeof value === 'object') { + for (const nestedValue of Object.values(value as Record)) { + for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) { + refs.add(ref); + } + } + } + + return refs; +} + +function collectExplicitRefsForKeys(value: unknown, keys: Set, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectExplicitRefsForKeys(item, keys, depth + 1)) { + refs.add(ref); + } + } + return refs; + } + + if (typeof value !== 'object') { + return refs; + } + + for (const [key, nestedValue] of Object.entries(value as Record)) { + if (keys.has(key.toLowerCase())) { + for (const ref of collectNormalizedRefs(nestedValue)) { + refs.add(ref); + } + continue; + } + + for (const ref of collectExplicitRefsForKeys(nestedValue, keys, depth + 1)) { + refs.add(ref); + } + } + + return refs; +} + +function refsIntersect(left: Set, right: Set): boolean { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +function markerInputReferencesTaskInTeam( + input: unknown, + teamName: string, + taskRefs: Set +): boolean { + const normalizedTeamName = normalizeTaskRef(teamName); + const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS); + if ( + normalizedTeamName && + explicitTeamRefs.size > 0 && + !explicitTeamRefs.has(normalizedTeamName) + ) { + return false; + } + + const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS); + if (explicitTaskRefs.size > 0) { + return refsIntersect(explicitTaskRefs, taskRefs); + } + + return valueReferencesTask(input, taskRefs); +} + +function collectTaskMarkerCalls( + message: ParsedMessage, + teamName: string, + taskRefs: Set +): TaskMarkerCall[] { + if (taskRefs.size === 0) { + return []; + } + + return message.toolCalls.flatMap((toolCall) => { + const toolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '').toLowerCase(); + return TASK_LOG_MARKER_TOOL_NAMES.has(toolName) && + markerInputReferencesTaskInTeam(toolCall.input, teamName, taskRefs) + ? [{ toolName, input: toolCall.input }] + : []; + }); +} + +function isTerminalTaskMarkerCall(markerCall: TaskMarkerCall): boolean { + if (TERMINAL_TASK_MARKER_TOOL_NAMES.has(markerCall.toolName)) { + return true; + } + + if ( + markerCall.toolName === 'task_set_status' && + markerCall.input && + typeof markerCall.input === 'object' && + !Array.isArray(markerCall.input) + ) { + const status = (markerCall.input as Record).status; + return ( + typeof status === 'string' && TERMINAL_TASK_SET_STATUS_VALUES.has(status.trim().toLowerCase()) + ); + } + + return false; +} + +function isTerminalTaskMarkerMatch(match: TaskMarkerMatch): boolean { + return match.markerCalls.some(isTerminalTaskMarkerCall); +} + +function sortParsedMessagesByTime(messages: ParsedMessage[]): ParsedMessage[] { + return messages + .map((message, index) => ({ message, index })) + .sort((left, right) => { + const timeDiff = left.message.timestamp.getTime() - right.message.timestamp.getTime(); + return timeDiff !== 0 ? timeDiff : left.index - right.index; + }) + .map(({ message }) => message); +} + +function isWithinSingleTimeWindow(timestamp: Date, window: TimeWindow): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + + const endMs = window.endMs ?? Date.now(); + return messageTime >= window.startMs && messageTime <= endMs; +} + +function findContainingWindowIndex(timestamp: Date, windows: TimeWindow[]): number | null { + if (windows.length === 0) { + return null; + } + + const index = windows.findIndex((window) => isWithinSingleTimeWindow(timestamp, window)); + return index >= 0 ? index : null; +} + +function groupMarkerMatchesByWindow(matches: TaskMarkerMatch[]): TaskMarkerMatch[][] { + const groups = new Map(); + for (const match of matches) { + if (match.windowIndex === null) { + continue; + } + const existing = groups.get(match.windowIndex) ?? []; + existing.push(match); + groups.set(match.windowIndex, existing); + } + + return [...groups.entries()].sort(([left], [right]) => left - right).map(([, group]) => group); +} + +function groupMarkerMatchesByLifecycle(matches: TaskMarkerMatch[]): TaskMarkerMatch[][] { + const groups: TaskMarkerMatch[][] = []; + let currentGroup: TaskMarkerMatch[] = []; + + for (const match of matches) { + currentGroup.push(match); + if (isTerminalTaskMarkerMatch(match)) { + groups.push(currentGroup); + currentGroup = []; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +function groupMarkerMatches( + matches: TaskMarkerMatch[], + windows: TimeWindow[] +): TaskMarkerMatch[][] { + return windows.length > 0 + ? groupMarkerMatchesByWindow(matches) + : groupMarkerMatchesByLifecycle(matches); +} + +function shouldIncludeMarkerContext( + previousMessage: ParsedMessage | undefined, + markerMessage: ParsedMessage +): boolean { + if (!previousMessage || previousMessage.isMeta) { + return false; + } + + if (markerMessage.parentUuid && previousMessage.uuid === markerMessage.parentUuid) { + return true; + } + + const diffMs = markerMessage.timestamp.getTime() - previousMessage.timestamp.getTime(); + return ( + previousMessage.type === 'user' && + Number.isFinite(diffMs) && + diffMs >= 0 && + diffMs <= TASK_MARKER_CONTEXT_MAX_MS + ); +} + +function resolveMarkerSpanStart(messages: ParsedMessage[], markerIndex: number): number { + const contextIndex = markerIndex - TASK_MARKER_CONTEXT_BEFORE_MESSAGES; + if ( + contextIndex >= 0 && + shouldIncludeMarkerContext(messages[contextIndex], messages[markerIndex]) + ) { + return contextIndex; + } + return markerIndex; +} + +function findLastMessageIndexInWindow( + messages: ParsedMessage[], + startIndex: number, + window: TimeWindow +): number { + let endIndex = startIndex; + for (let index = startIndex + 1; index < messages.length; index += 1) { + if (!isWithinSingleTimeWindow(messages[index].timestamp, window)) { + break; + } + endIndex = index; + } + return endIndex; +} + +function extendSpanEndForToolResults( + messages: ParsedMessage[], + startIndex: number, + endIndex: number +): number { + const includedAssistantUuids = new Set(); + for (let index = startIndex; index <= endIndex; index += 1) { + const message = messages[index]; + if (message?.type === 'assistant') { + includedAssistantUuids.add(message.uuid); + } + } + + let extendedEndIndex = endIndex; + while (extendedEndIndex + 1 < messages.length) { + const nextMessage = messages[extendedEndIndex + 1]; + if ( + !nextMessage?.isMeta || + !nextMessage.sourceToolAssistantUUID || + !includedAssistantUuids.has(nextMessage.sourceToolAssistantUUID) + ) { + break; + } + extendedEndIndex += 1; + } + + return extendedEndIndex; +} + +function buildMarkerSpan( + messages: ParsedMessage[], + markerGroup: TaskMarkerMatch[], + windows: TimeWindow[] +): { startIndex: number; endIndex: number } | null { + const firstMarker = markerGroup[0]; + const lastMarker = markerGroup[markerGroup.length - 1]; + if (!firstMarker || !lastMarker) { + return null; + } + + const startIndex = resolveMarkerSpanStart(messages, firstMarker.index); + let endIndex = lastMarker.index; + const window = + lastMarker.windowIndex === null ? undefined : (windows[lastMarker.windowIndex] ?? undefined); + + if (!isTerminalTaskMarkerMatch(lastMarker) && window) { + endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window); + } + + return { + startIndex, + endIndex: extendSpanEndForToolResults(messages, startIndex, endIndex), + }; +} + +function buildTaskMarkerProjection( + projectedMessages: OpenCodeRuntimeTranscriptLogMessage[], + teamName: string, + task: TeamTask +): TaskMarkerProjection | null { + const parsedMessages = sortParsedMessagesByTime( + projectedMessages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null) + ); + const taskRefs = buildTaskRefSet(task); + const taskWindows = buildTaskTimeWindows(task); + + const markerMatches = parsedMessages.flatMap((message, index) => { + const markerCalls = collectTaskMarkerCalls(message, teamName, taskRefs); + const windowIndex = findContainingWindowIndex(message.timestamp, taskWindows); + return markerCalls.length > 0 && (taskWindows.length === 0 || windowIndex !== null) + ? [{ index, markerCalls, windowIndex }] + : []; + }); + if (markerMatches.length === 0) { + return null; + } + + const spans = groupMarkerMatches(markerMatches, taskWindows) + .map((group) => buildMarkerSpan(parsedMessages, group, taskWindows)) + .filter((span): span is { startIndex: number; endIndex: number } => span !== null); + const includedIndexes = new Set(); + for (const span of spans) { + for (let index = span.startIndex; index <= span.endIndex; index += 1) { + includedIndexes.add(index); + } + } + + const messages = [...includedIndexes] + .sort((left, right) => left - right) + .map((index) => parsedMessages[index]) + .filter((message): message is ParsedMessage => message !== undefined); + const markerMatchCount = markerMatches.reduce( + (count, match) => count + match.markerCalls.length, + 0 + ); + + return messages.length > 0 + ? { + messages, + markerMatchCount, + markerSpanCount: spans.length, + } + : null; +} + +function buildTaskTimeWindows(task: TeamTask): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + return []; +} + +function buildAttributionTimeWindows(record: OpenCodeTaskLogAttributionRecord): TimeWindow[] { + const sinceMs = record.since ? Date.parse(record.since) : Number.NaN; + const untilMs = record.until ? Date.parse(record.until) : Number.NaN; + if (!Number.isFinite(sinceMs) && !Number.isFinite(untilMs)) { + return []; + } + + return [ + { + startMs: Number.isFinite(sinceMs) + ? sinceMs - ATTRIBUTION_WINDOW_GRACE_MS + : Number.NEGATIVE_INFINITY, + endMs: Number.isFinite(untilMs) ? untilMs + ATTRIBUTION_WINDOW_GRACE_MS : null, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function filterByMessageUuidRange( + messages: ParsedMessage[], + record: OpenCodeTaskLogAttributionRecord +): ParsedMessage[] { + const startIndex = record.startMessageUuid + ? messages.findIndex((message) => message.uuid === record.startMessageUuid) + : 0; + if (startIndex < 0) { + return []; + } + + const endIndex = record.endMessageUuid + ? messages.findIndex((message) => message.uuid === record.endMessageUuid) + : messages.length - 1; + if (endIndex < 0 || endIndex < startIndex) { + return []; + } + + return messages.slice(startIndex, endIndex + 1); +} + +function filterMessagesForAttribution( + messages: OpenCodeRuntimeTranscriptLogMessage[], + record: OpenCodeTaskLogAttributionRecord +): ParsedMessage[] { + const parsedMessages = messages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null); + + const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid); + const hasTimeBounds = Boolean(record.since || record.until); + const canUseTaskSessionScope = record.scope === 'task_session' && Boolean(record.sessionId); + if (!hasMessageBounds && !hasTimeBounds && !canUseTaskSessionScope) { + return []; + } + + const rangeFiltered = filterByMessageUuidRange(parsedMessages, record); + const windows = buildAttributionTimeWindows(record); + return rangeFiltered + .filter((message) => isWithinTimeWindows(message.timestamp, windows)) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); +} + +function mapOpenCodeContentBlock( + block: OpenCodeRuntimeTranscriptLogContentBlock +): ContentBlock | null { + switch (block.type) { + case 'text': + return { type: 'text', text: block.text }; + case 'thinking': + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + }; + case 'tool_use': + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }; + case 'tool_result': + return { + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: Array.isArray(block.content) + ? block.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null) + : block.content, + ...(block.is_error ? { is_error: true } : {}), + }; + default: + return null; + } +} + +function buildToolUseResultData( + message: OpenCodeRuntimeTranscriptLogMessage +): ToolUseResultData | undefined { + if (!message.sourceToolUseID || message.toolResults.length !== 1) { + return undefined; + } + + const toolResult = message.toolResults[0]; + if (!toolResult) { + return undefined; + } + + return { + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + }; +} + +function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null { + const timestamp = new Date(message.timestamp); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const normalizedContent: ContentBlock[] | string = + typeof message.content === 'string' + ? message.content + : message.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null); + + const toolCalls = message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + isTask: toolCall.isTask, + ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), + ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), + })); + + const toolResults = message.toolResults.map((toolResult) => ({ + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + })); + const toolUseResult = buildToolUseResultData(message); + + return { + uuid: message.uuid, + parentUuid: message.parentUuid, + type: message.type, + timestamp, + role: message.role, + content: normalizedContent, + model: message.model, + agentName: message.agentName, + isSidechain: true, + isMeta: message.isMeta, + sessionId: message.sessionId, + toolCalls, + toolResults, + ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + ...(message.subtype ? { subtype: message.subtype } : {}), + ...(message.level ? { level: message.level } : {}), + }; +} + +export class OpenCodeTaskLogStreamSource { + private readonly cache = new Map< + string, + { + expiresAt: number; + response: BoardTaskLogStreamResponse | null; + } + >(); + + private readonly inFlight = new Map>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService = new ClaudeMultimodelBridgeService(), + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver, + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly attributionStore: OpenCodeTaskLogAttributionReader = new OpenCodeTaskLogAttributionStore() + ) {} + + private async resolveTask(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + ]); + return [...activeTasks, ...deletedTasks].find((task) => task.id === taskId) ?? null; + } + + async getTaskLogStream( + teamName: string, + taskId: string + ): Promise { + const task = await this.resolveTask(teamName, taskId); + if (!task) { + return null; + } + + const attributionRecords = await this.attributionStore.readTaskRecords(teamName, taskId); + if (!task.owner?.trim() && attributionRecords.length === 0) { + return null; + } + + const cacheKey = `${teamName}::${stableTaskWindowKey(task)}::${stableAttributionKey(attributionRecords)}`; + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.response; + } + + const existingPromise = this.inFlight.get(cacheKey); + if (existingPromise) { + return await existingPromise; + } + + const promise = this.buildTaskLogStream(teamName, task, attributionRecords) + .catch((error) => { + logger.warn( + `[${teamName}/${task.id}] OpenCode task-log fallback failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + }) + .then((response) => { + this.cache.set(cacheKey, { + expiresAt: Date.now() + CACHE_TTL_MS, + response, + }); + return response; + }) + .finally(() => { + this.inFlight.delete(cacheKey); + }); + + this.inFlight.set(cacheKey, promise); + return await promise; + } + + private async buildTaskLogStream( + teamName: string, + task: TeamTask, + attributionRecords: OpenCodeTaskLogAttributionRecord[] + ): Promise { + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return null; + } + + let fallbackReason: HeuristicFallbackReason = 'no_attribution_records'; + if (attributionRecords.length > 0) { + const attributedResponse = await this.buildAttributedTaskLogStream( + binaryPath, + teamName, + task, + attributionRecords + ); + if (attributedResponse) { + return attributedResponse; + } + fallbackReason = 'attribution_no_projected_messages'; + } + + return await this.buildHeuristicTaskLogStream(binaryPath, teamName, task, { + attributionRecordCount: attributionRecords.length, + fallbackReason, + }); + } + + private async buildHeuristicTaskLogStream( + binaryPath: string, + teamName: string, + task: TeamTask, + projectionContext: { + attributionRecordCount: number; + fallbackReason: HeuristicFallbackReason; + } + ): Promise { + const ownerName = task.owner?.trim(); + if (!ownerName) { + return null; + } + + const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: teamName, + memberName: ownerName, + limit: HEURISTIC_TRANSCRIPT_LIMIT, + }); + + const projectedMessages = transcript?.logProjection?.messages ?? []; + if (projectedMessages.length === 0) { + return null; + } + + const markerProjection = buildTaskMarkerProjection(projectedMessages, teamName, task); + const timeWindows = markerProjection ? [] : buildTaskTimeWindows(task); + const projectionReason: HeuristicFallbackReason = markerProjection + ? 'task_tool_markers' + : projectionContext.fallbackReason; + const filteredMessages = + markerProjection?.messages ?? + projectedMessages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null) + .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + + if (filteredMessages.length === 0) { + return null; + } + + const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages); + if (chunks.length === 0) { + return null; + } + + const firstMessage = filteredMessages[0]; + const lastMessage = filteredMessages[filteredMessages.length - 1]; + if (!firstMessage || !lastMessage) { + return null; + } + + const actor = buildActor(ownerName, transcript?.sessionId ?? firstMessage.sessionId); + const participant = buildParticipant(ownerName); + const segment: BoardTaskLogSegment = { + id: `opencode:${teamName}:${task.id}:${normalizeMemberName(ownerName)}`, + participantKey: participant.key, + actor, + startTimestamp: firstMessage.timestamp.toISOString(), + endTimestamp: lastMessage.timestamp.toISOString(), + chunks, + }; + + logger.debug( + `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName})` + ); + + return { + participants: [participant], + defaultFilter: participant.key, + segments: [segment], + source: 'opencode_runtime_fallback', + runtimeProjection: { + provider: 'opencode', + mode: 'heuristic', + attributionRecordCount: projectionContext.attributionRecordCount, + projectedMessageCount: filteredMessages.length, + fallbackReason: projectionReason, + ...(markerProjection + ? { + markerMatchCount: markerProjection.markerMatchCount, + markerSpanCount: markerProjection.markerSpanCount, + } + : {}), + }, + }; + } + + private async buildAttributedTaskLogStream( + binaryPath: string, + teamName: string, + task: TeamTask, + attributionRecords: OpenCodeTaskLogAttributionRecord[] + ): Promise { + const projectedByParticipant = new Map(); + const transcriptCache = new Map< + string, + Awaited> + >(); + + for (const record of attributionRecords) { + const memberName = record.memberName.trim(); + if (!memberName) { + continue; + } + + const memberKey = normalizeMemberName(memberName); + if (!transcriptCache.has(memberKey)) { + transcriptCache.set( + memberKey, + await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: teamName, + memberName, + limit: ATTRIBUTED_TRANSCRIPT_LIMIT, + }) + ); + } + + const transcript = transcriptCache.get(memberKey); + if (!transcript) { + continue; + } + if (record.sessionId && transcript.sessionId !== record.sessionId) { + continue; + } + + const filteredMessages = filterMessagesForAttribution( + transcript.logProjection?.messages ?? [], + record + ); + if (filteredMessages.length === 0) { + continue; + } + + const participantKey = buildParticipantKey(memberName); + const existing = projectedByParticipant.get(participantKey); + if (existing) { + const seen = new Set(existing.messages.map((message) => message.uuid)); + for (const message of filteredMessages) { + if (!seen.has(message.uuid)) { + existing.messages.push(message); + seen.add(message.uuid); + } + } + existing.messages.sort( + (left, right) => left.timestamp.getTime() - right.timestamp.getTime() + ); + } else { + projectedByParticipant.set(participantKey, { + memberName, + sessionId: transcript.sessionId ?? record.sessionId, + messages: filteredMessages, + }); + } + } + + const members = Array.from(projectedByParticipant.values()).filter( + (item) => item.messages.length > 0 + ); + if (members.length === 0) { + logger.debug( + `[${teamName}/${task.id}] OpenCode task-log attribution yielded no projected messages; falling back to owner/time-window heuristic` + ); + return null; + } + + const participants: BoardTaskLogParticipant[] = []; + const segments: BoardTaskLogSegment[] = []; + let projectedMessageCount = 0; + for (const member of members.sort((left, right) => { + const leftStart = left.messages[0]?.timestamp.getTime() ?? 0; + const rightStart = right.messages[0]?.timestamp.getTime() ?? 0; + if (leftStart !== rightStart) { + return leftStart - rightStart; + } + return left.memberName.localeCompare(right.memberName); + })) { + const chunks = this.chunkBuilder.buildBundleChunks(member.messages); + if (chunks.length === 0) { + continue; + } + + const firstMessage = member.messages[0]; + const lastMessage = member.messages[member.messages.length - 1]; + if (!firstMessage || !lastMessage) { + continue; + } + + const participant = buildParticipant(member.memberName); + projectedMessageCount += member.messages.length; + participants.push(participant); + segments.push({ + id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`, + participantKey: participant.key, + actor: buildActor(member.memberName, member.sessionId ?? firstMessage.sessionId), + startTimestamp: firstMessage.timestamp.toISOString(), + endTimestamp: lastMessage.timestamp.toISOString(), + chunks, + }); + } + + if (segments.length === 0) { + return null; + } + + logger.debug( + `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s))` + ); + + return { + participants, + defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all', + segments, + source: 'opencode_runtime_attribution', + runtimeProjection: { + provider: 'opencode', + mode: 'attribution', + attributionRecordCount: attributionRecords.length, + projectedMessageCount, + }, + }; + } +} diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 9b415bf0..da249f22 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -279,6 +279,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Codex'; case 'gemini': return 'Gemini'; + case 'opencode': + return 'OpenCode'; } } @@ -1132,7 +1134,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleProviderRefresh = useCallback( (providerId: CliProviderId) => { - void fetchCliProviderStatus(providerId); + void fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }); }, [fetchCliProviderStatus] ); @@ -1218,7 +1222,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { providerStatusLoading={cliProviderStatusLoading} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} onSelectBackend={handleProviderBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={(providerId) => + fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }) + } onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} /> {providerTerminal && renderCliStatus.binaryPath && ( diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ba19a617..874e6b08 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -61,7 +61,7 @@ const ProviderCapabilityCardSkeleton = ({ providerId, displayName, }: { - providerId: 'anthropic' | 'codex' | 'gemini'; + providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode'; displayName: string; }): React.JSX.Element => (
diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 970a0b4c..d48363fb 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -124,6 +124,8 @@ function getConnectionDescription(provider: CliProviderStatus): string { return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.'; case 'gemini': return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.'; + case 'opencode': + return 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.'; } } @@ -135,6 +137,8 @@ function getRuntimeDescription(provider: CliProviderStatus): string { return 'Codex now runs only through the native runtime path.'; case 'gemini': return 'Choose which Gemini runtime backend multimodel should use.'; + case 'opencode': + return 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.'; } } @@ -1093,6 +1097,26 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
+ {selectedProvider.detailMessage ? ( +
+ {selectedProvider.detailMessage} +
+ ) : null} + {selectedProvider.externalRuntimeDiagnostics && + selectedProvider.externalRuntimeDiagnostics.length > 0 ? ( +
+ {selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => ( +
+ {diagnostic.label}:{' '} + {diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')} + {diagnostic.detailMessage ? ` - ${diagnostic.detailMessage}` : ''} +
+ ))} +
+ ) : null} ) : null} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 9122dda6..a9e78dfb 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -122,6 +122,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Codex'; case 'gemini': return 'Gemini'; + case 'opencode': + return 'OpenCode'; } } @@ -697,7 +699,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { providerStatusLoading={cliProviderStatusLoading} disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading} onSelectBackend={handleRuntimeBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={(providerId) => + fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }) + } onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' }) } diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 59c304f3..fe578e07 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -408,6 +408,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { anthropic: 0, codex: 0, gemini: 0, + opencode: 0, }; for (const session of searchedSessions) { diff --git a/src/renderer/components/sidebar/SessionFiltersPopover.tsx b/src/renderer/components/sidebar/SessionFiltersPopover.tsx index f0fe0290..e2855b1e 100644 --- a/src/renderer/components/sidebar/SessionFiltersPopover.tsx +++ b/src/renderer/components/sidebar/SessionFiltersPopover.tsx @@ -14,6 +14,7 @@ export const SESSION_PROVIDER_IDS = [ 'anthropic', 'codex', 'gemini', + 'opencode', ] as const satisfies readonly TeamProviderId[]; interface SessionFiltersPopoverProps { diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index b0eb827c..7c766e07 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -48,11 +48,16 @@ export function getProvisioningProviderBackendSummary( const optionById = new Map(options.map((option) => [option.id, option.label])); const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId; const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null; - const inferredProviderId = - provider.providerId ?? - (effectiveBackendId === 'codex-native' || options.some((option) => option.id === 'codex-native') - ? 'codex' - : undefined); + const inferredProviderId: TeamProviderId | undefined = + provider.providerId === 'anthropic' || + provider.providerId === 'codex' || + provider.providerId === 'gemini' || + provider.providerId === 'opencode' + ? provider.providerId + : effectiveBackendId === 'codex-native' || + options.some((option) => option.id === 'codex-native') + ? 'codex' + : undefined; const normalizedLabel = formatProviderBackendLabel(inferredProviderId, effectiveBackendId ?? undefined) ?? null; diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index e5b0fc67..f043c1fe 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -28,22 +28,24 @@ import { getProviderScopedTeamModelLabel, getRuntimeAwareProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, + getTeamModelSourceBadgeLabel, getTeamProviderLabel as getCatalogTeamProviderLabel, isAnthropicHaikuTeamModel, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, Info } from 'lucide-react'; -import type { CliProviderStatus } from '@shared/types'; +import type { CliProviderStatus, TeamProviderId } from '@shared/types'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; // --- Provider definitions --- interface ProviderDef { - id: 'anthropic' | 'codex' | 'gemini' | 'opencode'; + id: TeamProviderId; label: string; comingSoon: boolean; } @@ -55,13 +57,13 @@ const PROVIDERS: ProviderDef[] = [ { id: 'opencode', label: 'OpenCode', comingSoon: false }, ]; -const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development'; +const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; export function getTeamModelLabel(model: string): string { return getCatalogTeamModelLabel(model) ?? model; } -export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { +export function getTeamProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } @@ -73,11 +75,15 @@ export function getTeamEffortLabel(effort: string): string { } export function formatTeamModelSummary( - providerId: 'anthropic' | 'codex' | 'gemini', + providerId: TeamProviderId, model: string, effort?: string ): string { const providerLabel = getTeamProviderLabel(providerId); + const routeLabel = + providerId === 'opencode' + ? (getTeamModelSourceBadgeLabel(providerId, model.trim()) ?? providerLabel) + : providerLabel; const rawModelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; const modelLabel = model.trim() ? getProviderScopedTeamModelLabel(providerId, model.trim()) @@ -93,7 +99,7 @@ export function formatTeamModelSummary( const parts = modelAlreadyCarriesProviderBrand ? [modelLabel, effortLabel] : providerActsAsBackendOnly - ? [modelLabel, `via ${providerLabel}`, effortLabel] + ? [modelLabel, `via ${routeLabel}`, effortLabel] : [providerLabel, modelLabel, effortLabel]; return parts.filter(Boolean).join(' · '); @@ -108,7 +114,7 @@ export function formatTeamModelSummary( export function computeEffectiveTeamModel( selectedModel: string, limitContext: boolean, - providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic', + providerId: TeamProviderId = 'anthropic', providerStatus?: Pick | null ): string | undefined { if (providerId !== 'anthropic') { @@ -129,8 +135,8 @@ export function computeEffectiveTeamModel( } export interface TeamModelSelectorProps { - providerId: 'anthropic' | 'codex' | 'gemini'; - onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void; + providerId: TeamProviderId; + onProviderChange: (providerId: TeamProviderId) => void; value: string; onValueChange: (value: string) => void; id?: string; @@ -158,6 +164,13 @@ export const TeamModelSelector: React.FC = ({ } = useEffectiveCliProviderStatus(effectiveProviderId); const multimodelAvailable = multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; + const runtimeProviderStatusById = useMemo( + () => + new Map( + (effectiveCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider]) + ), + [effectiveCliStatus?.providers] + ); const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { const defaultLongContextModel = @@ -179,7 +192,32 @@ export const TeamModelSelector: React.FC = ({ }, [effectiveProviderId, runtimeProviderStatus]); const getProviderDisabledReason = (candidateProviderId: string): string | null => { if (candidateProviderId === 'opencode') { - return OPENCODE_UI_DISABLED_REASON; + const providerStatus = runtimeProviderStatusById.get('opencode') ?? null; + if (!providerStatus) { + return 'OpenCode runtime status is still loading.'; + } + if (!providerStatus.supported) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + 'OpenCode CLI is not installed.' + ); + } + if (!providerStatus.authenticated) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + 'OpenCode has no connected provider.' + ); + } + if (!providerStatus.capabilities.teamLaunch) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + OPENCODE_UI_DISABLED_REASON + ); + } + return null; } if (disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini') { return GEMINI_UI_DISABLED_REASON; @@ -194,7 +232,7 @@ export const TeamModelSelector: React.FC = ({ const activeProviderSelectable = isProviderSelectable(effectiveProviderId); const getProviderStatusBadge = (candidateProviderId: string): string | null => { if (candidateProviderId === 'opencode') { - return 'In development'; + return getProviderDisabledReason(candidateProviderId) ? 'Gated' : null; } const providerDisabledReason = getProviderDisabledReason(candidateProviderId); @@ -209,8 +247,8 @@ export const TeamModelSelector: React.FC = ({ return null; }; const getProviderStatusBadgeLabel = (statusBadge: string | null): string | null => { - if (statusBadge === 'In development') { - return 'Dev'; + if (statusBadge === 'Gated') { + return 'Gate'; } if (statusBadge === 'Multimodel off') { @@ -250,10 +288,7 @@ export const TeamModelSelector: React.FC = ({ { - if ( - (nextValue === 'anthropic' || nextValue === 'codex' || nextValue === 'gemini') && - isProviderSelectable(nextValue) - ) { + if (isTeamProviderId(nextValue) && isProviderSelectable(nextValue)) { onProviderChange(nextValue); } }} @@ -353,6 +388,10 @@ export const TeamModelSelector: React.FC = ({ availabilityStatus === 'available'); const modelStatusMessage = modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null; + const sourceBadgeLabel = + effectiveProviderId === 'opencode' && opt.value !== '' + ? opt.badgeLabel?.trim() || null + : null; return (