feat(opencode): add team runtime integration
This commit is contained in:
parent
2e87e12774
commit
5e31bd1c06
117 changed files with 23210 additions and 178 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
4
mcp-server/src/agent-teams-controller.d.ts
vendored
4
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -76,6 +76,10 @@ declare module 'agent-teams-controller' {
|
|||
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeBootstrapCheckin(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeDeliverMessage(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeTaskEvent(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTeamsController {
|
||||
|
|
|
|||
|
|
@ -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<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'team_launch',
|
||||
|
|
@ -75,4 +91,168 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
})
|
||||
),
|
||||
});
|
||||
|
||||
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 } : {}),
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) : {};
|
||||
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<string, unknown> {
|
||||
const payload =
|
||||
body && typeof body === 'object' && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown> }>(
|
||||
'/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<string, unknown> }>(
|
||||
'/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<string, unknown> }>(
|
||||
'/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<string, unknown> }>(
|
||||
'/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) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TeamRuntimeAdapterRegistry> {
|
||||
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<string, { value: string; expiresAt: number }>();
|
||||
|
|
@ -838,6 +935,7 @@ async function initializeServices(): Promise<void> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
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;
|
||||
}[] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content: string | OpenCodeRuntimeTranscriptLogContentBlock[];
|
||||
is_error?: boolean;
|
||||
};
|
||||
|
||||
export interface OpenCodeRuntimeTranscriptLogToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
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<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
|
|
@ -198,17 +340,17 @@ function extractJsonObject<T>(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<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
|
||||
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<OpenCodeRuntimeVerifyResponse>(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<CliProviderStatus> {
|
||||
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<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
|
||||
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<OpenCodeRuntimeTranscriptResponse>(stdout);
|
||||
return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null;
|
||||
}
|
||||
|
||||
private async verifyOpenCodeModel(
|
||||
binaryPath: string,
|
||||
modelId: string
|
||||
): Promise<CliProviderModelAvailability> {
|
||||
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<OpenCodeRuntimeVerifyModelResponse>(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<CliProviderStatus> {
|
||||
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<CliProviderStatus> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ const PROVIDER_CAPABILITIES: Record<
|
|||
supportsApiKey: true,
|
||||
configurableAuthModes: [],
|
||||
},
|
||||
opencode: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: false,
|
||||
configurableAuthModes: [],
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
||||
|
|
@ -184,7 +189,7 @@ export class ProviderConnectionService {
|
|||
|
||||
async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
|
||||
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<NodeJS.ProcessEnv> {
|
||||
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<Record<CliProviderId, string>>
|
||||
): Promise<Partial<Record<CliProviderId, string>>> {
|
||||
const issues: Partial<Record<CliProviderId, string>> = {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}[];
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
const checked: string[] = [];
|
||||
|
||||
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
|
||||
|
|
@ -250,7 +250,7 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
|
||||
export class TeamMcpConfigBuilder {
|
||||
async writeConfigFile(_projectPath?: string): Promise<string> {
|
||||
const launchSpec = await resolveMcpLaunchSpec();
|
||||
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const configDir = getMcpConfigsBasePath();
|
||||
const configPath = path.join(
|
||||
configDir,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<OpenCodeBridgeProcessRunResult>;
|
||||
}
|
||||
|
||||
export interface OpenCodeBridgeDiagnosticsSink {
|
||||
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
|
||||
}
|
||||
|
||||
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<OpenCodeBridgeProcessRunResult> {
|
||||
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<TBody, TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: TBody,
|
||||
options: {
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
requestId?: string;
|
||||
stdoutLimitBytes?: number;
|
||||
stderrLimitBytes?: number;
|
||||
}
|
||||
): Promise<OpenCodeBridgeResult<TData>> {
|
||||
const envelope: OpenCodeBridgeCommandEnvelope<TBody> = {
|
||||
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<TData>(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<TBody>(
|
||||
envelope: OpenCodeBridgeCommandEnvelope<TBody>
|
||||
): Promise<string> {
|
||||
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<TBody>(
|
||||
envelope: OpenCodeBridgeCommandEnvelope<TBody>,
|
||||
kind: OpenCodeBridgeFailureKind,
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
details: Record<string, unknown>
|
||||
): Promise<OpenCodeBridgeFailure> {
|
||||
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 '';
|
||||
}
|
||||
|
|
@ -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<string, OpenCodeTeamMemberLaunchCommandData>;
|
||||
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<string, { sessionId?: string; stopped: boolean; diagnostics: string[] }>;
|
||||
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<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeBridgeCommandEnvelope<TBody> {
|
||||
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<TData> {
|
||||
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<string, unknown>;
|
||||
};
|
||||
diagnostics: OpenCodeBridgeDiagnosticEvent[];
|
||||
}
|
||||
|
||||
export type OpenCodeBridgeResult<TData> = OpenCodeBridgeSuccess<TData> | 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<TBody> {
|
||||
stateChanging: true;
|
||||
preconditions: OpenCodeBridgeCommandPreconditions;
|
||||
}
|
||||
|
||||
export interface RuntimeStoreManifestEvidence {
|
||||
highWatermark: number;
|
||||
activeRunId?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}
|
||||
|
||||
const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = 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<OpenCodeBridgeFailureKind> = 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<TData>(
|
||||
stdout: string
|
||||
): { ok: true; value: OpenCodeBridgeResult<TData> } | { 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<TData> };
|
||||
}
|
||||
|
||||
export function validateBridgeResultEnvelope<TBody, TData>(
|
||||
result: OpenCodeBridgeResult<TData>,
|
||||
envelope: Pick<OpenCodeBridgeCommandEnvelope<TBody>, '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<TData>(
|
||||
result: OpenCodeBridgeResult<TData>,
|
||||
expected: {
|
||||
requestId: string;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
runId: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
}
|
||||
): asserts result is OpenCodeBridgeSuccess<TData> {
|
||||
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, 'identityHash'> | OpenCodeBridgeHandshake
|
||||
): string {
|
||||
const { identityHash: _ignored, ...hashable } = handshake as OpenCodeBridgeHandshake;
|
||||
return stableHash(hashable);
|
||||
}
|
||||
|
||||
export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
||||
result: OpenCodeBridgeResult<unknown>;
|
||||
requestId: string;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
runId: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
manifest: RuntimeStoreManifestEvidence;
|
||||
idempotencyKey: string;
|
||||
}): asserts input is {
|
||||
result: OpenCodeBridgeSuccess<unknown>;
|
||||
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<unknown> } | { 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<unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const nested = (value as Record<string, unknown>)[key];
|
||||
if (nested !== undefined) {
|
||||
output[key] = normalizeStableJson(nested);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<OpenCodeBridgeCommandLedgerEntry[]>,
|
||||
private readonly clock: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async begin(input: {
|
||||
idempotencyKey: string;
|
||||
requestId: string;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
requestHash: string;
|
||||
}): Promise<OpenCodeBridgeLedgerBeginResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.updateExisting(input.idempotencyKey, (entry) => ({
|
||||
...entry,
|
||||
status: 'unknown_after_timeout',
|
||||
retryable: false,
|
||||
completedAt: null,
|
||||
lastError: input.error,
|
||||
}));
|
||||
}
|
||||
|
||||
async getByIdempotencyKey(
|
||||
idempotencyKey: string
|
||||
): Promise<OpenCodeBridgeCommandLedgerEntry | null> {
|
||||
const entries = await this.readRequired();
|
||||
return entries.find((entry) => entry.idempotencyKey === idempotencyKey) ?? null;
|
||||
}
|
||||
|
||||
async list(): Promise<OpenCodeBridgeCommandLedgerEntry[]> {
|
||||
return this.readRequired();
|
||||
}
|
||||
|
||||
private async updateExisting(
|
||||
idempotencyKey: string,
|
||||
updater: (entry: OpenCodeBridgeCommandLedgerEntry) => OpenCodeBridgeCommandLedgerEntry
|
||||
): Promise<void> {
|
||||
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<OpenCodeBridgeCommandLedgerEntry[]> {
|
||||
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<OpenCodeBridgeCommandLease[]>,
|
||||
private readonly idFactory: () => string,
|
||||
private readonly clock: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async acquire(input: {
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
ttlMs: number;
|
||||
}): Promise<OpenCodeBridgeCommandLease> {
|
||||
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<void> {
|
||||
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<OpenCodeBridgeCommandLease | null> {
|
||||
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<OpenCodeBridgeCommandLedgerEntry[]>({
|
||||
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<OpenCodeBridgeCommandLease[]>({
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, unknown> {
|
||||
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';
|
||||
}
|
||||
|
|
@ -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<OpenCodeBridgeHandshake> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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<TBody, TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: TBody,
|
||||
options: {
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
requestId?: string;
|
||||
stdoutLimitBytes?: number;
|
||||
stderrLimitBytes?: number;
|
||||
}
|
||||
): Promise<OpenCodeBridgeResult<TData>>;
|
||||
}
|
||||
|
||||
export interface OpenCodeReadinessBridgeOptions {
|
||||
timeoutMs?: number;
|
||||
launchTimeoutMs?: number;
|
||||
reconcileTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
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<OpenCodeTeamLaunchReadiness> {
|
||||
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<OpenCodeTeamLaunchReadiness> {
|
||||
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<OpenCodeLaunchTeamCommandData> {
|
||||
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<OpenCodeLaunchTeamCommandData> {
|
||||
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<OpenCodeStopTeamCommandData> {
|
||||
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<TBody, TData>(
|
||||
command: OpenCodeStateChangingTeamCommandName,
|
||||
body: TBody,
|
||||
input: {
|
||||
teamName: string;
|
||||
runId: string;
|
||||
capabilitySnapshotId: string | null;
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<OpenCodeBridgeResult<TData>> {
|
||||
if (this.options.stateChangingCommands) {
|
||||
try {
|
||||
return await this.options.stateChangingCommands.execute<TBody, TData>({
|
||||
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<TBody, TData>(command, body, {
|
||||
cwd: input.cwd,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OpenCodeStateChangingTeamCommandName = Extract<
|
||||
OpenCodeBridgeCommandName,
|
||||
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam'
|
||||
>;
|
||||
|
||||
function blockedLaunchData(
|
||||
runId: string,
|
||||
result: OpenCodeBridgeResult<unknown>
|
||||
): 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<TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
runId: string,
|
||||
error: unknown
|
||||
): OpenCodeBridgeResult<TData> {
|
||||
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))];
|
||||
}
|
||||
|
|
@ -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<TBody, TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: TBody,
|
||||
options: {
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
requestId?: string;
|
||||
stdoutLimitBytes?: number;
|
||||
stderrLimitBytes?: number;
|
||||
}
|
||||
): Promise<OpenCodeBridgeResult<TData>>;
|
||||
}
|
||||
|
||||
export interface OpenCodeBridgeHandshakePort {
|
||||
handshake(input: {
|
||||
requiredCommand: OpenCodeBridgeCommandName;
|
||||
expectedRunId: string | null;
|
||||
expectedCapabilitySnapshotId: string | null;
|
||||
expectedManifestHighWatermark: number | null;
|
||||
cwd?: string;
|
||||
}): Promise<OpenCodeBridgeHandshake>;
|
||||
}
|
||||
|
||||
export interface RuntimeStoreManifestReader {
|
||||
read(teamName: string): Promise<RuntimeStoreManifestEvidence>;
|
||||
}
|
||||
|
||||
export interface OpenCodeStateChangingBridgeDiagnosticsSink {
|
||||
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
|
||||
}
|
||||
|
||||
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<TBody, TData>(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
behaviorFingerprint: string | null;
|
||||
body: TBody;
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||
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<typeof bodyWithPreconditions, TData>(
|
||||
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<unknown>;
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
idempotencyKey: string;
|
||||
leaseId: string;
|
||||
}): Promise<void> {
|
||||
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<TBody>(
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -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<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
|
||||
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<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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<Record<OpenCodeApiEndpointKey, DirectSafeProbe>> = {
|
||||
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<OpenCodeApiCapabilities> {
|
||||
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<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>
|
||||
): 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<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence> {
|
||||
return Object.fromEntries(
|
||||
(Object.keys(createEmptyEndpointMap()) as OpenCodeApiEndpointKey[]).map((key) => [
|
||||
key,
|
||||
'missing',
|
||||
])
|
||||
) as Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
|
||||
}
|
||||
|
||||
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<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
|
||||
diagnostics: string[];
|
||||
}): Promise<void> {
|
||||
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<string | null> {
|
||||
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<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>
|
||||
): 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<Response> {
|
||||
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<void> {
|
||||
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 '<invalid-url>';
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
401
src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts
Normal file
401
src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts
Normal file
|
|
@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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<OpenCodeManagedOverlay> {
|
||||
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<OpenCodeBehaviorSource[]> {
|
||||
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<Set<string>> {
|
||||
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<string>();
|
||||
|
||||
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<OpenCodeBehaviorSource> {
|
||||
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<void> => {
|
||||
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<Record<string, unknown> | 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<string, string>;
|
||||
timeout: number;
|
||||
}): { mcp: Record<string, OpenCodeMcpServerConfig> } {
|
||||
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<string, unknown>): 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>): 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<string>;
|
||||
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<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
|
@ -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<RuntimeDeliveryJournalRecord[]>) {}
|
||||
|
||||
async begin(input: RuntimeDeliveryJournalBeginInput): Promise<RuntimeDeliveryJournalBeginResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.updateExisting(input.idempotencyKey, (record) => ({
|
||||
...record,
|
||||
status: input.status,
|
||||
updatedAt: input.updatedAt,
|
||||
lastError: input.error,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(idempotencyKey: string): Promise<RuntimeDeliveryJournalRecord | null> {
|
||||
const records = await this.readRequired();
|
||||
return records.find((record) => record.idempotencyKey === idempotencyKey) ?? null;
|
||||
}
|
||||
|
||||
async listRecoverable(teamName: string): Promise<RuntimeDeliveryJournalRecord[]> {
|
||||
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<Map<string, RuntimeDeliveryJournalRecord>> {
|
||||
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<RuntimeDeliveryJournalRecord[]> {
|
||||
return this.readRequired();
|
||||
}
|
||||
|
||||
private async updateExisting(
|
||||
idempotencyKey: string,
|
||||
updater: (record: RuntimeDeliveryJournalRecord) => RuntimeDeliveryJournalRecord
|
||||
): Promise<void> {
|
||||
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<RuntimeDeliveryJournalRecord[]> {
|
||||
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<RuntimeDeliveryJournalRecord[]>({
|
||||
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<string>();
|
||||
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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<RuntimeDeliveryLocation>;
|
||||
|
||||
verify(input: {
|
||||
destination: RuntimeDeliveryDestinationRef;
|
||||
destinationMessageId: string;
|
||||
}): Promise<RuntimeDeliveryVerifyResult>;
|
||||
|
||||
buildChangeEvent(input: {
|
||||
teamName: string;
|
||||
location: RuntimeDeliveryLocation;
|
||||
}): RuntimeDeliveryTeamChangeEvent | null;
|
||||
}
|
||||
|
||||
export interface RuntimeDeliveryTeamChangeEvent {
|
||||
type: string;
|
||||
teamName: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuntimeDeliveryRunStateReader {
|
||||
getCurrentRunId(teamName: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface RuntimeDeliveryDiagnosticsSink {
|
||||
append(event: RuntimeDeliveryDiagnosticEvent): Promise<void>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<RuntimeDeliveryAck> {
|
||||
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<void> {
|
||||
const records = await this.journal.listRecoverable(teamName);
|
||||
for (const record of records) {
|
||||
await this.reconcileRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileRecord(record: RuntimeDeliveryJournalRecord): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
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<OpenCodeProductionE2ERequiredSignal, boolean> {
|
||||
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<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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);
|
||||
}
|
||||
|
|
@ -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<OpenCodeProductionE2EEvidence | null>;
|
||||
|
||||
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidence | null>({
|
||||
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<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||
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<void> {
|
||||
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
||||
await this.store.updateLocked(() => ({
|
||||
...validated,
|
||||
artifactPath: validated.artifactPath ?? this.filePath,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
|
@ -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<string[]>;
|
||||
listExperimentalTools(input: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}): Promise<OpenCodeToolListItem[]>;
|
||||
}
|
||||
|
||||
export type OpenCodeMcpToolProofRoute = '/experimental/tool/ids' | '/experimental/tool' | null;
|
||||
|
||||
export interface OpenCodeMcpToolProof {
|
||||
ok: boolean;
|
||||
route: OpenCodeMcpToolProofRoute;
|
||||
canonicalServerName: string;
|
||||
canonicalExpectedIds: Record<string, string>;
|
||||
observedTools: string[];
|
||||
missingTools: string[];
|
||||
matchedByRequiredTool: Record<string, string | null>;
|
||||
aliasMatchedByRequiredTool: Record<string, string | null>;
|
||||
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<OpenCodeMcpToolProof> {
|
||||
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<OpenCodeMcpToolProof> {
|
||||
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<OpenCodeMcpToolProof> {
|
||||
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<OpenCodeMcpToolProofRoute, null>;
|
||||
serverName: string;
|
||||
requiredTools: string[];
|
||||
observedTools: string[];
|
||||
}): OpenCodeMcpToolProof {
|
||||
const observed = new Set(input.observedTools);
|
||||
const matchedByRequiredTool: Record<string, string | null> = {};
|
||||
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
|
||||
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<string, string | null> = {};
|
||||
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
|
||||
|
||||
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<OpenCodeMcpToolProofRoute, null>;
|
||||
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<string, string> {
|
||||
return Object.fromEntries(
|
||||
requiredTools.map((tool) => [tool, buildOpenCodeCanonicalMcpToolId(serverName, tool)])
|
||||
);
|
||||
}
|
||||
|
||||
function unique<T>(items: T[]): T[] {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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);
|
||||
}
|
||||
936
src/main/services/team/opencode/permissions/RuntimePermission.ts
Normal file
936
src/main/services/team/opencode/permissions/RuntimePermission.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimePermissionDiagnosticsSink {
|
||||
append(event: RuntimePermissionDiagnosticEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimePermissionLaunchStateStore {
|
||||
read(teamName: string): Promise<{ runId: string | null } | null>;
|
||||
updateMember(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
updater: (member: RuntimePermissionLaunchMemberState) => RuntimePermissionLaunchMemberState
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimePermissionLaunchMemberState {
|
||||
launchState?: string;
|
||||
bootstrapConfirmed?: boolean;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
lastRuntimeEventAt?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodePermissionClientPort {
|
||||
listPendingPermissions(): Promise<OpenCodeNormalizedPermissionRequest[]>;
|
||||
answerPermission(input: {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
decision: OpenCodePermissionDecision;
|
||||
message?: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
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<RuntimePermissionRequestRecord[]>) {}
|
||||
|
||||
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<RuntimePermissionAnswerProjectionResult> {
|
||||
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<string>([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<void> {
|
||||
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<string>;
|
||||
now: string;
|
||||
}): Promise<string[]> {
|
||||
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<RuntimePermissionRequestRecord[]> {
|
||||
const records = await this.readRequired();
|
||||
return records.filter((record) => record.teamName === teamName && record.state === 'pending');
|
||||
}
|
||||
|
||||
async get(appRequestId: string): Promise<RuntimePermissionRequestRecord | null> {
|
||||
const records = await this.readRequired();
|
||||
return records.find((record) => record.appRequestId === appRequestId) ?? null;
|
||||
}
|
||||
|
||||
async list(): Promise<RuntimePermissionRequestRecord[]> {
|
||||
return this.readRequired();
|
||||
}
|
||||
|
||||
private async updateExisting(
|
||||
appRequestId: string,
|
||||
updater: (record: RuntimePermissionRequestRecord) => RuntimePermissionRequestRecord
|
||||
): Promise<void> {
|
||||
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<RuntimePermissionRequestRecord[]> {
|
||||
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<OpenCodePermissionAnswerResult> {
|
||||
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<string, OpenCodeSessionPermissionRef>;
|
||||
}): Promise<void> {
|
||||
const now = this.clock().toISOString();
|
||||
const pending = await this.client.listPendingPermissions();
|
||||
const visibleProviderRequestIds = new Set<string>();
|
||||
const pendingByMember = new Map<string, string[]>();
|
||||
|
||||
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<RuntimePermissionRequestRecord[]>({
|
||||
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<string>();
|
||||
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, unknown>;
|
||||
}): 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<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<OpenCodeRuntimeInventory>;
|
||||
}
|
||||
|
||||
export interface OpenCodeApiCapabilityPort {
|
||||
detect(input: {
|
||||
projectPath: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
}): Promise<OpenCodeApiCapabilities>;
|
||||
}
|
||||
|
||||
export interface OpenCodeMcpToolProofPort {
|
||||
prove(input: {
|
||||
projectPath: string;
|
||||
modelId: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
}): Promise<OpenCodeMcpToolProof>;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeStoreReadinessPort {
|
||||
check(input: { projectPath: string }): Promise<RuntimeStoreReadinessCheck>;
|
||||
}
|
||||
|
||||
export interface OpenCodeModelExecutionProbePort {
|
||||
verify(input: {
|
||||
projectPath: string;
|
||||
modelId: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
}): Promise<OpenCodeModelExecutionProbeResult>;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidencePort {
|
||||
read(input: {
|
||||
projectPath: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
}): Promise<OpenCodeProductionE2EEvidence | null>;
|
||||
}
|
||||
|
||||
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<OpenCodeTeamLaunchReadiness> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<OpenCodeLaunchTransaction[]>,
|
||||
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<boolean> {
|
||||
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<OpenCodeLaunchTransaction | null> {
|
||||
const transactions = await this.readRequired();
|
||||
return (
|
||||
transactions.find(
|
||||
(transaction) => transaction.teamName === teamName && transaction.status === 'active'
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async read(teamName: string, runId: string): Promise<OpenCodeLaunchTransaction | null> {
|
||||
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<OpenCodeLaunchTransaction[]> {
|
||||
return this.readRequired();
|
||||
}
|
||||
|
||||
private async hasTransaction(teamName: string, runId: string): Promise<boolean> {
|
||||
return (await this.read(teamName, runId)) !== null;
|
||||
}
|
||||
|
||||
private async readRequired(): Promise<OpenCodeLaunchTransaction[]> {
|
||||
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<string, unknown> = {};
|
||||
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<OpenCodeLaunchTransaction[]>({
|
||||
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<string, unknown> {
|
||||
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';
|
||||
}
|
||||
|
|
@ -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<RuntimeStoreManifestEvidence> {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
@ -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<RuntimeRunTombstone[]>,
|
||||
private readonly options: {
|
||||
idFactory?: () => string;
|
||||
clock?: () => Date;
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
async add(input: {
|
||||
teamName: string;
|
||||
runId: string;
|
||||
reason: RuntimeRunTombstoneReason;
|
||||
evidenceKinds?: RuntimeEvidenceKind[];
|
||||
ttlMs?: number;
|
||||
diagnostic?: string | null;
|
||||
}): Promise<RuntimeRunTombstone> {
|
||||
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<RuntimeRunTombstone[]> {
|
||||
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<RuntimeRunTombstone | null> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<RuntimeRunTombstone[]> {
|
||||
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<RuntimeRunTombstone[]>({
|
||||
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<string>();
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
1144
src/main/services/team/opencode/store/RuntimeStoreManifest.ts
Normal file
1144
src/main/services/team/opencode/store/RuntimeStoreManifest.ts
Normal file
File diff suppressed because it is too large
Load diff
292
src/main/services/team/opencode/store/VersionedJsonStore.ts
Normal file
292
src/main/services/team/opencode/store/VersionedJsonStore.ts
Normal file
|
|
@ -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<TData> {
|
||||
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<TData> =
|
||||
| {
|
||||
ok: true;
|
||||
status: VersionedJsonStoreReadStatus;
|
||||
data: TData;
|
||||
envelope: VersionedJsonStoreEnvelope<TData> | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: VersionedJsonStoreFailureReason;
|
||||
message: string;
|
||||
quarantinePath: string | null;
|
||||
};
|
||||
|
||||
export interface VersionedJsonStoreUpdateResult<TData> {
|
||||
changed: boolean;
|
||||
data: TData;
|
||||
envelope: VersionedJsonStoreEnvelope<TData>;
|
||||
}
|
||||
|
||||
export interface VersionedJsonStoreOptions<TData> {
|
||||
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<TData> {
|
||||
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<TData>) {
|
||||
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<VersionedJsonStoreReadResult<TData>> {
|
||||
return this.readUnlocked();
|
||||
}
|
||||
|
||||
async updateLocked(
|
||||
updater: (current: TData) => TData | Promise<TData>
|
||||
): Promise<VersionedJsonStoreUpdateResult<TData>> {
|
||||
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<TData> = {
|
||||
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<VersionedJsonStoreReadResult<TData>> {
|
||||
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<unknown> }
|
||||
| { 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<string | null> {
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJson<T>(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<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const nested = (value as Record<string, unknown>)[key];
|
||||
if (nested !== undefined) {
|
||||
output[key] = normalizeStableJson(nested);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
284
src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts
Normal file
284
src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
499
src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
Normal file
499
src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
Normal file
|
|
@ -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<OpenCodeTeamLaunchReadiness>;
|
||||
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
||||
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
reconcileOpenCodeTeam?(
|
||||
input: OpenCodeReconcileTeamCommandBody
|
||||
): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
||||
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
|
||||
) {}
|
||||
|
||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
||||
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<TeamRuntimeLaunchResult> {
|
||||
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<TeamRuntimeReconcileResult> {
|
||||
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<TeamRuntimeStopResult> {
|
||||
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<string> {
|
||||
const names = new Set<string>();
|
||||
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))];
|
||||
}
|
||||
184
src/main/services/team/runtime/TeamRuntimeAdapter.ts
Normal file
184
src/main/services/team/runtime/TeamRuntimeAdapter.ts
Normal file
|
|
@ -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<string, TeamRuntimeMemberLaunchEvidence>;
|
||||
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<string, TeamRuntimeMemberLaunchEvidence>;
|
||||
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<string, TeamRuntimeMemberStopEvidence>;
|
||||
warnings: string[];
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface TeamLaunchRuntimeAdapter {
|
||||
readonly providerId: TeamRuntimeProviderId;
|
||||
prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult>;
|
||||
launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>;
|
||||
reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>;
|
||||
stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>;
|
||||
}
|
||||
|
||||
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<TeamRuntimeProviderId, TeamLaunchRuntimeAdapter>();
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
29
src/main/services/team/runtime/index.ts
Normal file
29
src/main/services/team/runtime/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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<BoardTaskLogStreamSummary> {
|
||||
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<BoardTaskLogStreamResponse> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
import {
|
||||
OpenCodeTaskLogAttributionStore,
|
||||
OpenCodeTaskLogAttributionRecord,
|
||||
OpenCodeTaskLogAttributionScope,
|
||||
OpenCodeTaskLogAttributionSource,
|
||||
OpenCodeTaskLogAttributionWriteResult,
|
||||
} from './OpenCodeTaskLogAttributionStore';
|
||||
|
||||
export interface OpenCodeTaskLogAttributionWriter {
|
||||
upsertTaskRecord(
|
||||
teamName: string,
|
||||
record: OpenCodeTaskLogAttributionRecord,
|
||||
options?: { now?: Date }
|
||||
): Promise<OpenCodeTaskLogAttributionWriteResult>;
|
||||
replaceTaskRecords(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
records: OpenCodeTaskLogAttributionRecord[],
|
||||
options?: { now?: Date }
|
||||
): Promise<OpenCodeTaskLogAttributionWriteResult>;
|
||||
clearTaskRecords(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<OpenCodeTaskLogAttributionWriteResult>;
|
||||
}
|
||||
|
||||
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<OpenCodeTaskLogAttributionSource>([
|
||||
'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<OpenCodeTaskLogAttributionRecordWriteOutcome> {
|
||||
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<OpenCodeTaskLogAttributionRecordWriteOutcome> {
|
||||
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<OpenCodeTaskLogAttributionBulkWriteOutcome> {
|
||||
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<OpenCodeTaskLogAttributionBulkWriteOutcome> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown> {
|
||||
taskId?: unknown;
|
||||
}
|
||||
|
||||
interface OpenCodeTaskLogAttributionFile {
|
||||
schemaVersion: 1;
|
||||
tasks: Record<string, OpenCodeTaskLogAttributionRecord[]>;
|
||||
}
|
||||
|
||||
export type OpenCodeTaskLogAttributionWriteResult = 'created' | 'updated' | 'unchanged' | 'deleted';
|
||||
|
||||
export interface OpenCodeTaskLogAttributionReader {
|
||||
readTaskRecords(teamName: string, taskId: string): Promise<OpenCodeTaskLogAttributionRecord[]>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)[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<string, unknown>;
|
||||
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<string, unknown>)) {
|
||||
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<string, OpenCodeTaskLogAttributionRecord>();
|
||||
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<string, OpenCodeTaskLogAttributionRecord[]>();
|
||||
for (const record of records) {
|
||||
const existing = byTask.get(record.taskId) ?? [];
|
||||
existing.push(record);
|
||||
byTask.set(record.taskId, existing);
|
||||
}
|
||||
|
||||
const tasks: Record<string, OpenCodeTaskLogAttributionRecord[]> = {};
|
||||
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<OpenCodeTaskLogAttributionRecord, 'createdAt' | 'updatedAt'> {
|
||||
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<OpenCodeTaskLogAttributionFile> {
|
||||
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<boolean> {
|
||||
if (sameJson(previous, next)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await atomicWriteAsync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async readTaskRecords(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<OpenCodeTaskLogAttributionRecord[]> {
|
||||
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<OpenCodeTaskLogAttributionWriteResult> {
|
||||
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<OpenCodeTaskLogAttributionWriteResult> {
|
||||
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<OpenCodeTaskLogAttributionWriteResult> {
|
||||
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';
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const ProviderCapabilityCardSkeleton = ({
|
|||
providerId,
|
||||
displayName,
|
||||
}: {
|
||||
providerId: 'anthropic' | 'codex' | 'gemini';
|
||||
providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
displayName: string;
|
||||
}): React.JSX.Element => (
|
||||
<div className="rounded-md border border-border bg-surface-raised px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedProvider.detailMessage ? (
|
||||
<div className="mt-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{selectedProvider.detailMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProvider.externalRuntimeDiagnostics &&
|
||||
selectedProvider.externalRuntimeDiagnostics.length > 0 ? (
|
||||
<div
|
||||
className="mt-2 space-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => (
|
||||
<div key={diagnostic.id}>
|
||||
{diagnostic.label}:{' '}
|
||||
{diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')}
|
||||
{diagnostic.detailMessage ? ` - ${diagnostic.detailMessage}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,6 +408,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
anthropic: 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
|
||||
for (const session of searchedSessions) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const SESSION_PROVIDER_IDS = [
|
|||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
] as const satisfies readonly TeamProviderId[];
|
||||
|
||||
interface SessionFiltersPopoverProps {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CliProviderStatus, 'providerId' | 'modelCatalog'> | 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<TeamModelSelectorProps> = ({
|
|||
} = 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<TeamModelSelectorProps> = ({
|
|||
}, [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<TeamModelSelectorProps> = ({
|
|||
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<TeamModelSelectorProps> = ({
|
|||
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<TeamModelSelectorProps> = ({
|
|||
<Tabs
|
||||
value={effectiveProviderId}
|
||||
onValueChange={(nextValue) => {
|
||||
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<TeamModelSelectorProps> = ({
|
|||
availabilityStatus === 'available');
|
||||
const modelStatusMessage =
|
||||
modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null;
|
||||
const sourceBadgeLabel =
|
||||
effectiveProviderId === 'opencode' && opt.value !== ''
|
||||
? opt.badgeLabel?.trim() || null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -382,6 +421,19 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
>
|
||||
<span className="flex flex-col items-center justify-center gap-0.5">
|
||||
<span className="leading-tight">{opt.label}</span>
|
||||
{sourceBadgeLabel ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
title={`Source: ${sourceBadgeLabel}`}
|
||||
>
|
||||
{sourceBadgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{opt.value === '' && (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
|||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { EffortLevel, ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
|
||||
import type {
|
||||
EffortLevel,
|
||||
ResolvedTeamMember,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
} from '@shared/types';
|
||||
|
||||
function normalizeRestartSensitiveMemberContract(member: {
|
||||
role?: string;
|
||||
|
|
@ -13,7 +18,7 @@ function normalizeRestartSensitiveMemberContract(member: {
|
|||
}): {
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
} {
|
||||
|
|
@ -124,7 +129,7 @@ function normalizeEditableMemberSnapshot(member: {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
} | null {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogSt
|
|||
return {
|
||||
participants: response.participants,
|
||||
defaultFilter: response.defaultFilter,
|
||||
source: response.source,
|
||||
runtimeProjection: response.runtimeProjection,
|
||||
segments: response.segments.map((segment) => ({
|
||||
...segment,
|
||||
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
|
||||
|
|
@ -58,6 +60,22 @@ function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogSt
|
|||
};
|
||||
}
|
||||
|
||||
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
|
||||
if (stream?.source === 'opencode_runtime_attribution') {
|
||||
return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.';
|
||||
}
|
||||
if (stream?.source === 'opencode_runtime_fallback') {
|
||||
if (stream.runtimeProjection?.fallbackReason === 'task_tool_markers') {
|
||||
const spanCount = stream.runtimeProjection.markerSpanCount;
|
||||
const spanDetails =
|
||||
typeof spanCount === 'number' && spanCount > 1 ? ` across ${spanCount} spans` : '';
|
||||
return `Task-scoped OpenCode runtime logs projected from matched task tool markers${spanDetails} into the same execution-log components used in Logs.`;
|
||||
}
|
||||
return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.';
|
||||
}
|
||||
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
|
||||
}
|
||||
|
||||
const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -211,11 +229,13 @@ export const TaskLogStreamSection = ({
|
|||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||
if (
|
||||
event.teamName !== teamName ||
|
||||
event.type !== 'task-log-change' ||
|
||||
event.taskId !== taskId
|
||||
) {
|
||||
if (event.teamName !== teamName) {
|
||||
return;
|
||||
}
|
||||
const shouldReload =
|
||||
event.type === 'log-source-change' ||
|
||||
(event.type === 'task-log-change' && event.taskId === taskId);
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
|
|
@ -247,6 +267,7 @@ export const TaskLogStreamSection = ({
|
|||
|
||||
const participants = stream?.participants ?? [];
|
||||
const showChips = participants.length > 1;
|
||||
const streamDescription = useMemo(() => describeStreamSource(stream), [stream]);
|
||||
const visibleSegments = useMemo(() => {
|
||||
const source = stream?.segments ?? [];
|
||||
const filtered =
|
||||
|
|
@ -292,9 +313,7 @@ export const TaskLogStreamSection = ({
|
|||
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Task Log Stream
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Task-scoped transcript logs rendered with the same execution-log components used in Logs.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{streamDescription}</p>
|
||||
|
||||
{showChips ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
|
|
@ -331,8 +350,8 @@ export const TaskLogStreamSection = ({
|
|||
<FileText size={20} className="mx-auto mb-2 opacity-40" />
|
||||
No task log stream yet
|
||||
<p className="mt-1 text-[10px] opacity-60">
|
||||
Task-linked transcript logs will appear here when explicit task-linked transcript
|
||||
metadata is available.
|
||||
Task-linked logs will appear here when transcript metadata or runtime projection is
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
*/
|
||||
|
||||
import { del, get, set } from 'idb-keyval';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
|
||||
|
|
@ -29,7 +32,7 @@ export interface SerializedMemberDraft {
|
|||
roleSelection: string;
|
||||
customRole: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
|
@ -66,10 +69,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
|
|||
typeof obj.name === 'string' &&
|
||||
typeof obj.roleSelection === 'string' &&
|
||||
typeof obj.customRole === 'string' &&
|
||||
(obj.providerId === undefined ||
|
||||
obj.providerId === 'anthropic' ||
|
||||
obj.providerId === 'codex' ||
|
||||
obj.providerId === 'gemini') &&
|
||||
(obj.providerId === undefined || isTeamProviderId(obj.providerId)) &&
|
||||
(obj.model === undefined || typeof obj.model === 'string') &&
|
||||
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ const logger = createLogger('Store:cliInstaller');
|
|||
|
||||
/** Max log lines to keep in UI (reserved for future use) */
|
||||
const _MAX_LOG_LINES = 50;
|
||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
|
||||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
];
|
||||
|
||||
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||
const providers: CliProviderStatus[] = (
|
||||
|
|
@ -22,6 +27,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'codex', displayName: 'Codex' },
|
||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode' },
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
|
|
@ -33,7 +39,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
canLoginFromUi: provider.providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
|
|
|
|||
|
|
@ -1522,7 +1522,7 @@ export interface GlobalTaskDetailState {
|
|||
|
||||
/** Per-team launch parameters shown in the header badge. */
|
||||
export interface TeamLaunchParams {
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: string;
|
||||
model?: string; // 'opus' | 'sonnet' | 'haiku'
|
||||
effort?: EffortLevel;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getTeamProviderLabel,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
import type { InboxMessage, TeamProviderId } from '@shared/types';
|
||||
|
||||
const BOOTSTRAP_REQUIRED_MARKER_SETS = [
|
||||
[
|
||||
|
|
@ -31,11 +31,14 @@ const BOOTSTRAP_SUPPORTING_MARKERS = [
|
|||
'resume your queue normally and prioritize already-assigned board work',
|
||||
] as const;
|
||||
|
||||
type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
|
||||
function parseProviderId(value: string | undefined): TeamProviderId | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === 'anthropic' || normalized === 'codex' || normalized === 'gemini') {
|
||||
if (
|
||||
normalized === 'anthropic' ||
|
||||
normalized === 'codex' ||
|
||||
normalized === 'gemini' ||
|
||||
normalized === 'opencode'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -283,6 +283,8 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined)
|
|||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isTeamProviderBackendId,
|
||||
migrateProviderBackendId,
|
||||
} from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { CliProviderStatus, TeamProviderBackendId, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -27,8 +28,9 @@ export function resolveUiOwnedProviderBackendId(
|
|||
providerId: TeamProviderId | CliProviderStatus['providerId'] | undefined,
|
||||
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
|
||||
): TeamProviderBackendId | undefined {
|
||||
const normalizedProviderId = normalizeOptionalTeamProviderId(providerId);
|
||||
return migrateProviderBackendId(
|
||||
providerId,
|
||||
normalizedProviderId,
|
||||
provider?.selectedBackendId ?? provider?.resolvedBackendId
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
getRuntimeAwareProviderScopedTeamModelLabel,
|
||||
getRuntimeAwareTeamModelBadgeLabel,
|
||||
getRuntimeAwareTeamModelUiDisabledReason,
|
||||
getTeamModelSourceBadgeLabel,
|
||||
getTeamProviderLabel,
|
||||
getTeamProviderModelOptions,
|
||||
getVisibleTeamProviderModels,
|
||||
|
|
@ -314,6 +315,10 @@ export function getAvailableTeamProviderModelOptions(
|
|||
return {
|
||||
value: model,
|
||||
label: getProviderScopedTeamModelLabel(providerId, model) ?? model,
|
||||
badgeLabel:
|
||||
providerId === 'opencode'
|
||||
? (getTeamModelSourceBadgeLabel(providerId, model) ?? undefined)
|
||||
: undefined,
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { parseModelString } from '@shared/utils/modelParser';
|
||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||
import {
|
||||
getOpenCodeQualifiedModelSourceLabel,
|
||||
parseOpenCodeQualifiedModelRef,
|
||||
} from '@shared/utils/opencodeModelRef';
|
||||
import {
|
||||
filterVisibleProviderRuntimeModels,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||
} from '@shared/utils/providerModelVisibility';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -38,6 +47,7 @@ const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
|
|||
anthropic: 'Anthropic',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
const ANTHROPIC_ALIAS_LABELS = {
|
||||
|
|
@ -133,12 +143,16 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
|
|||
badgeLabel: '2.5-flash-lite',
|
||||
},
|
||||
],
|
||||
opencode: [{ value: '', label: 'Default', badgeLabel: 'Default' }],
|
||||
};
|
||||
|
||||
const TEAM_PROVIDER_MODEL_ORDER: Record<SupportedProviderId, Map<string, number>> = {
|
||||
anthropic: new Map(ANTHROPIC_MODEL_ORDER.map((model, index) => [model, index])),
|
||||
codex: new Map(TEAM_PROVIDER_MODEL_OPTIONS.codex.map((option, index) => [option.value, index])),
|
||||
gemini: new Map(TEAM_PROVIDER_MODEL_OPTIONS.gemini.map((option, index) => [option.value, index])),
|
||||
opencode: new Map(
|
||||
TEAM_PROVIDER_MODEL_OPTIONS.opencode.map((option, index) => [option.value, index])
|
||||
),
|
||||
};
|
||||
|
||||
function getKnownTeamProviderModelOption(
|
||||
|
|
@ -247,12 +261,15 @@ export function getTeamModelLabel(model: string | undefined): string | undefined
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const overrideLabel = TEAM_MODEL_LABEL_OVERRIDES[trimmed];
|
||||
const parsedOpenCodeModel = parseOpenCodeQualifiedModelRef(trimmed);
|
||||
const labelTarget = parsedOpenCodeModel?.modelId ?? trimmed;
|
||||
|
||||
const overrideLabel = TEAM_MODEL_LABEL_OVERRIDES[labelTarget];
|
||||
if (overrideLabel) {
|
||||
return overrideLabel;
|
||||
}
|
||||
|
||||
return formatParsedClaudeModelLabel(trimmed) ?? trimmed;
|
||||
return formatParsedClaudeModelLabel(labelTarget) ?? labelTarget;
|
||||
}
|
||||
|
||||
function getRuntimeCatalogModel(
|
||||
|
|
@ -299,9 +316,23 @@ export function getTeamModelBadgeLabel(
|
|||
if (providerId === 'gemini') {
|
||||
return trimmed.replace(/^gemini-/, '');
|
||||
}
|
||||
if (providerId === 'opencode') {
|
||||
return getTeamModelLabel(trimmed) ?? trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function getTeamModelSourceBadgeLabel(
|
||||
providerId: SupportedProviderId,
|
||||
model: string | undefined
|
||||
): string | undefined {
|
||||
if (providerId !== 'opencode') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getOpenCodeQualifiedModelSourceLabel(model) ?? undefined;
|
||||
}
|
||||
|
||||
export function getProviderScopedTeamModelLabel(
|
||||
providerId: SupportedProviderId,
|
||||
model: string | undefined
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
||||
|
||||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||
|
||||
export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
|
|
@ -13,7 +15,7 @@ export function stripTrailingOneMillionSuffixes(model: string | undefined): stri
|
|||
|
||||
export function extractProviderScopedBaseModel(
|
||||
model: string | undefined,
|
||||
providerId?: TeamProviderId
|
||||
providerId?: SupportedProviderId
|
||||
): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) {
|
||||
|
|
|
|||
1
src/shared/constants/opencodeTaskLogAttribution.ts
Normal file
1
src/shared/constants/opencodeTaskLogAttribution.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const OPENCODE_TASK_LOG_ATTRIBUTION_FILE = 'opencode-task-log-attribution.json';
|
||||
|
|
@ -33,7 +33,7 @@ export type CliPlatform =
|
|||
|
||||
export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
|
||||
|
||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
|
||||
|
||||
export interface CliProviderConnectionInfo {
|
||||
|
|
@ -194,6 +194,7 @@ export interface CliProviderStatus {
|
|||
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
modelVerificationState?: 'idle' | 'verifying' | 'verified';
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
models: string[];
|
||||
modelCatalog?: CliProviderModelCatalog | null;
|
||||
modelAvailability?: CliProviderModelAvailability[];
|
||||
|
|
|
|||
|
|
@ -336,10 +336,25 @@ export interface BoardTaskLogSegment {
|
|||
chunks: EnhancedChunk[];
|
||||
}
|
||||
|
||||
export interface BoardTaskLogStreamRuntimeProjection {
|
||||
provider: 'opencode';
|
||||
mode: 'attribution' | 'heuristic';
|
||||
attributionRecordCount: number;
|
||||
projectedMessageCount: number;
|
||||
fallbackReason?:
|
||||
| 'no_attribution_records'
|
||||
| 'attribution_no_projected_messages'
|
||||
| 'task_tool_markers';
|
||||
markerMatchCount?: number;
|
||||
markerSpanCount?: number;
|
||||
}
|
||||
|
||||
export interface BoardTaskLogStreamResponse {
|
||||
participants: BoardTaskLogParticipant[];
|
||||
defaultFilter: 'all' | string;
|
||||
segments: BoardTaskLogSegment[];
|
||||
source?: 'transcript' | 'opencode_runtime_fallback' | 'opencode_runtime_attribution';
|
||||
runtimeProjection?: BoardTaskLogStreamRuntimeProjection;
|
||||
}
|
||||
|
||||
export interface BoardTaskLogStreamSummary {
|
||||
|
|
@ -450,11 +465,7 @@ export interface TeamTask {
|
|||
}
|
||||
|
||||
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||
export type TaskChangePresenceState =
|
||||
| 'has_changes'
|
||||
| 'needs_attention'
|
||||
| 'no_changes'
|
||||
| 'unknown';
|
||||
export type TaskChangePresenceState = 'has_changes' | 'needs_attention' | 'no_changes' | 'unknown';
|
||||
|
||||
export interface TeamTaskWithKanban extends TeamTask {
|
||||
/** Set when task is in team kanban (review or approved column). */
|
||||
|
|
@ -787,7 +798,7 @@ export interface TeamViewSnapshot {
|
|||
}
|
||||
|
||||
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native';
|
||||
export type TeamFastMode = 'inherit' | 'on' | 'off';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { inferTeamProviderIdFromModel } from '../teamProvider';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
isTeamProviderId,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '../teamProvider';
|
||||
|
||||
describe('inferTeamProviderIdFromModel', () => {
|
||||
it('recognizes Anthropic aliases with 1m suffixes', () => {
|
||||
|
|
@ -13,5 +17,13 @@ describe('inferTeamProviderIdFromModel', () => {
|
|||
expect(inferTeamProviderIdFromModel('claude-opus-4-6')).toBe('anthropic');
|
||||
expect(inferTeamProviderIdFromModel('gpt-5.4')).toBe('codex');
|
||||
expect(inferTeamProviderIdFromModel('gemini-2.5-pro')).toBe('gemini');
|
||||
expect(inferTeamProviderIdFromModel('opencode/default')).toBe('opencode');
|
||||
expect(inferTeamProviderIdFromModel('openai/gpt-5.4')).toBe('opencode');
|
||||
expect(inferTeamProviderIdFromModel('openrouter/moonshotai/kimi-k2')).toBe('opencode');
|
||||
});
|
||||
|
||||
it('treats OpenCode as a valid explicit team provider id', () => {
|
||||
expect(isTeamProviderId('opencode')).toBe(true);
|
||||
expect(normalizeOptionalTeamProviderId('opencode')).toBe('opencode');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
78
src/shared/utils/opencodeModelRef.ts
Normal file
78
src/shared/utils/opencodeModelRef.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
export interface OpenCodeQualifiedModelRef {
|
||||
sourceId: string;
|
||||
modelId: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const OPEN_CODE_MODEL_REF_PATTERN = /^(?<source>[a-z0-9-]+)\/(?<model>\S.*)$/i;
|
||||
|
||||
const OPEN_CODE_SOURCE_LABELS: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
azure: 'Azure',
|
||||
bedrock: 'Bedrock',
|
||||
deepseek: 'DeepSeek',
|
||||
gemini: 'Gemini',
|
||||
google: 'Google',
|
||||
groq: 'Groq',
|
||||
minimax: 'MiniMax',
|
||||
mistral: 'Mistral',
|
||||
moonshot: 'Moonshot',
|
||||
ollama: 'Ollama',
|
||||
opencode: 'OpenCode',
|
||||
openai: 'OpenAI',
|
||||
'openai-compatible': 'OpenAI Compatible',
|
||||
openrouter: 'OpenRouter',
|
||||
together: 'Together',
|
||||
vertex: 'Vertex',
|
||||
xai: 'xAI',
|
||||
'z-ai': 'Z.AI',
|
||||
};
|
||||
|
||||
function humanizeOpenCodeSourceId(sourceId: string): string {
|
||||
const normalized = sourceId.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
const knownLabel = OPEN_CODE_SOURCE_LABELS[normalized];
|
||||
if (knownLabel) {
|
||||
return knownLabel;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function parseOpenCodeQualifiedModelRef(
|
||||
model: string | undefined | null
|
||||
): OpenCodeQualifiedModelRef | null {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = OPEN_CODE_MODEL_REF_PATTERN.exec(trimmed);
|
||||
if (!match?.groups?.source || !match.groups.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: trimmed,
|
||||
sourceId: match.groups.source.toLowerCase(),
|
||||
modelId: match.groups.model,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpenCodeQualifiedModelSourceLabel(
|
||||
model: string | undefined | null
|
||||
): string | null {
|
||||
const parsed = parseOpenCodeQualifiedModelRef(model);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return humanizeOpenCodeSourceId(parsed.sourceId);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import { parseOpenCodeQualifiedModelRef } from './opencodeModelRef';
|
||||
|
||||
export function isTeamProviderId(value: unknown): value is TeamProviderId {
|
||||
return value === 'anthropic' || value === 'codex' || value === 'gemini';
|
||||
return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode';
|
||||
}
|
||||
|
||||
export function normalizeOptionalTeamProviderId(value: unknown): TeamProviderId | undefined {
|
||||
|
|
@ -24,6 +26,13 @@ export function inferTeamProviderIdFromModel(
|
|||
}
|
||||
const normalizedWithoutExtendedContextSuffix = normalized.replace(/(?:\[1m\])+$/, '');
|
||||
|
||||
if (
|
||||
normalized.startsWith('opencode/') ||
|
||||
normalizedWithoutExtendedContextSuffix.startsWith('opencode/')
|
||||
) {
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith('gpt-') ||
|
||||
normalized.startsWith('codex') ||
|
||||
|
|
@ -53,5 +62,12 @@ export function inferTeamProviderIdFromModel(
|
|||
return 'anthropic';
|
||||
}
|
||||
|
||||
if (
|
||||
parseOpenCodeQualifiedModelRef(normalized) ||
|
||||
parseOpenCodeQualifiedModelRef(normalizedWithoutExtendedContextSuffix)
|
||||
) {
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ function formatProviderName(providerId: string): string {
|
|||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
default:
|
||||
return providerId;
|
||||
}
|
||||
|
|
|
|||
4
src/types/agent-teams-controller.d.ts
vendored
4
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -89,6 +89,10 @@ declare module 'agent-teams-controller' {
|
|||
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeBootstrapCheckin(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeDeliverMessage(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeTaskEvent(flags: Record<string, unknown>): Promise<unknown>;
|
||||
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AgentBlocksApi {
|
||||
|
|
|
|||
|
|
@ -407,6 +407,188 @@ describe('CliInstallerService', () => {
|
|||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses execution-grade OpenCode model verification for explicit verify requests', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
});
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
|
||||
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockResolvedValue([
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
connection: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyProviderStatus').mockResolvedValue({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
connection: null,
|
||||
} as never);
|
||||
|
||||
const verifyOpenCodeModelsSpy = vi
|
||||
.spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyOpenCodeModels')
|
||||
.mockResolvedValue({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
reason: 'Token refresh failed: 401',
|
||||
},
|
||||
{
|
||||
modelId: 'opencode/big-pickle',
|
||||
status: 'available',
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
canLoginFromUi: false,
|
||||
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
connection: null,
|
||||
} as never);
|
||||
|
||||
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
if (normalizedArgs === '--version') {
|
||||
return { stdout: '2.3.4', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
||||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
expect(status.providers.find((provider) => provider.providerId === 'opencode')?.modelAvailability).toEqual([]);
|
||||
|
||||
const verifiedProvider = await service.verifyProviderModels('opencode');
|
||||
|
||||
expect(verifyOpenCodeModelsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(verifiedProvider?.modelVerificationState).toBe('verified');
|
||||
expect(verifiedProvider?.modelAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'opencode/big-pickle',
|
||||
status: 'available',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install mutex', () => {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import * as fsp from 'fs/promises';
|
|||
import { errorDetector } from '../../../../src/main/services/error/ErrorDetector';
|
||||
import { DataCache } from '../../../../src/main/services/infrastructure/DataCache';
|
||||
import { FileWatcher } from '../../../../src/main/services/infrastructure/FileWatcher';
|
||||
import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '../../../../src/shared/constants/opencodeTaskLogAttribution';
|
||||
|
||||
function createFakeWatcher(): FsType.FSWatcher {
|
||||
const emitter = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
|
|
@ -168,6 +169,27 @@ describe('FileWatcher', () => {
|
|||
watcher.stop();
|
||||
});
|
||||
|
||||
it('emits log-source-change when OpenCode task-log attribution manifest changes', () => {
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
|
||||
const events: unknown[] = [];
|
||||
watcher.on('team-change', (event) => events.push(event));
|
||||
|
||||
(
|
||||
watcher as unknown as {
|
||||
processTeamsChange: (eventType: string, filename: string) => void;
|
||||
}
|
||||
).processTeamsChange('change', `team-a/${OPENCODE_TASK_LOG_ATTRIBUTION_FILE}`);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'log-source-change',
|
||||
teamName: 'team-a',
|
||||
detail: OPENCODE_TASK_LOG_ATTRIBUTION_FILE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps append offset pinned for partial trailing lines until completed', async () => {
|
||||
vi.useRealTimers();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-'));
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
expect(providers).toHaveLength(4);
|
||||
expect(providers[0]).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
|
|
@ -218,6 +218,18 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
projectId: 'demo-project',
|
||||
},
|
||||
});
|
||||
expect(providers[3]).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||
|
|
@ -693,4 +705,313 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
statusMessage: 'Codex CLI not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses live OpenCode verification on explicit provider verify', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (normalizedArgs === 'runtime status --json --provider opencode') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
opencode: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: null,
|
||||
detailMessage: 'version 1.4.0 - connected openai',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: {
|
||||
plugins: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
mcp: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
skills: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
apiKeys: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
},
|
||||
},
|
||||
models: ['openai/gpt-5.4-mini'],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
externalRuntimeDiagnostics: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'runtime verify --json --provider opencode') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
snapshot: {
|
||||
detected: true,
|
||||
hostHealthy: true,
|
||||
probeError: null,
|
||||
diagnostics: [],
|
||||
host: {
|
||||
version: '1.4.0',
|
||||
resolvedConfigFingerprint: 'resolved-fingerprint-123456',
|
||||
},
|
||||
profile: {
|
||||
profileRootKey: 'profile-root',
|
||||
projectBehaviorFingerprint: 'behavior-fingerprint-123456',
|
||||
managedConfigFingerprint: 'managed-fingerprint-123456',
|
||||
},
|
||||
config: {
|
||||
default_agent: 'teammate',
|
||||
share: 'disabled',
|
||||
snapshot: false,
|
||||
autoupdate: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const provider = await service.verifyProviderStatus('/mock/agent_teams_orchestrator', 'opencode');
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
verificationState: 'verified',
|
||||
detailMessage: expect.stringContaining('live resolved-fin'),
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
authMethodDetail: 'managed teammate agent',
|
||||
},
|
||||
});
|
||||
expect(provider.externalRuntimeDiagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'opencode-live-host',
|
||||
detected: true,
|
||||
statusMessage: 'Healthy',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'opencode-managed-runtime',
|
||||
detected: true,
|
||||
statusMessage: 'Managed runtime verified',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('loads projected OpenCode transcript data through the runtime transcript command', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime transcript --json --provider opencode --team team-a --member alice --limit 20'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
transcript: {
|
||||
sessionId: 'session-1',
|
||||
durableState: 'idle',
|
||||
messageCount: 2,
|
||||
toolCallCount: 1,
|
||||
errorCount: 0,
|
||||
latestAssistantText: '/tmp/project',
|
||||
latestAssistantPreview: '/tmp/project',
|
||||
messages: [],
|
||||
diagnostics: [],
|
||||
logProjection: {
|
||||
sessionId: 'session-1',
|
||||
durableState: 'idle',
|
||||
sourceMessageCount: 2,
|
||||
projectedMessageCount: 3,
|
||||
syntheticMessageCount: 1,
|
||||
toolCallCount: 1,
|
||||
errorCount: 0,
|
||||
diagnostics: [],
|
||||
messages: [
|
||||
{
|
||||
uuid: 'msg-assistant-1',
|
||||
type: 'assistant',
|
||||
toolCalls: [{ id: 'call_pwd', name: 'bash' }],
|
||||
},
|
||||
{
|
||||
uuid: 'msg-assistant-1::tool_results',
|
||||
type: 'user',
|
||||
isMeta: true,
|
||||
toolResults: [{ toolUseId: 'call_pwd', isError: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', {
|
||||
teamId: 'team-a',
|
||||
memberName: 'alice',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(transcript).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
durableState: 'idle',
|
||||
toolCallCount: 1,
|
||||
logProjection: {
|
||||
projectedMessageCount: 3,
|
||||
syntheticMessageCount: 1,
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
uuid: 'msg-assistant-1',
|
||||
type: 'assistant',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
uuid: 'msg-assistant-1::tool_results',
|
||||
type: 'user',
|
||||
isMeta: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies OpenCode models through execution-grade runtime probes', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model openai/gpt-5.4-mini'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
outcome: 'unavailable',
|
||||
reason: 'Token refresh failed: 401',
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model opencode/big-pickle'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'opencode/big-pickle',
|
||||
outcome: 'available',
|
||||
reason: null,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model openrouter/moonshotai/kimi-k2'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'openrouter/moonshotai/kimi-k2',
|
||||
outcome: 'available',
|
||||
reason: null,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const provider = await service.verifyOpenCodeModels('/mock/agent_teams_orchestrator', {
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['openai/gpt-5.4-mini', 'openrouter/moonshotai/kimi-k2', 'opencode/big-pickle'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: {
|
||||
plugins: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
mcp: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
skills: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
apiKeys: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
},
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
connection: null,
|
||||
});
|
||||
|
||||
expect(provider.modelVerificationState).toBe('verified');
|
||||
expect(provider.modelAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
reason: 'Token refresh failed: 401',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'openrouter/moonshotai/kimi-k2',
|
||||
status: 'available',
|
||||
reason: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'opencode/big-pickle',
|
||||
status: 'available',
|
||||
reason: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe('providerRuntimeEnv', () => {
|
|||
it('preserves gemini as a valid team provider id', () => {
|
||||
expect(resolveTeamProviderId('gemini')).toBe('gemini');
|
||||
expect(resolveTeamProviderId('codex')).toBe('codex');
|
||||
expect(resolveTeamProviderId('opencode')).toBe('opencode');
|
||||
expect(resolveTeamProviderId(undefined)).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,61 @@ describe('BoardTaskLogStreamService', () => {
|
|||
expect(recordSource.getTaskRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to OpenCode runtime stream when transcript slices are empty', async () => {
|
||||
const runtimeFallbackSource = {
|
||||
getTaskLogStream: vi.fn(async () => ({
|
||||
participants: [
|
||||
{
|
||||
key: 'member:alice',
|
||||
label: 'alice',
|
||||
role: 'member' as const,
|
||||
isLead: false,
|
||||
isSidechain: true,
|
||||
},
|
||||
],
|
||||
defaultFilter: 'member:alice',
|
||||
segments: [
|
||||
{
|
||||
id: 'opencode:segment-1',
|
||||
participantKey: 'member:alice',
|
||||
actor: {
|
||||
memberName: 'alice',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-opencode',
|
||||
isSidechain: true,
|
||||
},
|
||||
startTimestamp: '2026-04-21T10:00:00.000Z',
|
||||
endTimestamp: '2026-04-21T10:01:00.000Z',
|
||||
chunks: [{ id: 'chunk-1' }],
|
||||
},
|
||||
],
|
||||
source: 'opencode_runtime_fallback' as const,
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
{
|
||||
getTaskRecords: vi.fn(async () => []),
|
||||
} as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
runtimeFallbackSource as never
|
||||
);
|
||||
|
||||
const response = await service.getTaskLogStream('demo', 'task-a');
|
||||
|
||||
expect(response.source).toBe('opencode_runtime_fallback');
|
||||
expect(response.segments).toHaveLength(1);
|
||||
expect(await service.getTaskLogStreamSummary('demo', 'task-a')).toEqual({
|
||||
segmentCount: 0,
|
||||
});
|
||||
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => {
|
||||
const tom = {
|
||||
memberName: 'tom',
|
||||
|
|
|
|||
223
test/main/services/team/OpenCodeApiCapabilities.test.ts
Normal file
223
test/main/services/team/OpenCodeApiCapabilities.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createEmptyEndpointMap,
|
||||
createOpenCodeApiDiscoverySnapshot,
|
||||
detectOpenCodeApiCapabilities,
|
||||
resolveMissingOpenCodeCapabilities,
|
||||
type OpenCodeApiEndpointMap,
|
||||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
|
||||
describe('OpenCodeApiCapabilities', () => {
|
||||
it('proves production launch capabilities from OpenAPI v1.14-style paths', async () => {
|
||||
const fetch = fakeFetch({
|
||||
'/doc': jsonResponse(openApiDocument()),
|
||||
'/global/health': jsonResponse({ version: '1.14.19' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
detectOpenCodeApiCapabilities({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
fetchImpl: fetch,
|
||||
timeoutMs: 100,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
version: '1.14.19',
|
||||
source: 'openapi_doc',
|
||||
endpoints: {
|
||||
permissionReply: true,
|
||||
experimentalToolIds: true,
|
||||
},
|
||||
evidence: {
|
||||
permissionReply: 'openapi',
|
||||
experimentalToolIds: 'openapi',
|
||||
},
|
||||
requiredForTeamLaunch: {
|
||||
ready: true,
|
||||
missing: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps launch blocked when no permission reply route is proven', async () => {
|
||||
const document = openApiDocument({
|
||||
withoutPaths: ['/permission/{requestID}/reply'],
|
||||
});
|
||||
const fetch = fakeFetch({
|
||||
'/doc': jsonResponse(document),
|
||||
'/global/health': jsonResponse({ version: '1.14.19' }),
|
||||
});
|
||||
|
||||
const capabilities = await detectOpenCodeApiCapabilities({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
fetchImpl: fetch,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(capabilities.endpoints.permissionReply).toBe(false);
|
||||
expect(capabilities.requiredForTeamLaunch).toEqual({
|
||||
ready: false,
|
||||
missing: ['POST permission reply route'],
|
||||
});
|
||||
expect(capabilities.diagnostics).toContain(
|
||||
'OpenCode permission response endpoint was not proven by OpenAPI; require real permission E2E before production launch'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the legacy session permission response route as compatibility fallback', async () => {
|
||||
const document = openApiDocument({
|
||||
withoutPaths: ['/permission/{requestID}/reply'],
|
||||
extraPaths: {
|
||||
'/session/{sessionID}/permissions/{permissionID}': { post: {} },
|
||||
},
|
||||
});
|
||||
const fetch = fakeFetch({
|
||||
'/doc': jsonResponse(document),
|
||||
'/global/health': jsonResponse({ version: '1.14.19' }),
|
||||
});
|
||||
|
||||
const capabilities = await detectOpenCodeApiCapabilities({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
fetchImpl: fetch,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(capabilities.endpoints.permissionReply).toBe(false);
|
||||
expect(capabilities.endpoints.permissionLegacySessionRespond).toBe(true);
|
||||
expect(capabilities.requiredForTeamLaunch).toEqual({ ready: true, missing: [] });
|
||||
});
|
||||
|
||||
it('uses safe direct probes as evidence when OpenAPI doc is unavailable', async () => {
|
||||
const fetch = fakeFetch({
|
||||
'/doc': new Response('missing', { status: 404 }),
|
||||
'/doc.json': new Response('<html>not json</html>', { status: 200 }),
|
||||
'/openapi.json': new Response('missing', { status: 404 }),
|
||||
'/global/health': jsonResponse({ build: { version: '1.14.19' } }),
|
||||
'/session/status': jsonResponse({}),
|
||||
'/permission/': jsonResponse([]),
|
||||
'/event': eventStreamResponse(),
|
||||
'/global/event': eventStreamResponse(),
|
||||
'/mcp': jsonResponse([]),
|
||||
'/experimental/tool/ids': jsonResponse(['agent-teams_runtime_deliver_message']),
|
||||
});
|
||||
|
||||
const capabilities = await detectOpenCodeApiCapabilities({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
fetchImpl: fetch,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(capabilities.version).toBe('1.14.19');
|
||||
expect(capabilities.source).toBe('direct_probe');
|
||||
expect(capabilities.endpoints.permissionList).toBe(true);
|
||||
expect(capabilities.evidence.permissionList).toBe('direct_probe');
|
||||
expect(capabilities.requiredForTeamLaunch.ready).toBe(false);
|
||||
expect(capabilities.requiredForTeamLaunch.missing).toContain('POST /session');
|
||||
expect(capabilities.requiredForTeamLaunch.missing).toContain('POST permission reply route');
|
||||
});
|
||||
|
||||
it('uses experimental tool list as fallback for tool availability proof', () => {
|
||||
const endpoints: OpenCodeApiEndpointMap = {
|
||||
...createEmptyEndpointMap(),
|
||||
health: true,
|
||||
sessionCreate: true,
|
||||
sessionGet: true,
|
||||
sessionMessageList: true,
|
||||
sessionPromptAsync: true,
|
||||
sessionAbort: true,
|
||||
sessionStatus: true,
|
||||
permissionList: true,
|
||||
permissionReply: true,
|
||||
sessionEventStream: true,
|
||||
globalEventStream: true,
|
||||
mcpList: true,
|
||||
mcpCreate: true,
|
||||
experimentalToolIds: false,
|
||||
experimentalToolList: true,
|
||||
};
|
||||
|
||||
expect(resolveMissingOpenCodeCapabilities(endpoints)).toEqual([]);
|
||||
});
|
||||
|
||||
it('redacts credentials and hashes the OpenAPI document in discovery snapshots', async () => {
|
||||
const capabilities = await detectOpenCodeApiCapabilities({
|
||||
baseUrl: 'http://user:secret@127.0.0.1:4096',
|
||||
fetchImpl: fakeFetch({
|
||||
'/doc': jsonResponse(openApiDocument()),
|
||||
'/global/health': jsonResponse({ version: '1.14.19' }),
|
||||
}),
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
const snapshot = createOpenCodeApiDiscoverySnapshot({
|
||||
baseUrl: 'http://user:secret@127.0.0.1:4096',
|
||||
checkedAt: '2026-04-21T12:00:00.000Z',
|
||||
capabilities,
|
||||
openApiDocument: openApiDocument(),
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
checkedAt: '2026-04-21T12:00:00.000Z',
|
||||
opencodeVersion: '1.14.19',
|
||||
baseUrlRedacted: 'http://redacted:redacted@127.0.0.1:4096/',
|
||||
});
|
||||
expect(snapshot.openApiHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
function openApiDocument(options: {
|
||||
withoutPaths?: string[];
|
||||
extraPaths?: Record<string, Record<string, unknown>>;
|
||||
} = {}): Record<string, unknown> {
|
||||
const paths: Record<string, Record<string, unknown>> = {
|
||||
'/global/health': { get: {} },
|
||||
'/session': { post: {} },
|
||||
'/session/{id}': { get: {} },
|
||||
'/session/{id}/message': { get: {} },
|
||||
'/session/{id}/prompt_async': { post: {} },
|
||||
'/session/{id}/abort': { post: {} },
|
||||
'/session/status': { get: {} },
|
||||
'/permission': { get: {} },
|
||||
'/permission/{requestID}/reply': { post: {} },
|
||||
'/event': { get: {} },
|
||||
'/global/event': { get: {} },
|
||||
'/mcp': { get: {}, post: {} },
|
||||
'/experimental/tool/ids': { get: {} },
|
||||
...options.extraPaths,
|
||||
};
|
||||
|
||||
for (const path of options.withoutPaths ?? []) {
|
||||
delete paths[path];
|
||||
}
|
||||
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'OpenCode', version: '1.14.19' },
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeFetch(routes: Record<string, Response>): typeof fetch {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const response = routes[url.pathname];
|
||||
if (!response) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
return response.clone();
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function eventStreamResponse(): Response {
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
269
test/main/services/team/OpenCodeBridgeCommandClient.test.ts
Normal file
269
test/main/services/team/OpenCodeBridgeCommandClient.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeBridgeCommandClient,
|
||||
redactBridgeDiagnosticText,
|
||||
type OpenCodeBridgeDiagnosticsSink,
|
||||
type OpenCodeBridgeProcessRunInput,
|
||||
type OpenCodeBridgeProcessRunResult,
|
||||
type OpenCodeBridgeProcessRunner,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import type {
|
||||
OpenCodeBridgeDiagnosticEvent,
|
||||
OpenCodeBridgeSuccess,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
let tempDir: string;
|
||||
let runner: FakeBridgeProcessRunner;
|
||||
let diagnostics: FakeDiagnosticsSink;
|
||||
|
||||
describe('OpenCodeBridgeCommandClient', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-client-'));
|
||||
runner = new FakeBridgeProcessRunner();
|
||||
diagnostics = new FakeDiagnosticsSink();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a private input envelope, executes the bridge command, and removes the input file', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
});
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0]).toMatchObject({
|
||||
binaryPath: '/usr/local/bin/agent-teams-controller',
|
||||
args: ['runtime', 'opencode-command', '--json', '--input', expect.any(String)],
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const inputPath = runner.calls[0].args[4];
|
||||
expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
body: { runId: 'run-1' },
|
||||
});
|
||||
await expect(fs.access(inputPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('fails closed when stdout contains logs plus json', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: 'debug token=secret\n{"ok":true}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'contract_violation',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(diagnostics.append).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'opencode_bridge_contract_violation',
|
||||
severity: 'error',
|
||||
runId: 'run-1',
|
||||
data: {
|
||||
stdoutPreview: 'debug token=[redacted]\n{"ok":true}\n',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('records bridge timeout as unknown outcome with redacted diagnostics', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: '',
|
||||
stderr: 'Authorization: Bearer live-token',
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'timeout',
|
||||
retryable: true,
|
||||
details: {
|
||||
stderr: 'Authorization: Bearer [redacted]',
|
||||
},
|
||||
},
|
||||
diagnostics: [
|
||||
expect.objectContaining({
|
||||
type: 'opencode_bridge_unknown_outcome',
|
||||
severity: 'warning',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns non-zero process exit into provider_error without parsing stdout', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: `${JSON.stringify(bridgeSuccess())}\n`,
|
||||
stderr: 'api_key=secret failed',
|
||||
exitCode: 2,
|
||||
timedOut: false,
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'provider_error',
|
||||
retryable: true,
|
||||
details: {
|
||||
exitCode: 2,
|
||||
stderr: 'api_key=[redacted] failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bridge result envelope mismatches before caller can mutate state', async () => {
|
||||
runner.nextResult = {
|
||||
stdout: `${JSON.stringify(bridgeSuccess({ requestId: 'other-req' }))}\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = await client.execute('opencode.launchTeam', { runId: 'run-1' }, {
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'contract_violation',
|
||||
message: 'OpenCode bridge requestId mismatch',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactBridgeDiagnosticText', () => {
|
||||
it('redacts common secret forms and caps large payloads', () => {
|
||||
const value = `token=abc password:secret Authorization: Bearer live ${'x'.repeat(5000)}`;
|
||||
|
||||
const redacted = redactBridgeDiagnosticText(value);
|
||||
|
||||
expect(redacted).toContain('token=[redacted]');
|
||||
expect(redacted).toContain('password:[redacted]');
|
||||
expect(redacted).toContain('Authorization: Bearer [redacted]');
|
||||
expect(redacted).toContain('[truncated]');
|
||||
expect(redacted.length).toBeLessThan(4_100);
|
||||
});
|
||||
});
|
||||
|
||||
function createClient(): OpenCodeBridgeCommandClient {
|
||||
return new OpenCodeBridgeCommandClient({
|
||||
binaryPath: '/usr/local/bin/agent-teams-controller',
|
||||
tempDirectory: tempDir,
|
||||
processRunner: runner,
|
||||
diagnostics,
|
||||
requestIdFactory: () => 'req-1',
|
||||
diagnosticIdFactory: () => 'diag-1',
|
||||
clock: () => new Date('2026-04-21T12:00:00.000Z'),
|
||||
env: { PATH: '/usr/bin' },
|
||||
});
|
||||
}
|
||||
|
||||
function bridgeSuccess(
|
||||
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
|
||||
): OpenCodeBridgeSuccess<unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
|
||||
calls: OpenCodeBridgeProcessRunInput[] = [];
|
||||
inputEnvelopes: string[] = [];
|
||||
nextResult: OpenCodeBridgeProcessRunResult = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
|
||||
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
|
||||
this.calls.push(input);
|
||||
this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8'));
|
||||
return this.nextResult;
|
||||
}
|
||||
|
||||
async readInputEnvelope(index: number): Promise<string> {
|
||||
return this.inputEnvelopes[index];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDiagnosticsSink implements OpenCodeBridgeDiagnosticsSink {
|
||||
readonly events: OpenCodeBridgeDiagnosticEvent[] = [];
|
||||
readonly append = vi.fn(async (event: OpenCodeBridgeDiagnosticEvent) => {
|
||||
this.events.push(event);
|
||||
});
|
||||
}
|
||||
303
test/main/services/team/OpenCodeBridgeCommandContract.test.ts
Normal file
303
test/main/services/team/OpenCodeBridgeCommandContract.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assertBridgeEvidenceCanCommitToRuntimeStores,
|
||||
assertBridgeResultCanMutateState,
|
||||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
createOpenCodeBridgeIdempotencyKey,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
validateBridgeResultEnvelope,
|
||||
validateOpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgeCommandEnvelope,
|
||||
type OpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgePeerIdentity,
|
||||
type OpenCodeBridgeRuntimeSnapshot,
|
||||
type OpenCodeBridgeSuccess,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
describe('OpenCodeBridgeCommandContract', () => {
|
||||
it('rejects bridge stdout with logs plus json', () => {
|
||||
const result = parseSingleBridgeJsonResult('debug log\n{"ok":true}\n');
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Bridge stdout must contain exactly one JSON line, got 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses exactly one bridge result JSON line', () => {
|
||||
const stdout = `${JSON.stringify(
|
||||
bridgeSuccess({
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
})
|
||||
)}\n`;
|
||||
|
||||
const parsed = parseSingleBridgeJsonResult(stdout);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('validates result request id and command against the command envelope', () => {
|
||||
const envelope: OpenCodeBridgeCommandEnvelope<Record<string, never>> = {
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-expected',
|
||||
command: 'opencode.launchTeam',
|
||||
cwd: '/tmp/project',
|
||||
startedAt: '2026-04-21T12:00:00.000Z',
|
||||
timeoutMs: 10_000,
|
||||
body: {},
|
||||
};
|
||||
|
||||
expect(validateBridgeResultEnvelope(bridgeSuccess({ requestId: 'other' }), envelope)).toEqual({
|
||||
ok: false,
|
||||
reason: 'OpenCode bridge requestId mismatch',
|
||||
});
|
||||
|
||||
expect(
|
||||
validateBridgeResultEnvelope(
|
||||
bridgeSuccess({ requestId: 'req-expected', command: 'opencode.stopTeam' }),
|
||||
envelope
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: 'OpenCode bridge command mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allow state mutation when capability snapshot mismatches', () => {
|
||||
const result = bridgeSuccess({
|
||||
runtime: { capabilitySnapshotId: 'old-snapshot' },
|
||||
data: { runId: 'run-1' },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertBridgeResultCanMutateState(result, {
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'new-snapshot',
|
||||
})
|
||||
).toThrow('OpenCode bridge capability snapshot mismatch');
|
||||
});
|
||||
|
||||
it('allows state mutation when caller has no capability snapshot evidence to compare', () => {
|
||||
const result = bridgeSuccess({
|
||||
runtime: { capabilitySnapshotId: 'runtime-snapshot' },
|
||||
data: { runId: 'run-1' },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertBridgeResultCanMutateState(result, {
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: null,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects state-changing bridge evidence with stale manifest or idempotency mismatch', () => {
|
||||
const result = bridgeSuccess({
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
runtimeStoreManifestHighWatermark: 9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertBridgeEvidenceCanCommitToRuntimeStores({
|
||||
result,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
manifest: { highWatermark: 10 },
|
||||
idempotencyKey: 'key-1',
|
||||
})
|
||||
).toThrow('Bridge result manifest high watermark is stale');
|
||||
|
||||
expect(() =>
|
||||
assertBridgeEvidenceCanCommitToRuntimeStores({
|
||||
result: bridgeSuccess({
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'other-key',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
}),
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
manifest: { highWatermark: 10 },
|
||||
idempotencyKey: 'key-1',
|
||||
})
|
||||
).toThrow('Bridge result idempotency key mismatch');
|
||||
});
|
||||
|
||||
it('rejects handshake when server manifest high watermark is stale', () => {
|
||||
const client = peerIdentity('claude_team');
|
||||
const server = peerIdentity('agent_teams_orchestrator', {
|
||||
runtimeStoreManifestHighWatermark: 9,
|
||||
});
|
||||
const handshake = buildHandshake({ client, server });
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.launchTeam',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: 'Bridge server runtime manifest high watermark is stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects handshake when identity hash does not match peer evidence', () => {
|
||||
const client = peerIdentity('claude_team');
|
||||
const server = peerIdentity('agent_teams_orchestrator');
|
||||
const handshake: OpenCodeBridgeHandshake = {
|
||||
...buildHandshake({ client, server }),
|
||||
identityHash: 'tampered',
|
||||
};
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.launchTeam',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: 'Bridge handshake identity hash mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
|
||||
const first = createOpenCodeBridgeIdempotencyKey({
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'Team A',
|
||||
runId: 'run-1',
|
||||
body: { a: 1, b: { c: true, d: ['x'] } },
|
||||
});
|
||||
const second = createOpenCodeBridgeIdempotencyKey({
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'Team A',
|
||||
runId: 'run-1',
|
||||
body: { b: { d: ['x'], c: true }, a: 1 },
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^opencode:opencode.launchTeam:Team_A:run-1:[a-f0-9]{32}$/);
|
||||
expect(stableHash({ b: 2, a: 1 })).toBe(stableHash({ a: 1, b: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
type BridgeSuccessOverrides = Omit<Partial<OpenCodeBridgeSuccess<unknown>>, 'runtime'> & {
|
||||
runtime?: Partial<OpenCodeBridgeRuntimeSnapshot>;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
function bridgeSuccess(overrides: BridgeSuccessOverrides = {}): OpenCodeBridgeSuccess<unknown> {
|
||||
const { runtime: runtimeOverrides, ...rest } = overrides;
|
||||
return {
|
||||
ok: true,
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
...runtimeOverrides,
|
||||
},
|
||||
diagnostics: [],
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function peerIdentity(
|
||||
peer: OpenCodeBridgePeerIdentity['peer'],
|
||||
runtimeOverrides: Partial<OpenCodeBridgePeerIdentity['runtime']> = {}
|
||||
): OpenCodeBridgePeerIdentity {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
peer,
|
||||
appVersion: '1.0.0',
|
||||
gitSha: 'git-1',
|
||||
buildId: 'build-1',
|
||||
bridgeProtocol: {
|
||||
minVersion: 1,
|
||||
currentVersion: 1,
|
||||
supportedCommands: [
|
||||
'opencode.handshake',
|
||||
'opencode.commandStatus',
|
||||
'opencode.launchTeam',
|
||||
'opencode.stopTeam',
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
activeRunId: 'run-1',
|
||||
...runtimeOverrides,
|
||||
},
|
||||
featureFlags: {
|
||||
opencodeTeamLaunch: true,
|
||||
opencodeStateChangingCommands: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildHandshake(input: {
|
||||
client: OpenCodeBridgePeerIdentity;
|
||||
server: OpenCodeBridgePeerIdentity;
|
||||
}): OpenCodeBridgeHandshake {
|
||||
const withoutHash: Omit<OpenCodeBridgeHandshake, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
requestId: 'handshake-1',
|
||||
client: input.client,
|
||||
server: input.server,
|
||||
agreedProtocolVersion: 1,
|
||||
acceptedCommands: ['opencode.launchTeam', 'opencode.stopTeam'],
|
||||
serverTime: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
|
||||
return {
|
||||
...withoutHash,
|
||||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
||||
};
|
||||
}
|
||||
234
test/main/services/team/OpenCodeBridgeCommandLedgerStore.test.ts
Normal file
234
test/main/services/team/OpenCodeBridgeCommandLedgerStore.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { stableHash } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
||||
|
||||
describe('OpenCodeBridgeCommandLedgerStore', () => {
|
||||
let tempDir: string;
|
||||
let now: Date;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-ledger-'));
|
||||
now = new Date('2026-04-21T12:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('blocks idempotency key reuse with a different payload', async () => {
|
||||
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(tempDir, 'ledger.json'),
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ledger.begin({
|
||||
idempotencyKey: 'same',
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash: stableHash({ prompt: 'first' }),
|
||||
})
|
||||
).resolves.toBe('started');
|
||||
|
||||
await expect(
|
||||
ledger.begin({
|
||||
idempotencyKey: 'same',
|
||||
requestId: 'req-2',
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash: stableHash({ prompt: 'second' }),
|
||||
})
|
||||
).rejects.toThrow('OpenCode bridge idempotency key reused with different payload');
|
||||
});
|
||||
|
||||
it('marks timeout as unknown outcome and blocks retry until recovery', async () => {
|
||||
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(tempDir, 'ledger.json'),
|
||||
clock: () => now,
|
||||
});
|
||||
const requestHash = stableHash({ teamName: 'team-a', runId: 'run-1' });
|
||||
|
||||
await ledger.begin({
|
||||
idempotencyKey: 'launch:team-a:run-1',
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash,
|
||||
});
|
||||
await ledger.markUnknownAfterTimeout({
|
||||
idempotencyKey: 'launch:team-a:run-1',
|
||||
error: 'timeout',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ledger.begin({
|
||||
idempotencyKey: 'launch:team-a:run-1',
|
||||
requestId: 'req-2',
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash,
|
||||
})
|
||||
).rejects.toThrow('OpenCode bridge command outcome must be reconciled before retry');
|
||||
|
||||
await expect(ledger.getByIdempotencyKey('launch:team-a:run-1')).resolves.toMatchObject({
|
||||
status: 'unknown_after_timeout',
|
||||
retryable: false,
|
||||
lastError: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows same-payload duplicate only after a completed command', async () => {
|
||||
const ledger = createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(tempDir, 'ledger.json'),
|
||||
clock: () => now,
|
||||
});
|
||||
const requestHash = stableHash({ body: 'same' });
|
||||
|
||||
await ledger.begin({
|
||||
idempotencyKey: 'key-1',
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.stopTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ledger.begin({
|
||||
idempotencyKey: 'key-1',
|
||||
requestId: 'req-2',
|
||||
command: 'opencode.stopTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash,
|
||||
})
|
||||
).rejects.toThrow('OpenCode bridge command already started');
|
||||
|
||||
await ledger.markCompleted({
|
||||
idempotencyKey: 'key-1',
|
||||
response: { ok: true, runId: 'run-1' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ledger.begin({
|
||||
idempotencyKey: 'key-1',
|
||||
requestId: 'req-3',
|
||||
command: 'opencode.stopTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
requestHash,
|
||||
})
|
||||
).resolves.toBe('duplicate_same_payload_completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeBridgeCommandLeaseStore', () => {
|
||||
let tempDir: string;
|
||||
let now: Date;
|
||||
let nextId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-lease-'));
|
||||
now = new Date('2026-04-21T12:00:00.000Z');
|
||||
nextId = 1;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serializes state-changing commands per team through an active lease', async () => {
|
||||
const leaseStore = createOpenCodeBridgeCommandLeaseStore({
|
||||
filePath: path.join(tempDir, 'leases.json'),
|
||||
idFactory: () => `lease-${nextId++}`,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
const first = await leaseStore.acquire({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
command: 'opencode.launchTeam',
|
||||
ttlMs: 10_000,
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
leaseId: 'lease-1',
|
||||
state: 'active',
|
||||
expiresAt: '2026-04-21T12:00:10.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
leaseStore.acquire({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
command: 'opencode.stopTeam',
|
||||
ttlMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow('OpenCode bridge command lease already active: lease-1');
|
||||
|
||||
await leaseStore.release('lease-1');
|
||||
await expect(
|
||||
leaseStore.acquire({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
command: 'opencode.stopTeam',
|
||||
ttlMs: 10_000,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
leaseId: 'lease-2',
|
||||
command: 'opencode.stopTeam',
|
||||
state: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('expires stale active leases before acquiring a new one', async () => {
|
||||
const leaseStore = createOpenCodeBridgeCommandLeaseStore({
|
||||
filePath: path.join(tempDir, 'leases.json'),
|
||||
idFactory: () => `lease-${nextId++}`,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await leaseStore.acquire({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
command: 'opencode.launchTeam',
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
now = new Date('2026-04-21T12:00:02.000Z');
|
||||
await expect(
|
||||
leaseStore.acquire({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
command: 'opencode.reconcileTeam',
|
||||
ttlMs: 1000,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
leaseId: 'lease-2',
|
||||
state: 'active',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await fs.readFile(path.join(tempDir, 'leases.json'), 'utf8')
|
||||
) as {
|
||||
data: Array<{ leaseId: string; state: string }>;
|
||||
};
|
||||
expect(persisted.data).toEqual([
|
||||
expect.objectContaining({ leaseId: 'lease-1', state: 'expired' }),
|
||||
expect.objectContaining({ leaseId: 'lease-2', state: 'active' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
240
test/main/services/team/OpenCodeEventNormalizer.test.ts
Normal file
240
test/main/services/team/OpenCodeEventNormalizer.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
mapOpenCodeStatusToDurableState,
|
||||
normalizeOpenCodeEvent,
|
||||
normalizeOpenCodeSessionStatus,
|
||||
} from '../../../../src/main/services/team/opencode/events/OpenCodeEventNormalizer';
|
||||
|
||||
describe('OpenCodeEventNormalizer', () => {
|
||||
it('normalizes v1.14 session.status object', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'session.status',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
status: { type: 'retry', attempt: 2, message: 'rate limited', next: 123 },
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'session_status',
|
||||
sessionId: 'ses_1',
|
||||
status: {
|
||||
type: 'retry',
|
||||
retryAttempt: 2,
|
||||
retryMessage: 'rate limited',
|
||||
retryNextAt: 123,
|
||||
rawShape: 'v1.14',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy string status and active compatibility status', () => {
|
||||
expect(normalizeOpenCodeSessionStatus('active')).toMatchObject({
|
||||
type: 'busy',
|
||||
rawShape: 'legacy-string',
|
||||
});
|
||||
expect(normalizeOpenCodeSessionStatus('idle')).toMatchObject({
|
||||
type: 'idle',
|
||||
rawShape: 'legacy-string',
|
||||
});
|
||||
expect(normalizeOpenCodeSessionStatus('unexpected')).toMatchObject({
|
||||
type: 'unknown',
|
||||
rawShape: 'legacy-string',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes deprecated session.idle as an idle session status', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'ses_1' },
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'session_status',
|
||||
sessionId: 'ses_1',
|
||||
status: {
|
||||
type: 'idle',
|
||||
rawShape: 'v1.14',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes global event envelopes without losing directory evidence', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
directory: '/repo',
|
||||
payload: {
|
||||
type: 'server.heartbeat',
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'server_heartbeat',
|
||||
scope: 'global',
|
||||
directory: '/repo',
|
||||
raw: {
|
||||
directory: '/repo',
|
||||
payload: {
|
||||
type: 'server.heartbeat',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes message.updated role and message id from info snapshot', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'message.updated',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
info: { id: 'msg_1', role: 'assistant' },
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'message_updated',
|
||||
sessionId: 'ses_1',
|
||||
messageId: 'msg_1',
|
||||
role: 'assistant',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes message.part.updated snapshots separately from streaming deltas', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
part: {
|
||||
id: 'part_1',
|
||||
messageID: 'msg_1',
|
||||
type: 'text',
|
||||
text: 'complete text',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'message_part_updated',
|
||||
sessionId: 'ses_1',
|
||||
messageId: 'msg_1',
|
||||
partId: 'part_1',
|
||||
partType: 'text',
|
||||
textSnapshot: 'complete text',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes streaming text from message.part.delta', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'message.part.delta',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
messageID: 'msg_1',
|
||||
partID: 'part_1',
|
||||
field: 'text',
|
||||
delta: 'hello',
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'message_part_delta',
|
||||
sessionId: 'ses_1',
|
||||
messageId: 'msg_1',
|
||||
partId: 'part_1',
|
||||
field: 'text',
|
||||
delta: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes permission events across v1.14 and legacy ids', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'permission.asked',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
id: 'perm_1',
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'permission_asked',
|
||||
sessionId: 'ses_1',
|
||||
requestId: 'perm_1',
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'permission.replied',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
requestID: 'perm_legacy',
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'permission_replied',
|
||||
sessionId: 'ses_1',
|
||||
requestId: 'perm_legacy',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unknown event instead of throwing on incomplete known payloads', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'message.part.delta',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
messageID: 'msg_1',
|
||||
field: 'text',
|
||||
delta: 'hello',
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'unknown',
|
||||
type: 'message.part.delta',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps normalized status and projections to durable session state', () => {
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'busy' }), {
|
||||
hasPendingPermission: true,
|
||||
hasLatestAssistantError: false,
|
||||
replyPendingSinceMessageId: null,
|
||||
})
|
||||
).toBe('blocked');
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'busy' }), {
|
||||
hasPendingPermission: false,
|
||||
hasLatestAssistantError: true,
|
||||
replyPendingSinceMessageId: null,
|
||||
})
|
||||
).toBe('error');
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'retry' }), {
|
||||
hasPendingPermission: false,
|
||||
hasLatestAssistantError: false,
|
||||
replyPendingSinceMessageId: null,
|
||||
})
|
||||
).toBe('retrying');
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'busy' }), {
|
||||
hasPendingPermission: false,
|
||||
hasLatestAssistantError: false,
|
||||
replyPendingSinceMessageId: null,
|
||||
})
|
||||
).toBe('running');
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'idle' }), {
|
||||
hasPendingPermission: false,
|
||||
hasLatestAssistantError: false,
|
||||
replyPendingSinceMessageId: 'msg_1',
|
||||
})
|
||||
).toBe('reply_pending');
|
||||
expect(
|
||||
mapOpenCodeStatusToDurableState(normalizeOpenCodeSessionStatus({ type: 'idle' }), {
|
||||
hasPendingPermission: false,
|
||||
hasLatestAssistantError: false,
|
||||
replyPendingSinceMessageId: null,
|
||||
})
|
||||
).toBe('idle');
|
||||
});
|
||||
});
|
||||
258
test/main/services/team/OpenCodeLaunchTransactionStore.test.ts
Normal file
258
test/main/services/team/OpenCodeLaunchTransactionStore.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canMarkOpenCodeRunReady,
|
||||
createOpenCodeLaunchEvidenceHash,
|
||||
createOpenCodeLaunchTransactionStore,
|
||||
redactOpenCodeLaunchEvidence,
|
||||
type OpenCodeLaunchCheckpoint,
|
||||
type OpenCodeLaunchTransaction,
|
||||
} from '../../../../src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore';
|
||||
|
||||
let tempDir: string;
|
||||
let now: Date;
|
||||
|
||||
describe('OpenCodeLaunchTransactionStore', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-launch-tx-'));
|
||||
now = new Date('2026-04-21T12:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('begins a run and blocks duplicate launch while active', async () => {
|
||||
const store = createStore();
|
||||
|
||||
await expect(
|
||||
store.beginRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'created',
|
||||
transaction: {
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.beginRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-2',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'already_active',
|
||||
transaction: {
|
||||
runId: 'run-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adds checkpoints idempotently and ignores late checkpoints from old runs', async () => {
|
||||
const store = createStore();
|
||||
await store.beginRun({ teamName: 'team-a', runId: 'run-1' });
|
||||
const checkpoint = buildCheckpoint({
|
||||
name: 'member_session_recorded',
|
||||
memberName: 'Builder',
|
||||
evidenceHash: createOpenCodeLaunchEvidenceHash({ sessionId: 'session-1' }),
|
||||
});
|
||||
|
||||
await expect(store.addCheckpoint(checkpoint)).resolves.toBe('created');
|
||||
await expect(store.addCheckpoint(checkpoint)).resolves.toBe('unchanged');
|
||||
await expect(
|
||||
store.hasCheckpoint({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
memberName: 'Builder',
|
||||
name: 'member_session_recorded',
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
store.addCheckpoint({
|
||||
...checkpoint,
|
||||
runId: 'old-run',
|
||||
})
|
||||
).rejects.toThrow('OpenCode launch transaction not found: old-run');
|
||||
await expect(store.read('team-a', 'run-1')).resolves.toMatchObject({
|
||||
checkpoints: [expect.objectContaining({ name: 'member_session_recorded' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('finishes active transaction and rejects stale finish for another run', async () => {
|
||||
const store = createStore();
|
||||
await store.beginRun({ teamName: 'team-a', runId: 'run-1' });
|
||||
|
||||
await expect(
|
||||
store.finish({
|
||||
teamName: 'team-a',
|
||||
runId: 'old-run',
|
||||
status: 'failed',
|
||||
})
|
||||
).rejects.toThrow('OpenCode launch transaction old-run is stale; active run is run-1');
|
||||
|
||||
await expect(
|
||||
store.finish({
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
status: 'ready',
|
||||
})
|
||||
).resolves.toBe('finished');
|
||||
await expect(store.readActive('team-a')).resolves.toBeNull();
|
||||
await expect(store.read('team-a', 'run-1')).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('quarantines future or invalid transaction data through VersionedJsonStore', async () => {
|
||||
const filePath = path.join(tempDir, 'launch-transactions.json');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
updatedAt: '2026-04-21T12:00:00.000Z',
|
||||
data: [{ teamName: 'team-a', runId: '' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const store = createStore(filePath);
|
||||
|
||||
await expect(store.list()).rejects.toMatchObject({
|
||||
reason: 'invalid_data',
|
||||
});
|
||||
const files = await fs.readdir(tempDir);
|
||||
expect(files.some((file) => file.includes('invalid_data'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canMarkOpenCodeRunReady', () => {
|
||||
it('lists exact missing readiness checkpoints before run_ready', () => {
|
||||
const transaction = transactionWithCheckpoints([
|
||||
buildCheckpoint({
|
||||
name: 'member_session_recorded',
|
||||
memberName: 'Builder',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
canMarkOpenCodeRunReady({
|
||||
members: [
|
||||
{ name: 'Builder', launchState: 'confirmed_alive' },
|
||||
{ name: 'Reviewer', launchState: 'pending' },
|
||||
],
|
||||
transaction,
|
||||
toolProof: { ok: false },
|
||||
deliveryReady: false,
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
missing: [
|
||||
'Builder:required_tools_proven',
|
||||
'Reviewer:member_session_recorded',
|
||||
'Reviewer:required_tools_proven',
|
||||
'Reviewer:bootstrap_confirmed',
|
||||
'required_runtime_tools',
|
||||
'runtime_delivery_service',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows ready only when every member and runtime delivery proof exists', () => {
|
||||
const transaction = transactionWithCheckpoints([
|
||||
buildCheckpoint({ name: 'member_session_recorded', memberName: 'Builder' }),
|
||||
buildCheckpoint({ name: 'required_tools_proven', memberName: 'Builder' }),
|
||||
buildCheckpoint({ name: 'member_session_recorded', memberName: 'Reviewer' }),
|
||||
buildCheckpoint({ name: 'required_tools_proven', memberName: 'Reviewer' }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
canMarkOpenCodeRunReady({
|
||||
members: [
|
||||
{ name: 'Builder', launchState: 'confirmed_alive' },
|
||||
{ name: 'Reviewer', launchState: 'confirmed_alive' },
|
||||
],
|
||||
transaction,
|
||||
toolProof: { ok: true },
|
||||
deliveryReady: true,
|
||||
})
|
||||
).toEqual({
|
||||
ok: true,
|
||||
missing: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCode launch evidence redaction', () => {
|
||||
it('redacts secret fields before hashing evidence', () => {
|
||||
const evidence = {
|
||||
sessionId: 'session-1',
|
||||
token: 'live-token',
|
||||
nested: {
|
||||
apiKey: 'live-key',
|
||||
},
|
||||
};
|
||||
|
||||
expect(redactOpenCodeLaunchEvidence(evidence)).toEqual({
|
||||
sessionId: 'session-1',
|
||||
token: '[redacted]',
|
||||
nested: {
|
||||
apiKey: '[redacted]',
|
||||
},
|
||||
});
|
||||
expect(createOpenCodeLaunchEvidenceHash(evidence)).toBe(
|
||||
createOpenCodeLaunchEvidenceHash({
|
||||
sessionId: 'session-1',
|
||||
token: 'other-token',
|
||||
nested: {
|
||||
apiKey: 'other-key',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createStore(filePath = path.join(tempDir, 'launch-transactions.json')) {
|
||||
return createOpenCodeLaunchTransactionStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
}
|
||||
|
||||
function buildCheckpoint(
|
||||
overrides: Partial<OpenCodeLaunchCheckpoint>
|
||||
): OpenCodeLaunchCheckpoint {
|
||||
return {
|
||||
name: 'run_created',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
memberName: null,
|
||||
runtimeSessionId: null,
|
||||
hostKey: null,
|
||||
evidenceHash: createOpenCodeLaunchEvidenceHash({ ok: true }),
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function transactionWithCheckpoints(
|
||||
checkpoints: OpenCodeLaunchCheckpoint[]
|
||||
): OpenCodeLaunchTransaction {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
providerId: 'opencode',
|
||||
startedAt: '2026-04-21T12:00:00.000Z',
|
||||
updatedAt: '2026-04-21T12:00:00.000Z',
|
||||
status: 'active',
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
158
test/main/services/team/OpenCodeManagedOverlay.test.ts
Normal file
158
test/main/services/team/OpenCodeManagedOverlay.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assertManagedOverlayDoesNotShadowUserConfig,
|
||||
buildManagedOverlayConfig,
|
||||
OpenCodeBehaviorSourceScanner,
|
||||
OpenCodeManagedOverlayBuilder,
|
||||
pickAppOwnedMcpServerName,
|
||||
} from '../../../../src/main/services/team/opencode/config/OpenCodeManagedOverlay';
|
||||
|
||||
describe('OpenCodeManagedOverlay', () => {
|
||||
let tempDir: string;
|
||||
let homePath: string;
|
||||
let projectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-managed-overlay-'));
|
||||
homePath = path.join(tempDir, 'home');
|
||||
projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('builds a minimal inline MCP overlay without user behavior keys or pure mode', async () => {
|
||||
await writeJson(path.join(projectPath, 'opencode.json'), {
|
||||
plugin: ['user-plugin'],
|
||||
mcp: { user_server: { type: 'local', command: 'custom', enabled: true } },
|
||||
});
|
||||
const builder = new OpenCodeManagedOverlayBuilder(
|
||||
new OpenCodeBehaviorSourceScanner({ homePath }),
|
||||
() => new Date('2026-04-21T12:00:00.000Z')
|
||||
);
|
||||
|
||||
const overlay = await builder.build({
|
||||
projectPath,
|
||||
preferredMcpName: 'agent-teams',
|
||||
appMcpCommand: 'node',
|
||||
appMcpArgs: ['server.js'],
|
||||
appMcpEnv: { TEAM_RUNTIME: '1' },
|
||||
});
|
||||
const config = JSON.parse(overlay.env.OPENCODE_CONFIG_CONTENT);
|
||||
|
||||
expect(config).toEqual({
|
||||
mcp: {
|
||||
'agent-teams': {
|
||||
type: 'local',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
enabled: true,
|
||||
environment: { TEAM_RUNTIME: '1' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(overlay.env).not.toHaveProperty('OPENCODE_PURE');
|
||||
expect(overlay.env).not.toHaveProperty('OPENCODE_DISABLE_PROJECT_CONFIG');
|
||||
expect(config).not.toHaveProperty('plugin');
|
||||
expect(config).not.toHaveProperty('model');
|
||||
expect(overlay.diagnostics).toContain('OpenCode managed overlay checked at 2026-04-21T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('renames the app-owned MCP server when user config already declares the preferred name', async () => {
|
||||
await writeJson(path.join(projectPath, 'opencode.json'), {
|
||||
mcp: {
|
||||
'agent-teams': { type: 'local', command: 'custom', enabled: true },
|
||||
'agent-teams-runtime-1': { type: 'local', command: 'custom-2', enabled: true },
|
||||
},
|
||||
});
|
||||
const builder = new OpenCodeManagedOverlayBuilder(new OpenCodeBehaviorSourceScanner({ homePath }));
|
||||
|
||||
const overlay = await builder.build({
|
||||
projectPath,
|
||||
preferredMcpName: 'agent-teams',
|
||||
appMcpCommand: 'node',
|
||||
appMcpArgs: ['server.js'],
|
||||
appMcpEnv: {},
|
||||
});
|
||||
|
||||
expect(overlay.appMcpServerName).toBe('agent-teams-runtime-2');
|
||||
expect(overlay.diagnostics.join('\n')).toContain('already declares MCP server "agent-teams"');
|
||||
});
|
||||
|
||||
it('reads JSONC project config and fingerprints plugin behavior sources', async () => {
|
||||
await fs.mkdir(path.join(projectPath, '.opencode/plugins'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'opencode.jsonc'),
|
||||
`{
|
||||
// user-owned MCP must be observed, not overwritten
|
||||
"mcp": {
|
||||
"agent-teams": { "type": "local", "command": "custom", "enabled": true }
|
||||
}
|
||||
}`,
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(projectPath, '.opencode/plugins/example.ts'), 'export default {}', 'utf8');
|
||||
const scanner = new OpenCodeBehaviorSourceScanner({ homePath });
|
||||
|
||||
await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual(new Set(['agent-teams']));
|
||||
const sources = await scanner.scan(projectPath);
|
||||
|
||||
expect(sources).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'project_plugin_dir',
|
||||
exists: true,
|
||||
fileCount: 1,
|
||||
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
);
|
||||
expect(sources).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'project_opencode_dir',
|
||||
exists: true,
|
||||
fileCount: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects managed overlays that would shadow user behavior keys', () => {
|
||||
expect(() =>
|
||||
assertManagedOverlayDoesNotShadowUserConfig({
|
||||
...buildManagedOverlayConfig({
|
||||
serverName: 'agent-teams',
|
||||
command: 'node',
|
||||
args: [],
|
||||
environment: {},
|
||||
timeout: 10_000,
|
||||
}),
|
||||
plugin: ['managed-plugin'],
|
||||
model: 'managed-model',
|
||||
})
|
||||
).toThrow('Managed OpenCode overlay must not set user behavior keys: plugin, model');
|
||||
});
|
||||
|
||||
it('picks deterministic collision-safe app MCP names', () => {
|
||||
expect(pickAppOwnedMcpServerName('agent-teams', new Set())).toBe('agent-teams');
|
||||
expect(pickAppOwnedMcpServerName('agent-teams', new Set(['agent-teams']))).toBe(
|
||||
'agent-teams-runtime-1'
|
||||
);
|
||||
expect(
|
||||
pickAppOwnedMcpServerName(
|
||||
'agent-teams',
|
||||
new Set(['agent-teams', 'agent-teams-runtime-1', 'agent-teams-runtime-2'])
|
||||
)
|
||||
).toBe('agent-teams-runtime-3');
|
||||
});
|
||||
});
|
||||
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
224
test/main/services/team/OpenCodeMcpToolAvailability.test.ts
Normal file
224
test/main/services/team/OpenCodeMcpToolAvailability.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
APP_MCP_RUNTIME_TOOL_CONTRACTS,
|
||||
assertRuntimeDeliverMessageSchema,
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
matchRequiredOpenCodeTools,
|
||||
OpenCodeMcpToolAvailabilityProbe,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
sanitizeOpenCodeMcpToolPart,
|
||||
verifyAppMcpRuntimeToolContracts,
|
||||
type OpenCodeInfrastructureToolClient,
|
||||
type OpenCodeToolListItem,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
describe('OpenCode MCP tool availability', () => {
|
||||
it('builds source-verified canonical MCP tool ids', () => {
|
||||
expect(sanitizeOpenCodeMcpToolPart('agent-teams')).toBe('agent-teams');
|
||||
expect(buildOpenCodeCanonicalMcpToolId('agent-teams', 'runtime_deliver_message')).toBe(
|
||||
'agent-teams_runtime_deliver_message'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails production proof when only alias ids are observed', () => {
|
||||
const proof = matchRequiredOpenCodeTools({
|
||||
route: '/experimental/tool/ids',
|
||||
serverName: 'agent-teams',
|
||||
requiredTools: ['runtime_deliver_message'],
|
||||
observedTools: ['mcp__agent-teams__runtime_deliver_message'],
|
||||
});
|
||||
|
||||
expect(proof).toMatchObject({
|
||||
ok: false,
|
||||
missingTools: ['runtime_deliver_message'],
|
||||
matchedByRequiredTool: {
|
||||
runtime_deliver_message: null,
|
||||
},
|
||||
aliasMatchedByRequiredTool: {
|
||||
runtime_deliver_message: 'mcp__agent-teams__runtime_deliver_message',
|
||||
},
|
||||
});
|
||||
expect(proof.diagnostics).toContain(
|
||||
'OpenCode observed alias mcp__agent-teams__runtime_deliver_message but missing canonical app MCP tool id agent-teams_runtime_deliver_message'
|
||||
);
|
||||
});
|
||||
|
||||
it('proves required tools through experimental tool ids', async () => {
|
||||
const client = fakeToolClient({
|
||||
ids: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
tools: [],
|
||||
});
|
||||
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
||||
|
||||
await expect(
|
||||
probe.proveRequiredTools({
|
||||
serverName: 'agent-teams',
|
||||
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-sonnet',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
route: '/experimental/tool/ids',
|
||||
missingTools: [],
|
||||
matchedByRequiredTool: {
|
||||
runtime_deliver_message: 'agent-teams_runtime_deliver_message',
|
||||
},
|
||||
});
|
||||
expect(client.listExperimentalTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to provider/model tool definitions when ids route fails', async () => {
|
||||
const client = fakeToolClient({
|
||||
idsError: new Error('ids unavailable'),
|
||||
tools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => ({
|
||||
id: buildOpenCodeCanonicalMcpToolId('agent-teams', tool),
|
||||
})),
|
||||
});
|
||||
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
||||
|
||||
await expect(
|
||||
probe.proveRequiredTools({
|
||||
serverName: 'agent-teams',
|
||||
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-sonnet',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
route: '/experimental/tool',
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(client.listExperimentalTools).toHaveBeenCalledWith({
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-sonnet',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps launch blocked when neither tool endpoint proves canonical tools', async () => {
|
||||
const client = fakeToolClient({
|
||||
ids: ['agent-teams_runtime_bootstrap_checkin'],
|
||||
toolsError: new Error('definitions unavailable'),
|
||||
});
|
||||
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
||||
|
||||
const proof = await probe.proveRequiredTools({
|
||||
serverName: 'agent-teams',
|
||||
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-sonnet',
|
||||
});
|
||||
|
||||
expect(proof.ok).toBe(false);
|
||||
expect(proof.missingTools).toEqual(
|
||||
expect.arrayContaining(['runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat'])
|
||||
);
|
||||
expect(proof.diagnostics).toContain(
|
||||
'OpenCode app-owned MCP server is connected but required runtime tools were not proven available'
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies direct app MCP runtime tool contracts', () => {
|
||||
const result = verifyAppMcpRuntimeToolContracts(
|
||||
APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => ({
|
||||
name: contract.name,
|
||||
inputSchema: schemaWithRequired(contract.requiredInputFields),
|
||||
}))
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
observedToolNames: [
|
||||
'runtime_bootstrap_checkin',
|
||||
'runtime_deliver_message',
|
||||
'runtime_heartbeat',
|
||||
'runtime_task_event',
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails direct app MCP preflight when delivery schema misses idempotencyKey', () => {
|
||||
const tools = APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => ({
|
||||
name: contract.name,
|
||||
inputSchema:
|
||||
contract.name === 'runtime_deliver_message'
|
||||
? schemaWithRequired(['runId', 'teamName', 'fromMemberName', 'runtimeSessionId', 'to', 'text'])
|
||||
: schemaWithRequired(contract.requiredInputFields),
|
||||
}));
|
||||
|
||||
expect(verifyAppMcpRuntimeToolContracts(tools).diagnostics).toContain(
|
||||
'App MCP tool runtime_deliver_message missing required field idempotencyKey'
|
||||
);
|
||||
});
|
||||
|
||||
it('validates provider/model runtime_deliver_message tool schema', () => {
|
||||
const tools: OpenCodeToolListItem[] = [
|
||||
{
|
||||
id: 'agent-teams_runtime_deliver_message',
|
||||
parameters: schemaWithRequired([
|
||||
'idempotencyKey',
|
||||
'runId',
|
||||
'teamName',
|
||||
'fromMemberName',
|
||||
'runtimeSessionId',
|
||||
'to',
|
||||
'text',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
expect(assertRuntimeDeliverMessageSchema(tools)).toEqual([]);
|
||||
expect(
|
||||
assertRuntimeDeliverMessageSchema([
|
||||
{
|
||||
id: 'agent-teams_runtime_deliver_message',
|
||||
parameters: schemaWithRequired(['runId', 'teamName']),
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
severity: 'error',
|
||||
message:
|
||||
'runtime_deliver_message schema missing required fields: idempotencyKey, fromMemberName, runtimeSessionId, to, text',
|
||||
missingFields: ['idempotencyKey', 'fromMemberName', 'runtimeSessionId', 'to', 'text'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeToolClient(options: {
|
||||
ids?: string[];
|
||||
tools?: OpenCodeToolListItem[];
|
||||
idsError?: Error;
|
||||
toolsError?: Error;
|
||||
}): OpenCodeInfrastructureToolClient & {
|
||||
listExperimentalToolIds: ReturnType<typeof vi.fn>;
|
||||
listExperimentalTools: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
listExperimentalToolIds: vi.fn(async () => {
|
||||
if (options.idsError) {
|
||||
throw options.idsError;
|
||||
}
|
||||
return options.ids ?? [];
|
||||
}),
|
||||
listExperimentalTools: vi.fn(async () => {
|
||||
if (options.toolsError) {
|
||||
throw options.toolsError;
|
||||
}
|
||||
return options.tools ?? [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function schemaWithRequired(required: string[]): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object',
|
||||
required,
|
||||
properties: Object.fromEntries(required.map((field) => [field, { type: 'string' }])),
|
||||
};
|
||||
}
|
||||
209
test/main/services/team/OpenCodeProductionE2EEvidence.test.ts
Normal file
209
test/main/services/team/OpenCodeProductionE2EEvidence.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
describe('OpenCodeProductionE2EEvidence', () => {
|
||||
let tempDir: string;
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts strict evidence only when runtime identity, model and required MCP tools match', () => {
|
||||
const evidence = passingEvidence();
|
||||
|
||||
expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence);
|
||||
expect(
|
||||
assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
now,
|
||||
expected: {
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requiredMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
ok: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for stale, mismatched or incomplete evidence', () => {
|
||||
const expired = passingEvidence({
|
||||
expiresAt: '2026-04-21T11:59:59.000Z',
|
||||
selectedModel: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
requiredSignals: requiredSignals({ stale_run_rejected: false }),
|
||||
mcpTools: {
|
||||
requiredTools: ['agent-teams_runtime_deliver_message'],
|
||||
observedTools: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: expired,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
now,
|
||||
expected: {
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requiredMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: expect.arrayContaining([
|
||||
'OpenCode production E2E evidence is expired',
|
||||
'OpenCode production E2E evidence is missing signals: stale_run_rejected',
|
||||
'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message',
|
||||
'OpenCode production E2E evidence model openrouter/anthropic/claude-sonnet-4.5 does not match selected model openai/gpt-5.4-mini',
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads missing evidence as a production-blocking diagnostic and quarantines corrupt artifacts', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(store.read()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: null,
|
||||
artifactPath: filePath,
|
||||
diagnostics: ['OpenCode production E2E evidence artifact has not been written yet'],
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{broken', 'utf8');
|
||||
const corrupt = await store.read();
|
||||
expect(corrupt).toMatchObject({
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: filePath,
|
||||
});
|
||||
expect(corrupt.diagnostics[0]).toContain(
|
||||
'OpenCode production E2E evidence store is unreadable'
|
||||
);
|
||||
});
|
||||
|
||||
it('writes evidence with the store path as artifactPath when the input omits it', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write({
|
||||
...passingEvidence(),
|
||||
artifactPath: null,
|
||||
});
|
||||
|
||||
await expect(store.read()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
artifactPath: filePath,
|
||||
evidenceId: 'e2e-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function passingEvidence(
|
||||
overrides: Partial<OpenCodeProductionE2EEvidence> = {}
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = '2026-04-21T12:00:00.000Z';
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: '2026-04-21T12:10:00.000Z',
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: requiredSignals(),
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSignals(
|
||||
overrides: Partial<
|
||||
Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean>
|
||||
> = {}
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'];
|
||||
}
|
||||
427
test/main/services/team/OpenCodeProductionGate.live.test.ts
Normal file
427
test/main/services/team/OpenCodeProductionGate.live.test.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { constants as fsConstants, promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
||||
import {
|
||||
createOpenCodeBridgeClientIdentity,
|
||||
OpenCodeBridgeCommandHandshakePort,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeStopTeamCommandData,
|
||||
RuntimeStoreManifestEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
|
||||
const liveDescribe = process.env.OPENCODE_E2E === '1' ? describe : describe.skip;
|
||||
|
||||
const PROJECT_PATH = '/Users/belief/dev/projects/claude/claude_team_opencode_integration';
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
|
||||
liveDescribe('OpenCode production gate live e2e', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-gate-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs live launch/reconcile/transcript/stop and accepts production evidence with app MCP tool proof', async () => {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const bridgeEnv = {
|
||||
...createStableBridgeEnv(),
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
};
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
binaryPath: orchestratorCli,
|
||||
tempDirectory: path.join(tempDir, 'bridge-input'),
|
||||
env: bridgeEnv,
|
||||
});
|
||||
const stateChangingCommands = createStateChangingCommands({
|
||||
bridge: bridgeClient,
|
||||
controlDir: path.join(tempDir, 'control'),
|
||||
});
|
||||
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
timeoutMs: 180_000,
|
||||
launchTimeoutMs: 180_000,
|
||||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
const readiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: PROJECT_PATH,
|
||||
selectedModel,
|
||||
requireExecutionProbe: false,
|
||||
});
|
||||
const runtime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
|
||||
if (!runtime) {
|
||||
throw new Error(
|
||||
`OpenCode live readiness did not return runtime snapshot: ${[
|
||||
...readiness.diagnostics,
|
||||
...readiness.missing,
|
||||
].join('; ')}`
|
||||
);
|
||||
}
|
||||
expect(runtime?.version).toBe('1.14.19');
|
||||
expect(runtime?.capabilitySnapshotId).toBeTruthy();
|
||||
|
||||
const runId = `opencode-e2e-${Date.now()}`;
|
||||
const teamName = `opencode-e2e-team-${Date.now()}`;
|
||||
const memberName = 'E2E';
|
||||
let launch: OpenCodeLaunchTeamCommandData | null = null;
|
||||
let reconcile: OpenCodeLaunchTeamCommandData | null = null;
|
||||
let stop: OpenCodeStopTeamCommandData | null = null;
|
||||
let transcriptMessages = 0;
|
||||
let staleRunRejected = false;
|
||||
|
||||
try {
|
||||
launch = await readinessBridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId,
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
selectedModel,
|
||||
members: [
|
||||
{
|
||||
name: memberName,
|
||||
role: 'e2e',
|
||||
prompt: 'Reply with exactly: opencode-production-gate-e2e',
|
||||
},
|
||||
],
|
||||
leadPrompt: 'Live OpenCode production gate e2e',
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
expect(launch.teamLaunchState).toBe('ready');
|
||||
expect(launch.members[memberName]?.launchState).toBe('confirmed_alive');
|
||||
|
||||
reconcile = await readinessBridge.reconcileOpenCodeTeam({
|
||||
runId,
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
expectedMembers: [{ name: memberName, model: selectedModel }],
|
||||
reason: 'production_gate_e2e',
|
||||
});
|
||||
expect(reconcile.teamLaunchState).toBe('ready');
|
||||
|
||||
const transcript = await bridgeClient.execute<
|
||||
{ teamId: string; teamName: string; memberName: string },
|
||||
{ logProjection?: { messages?: unknown[] }; messages?: unknown[] }
|
||||
>(
|
||||
'opencode.getRuntimeTranscript',
|
||||
{ teamId: teamName, teamName, memberName },
|
||||
{ cwd: PROJECT_PATH, timeoutMs: 60_000 }
|
||||
);
|
||||
expect(transcript.ok).toBe(true);
|
||||
if (transcript.ok) {
|
||||
transcriptMessages =
|
||||
transcript.data.logProjection?.messages?.length ?? transcript.data.messages?.length ?? 0;
|
||||
expect(transcriptMessages).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
staleRunRejected = await rejectsStaleCapability({
|
||||
stateChangingCommands,
|
||||
teamName,
|
||||
runId: `${runId}-stale`,
|
||||
selectedModel,
|
||||
});
|
||||
|
||||
stop = await readinessBridge.stopOpenCodeTeam({
|
||||
runId,
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_cleanup',
|
||||
force: true,
|
||||
});
|
||||
expect(stop.stopped).toBe(true);
|
||||
|
||||
const candidate = buildCandidateEvidence({
|
||||
runId,
|
||||
teamName,
|
||||
memberName,
|
||||
selectedModel,
|
||||
runtime: runtime!,
|
||||
readinessObservedTools: readiness.evidence.observedMcpTools,
|
||||
launch,
|
||||
reconcile,
|
||||
stop,
|
||||
transcriptMessages,
|
||||
staleRunRejected,
|
||||
appMcpToolsVisible: readiness.requiredToolsPresent,
|
||||
});
|
||||
const gate = assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: candidate,
|
||||
artifactPath: candidate.artifactPath,
|
||||
expected: {
|
||||
opencodeVersion: runtime?.version ?? null,
|
||||
binaryFingerprint: runtime?.binaryFingerprint ?? null,
|
||||
capabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
selectedModel,
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate).toEqual({
|
||||
ok: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
} finally {
|
||||
if (!stop) {
|
||||
await readinessBridge
|
||||
.stopOpenCodeTeam({
|
||||
runId,
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: runtime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_finally_cleanup',
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
function createStateChangingCommands(input: {
|
||||
bridge: OpenCodeBridgeCommandExecutor;
|
||||
controlDir: string;
|
||||
}): OpenCodeStateChangingBridgeCommandService {
|
||||
const clientIdentity = createOpenCodeBridgeClientIdentity({
|
||||
appVersion: '1.3.0-e2e',
|
||||
gitSha: null,
|
||||
buildId: 'opencode-production-gate-e2e',
|
||||
});
|
||||
|
||||
return new OpenCodeStateChangingBridgeCommandService({
|
||||
expectedClientIdentity: clientIdentity,
|
||||
handshakePort: new OpenCodeBridgeCommandHandshakePort({
|
||||
bridge: input.bridge,
|
||||
clientIdentity,
|
||||
}),
|
||||
leaseStore: createOpenCodeBridgeCommandLeaseStore({
|
||||
filePath: path.join(input.controlDir, 'leases.json'),
|
||||
}),
|
||||
ledger: createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(input.controlDir, 'ledger.json'),
|
||||
}),
|
||||
bridge: input.bridge,
|
||||
manifestReader: new StaticManifestReader(),
|
||||
});
|
||||
}
|
||||
|
||||
class StaticManifestReader implements RuntimeStoreManifestReader {
|
||||
async read(): Promise<RuntimeStoreManifestEvidence> {
|
||||
return {
|
||||
highWatermark: 0,
|
||||
activeRunId: null,
|
||||
capabilitySnapshotId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectsStaleCapability(input: {
|
||||
stateChangingCommands: OpenCodeStateChangingBridgeCommandService;
|
||||
teamName: string;
|
||||
runId: string;
|
||||
selectedModel: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await input.stateChangingCommands.execute({
|
||||
command: 'opencode.reconcileTeam',
|
||||
teamName: input.teamName,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: 'opencode:stale-capability',
|
||||
behaviorFingerprint: null,
|
||||
body: {
|
||||
runId: input.runId,
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: 'opencode:stale-capability',
|
||||
manifestHighWatermark: null,
|
||||
expectedMembers: [{ name: 'E2E', model: input.selectedModel }],
|
||||
reason: 'production_gate_stale_run_probe',
|
||||
},
|
||||
cwd: PROJECT_PATH,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
return error instanceof Error && error.message.includes('capability snapshot mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCandidateEvidence(input: {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
selectedModel: string;
|
||||
runtime: OpenCodeBridgeRuntimeSnapshot;
|
||||
readinessObservedTools: string[];
|
||||
launch: OpenCodeLaunchTeamCommandData;
|
||||
reconcile: OpenCodeLaunchTeamCommandData;
|
||||
stop: OpenCodeStopTeamCommandData;
|
||||
transcriptMessages: number;
|
||||
staleRunRejected: boolean;
|
||||
appMcpToolsVisible: boolean;
|
||||
}): OpenCodeProductionE2EEvidence {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const sessionId = input.launch.members[input.memberName]?.sessionId ?? 'missing-session';
|
||||
const checkpointByName = new Map<string, { name: string; observedAt: string }>();
|
||||
for (const checkpoint of input.launch.durableCheckpoints ?? []) {
|
||||
checkpointByName.set(checkpoint.name, {
|
||||
name: checkpoint.name,
|
||||
observedAt: checkpoint.observedAt,
|
||||
});
|
||||
}
|
||||
for (const evidence of input.launch.members[input.memberName]?.evidence ?? []) {
|
||||
checkpointByName.set(evidence.kind, {
|
||||
name: evidence.kind,
|
||||
observedAt: evidence.observedAt,
|
||||
});
|
||||
}
|
||||
for (const name of OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS) {
|
||||
checkpointByName.set(name, checkpointByName.get(name) ?? { name, observedAt: createdAt });
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
evidenceId: `live-${input.runId}`,
|
||||
createdAt,
|
||||
expiresAt: new Date(now.getTime() + 60 * 60 * 1000).toISOString(),
|
||||
version: input.runtime.version ?? 'unknown',
|
||||
passed: true,
|
||||
artifactPath: path.join(os.tmpdir(), `opencode-production-e2e-${input.runId}.json`),
|
||||
binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown',
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown',
|
||||
selectedModel: input.selectedModel,
|
||||
projectPathFingerprint: null,
|
||||
requiredSignals: {
|
||||
...Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
),
|
||||
app_mcp_tools_visible: input.appMcpToolsVisible,
|
||||
stale_run_rejected: input.staleRunRejected,
|
||||
} as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
launch: {
|
||||
runId: input.runId,
|
||||
teamId: input.teamName,
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: input.memberName,
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: Array.from(checkpointByName.values()),
|
||||
},
|
||||
reconcile: {
|
||||
runId: input.reconcile.runId,
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: Object.keys(input.reconcile.members).length,
|
||||
},
|
||||
stop: {
|
||||
runId: input.stop.runId,
|
||||
stopped: true,
|
||||
stoppedSessionIds: Object.values(input.stop.members)
|
||||
.map((member) => member.sessionId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: input.transcriptMessages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertExecutable(filePath: string): Promise<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
function withBunOnPath(pathValue: string): string {
|
||||
const bunDir = '/Users/belief/.bun/bin';
|
||||
return pathValue.split(path.delimiter).includes(bunDir)
|
||||
? pathValue
|
||||
: `${bunDir}${path.delimiter}${pathValue}`;
|
||||
}
|
||||
|
||||
function createStableBridgeEnv(): NodeJS.ProcessEnv {
|
||||
const realHome = os.userInfo().homedir;
|
||||
const passThroughKeys = [
|
||||
'USER',
|
||||
'LOGNAME',
|
||||
'SHELL',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'OPENCODE_E2E_MODEL',
|
||||
'OPENCODE_DISABLE_CLAUDE_CODE',
|
||||
'OPENCODE_DISABLE_AUTOUPDATE',
|
||||
];
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
passThroughKeys
|
||||
.map((key) => [key, process.env[key]] as const)
|
||||
.filter((entry): entry is readonly [string, string] => typeof entry[1] === 'string')
|
||||
),
|
||||
HOME: realHome,
|
||||
USERPROFILE: realHome,
|
||||
};
|
||||
}
|
||||
405
test/main/services/team/OpenCodeReadinessBridge.test.ts
Normal file
405
test/main/services/team/OpenCodeReadinessBridge.test.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeReadinessBridge,
|
||||
type OpenCodeReadinessBridgeCommandExecutor,
|
||||
type OpenCodeProductionE2EEvidenceReadPort,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
import type {
|
||||
OpenCodeBridgeFailureKind,
|
||||
OpenCodeBridgeCommandName,
|
||||
OpenCodeBridgeResult,
|
||||
OpenCodeBridgeSuccess,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
describe('OpenCodeReadinessBridge', () => {
|
||||
it('executes the read-only opencode.readiness command and returns readiness data', async () => {
|
||||
const readinessResult = readiness({ state: 'ready', launchAllowed: true });
|
||||
const executor = fakeExecutor(bridgeSuccess(readinessResult));
|
||||
const bridge = new OpenCodeReadinessBridge(executor, { timeoutMs: 15_000 });
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
})
|
||||
).resolves.toBe(readinessResult);
|
||||
|
||||
expect(executor.execute).toHaveBeenCalledWith(
|
||||
'opencode.readiness',
|
||||
{
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
},
|
||||
{
|
||||
cwd: '/repo',
|
||||
timeoutMs: 15_000,
|
||||
}
|
||||
);
|
||||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toMatchObject({
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
version: '1.14.19',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bridge failures into fail-closed readiness', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('timeout', 'OpenCode readiness command timed out', [
|
||||
{
|
||||
id: 'diag-1',
|
||||
type: 'opencode_bridge_unknown_outcome',
|
||||
providerId: 'opencode',
|
||||
severity: 'warning',
|
||||
message: 'timed out',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor);
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: false,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown_error',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
hostHealthy: false,
|
||||
requiredToolsPresent: false,
|
||||
missing: ['OpenCode readiness command timed out'],
|
||||
diagnostics: [
|
||||
'OpenCode readiness bridge failed: timeout: OpenCode readiness command timed out',
|
||||
'opencode_bridge_unknown_outcome: timed out',
|
||||
],
|
||||
});
|
||||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks production readiness when strict production E2E evidence is missing', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(null);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, { productionE2eEvidence: evidence });
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: ['OpenCode production launch requires a current production E2E evidence artifact'],
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
});
|
||||
expect(evidence.read).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows dogfood readiness while surfacing missing production E2E evidence diagnostics', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: fakeEvidenceStore(null),
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'dogfood',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps production readiness open when evidence matches runtime identity and raw model', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: fakeEvidenceStore(productionEvidence()),
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'production_supported',
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||
);
|
||||
const stateChangingExecute = vi.fn();
|
||||
const stateChangingCommands = {
|
||||
async execute<TBody, TData>(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
body: TBody;
|
||||
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||
stateChangingExecute(input);
|
||||
return bridgeCommandSuccess<OpenCodeLaunchTeamCommandData>({
|
||||
command: input.command,
|
||||
requestId: 'guarded-req-1',
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
idempotencyKey: 'idem-1',
|
||||
runtimeStoreManifestHighWatermark: 0,
|
||||
},
|
||||
}) as unknown as OpenCodeBridgeResult<TData>;
|
||||
},
|
||||
};
|
||||
const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands });
|
||||
|
||||
await expect(
|
||||
bridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
members: [],
|
||||
leadPrompt: '',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
manifestHighWatermark: 0,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
|
||||
expect(stateChangingExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
cwd: '/repo',
|
||||
})
|
||||
);
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeExecutor(
|
||||
result: OpenCodeBridgeResult<unknown>
|
||||
): OpenCodeReadinessBridgeCommandExecutor {
|
||||
return {
|
||||
execute: vi.fn(async () => result) as OpenCodeReadinessBridgeCommandExecutor['execute'],
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEvidenceStore(
|
||||
evidence: OpenCodeProductionE2EEvidence | null
|
||||
): OpenCodeProductionE2EEvidenceReadPort & { read: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
read: vi.fn(async () => ({
|
||||
ok: true,
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-production-e2e.json',
|
||||
diagnostics: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeSuccess(
|
||||
data: OpenCodeTeamLaunchReadiness
|
||||
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {
|
||||
return {
|
||||
ok: true,
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.readiness',
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeFailure(
|
||||
kind: OpenCodeBridgeFailureKind,
|
||||
message: string,
|
||||
diagnostics: OpenCodeBridgeResult<unknown>['diagnostics']
|
||||
): OpenCodeBridgeResult<unknown> {
|
||||
return {
|
||||
ok: false,
|
||||
schemaVersion: 1,
|
||||
requestId: 'req-1',
|
||||
command: 'opencode.readiness',
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
error: {
|
||||
kind,
|
||||
message,
|
||||
retryable: true,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeCommandSuccess<TData>(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
requestId: string;
|
||||
data: TData;
|
||||
}): OpenCodeBridgeSuccess<TData> {
|
||||
return {
|
||||
ok: true,
|
||||
schemaVersion: 1,
|
||||
requestId: input.requestId,
|
||||
command: input.command,
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
data: input.data,
|
||||
};
|
||||
}
|
||||
|
||||
function readiness(
|
||||
overrides: Partial<OpenCodeTeamLaunchReadiness> = {}
|
||||
): OpenCodeTeamLaunchReadiness {
|
||||
return {
|
||||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
opencodeVersion: '1.14.19',
|
||||
installMethod: 'brew',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
hostHealthy: true,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: true,
|
||||
permissionBridgeReady: true,
|
||||
runtimeStoresReady: true,
|
||||
supportLevel: 'production_supported',
|
||||
missing: [],
|
||||
diagnostics: [],
|
||||
evidence: {
|
||||
capabilitiesReady: true,
|
||||
mcpToolProofRoute: '/experimental/tool/ids',
|
||||
observedMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
runtimeStoreReadinessReason: 'runtime_store_manifest_valid',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function productionEvidence(
|
||||
overrides: Partial<OpenCodeProductionE2EEvidence> = {}
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = new Date().toISOString();
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-production-e2e.json',
|
||||
binaryFingerprint: 'bin-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
type OpenCodeBridgeCommandName,
|
||||
type OpenCodeBridgeHandshake,
|
||||
type OpenCodeBridgePeerIdentity,
|
||||
type OpenCodeBridgeResult,
|
||||
type OpenCodeBridgeSuccess,
|
||||
type RuntimeStoreManifestEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
type OpenCodeBridgeCommandLedger,
|
||||
type OpenCodeBridgeCommandLeaseStore,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
||||
import {
|
||||
OpenCodeStateChangingBridgeCommandService,
|
||||
type OpenCodeBridgeCommandExecutor,
|
||||
type OpenCodeBridgeHandshakePort,
|
||||
type OpenCodeStateChangingBridgeDiagnosticsSink,
|
||||
type RuntimeStoreManifestReader,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
|
||||
describe('OpenCodeStateChangingBridgeCommandService', () => {
|
||||
let tempDir: string;
|
||||
let now: Date;
|
||||
let nextLeaseId: number;
|
||||
let ledger: OpenCodeBridgeCommandLedger;
|
||||
let leaseStore: OpenCodeBridgeCommandLeaseStore;
|
||||
let bridge: FakeBridgeExecutor;
|
||||
let handshakePort: FakeHandshakePort;
|
||||
let manifestReader: FakeManifestReader;
|
||||
let diagnostics: FakeDiagnosticsSink;
|
||||
let clientIdentity: OpenCodeBridgePeerIdentity;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-state-bridge-'));
|
||||
now = new Date('2026-04-21T12:00:00.000Z');
|
||||
nextLeaseId = 1;
|
||||
ledger = createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(tempDir, 'ledger.json'),
|
||||
clock: () => now,
|
||||
});
|
||||
leaseStore = createOpenCodeBridgeCommandLeaseStore({
|
||||
filePath: path.join(tempDir, 'leases.json'),
|
||||
idFactory: () => `lease-${nextLeaseId++}`,
|
||||
clock: () => now,
|
||||
});
|
||||
clientIdentity = peerIdentity('claude_team');
|
||||
handshakePort = new FakeHandshakePort(buildHandshake({
|
||||
client: clientIdentity,
|
||||
server: peerIdentity('agent_teams_orchestrator'),
|
||||
}));
|
||||
manifestReader = new FakeManifestReader();
|
||||
bridge = new FakeBridgeExecutor();
|
||||
diagnostics = new FakeDiagnosticsSink();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects state-changing command when bridge handshake has stale manifest high watermark', async () => {
|
||||
handshakePort.nextHandshake = buildHandshake({
|
||||
client: clientIdentity,
|
||||
server: peerIdentity('agent_teams_orchestrator', {
|
||||
runtimeStoreManifestHighWatermark: 9,
|
||||
}),
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
await expect(service.execute(buildLaunchInput())).rejects.toThrow(
|
||||
'Bridge server runtime manifest high watermark is stale'
|
||||
);
|
||||
|
||||
expect(bridge.calls).toHaveLength(0);
|
||||
await expect(ledger.list()).resolves.toEqual([]);
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('adds preconditions, commits ledger, and releases lease on success', async () => {
|
||||
bridge.resultFactory = ({ body, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
const result = await service.execute(buildLaunchInput());
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(bridge.calls).toHaveLength(1);
|
||||
expect(bridge.calls[0].options).toMatchObject({ requestId: 'cmd-1' });
|
||||
expect(bridge.calls[0].body).toMatchObject({
|
||||
prompt: 'launch',
|
||||
preconditions: {
|
||||
handshakeIdentityHash: handshakePort.nextHandshake.identityHash,
|
||||
expectedRunId: 'run-1',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
expectedBehaviorFingerprint: 'behavior-1',
|
||||
expectedManifestHighWatermark: 10,
|
||||
commandLeaseId: 'lease-1',
|
||||
idempotencyKey: expect.stringMatching(/^opencode:opencode.launchTeam:team-a:run-1:/),
|
||||
},
|
||||
});
|
||||
await expect(ledger.getByIdempotencyKey(bridge.calls[0].body.preconditions.idempotencyKey))
|
||||
.resolves.toMatchObject({
|
||||
requestId: 'cmd-1',
|
||||
status: 'completed',
|
||||
retryable: false,
|
||||
completedAt: '2026-04-21T12:00:00.000Z',
|
||||
});
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('records unknown outcome after timeout and blocks retry before a duplicate bridge call', async () => {
|
||||
bridge.resultFactory = ({ body, command, options }) => ({
|
||||
ok: false,
|
||||
schemaVersion: 1,
|
||||
requestId: options.requestId,
|
||||
command,
|
||||
completedAt: '2026-04-21T12:00:10.000Z',
|
||||
durationMs: 10_000,
|
||||
error: {
|
||||
kind: 'timeout',
|
||||
message: 'timeout',
|
||||
retryable: true,
|
||||
},
|
||||
diagnostics: [],
|
||||
data: body,
|
||||
} as OpenCodeBridgeResult<unknown>);
|
||||
const service = createService();
|
||||
|
||||
const first = await service.execute(buildLaunchInput());
|
||||
|
||||
expect(first).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: 'timeout' },
|
||||
});
|
||||
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
|
||||
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
|
||||
status: 'unknown_after_timeout',
|
||||
retryable: false,
|
||||
lastError: 'timeout',
|
||||
});
|
||||
expect(diagnostics.append).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'opencode_bridge_unknown_outcome',
|
||||
data: expect.objectContaining({
|
||||
idempotencyKey,
|
||||
leaseId: 'lease-1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(service.execute(buildLaunchInput())).rejects.toThrow(
|
||||
'OpenCode bridge command outcome must be reconciled before retry'
|
||||
);
|
||||
expect(bridge.calls).toHaveLength(1);
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('marks result precondition mismatch as failed and does not leave active lease', async () => {
|
||||
bridge.resultFactory = ({ body, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 9,
|
||||
},
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
await expect(service.execute(buildLaunchInput())).rejects.toThrow(
|
||||
'Bridge result manifest high watermark is stale'
|
||||
);
|
||||
|
||||
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
|
||||
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
retryable: false,
|
||||
lastError: 'Bridge result manifest high watermark is stale',
|
||||
});
|
||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
function createService(): OpenCodeStateChangingBridgeCommandService {
|
||||
return new OpenCodeStateChangingBridgeCommandService({
|
||||
expectedClientIdentity: clientIdentity,
|
||||
handshakePort,
|
||||
leaseStore,
|
||||
ledger,
|
||||
bridge,
|
||||
manifestReader,
|
||||
diagnostics,
|
||||
requestIdFactory: () => 'cmd-1',
|
||||
diagnosticIdFactory: () => 'diag-1',
|
||||
clock: () => now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function buildLaunchInput(): Parameters<OpenCodeStateChangingBridgeCommandService['execute']>[0] {
|
||||
return {
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
behaviorFingerprint: 'behavior-1',
|
||||
body: { prompt: 'launch' },
|
||||
cwd: '/tmp/project',
|
||||
timeoutMs: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeSuccess(
|
||||
overrides: Partial<OpenCodeBridgeSuccess<unknown>> = {}
|
||||
): OpenCodeBridgeSuccess<unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
schemaVersion: 1,
|
||||
requestId: 'cmd-1',
|
||||
command: 'opencode.launchTeam',
|
||||
completedAt: '2026-04-21T12:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function peerIdentity(
|
||||
peer: OpenCodeBridgePeerIdentity['peer'],
|
||||
runtimeOverrides: Partial<OpenCodeBridgePeerIdentity['runtime']> = {}
|
||||
): OpenCodeBridgePeerIdentity {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
peer,
|
||||
appVersion: '1.0.0',
|
||||
gitSha: 'git-1',
|
||||
buildId: 'build-1',
|
||||
bridgeProtocol: {
|
||||
minVersion: 1,
|
||||
currentVersion: 1,
|
||||
supportedCommands: [
|
||||
'opencode.handshake',
|
||||
'opencode.commandStatus',
|
||||
'opencode.launchTeam',
|
||||
'opencode.stopTeam',
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
binaryPath: '/usr/local/bin/opencode',
|
||||
binaryFingerprint: 'bin-1',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
activeRunId: 'run-1',
|
||||
...runtimeOverrides,
|
||||
},
|
||||
featureFlags: {
|
||||
opencodeTeamLaunch: true,
|
||||
opencodeStateChangingCommands: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildHandshake(input: {
|
||||
client: OpenCodeBridgePeerIdentity;
|
||||
server: OpenCodeBridgePeerIdentity;
|
||||
}): OpenCodeBridgeHandshake {
|
||||
const withoutHash: Omit<OpenCodeBridgeHandshake, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
requestId: 'handshake-1',
|
||||
client: input.client,
|
||||
server: input.server,
|
||||
agreedProtocolVersion: 1,
|
||||
acceptedCommands: ['opencode.launchTeam', 'opencode.stopTeam'],
|
||||
serverTime: '2026-04-21T12:00:00.000Z',
|
||||
};
|
||||
|
||||
return {
|
||||
...withoutHash,
|
||||
identityHash: createOpenCodeBridgeHandshakeIdentityHash(withoutHash),
|
||||
};
|
||||
}
|
||||
|
||||
class FakeBridgeExecutor implements OpenCodeBridgeCommandExecutor {
|
||||
calls: Array<{
|
||||
command: OpenCodeBridgeCommandName;
|
||||
body: { prompt: string; preconditions: { idempotencyKey: string } };
|
||||
options: { cwd: string; timeoutMs: number; requestId?: string };
|
||||
}> = [];
|
||||
resultFactory: (input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
body: { prompt: string; preconditions: { idempotencyKey: string } };
|
||||
options: { cwd: string; timeoutMs: number; requestId?: string };
|
||||
}) => OpenCodeBridgeResult<unknown> = ({ body, options }) =>
|
||||
bridgeSuccess({
|
||||
requestId: options.requestId,
|
||||
data: {
|
||||
runId: 'run-1',
|
||||
idempotencyKey: body.preconditions.idempotencyKey,
|
||||
runtimeStoreManifestHighWatermark: 10,
|
||||
},
|
||||
});
|
||||
|
||||
async execute<TBody, TData>(
|
||||
command: OpenCodeBridgeCommandName,
|
||||
body: TBody,
|
||||
options: { cwd: string; timeoutMs: number; requestId?: string }
|
||||
): Promise<OpenCodeBridgeResult<TData>> {
|
||||
const call = {
|
||||
command,
|
||||
body: body as { prompt: string; preconditions: { idempotencyKey: string } },
|
||||
options,
|
||||
};
|
||||
this.calls.push(call);
|
||||
return this.resultFactory(call) as OpenCodeBridgeResult<TData>;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHandshakePort implements OpenCodeBridgeHandshakePort {
|
||||
constructor(public nextHandshake: OpenCodeBridgeHandshake) {}
|
||||
|
||||
async handshake(): Promise<OpenCodeBridgeHandshake> {
|
||||
return this.nextHandshake;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeManifestReader implements RuntimeStoreManifestReader {
|
||||
manifest: RuntimeStoreManifestEvidence = {
|
||||
highWatermark: 10,
|
||||
activeRunId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
};
|
||||
|
||||
async read(): Promise<RuntimeStoreManifestEvidence> {
|
||||
return this.manifest;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDiagnosticsSink implements OpenCodeStateChangingBridgeDiagnosticsSink {
|
||||
readonly append = vi.fn(async () => {});
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeTaskLogAttributionService,
|
||||
type OpenCodeTaskLogAttributionWriter,
|
||||
} from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService';
|
||||
import type { OpenCodeTaskLogAttributionWriteResult } from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
||||
|
||||
function createWriter(result: OpenCodeTaskLogAttributionWriteResult = 'created') {
|
||||
const writer: OpenCodeTaskLogAttributionWriter = {
|
||||
upsertTaskRecord: vi.fn<OpenCodeTaskLogAttributionWriter['upsertTaskRecord']>(
|
||||
async () => result
|
||||
),
|
||||
replaceTaskRecords: vi.fn<OpenCodeTaskLogAttributionWriter['replaceTaskRecords']>(
|
||||
async () => result
|
||||
),
|
||||
clearTaskRecords: vi.fn<OpenCodeTaskLogAttributionWriter['clearTaskRecords']>(
|
||||
async () => result
|
||||
),
|
||||
};
|
||||
return writer;
|
||||
}
|
||||
|
||||
describe('OpenCodeTaskLogAttributionService', () => {
|
||||
it('records task-session attribution through the writer with launch defaults', async () => {
|
||||
const writer = createWriter('created');
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
const service = new OpenCodeTaskLogAttributionService(writer, () => now);
|
||||
|
||||
const outcome = await service.recordTaskSession({
|
||||
teamName: ' team-a ',
|
||||
taskId: ' task-a ',
|
||||
memberName: ' alice ',
|
||||
sessionId: ' session-a ',
|
||||
since: new Date('2026-04-21T11:59:00.000Z'),
|
||||
});
|
||||
|
||||
const expectedRecord = {
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'task_session' as const,
|
||||
sessionId: 'session-a',
|
||||
since: '2026-04-21T11:59:00.000Z',
|
||||
source: 'launch_runtime' as const,
|
||||
};
|
||||
expect(outcome).toEqual({ result: 'created', record: expectedRecord });
|
||||
expect(writer.upsertTaskRecord).toHaveBeenCalledWith('team-a', expectedRecord, { now });
|
||||
expect(writer.replaceTaskRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects task-session attribution without a session id before writing', async () => {
|
||||
const writer = createWriter();
|
||||
const service = new OpenCodeTaskLogAttributionService(writer);
|
||||
|
||||
await expect(
|
||||
service.recordTaskSession({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
sessionId: ' ',
|
||||
})
|
||||
).rejects.toThrow('task_session requires sessionId');
|
||||
expect(writer.upsertTaskRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsafe team, task, and member identifiers before writing', async () => {
|
||||
const writer = createWriter();
|
||||
const service = new OpenCodeTaskLogAttributionService(writer);
|
||||
|
||||
await expect(
|
||||
service.recordTaskSession({
|
||||
teamName: '../team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-a',
|
||||
})
|
||||
).rejects.toThrow('teamName contains invalid characters');
|
||||
|
||||
await expect(
|
||||
service.recordTaskSession({
|
||||
teamName: 'team-a',
|
||||
taskId: '../task-a',
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-a',
|
||||
})
|
||||
).rejects.toThrow('taskId contains invalid characters');
|
||||
|
||||
await expect(
|
||||
service.recordTaskSession({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: '../alice',
|
||||
sessionId: 'session-a',
|
||||
})
|
||||
).rejects.toThrow('memberName contains invalid characters');
|
||||
expect(writer.upsertTaskRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects broad or invalid member-window attribution before writing', async () => {
|
||||
const writer = createWriter();
|
||||
const service = new OpenCodeTaskLogAttributionService(writer);
|
||||
|
||||
await expect(
|
||||
service.recordMemberSessionWindow({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-a',
|
||||
})
|
||||
).rejects.toThrow('requires since or startMessageUuid');
|
||||
|
||||
await expect(
|
||||
service.recordMemberSessionWindow({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
until: '2026-04-21T12:00:00.000Z',
|
||||
})
|
||||
).rejects.toThrow('requires since or startMessageUuid');
|
||||
|
||||
await expect(
|
||||
service.recordMemberSessionWindow({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
since: '2026-04-21T13:00:00.000Z',
|
||||
until: '2026-04-21T12:00:00.000Z',
|
||||
})
|
||||
).rejects.toThrow('since must be before or equal to until');
|
||||
expect(writer.upsertTaskRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records bounded member-window attribution through the writer with reconcile defaults', async () => {
|
||||
const writer = createWriter('updated');
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
const service = new OpenCodeTaskLogAttributionService(writer, () => now);
|
||||
|
||||
const outcome = await service.recordMemberSessionWindow({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-b',
|
||||
startMessageUuid: ' m-1 ',
|
||||
endMessageUuid: ' m-3 ',
|
||||
});
|
||||
|
||||
const expectedRecord = {
|
||||
taskId: 'task-a',
|
||||
memberName: 'bob',
|
||||
scope: 'member_session_window' as const,
|
||||
sessionId: 'session-b',
|
||||
startMessageUuid: 'm-1',
|
||||
endMessageUuid: 'm-3',
|
||||
source: 'reconcile' as const,
|
||||
};
|
||||
expect(outcome).toEqual({ result: 'updated', record: expectedRecord });
|
||||
expect(writer.upsertTaskRecord).toHaveBeenCalledWith('team-a', expectedRecord, { now });
|
||||
});
|
||||
|
||||
it('replaces task attribution as a validated runtime snapshot', async () => {
|
||||
const writer = createWriter('updated');
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
const service = new OpenCodeTaskLogAttributionService(writer, () => now);
|
||||
|
||||
const outcome = await service.replaceTaskAttribution({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
source: 'reconcile',
|
||||
records: [
|
||||
{
|
||||
memberName: 'alice',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-a',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{
|
||||
memberName: 'bob',
|
||||
since: '2026-04-21T11:00:00Z',
|
||||
until: new Date('2026-04-21T11:30:00.000Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ result: 'updated', recordCount: 2 });
|
||||
expect(writer.replaceTaskRecords).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-a',
|
||||
[
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-a',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'bob',
|
||||
scope: 'member_session_window',
|
||||
since: '2026-04-21T11:00:00.000Z',
|
||||
until: '2026-04-21T11:30:00.000Z',
|
||||
source: 'reconcile',
|
||||
},
|
||||
],
|
||||
{ now }
|
||||
);
|
||||
});
|
||||
|
||||
it('clears task attribution through the writer only for one task', async () => {
|
||||
const writer = createWriter('deleted');
|
||||
const service = new OpenCodeTaskLogAttributionService(writer);
|
||||
|
||||
await expect(
|
||||
service.clearTaskAttribution({ teamName: ' team-a ', taskId: ' task-a ' })
|
||||
).resolves.toEqual({ result: 'deleted', recordCount: 0 });
|
||||
expect(writer.clearTaskRecords).toHaveBeenCalledWith('team-a', 'task-a');
|
||||
expect(writer.upsertTaskRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
313
test/main/services/team/OpenCodeTaskLogAttributionStore.test.ts
Normal file
313
test/main/services/team/OpenCodeTaskLogAttributionStore.test.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
teamsBasePath: '',
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => hoisted.teamsBasePath,
|
||||
}));
|
||||
|
||||
import {
|
||||
OpenCodeTaskLogAttributionStore,
|
||||
getOpenCodeTaskLogAttributionPath,
|
||||
} from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
||||
|
||||
describe('OpenCodeTaskLogAttributionStore', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-attribution-'));
|
||||
hoisted.teamsBasePath = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads normalized task records from tasks map and flat records without duplicates', async () => {
|
||||
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
tasks: {
|
||||
'task-a': [
|
||||
{
|
||||
memberName: ' bob ',
|
||||
scope: 'member_session_window',
|
||||
sessionId: ' session-bob ',
|
||||
since: '2026-04-21T12:00:00Z',
|
||||
until: '2026-04-21T12:10:00Z',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{
|
||||
memberName: 'bob',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-bob',
|
||||
since: '2026-04-21T12:00:00.000Z',
|
||||
until: '2026-04-21T12:10:00.000Z',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{
|
||||
memberName: '',
|
||||
since: '2026-04-21T12:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
records: [
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'carol',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-carol',
|
||||
startMessageUuid: 'm-1',
|
||||
endMessageUuid: 'm-3',
|
||||
createdAt: '2026-04-21T11:59:00Z',
|
||||
},
|
||||
{
|
||||
taskId: 'other-task',
|
||||
memberName: 'dave',
|
||||
},
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'erin',
|
||||
since: '2026-04-21T13:00:00Z',
|
||||
until: '2026-04-21T12:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const records = await new OpenCodeTaskLogAttributionStore().readTaskRecords('team-a', 'task-a');
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'carol',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-carol',
|
||||
startMessageUuid: 'm-1',
|
||||
endMessageUuid: 'm-3',
|
||||
createdAt: '2026-04-21T11:59:00.000Z',
|
||||
},
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'bob',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-bob',
|
||||
since: '2026-04-21T12:00:00.000Z',
|
||||
until: '2026-04-21T12:10:00.000Z',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('degrades to empty records for missing, invalid, or unsupported files', async () => {
|
||||
const store = new OpenCodeTaskLogAttributionStore();
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
|
||||
|
||||
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{bad-json', 'utf8');
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
|
||||
expect(vi.mocked(console.warn)).toHaveBeenCalledWith(
|
||||
'[OpenCodeTaskLogAttributionStore]',
|
||||
expect.stringContaining('invalid OpenCode task-log attribution JSON')
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
records: [{ taskId: 'task-a', memberName: 'alice' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('upserts records atomically and does not rewrite unchanged attribution', async () => {
|
||||
const store = new OpenCodeTaskLogAttributionStore();
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
store.upsertTaskRecord(
|
||||
'team-a',
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: ' alice ',
|
||||
scope: 'member_session_window',
|
||||
sessionId: ' session-a ',
|
||||
since: '2026-04-21T11:59:00Z',
|
||||
until: '2026-04-21T12:05:00Z',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{ now }
|
||||
)
|
||||
).resolves.toBe('created');
|
||||
|
||||
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
|
||||
const firstRaw = await fs.readFile(filePath, 'utf8');
|
||||
await expect(
|
||||
store.upsertTaskRecord(
|
||||
'team-a',
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-a',
|
||||
since: '2026-04-21T11:59:00.000Z',
|
||||
until: '2026-04-21T12:05:00.000Z',
|
||||
source: 'launch_runtime',
|
||||
},
|
||||
{ now: new Date('2026-04-21T12:10:00.000Z') }
|
||||
)
|
||||
).resolves.toBe('unchanged');
|
||||
|
||||
expect(await fs.readFile(filePath, 'utf8')).toBe(firstRaw);
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-a',
|
||||
since: '2026-04-21T11:59:00.000Z',
|
||||
until: '2026-04-21T12:05:00.000Z',
|
||||
source: 'launch_runtime',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
updatedAt: '2026-04-21T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes concurrent upserts without losing records', async () => {
|
||||
const store = new OpenCodeTaskLogAttributionStore();
|
||||
|
||||
await Promise.all([
|
||||
store.upsertTaskRecord(
|
||||
'team-a',
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-a',
|
||||
since: '2026-04-21T10:00:00Z',
|
||||
},
|
||||
{ now: new Date('2026-04-21T10:00:01.000Z') }
|
||||
),
|
||||
store.upsertTaskRecord(
|
||||
'team-a',
|
||||
{
|
||||
taskId: 'task-b',
|
||||
memberName: 'bob',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-b',
|
||||
startMessageUuid: 'm-1',
|
||||
endMessageUuid: 'm-3',
|
||||
},
|
||||
{ now: new Date('2026-04-21T10:00:02.000Z') }
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toMatchObject([
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-a',
|
||||
},
|
||||
]);
|
||||
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toMatchObject([
|
||||
{
|
||||
taskId: 'task-b',
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-b',
|
||||
startMessageUuid: 'm-1',
|
||||
endMessageUuid: 'm-3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces and clears only the requested task records', async () => {
|
||||
const store = new OpenCodeTaskLogAttributionStore();
|
||||
|
||||
await store.upsertTaskRecord('team-a', {
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-a',
|
||||
since: '2026-04-21T10:00:00Z',
|
||||
});
|
||||
await store.upsertTaskRecord('team-a', {
|
||||
taskId: 'task-b',
|
||||
memberName: 'bob',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-b',
|
||||
since: '2026-04-21T11:00:00Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.replaceTaskRecords(
|
||||
'team-a',
|
||||
'task-a',
|
||||
[
|
||||
{
|
||||
taskId: 'ignored-by-replace',
|
||||
memberName: 'carol',
|
||||
scope: 'task_session',
|
||||
sessionId: 'session-c',
|
||||
startMessageUuid: 'm-1',
|
||||
},
|
||||
],
|
||||
{ now: new Date('2026-04-21T12:00:00.000Z') }
|
||||
)
|
||||
).resolves.toBe('updated');
|
||||
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toMatchObject([
|
||||
{
|
||||
taskId: 'task-a',
|
||||
memberName: 'carol',
|
||||
sessionId: 'session-c',
|
||||
startMessageUuid: 'm-1',
|
||||
},
|
||||
]);
|
||||
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toMatchObject([
|
||||
{
|
||||
taskId: 'task-b',
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-b',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(store.clearTaskRecords('team-a', 'task-a')).resolves.toBe('deleted');
|
||||
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
|
||||
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fails closed instead of overwriting invalid attribution JSON during writes', async () => {
|
||||
const store = new OpenCodeTaskLogAttributionStore();
|
||||
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{bad-json', 'utf8');
|
||||
|
||||
await expect(
|
||||
store.upsertTaskRecord('team-a', {
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
scope: 'member_session_window',
|
||||
sessionId: 'session-a',
|
||||
})
|
||||
).rejects.toThrow('Invalid OpenCode task-log attribution JSON');
|
||||
expect(await fs.readFile(filePath, 'utf8')).toBe('{bad-json');
|
||||
});
|
||||
});
|
||||
1110
test/main/services/team/OpenCodeTaskLogStreamSource.test.ts
Normal file
1110
test/main/services/team/OpenCodeTaskLogStreamSource.test.ts
Normal file
File diff suppressed because it is too large
Load diff
432
test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts
Normal file
432
test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import {
|
||||
OpenCodeTeamLaunchReadinessService,
|
||||
type OpenCodeApiCapabilityPort,
|
||||
type OpenCodeModelExecutionProbePort,
|
||||
type OpenCodeMcpToolProofPort,
|
||||
type OpenCodeProductionE2EEvidencePort,
|
||||
type OpenCodeRuntimeInventory,
|
||||
type OpenCodeRuntimeInventoryPort,
|
||||
type OpenCodeRuntimeStoreReadinessPort,
|
||||
} from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
|
||||
import type {
|
||||
OpenCodeApiCapabilities,
|
||||
OpenCodeApiEndpointKey,
|
||||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
|
||||
|
||||
describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||
it('returns not_installed before probing deeper runtime dependencies', async () => {
|
||||
const ports = createPorts({
|
||||
inventory: { detected: false, diagnostics: ['PATH checked'] },
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'not_installed',
|
||||
launchAllowed: false,
|
||||
hostHealthy: false,
|
||||
diagnostics: ['PATH checked', 'OpenCode CLI not detected on PATH'],
|
||||
});
|
||||
expect(ports.capabilities.detect).not.toHaveBeenCalled();
|
||||
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks unauthenticated OpenCode even when the binary is installed', async () => {
|
||||
const ports = createPorts({
|
||||
inventory: { authenticated: false, connectedProviders: [] },
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'not_authenticated',
|
||||
launchAllowed: false,
|
||||
opencodeVersion: '1.14.19',
|
||||
diagnostics: ['No connected OpenCode providers found'],
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks unsupported versions before MCP and model probes', async () => {
|
||||
const ports = createPorts({
|
||||
inventory: { version: '1.4.0' },
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'unsupported_version',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'unsupported_too_old',
|
||||
missing: ['OpenCode 1.4.0 is below supported minimum 1.14.19'],
|
||||
});
|
||||
expect(ports.mcpTools.prove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks when API capabilities are missing required permission or tool routes', async () => {
|
||||
const ports = createPorts({
|
||||
capabilities: capabilities({ ready: false, missing: ['POST permission reply route'] }),
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'capabilities_missing',
|
||||
launchAllowed: false,
|
||||
permissionBridgeReady: false,
|
||||
missing: ['POST permission reply route'],
|
||||
evidence: {
|
||||
capabilitiesReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks capability-compatible versions until production E2E evidence exists', async () => {
|
||||
const ports = createPorts({
|
||||
evidence: null,
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'],
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks when runtime stores need recovery before readiness', async () => {
|
||||
const ports = createPorts({
|
||||
runtimeStores: {
|
||||
ok: false,
|
||||
reason: 'runtime_store_recovery_required',
|
||||
diagnostics: ['Incomplete batch must be reconciled before readiness'],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'runtime_store_blocked',
|
||||
launchAllowed: false,
|
||||
runtimeStoresReady: false,
|
||||
missing: ['Incomplete batch must be reconciled before readiness'],
|
||||
evidence: {
|
||||
runtimeStoreReadinessReason: 'runtime_store_recovery_required',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks when required app MCP tools are not proven through OpenCode', async () => {
|
||||
const ports = createPorts({
|
||||
toolProof: toolProof({
|
||||
ok: false,
|
||||
missingTools: ['runtime_deliver_message'],
|
||||
diagnostics: [
|
||||
'OpenCode missing canonical app MCP tool id agent-teams_runtime_deliver_message',
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'mcp_unavailable',
|
||||
launchAllowed: false,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: false,
|
||||
missing: ['runtime_deliver_message'],
|
||||
diagnostics: [
|
||||
'OpenCode missing canonical app MCP tool id agent-teams_runtime_deliver_message',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('runs optional execution probe and blocks unavailable selected model', async () => {
|
||||
const ports = createPorts({
|
||||
modelProbe: {
|
||||
outcome: 'unavailable',
|
||||
reason: 'model rejected by provider',
|
||||
diagnostics: ['model rejected by provider'],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service(ports).check(readinessInput({ requireExecutionProbe: true }))
|
||||
).resolves.toMatchObject({
|
||||
state: 'model_unavailable',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
missing: ['model rejected by provider'],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed behind adapter feature gate after all runtime evidence is healthy', async () => {
|
||||
const ports = createPorts();
|
||||
|
||||
await expect(
|
||||
service(ports, { adapterEnabled: false }).check(readinessInput())
|
||||
).resolves.toMatchObject({
|
||||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
missing: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
});
|
||||
expect(ports.inventory.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows dogfood launch to continue without production E2E evidence after runtime checks pass', async () => {
|
||||
const ports = createPorts({ evidence: null });
|
||||
|
||||
await expect(
|
||||
service(ports, { launchMode: 'dogfood' }).check(
|
||||
readinessInput({ requireExecutionProbe: true })
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
requiredToolsPresent: true,
|
||||
runtimeStoresReady: true,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.',
|
||||
],
|
||||
});
|
||||
expect(ports.mcpTools.prove).toHaveBeenCalled();
|
||||
expect(ports.modelExecution.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows launch only when inventory, capabilities, E2E, stores, MCP and model probe are healthy', async () => {
|
||||
const ports = createPorts();
|
||||
|
||||
await expect(
|
||||
service(ports, { adapterEnabled: true }).check(readinessInput())
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
opencodeVersion: '1.14.19',
|
||||
hostHealthy: true,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: true,
|
||||
permissionBridgeReady: true,
|
||||
runtimeStoresReady: true,
|
||||
supportLevel: 'production_supported',
|
||||
evidence: {
|
||||
capabilitiesReady: true,
|
||||
mcpToolProofRoute: '/experimental/tool/ids',
|
||||
runtimeStoreReadinessReason: 'runtime_store_manifest_valid',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function service(
|
||||
ports: ReturnType<typeof createPorts>,
|
||||
options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {}
|
||||
): OpenCodeTeamLaunchReadinessService {
|
||||
return new OpenCodeTeamLaunchReadinessService(
|
||||
ports.inventory,
|
||||
ports.capabilities,
|
||||
ports.mcpTools,
|
||||
ports.runtimeStores,
|
||||
ports.modelExecution,
|
||||
ports.e2eEvidence,
|
||||
options.launchMode
|
||||
? { launchMode: options.launchMode }
|
||||
: { adapterEnabled: options.adapterEnabled ?? true }
|
||||
);
|
||||
}
|
||||
|
||||
function readinessInput(
|
||||
overrides: Partial<{
|
||||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPorts(
|
||||
overrides: {
|
||||
inventory?: Partial<OpenCodeRuntimeInventory>;
|
||||
capabilities?: OpenCodeApiCapabilities;
|
||||
toolProof?: OpenCodeMcpToolProof;
|
||||
runtimeStores?: RuntimeStoreReadinessCheck;
|
||||
modelProbe?: {
|
||||
outcome: 'available' | 'unavailable' | 'unknown';
|
||||
reason: string | null;
|
||||
diagnostics: string[];
|
||||
};
|
||||
evidence?: OpenCodeProductionE2EEvidence | null;
|
||||
} = {}
|
||||
): {
|
||||
inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType<typeof vi.fn> };
|
||||
capabilities: OpenCodeApiCapabilityPort & { detect: ReturnType<typeof vi.fn> };
|
||||
mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType<typeof vi.fn> };
|
||||
runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType<typeof vi.fn> };
|
||||
modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType<typeof vi.fn> };
|
||||
e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
inventory: {
|
||||
probe: vi.fn(async () => inventory(overrides.inventory)),
|
||||
},
|
||||
capabilities: {
|
||||
detect: vi.fn(async () => overrides.capabilities ?? capabilities()),
|
||||
},
|
||||
mcpTools: {
|
||||
prove: vi.fn(async () => overrides.toolProof ?? toolProof()),
|
||||
},
|
||||
runtimeStores: {
|
||||
check: vi.fn(async () => overrides.runtimeStores ?? runtimeStores()),
|
||||
},
|
||||
modelExecution: {
|
||||
verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()),
|
||||
},
|
||||
e2eEvidence: {
|
||||
read: vi.fn(async () => (overrides.evidence === undefined ? evidence() : overrides.evidence)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inventory(overrides: Partial<OpenCodeRuntimeInventory> = {}): OpenCodeRuntimeInventory {
|
||||
return {
|
||||
detected: true,
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
installMethod: 'brew',
|
||||
version: '1.14.19',
|
||||
authenticated: true,
|
||||
connectedProviders: ['openai'],
|
||||
models: ['openai/gpt-5.4-mini'],
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function capabilities(
|
||||
overrides: Partial<{
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
}> = {}
|
||||
): OpenCodeApiCapabilities {
|
||||
const endpoints = createEmptyEndpointMap();
|
||||
const evidence = {} as OpenCodeApiCapabilities['evidence'];
|
||||
for (const key of Object.keys(endpoints) as OpenCodeApiEndpointKey[]) {
|
||||
endpoints[key] = true;
|
||||
evidence[key] = 'openapi';
|
||||
}
|
||||
if (overrides.ready === false) {
|
||||
endpoints.permissionReply = false;
|
||||
endpoints.permissionLegacySessionRespond = false;
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1.14.19',
|
||||
source: 'openapi_doc',
|
||||
endpoints,
|
||||
requiredForTeamLaunch: {
|
||||
ready: overrides.ready ?? true,
|
||||
missing: overrides.missing ?? [],
|
||||
},
|
||||
evidence,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toolProof(overrides: Partial<OpenCodeMcpToolProof> = {}): OpenCodeMcpToolProof {
|
||||
return {
|
||||
ok: true,
|
||||
route: '/experimental/tool/ids',
|
||||
canonicalServerName: 'agent_teams',
|
||||
canonicalExpectedIds: Object.fromEntries(
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => [tool, `agent_teams_${tool}`])
|
||||
),
|
||||
observedTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`),
|
||||
missingTools: [],
|
||||
matchedByRequiredTool: Object.fromEntries(
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => [tool, `agent_teams_${tool}`])
|
||||
),
|
||||
aliasMatchedByRequiredTool: Object.fromEntries(
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => [tool, null])
|
||||
),
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeStores(): RuntimeStoreReadinessCheck {
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'runtime_store_manifest_valid',
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function modelProbe() {
|
||||
return {
|
||||
outcome: 'available' as const,
|
||||
reason: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = new Date().toISOString();
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
255
test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
Normal file
255
test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeTeamRuntimeAdapter,
|
||||
type OpenCodeTeamRuntimeBridgePort,
|
||||
type TeamRuntimeLaunchInput,
|
||||
} from '../../../../src/main/services/team/runtime';
|
||||
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types';
|
||||
|
||||
describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||
it('maps readiness failures to a structured prepare block', async () => {
|
||||
const bridge = bridgePort(
|
||||
readiness({
|
||||
state: 'mcp_unavailable',
|
||||
launchAllowed: false,
|
||||
missing: ['runtime_deliver_message'],
|
||||
diagnostics: ['OpenCode missing canonical app MCP tool id'],
|
||||
})
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
|
||||
await expect(adapter.prepare(launchInput())).resolves.toEqual({
|
||||
ok: false,
|
||||
providerId: 'opencode',
|
||||
reason: 'mcp_unavailable',
|
||||
retryable: true,
|
||||
diagnostics: ['OpenCode missing canonical app MCP tool id', 'runtime_deliver_message'],
|
||||
warnings: [],
|
||||
});
|
||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when launch mode is disabled', async () => {
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridge
|
||||
);
|
||||
|
||||
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
providerId: 'opencode',
|
||||
reason: 'opencode_team_launch_disabled',
|
||||
retryable: false,
|
||||
});
|
||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: 'oc-session-1',
|
||||
launchState: 'confirmed_alive',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [
|
||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}) satisfies OpenCodeLaunchTeamCommandData);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
||||
providerId: 'opencode' as const,
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
version: '1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
})),
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
);
|
||||
|
||||
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'clean_success',
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'opencode',
|
||||
launchState: 'confirmed_alive',
|
||||
sessionId: 'oc-session-1',
|
||||
hardFailure: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
manifestHighWatermark: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reconciles from existing persisted launch snapshot without treating OpenCode as truth', async () => {
|
||||
const snapshot = launchSnapshot();
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'adapter_disabled', launchAllowed: false }))
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapter.reconcile({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
providerId: 'opencode',
|
||||
expectedMembers: launchInput().expectedMembers,
|
||||
previousLaunchState: snapshot,
|
||||
reason: 'startup_recovery',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
launchPhase: 'active',
|
||||
teamLaunchState: 'partial_pending',
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'opencode',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('acknowledges stop without mutating live OpenCode ownership in the adapter shell', async () => {
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'adapter_disabled', launchAllowed: false }))
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapter.stop({
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
providerId: 'opencode',
|
||||
reason: 'user_requested',
|
||||
previousLaunchState: launchSnapshot(),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
stopped: true,
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'opencode',
|
||||
stopped: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function bridgePort(
|
||||
readinessResult: OpenCodeTeamLaunchReadiness,
|
||||
overrides: Partial<OpenCodeTeamRuntimeBridgePort> = {}
|
||||
): OpenCodeTeamRuntimeBridgePort {
|
||||
return {
|
||||
checkOpenCodeTeamLaunchReadiness: vi.fn(async () => readinessResult),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function launchInput(overrides: Partial<TeamRuntimeLaunchInput> = {}): TeamRuntimeLaunchInput {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
teamName: 'team-a',
|
||||
cwd: '/repo',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
skipPermissions: false,
|
||||
expectedMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
cwd: '/repo',
|
||||
},
|
||||
],
|
||||
previousLaunchState: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readiness(
|
||||
overrides: Partial<OpenCodeTeamLaunchReadiness> = {}
|
||||
): OpenCodeTeamLaunchReadiness {
|
||||
return {
|
||||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
opencodeVersion: '1.14.19',
|
||||
installMethod: 'brew',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
hostHealthy: true,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: true,
|
||||
permissionBridgeReady: true,
|
||||
runtimeStoresReady: true,
|
||||
supportLevel: 'production_supported',
|
||||
missing: [],
|
||||
diagnostics: [],
|
||||
evidence: {
|
||||
capabilitiesReady: true,
|
||||
mcpToolProofRoute: '/experimental/tool/ids',
|
||||
observedMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
runtimeStoreReadinessReason: 'runtime_store_manifest_valid',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function launchSnapshot(): PersistedTeamLaunchSnapshot {
|
||||
return {
|
||||
version: 2,
|
||||
teamName: 'team-a',
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice'],
|
||||
teamLaunchState: 'partial_pending',
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
},
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-21T00:00:00.000Z',
|
||||
diagnostics: ['waiting for teammate check-in'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue