From 99102565f36a0f4f169b3e210ea797f8a3cd9837 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 12:25:42 +0300 Subject: [PATCH 01/19] feat(ledger): integrate task change ledger functionality into file content resolution and review application processes --- .../services/team/ChangeExtractorService.ts | 40 ++ src/main/services/team/FileContentResolver.ts | 57 +- src/main/services/team/HunkSnippetMatcher.ts | 10 +- .../services/team/ReviewApplierService.ts | 455 ++++++++++++++- .../services/team/TaskChangeLedgerReader.ts | 538 ++++++++++++++++++ .../services/team/TeamLogSourceTracker.ts | 21 +- .../team/review/FileSectionDiff.tsx | 13 +- .../team/review/FileSectionHeader.tsx | 24 +- .../team/review/ReviewDiffContent.tsx | 34 +- src/shared/types/review.ts | 49 +- .../services/team/FileContentResolver.test.ts | 84 ++- .../team/ReviewApplierService.test.ts | 478 +++++++++++++++- .../team/TaskChangeLedgerReader.test.ts | 222 ++++++++ 13 files changed, 2000 insertions(+), 25 deletions(-) create mode 100644 src/main/services/team/TaskChangeLedgerReader.ts create mode 100644 test/main/services/team/TaskChangeLedgerReader.test.ts diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 8201aa23..58f783b3 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -22,6 +22,7 @@ import { type TaskChangeEffectiveOptions, type TaskChangeTaskMeta, } from './taskChangeWorkerTypes'; +import { TaskChangeLedgerReader } from './TaskChangeLedgerReader'; import { TeamConfigReader } from './TeamConfigReader'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -66,6 +67,7 @@ export class ChangeExtractorService { private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null; private readonly taskChangeComputer: TaskChangeComputer; + private readonly taskChangeLedgerReader = new TaskChangeLedgerReader(); constructor( private readonly logsFinder: TeamMemberLogsFinder, @@ -164,6 +166,18 @@ export class ChangeExtractorService { includeDetails, }; + const ledgerResult = await this.readLedgerTaskChanges(resolvedInput); + if (ledgerResult) { + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + ledgerResult + ); + return ledgerResult; + } + if (!shouldUseSummaryCache) { const result = await this.computeTaskChangesPreferred(resolvedInput); await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); @@ -293,6 +307,32 @@ export class ChangeExtractorService { return this.taskChangeComputer.computeTaskChanges(input); } + private async readLedgerTaskChanges( + input: ResolvedTaskChangeComputeInput + ): Promise { + try { + if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') { + return null; + } + const context = await this.logsFinder.getLogSourceWatchContext(input.teamName); + if (!context?.projectDir) { + return null; + } + return await this.taskChangeLedgerReader.readTaskChanges({ + teamName: input.teamName, + taskId: input.taskId, + projectDir: context.projectDir, + projectPath: input.projectPath ?? context.projectPath, + includeDetails: input.includeDetails, + }); + } catch (error) { + logger.warn( + `Task change ledger read failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + private isValidWorkerTaskChangeResult( result: TaskChangeSetV2, input: ResolvedTaskChangeComputeInput diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 5cbdf65b..bb80c25f 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -63,6 +63,11 @@ export class FileContentResolver { modified: string | null; source: FileChangeWithContent['contentSource']; }> { + const ledgerResult = this.tryLedgerContent(snippets); + if (ledgerResult) { + return ledgerResult; + } + // Read current file from disk (= modified state after agent's changes) let currentContent: string | null = null; try { @@ -183,7 +188,9 @@ export class FileContentResolver { } } - const isNewFile = snippets.some((s) => s.type === 'write-new'); + const isNewFile = snippets.some( + (s) => s.type === 'write-new' || s.ledger?.operation === 'create' + ); return { filePath, @@ -257,6 +264,47 @@ export class FileContentResolver { // ── Private: Resolution strategies ── + private tryLedgerContent(snippets: SnippetDiff[]): { + original: string | null; + modified: string | null; + source: FileChangeWithContent['contentSource']; + } | null { + const ledgerSnippets = snippets + .filter((snippet) => snippet.ledger && !snippet.isError) + .sort((a, b) => { + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) { + return aTime - bTime; + } + return a.toolUseId.localeCompare(b.toolUseId); + }); + + if (ledgerSnippets.length === 0) { + return null; + } + + const first = ledgerSnippets[0]?.ledger; + const last = ledgerSnippets[ledgerSnippets.length - 1]?.ledger; + if (!first || !last) { + return null; + } + const original = first.originalFullContent ?? (first.operation === 'create' ? '' : null); + const modified = last.modifiedFullContent ?? (last.operation === 'delete' ? '' : null); + if (original === null && modified === null) { + return null; + } + + const hasSnapshot = ledgerSnippets.some( + (snippet) => snippet.ledger?.source === 'ledger-snapshot' + ); + return { + original, + modified, + source: hasSnapshot ? 'ledger-snapshot' : 'ledger-exact', + }; + } + /** * Strategy 1: Read original content from Claude's file-history backup. * @@ -429,6 +477,13 @@ export class FileContentResolver { return null; } + case 'notebook-edit': + case 'shell-snapshot': + case 'hook-snapshot': { + // Snapshot/full-file changes are only safe when ledger content is available. + return null; + } + case 'edit': case 'multi-edit': { // Guard: empty newString means deletion — can't find position to reverse diff --git a/src/main/services/team/HunkSnippetMatcher.ts b/src/main/services/team/HunkSnippetMatcher.ts index 173c964b..e98cdde2 100644 --- a/src/main/services/team/HunkSnippetMatcher.ts +++ b/src/main/services/team/HunkSnippetMatcher.ts @@ -144,8 +144,14 @@ export class HunkSnippetMatcher { ): boolean { if (!snippet.newString && !snippet.oldString) return false; - if (snippet.type === 'write-new' || snippet.type === 'write-update') { - // Full-file writes are intentionally excluded from localized hunk↔snippet matching. + if ( + snippet.type === 'write-new' || + snippet.type === 'write-update' || + snippet.type === 'notebook-edit' || + snippet.type === 'shell-snapshot' || + snippet.type === 'hook-snapshot' + ) { + // Full-file and snapshot changes are intentionally excluded from localized hunk↔snippet matching. // They are handled by whole-file reject logic or hunk-level inverse patch. return false; } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 380fa0f2..41af1ca3 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -1,8 +1,10 @@ import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import { applyPatch, structuredPatch } from 'diff'; -import { readFile, unlink, writeFile } from 'fs/promises'; +import { mkdir, readFile, unlink, writeFile } from 'fs/promises'; import { diff3Merge } from 'node-diff3'; +import { dirname } from 'path'; import { HunkSnippetMatcher } from './HunkSnippetMatcher'; @@ -11,6 +13,7 @@ import type { ApplyReviewResult, ConflictCheckResult, FileChangeWithContent, + LedgerChangeRelation, RejectResult, SnippetDiff, } from '@shared/types'; @@ -18,6 +21,12 @@ import type { StructuredPatchHunk } from 'diff'; const logger = createLogger('Service:ReviewApplierService'); +type ApplyErrorCode = NonNullable; +type LedgerApplyOutcome = + | { handled: false } + | { handled: true; status: 'applied' | 'skipped' } + | { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode }; + /** * Service for applying reject decisions from code review. * @@ -255,16 +264,43 @@ export class ReviewApplierService { const allHunksRejected = Object.keys(decision.hunkDecisions).length > 0 && Object.values(decision.hunkDecisions).every((d) => d === 'rejected'); - const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new'); + const hasNewFileSnippet = fileContent.snippets.some( + (s) => s.type === 'write-new' || s.ledger?.operation === 'create' + ); // Special case: rejecting an entirely new file should remove it from disk. // IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file. const shouldDeleteNewFile = fileContent.isNewFile && - hasWriteNewSnippet && + hasNewFileSnippet && original === '' && (decision.fileDecision === 'rejected' || allHunksRejected); + const ledgerOutcome = await this.tryApplyLedgerDecision( + decision.filePath, + original, + modified, + decision.fileDecision === 'rejected', + allHunksRejected, + rejectedHunkIndices, + fileContent.snippets + ); + if (ledgerOutcome.handled) { + if (ledgerOutcome.status === 'applied') { + applied++; + } else if (ledgerOutcome.status === 'skipped') { + skipped++; + } else if (ledgerOutcome.status === 'conflict' || ledgerOutcome.status === 'error') { + if (ledgerOutcome.status === 'conflict') conflicts++; + errors.push({ + filePath: decision.filePath, + error: ledgerOutcome.error, + code: ledgerOutcome.code, + }); + } + continue; + } + if (shouldDeleteNewFile) { // If we have an expected modified baseline, guard against deleting a user-modified file. if (modified !== null) { @@ -275,6 +311,7 @@ export class ReviewApplierService { filePath: decision.filePath, error: 'File was modified since review was computed; refusing to delete new file automatically.', + code: 'conflict', }); continue; } @@ -289,6 +326,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: 'Cannot delete new file: expected modified content is unavailable.', + code: 'unavailable', }); continue; } @@ -304,6 +342,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: `Failed to delete new file: ${msg}`, + code: 'io-error', }); } } @@ -314,6 +353,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: 'Содержимое файла недоступно для применения review', + code: 'unavailable', }); continue; } @@ -393,6 +433,407 @@ export class ReviewApplierService { // ── Private: Rejection strategies ── + private async tryApplyLedgerDecision( + filePath: string, + original: string | null, + modified: string | null, + fileRejected: boolean, + allHunksRejected: boolean, + rejectedHunkIndices: number[], + snippets: SnippetDiff[] + ): Promise { + const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); + if (ledgerSnippets.length === 0) { + return { handled: false }; + } + + const firstLedger = ledgerSnippets[0]?.ledger; + const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger; + if (!firstLedger || !lastLedger) { + return { handled: false }; + } + + const fullReject = fileRejected || allHunksRejected; + const hasSnapshot = ledgerSnippets.some( + (snippet) => snippet.type === 'shell-snapshot' || snippet.type === 'hook-snapshot' + ); + const hasUnavailableState = ledgerSnippets.some( + (snippet) => + snippet.ledger?.beforeState?.unavailableReason || + snippet.ledger?.afterState?.unavailableReason + ); + const relation = this.resolveLedgerRelation(ledgerSnippets); + + if (!fullReject) { + if (relation?.kind === 'rename') { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename partial reject requires manual review.', + }; + } + if (!hasSnapshot) { + return { handled: false }; + } + if (original === null || modified === null) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger snapshot content is unavailable; partial reject requires manual review.', + }; + } + const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + if (!guard.ok) { + return guard.outcome; + } + const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices); + if (!patchResult) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger snapshot partial reject could not be applied safely.', + }; + } + try { + await writeFile(filePath, patchResult.newContent, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + if (relation?.kind === 'rename') { + return this.rejectLedgerRename(ledgerSnippets, relation, original, hasUnavailableState); + } + + const operation = this.resolveLedgerOperation(ledgerSnippets); + if (operation === 'create') { + const afterHash = lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined; + const current = await this.readCurrentText(filePath); + if (current.missing) { + return { handled: true, status: 'applied' }; + } + if (current.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: current.error, + }; + } + if (!afterHash) { + return { + handled: true, + status: 'error', + code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + error: 'Ledger after content hash is unavailable; refusing to delete file automatically.', + }; + } + if (this.hashText(current.content) !== afterHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File was modified since review was computed; refusing ledger delete.', + }; + } + try { + await unlink(filePath); + return { handled: true, status: 'applied' }; + } catch (err) { + const msg = String(err); + if (msg.includes('ENOENT')) { + return { handled: true, status: 'applied' }; + } + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Failed to delete new file: ${msg}`, + }; + } + } + + if (operation === 'delete') { + if (original === null) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger before content is unavailable; deleted file requires manual restore.', + }; + } + const current = await this.readCurrentText(filePath); + if (!current.missing) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: + current.error || 'File exists on disk; refusing to overwrite while rejecting delete.', + }; + } + try { + await writeFile(filePath, original, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + if (original === null) { + return { + handled: true, + status: 'error', + code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + error: + 'Ledger before content is unavailable; rejecting this change requires manual review.', + }; + } + const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + if (!guard.ok) { + return guard.outcome; + } + try { + await writeFile(filePath, original, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' { + if (snippets.some((snippet) => snippet.ledger?.operation === 'create')) return 'create'; + if (snippets[snippets.length - 1]?.ledger?.operation === 'delete') return 'delete'; + return 'modify'; + } + + private resolveLedgerRelation(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { + return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; + } + + private async rejectLedgerRename( + snippets: SnippetDiff[], + relation: LedgerChangeRelation, + original: string | null, + hasUnavailableState: boolean + ): Promise { + const oldSnippet = + snippets.find( + (snippet) => + snippet.ledger?.operation === 'delete' && + this.pathMatchesRelationPath(snippet.filePath, relation.oldPath) + ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'delete'); + const newSnippet = + snippets.find( + (snippet) => + snippet.ledger?.operation === 'create' && + this.pathMatchesRelationPath(snippet.filePath, relation.newPath) + ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'create'); + const oldFilePath = + oldSnippet?.filePath ?? + this.resolveRelatedLedgerPath(newSnippet?.filePath, relation.newPath, relation.oldPath); + const newFilePath = newSnippet?.filePath; + const oldContent = oldSnippet?.ledger?.originalFullContent ?? original; + const newHash = newSnippet?.ledger?.afterState?.sha256 ?? newSnippet?.ledger?.afterHash; + const oldHash = oldSnippet?.ledger?.beforeState?.sha256 ?? oldSnippet?.ledger?.beforeHash; + + if (!oldFilePath || !newFilePath || oldContent === null) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename metadata is incomplete; manual review is required.', + }; + } + if (hasUnavailableState || !newHash) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename content metadata is unavailable; manual review is required.', + }; + } + + const newCurrent = await this.readCurrentText(newFilePath); + if (!newCurrent.missing) { + if (newCurrent.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: newCurrent.error, + }; + } + if (this.hashText(newCurrent.content) !== newHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'Renamed file was modified since review was computed; refusing ledger reject.', + }; + } + } + + const oldCurrent = await this.readCurrentText(oldFilePath); + if (!oldCurrent.missing) { + if (oldCurrent.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: oldCurrent.error, + }; + } + if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'Original rename path already exists with different content; refusing overwrite.', + }; + } + } + + try { + if (oldCurrent.missing) { + await mkdir(dirname(oldFilePath), { recursive: true }); + await writeFile(oldFilePath, oldContent, 'utf8'); + } + if (!newCurrent.missing) { + await unlink(newFilePath); + } + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Failed to reject ledger rename: ${String(err)}`, + }; + } + } + + private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedRelationPath = relationPath.replace(/\\/g, '/'); + return ( + normalizedFilePath === normalizedRelationPath || + normalizedFilePath.endsWith(`/${normalizedRelationPath}`) + ); + } + + private resolveRelatedLedgerPath( + anchorPath: string | undefined, + anchorRelationPath: string, + targetRelationPath: string + ): string | null { + if (!anchorPath) { + return null; + } + const normalizedAnchor = anchorPath.replace(/\\/g, '/'); + const normalizedRelation = anchorRelationPath.replace(/\\/g, '/'); + if (!normalizedAnchor.endsWith(normalizedRelation)) { + return null; + } + return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; + } + + private async checkLedgerCurrentHash( + filePath: string, + expectedHash: string | undefined + ): Promise<{ ok: true } | { ok: false; outcome: LedgerApplyOutcome }> { + if (!expectedHash) { + return { + ok: false, + outcome: { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger expected content hash is unavailable; refusing automatic apply.', + }, + }; + } + const current = await this.readCurrentText(filePath); + if (current.missing) { + return { + ok: false, + outcome: { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File is missing on disk; refusing ledger apply.', + }, + }; + } + if (current.error) { + return { + ok: false, + outcome: { + handled: true, + status: 'error', + code: 'io-error', + error: current.error, + }, + }; + } + if (this.hashText(current.content) !== expectedHash) { + return { + ok: false, + outcome: { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File was modified since review was computed; refusing ledger apply.', + }, + }; + } + return { ok: true }; + } + + private async readCurrentText( + filePath: string + ): Promise< + | { missing: true; content: ''; error?: undefined } + | { missing: false; content: string; error?: undefined } + | { missing: false; content: ''; error: string } + > { + try { + return { missing: false, content: await readFile(filePath, 'utf8') }; + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? String((err as { code?: unknown }).code) + : ''; + if (code === 'ENOENT') { + return { missing: true, content: '' }; + } + return { missing: false, content: '', error: `Не удалось прочитать файл: ${String(err)}` }; + } + } + + private hashText(content: string): string { + return createHash('sha256').update(content).digest('hex'); + } + /** * Snippet-level rejection: reverse specific snippets by position (most accurate). * @@ -409,7 +850,13 @@ export class ReviewApplierService { // They are not localized, and matching a single hunk to a full-file write // can incorrectly delete/overwrite large parts of the file. const validSnippets = snippets.filter( - (s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update' + (s) => + !s.isError && + s.type !== 'write-new' && + s.type !== 'write-update' && + s.type !== 'notebook-edit' && + s.type !== 'shell-snapshot' && + s.type !== 'hook-snapshot' ); if (validSnippets.length === 0) return null; diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts new file mode 100644 index 00000000..01cd38b6 --- /dev/null +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -0,0 +1,538 @@ +import { createLogger } from '@shared/utils/logger'; +import { diffLines } from 'diff'; +import { readFile } from 'fs/promises'; +import * as path from 'path'; + +import type { + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; + +const logger = createLogger('Service:TaskChangeLedgerReader'); + +const TASK_CHANGE_LEDGER_SCHEMA_VERSION = 1; +const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; + +type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; + +interface LedgerContentRef { + sha256: string; + sizeBytes: number; + blobRef?: string; + unavailableReason?: string; +} + +interface LedgerContentState { + exists?: boolean; + sha256?: string; + sizeBytes?: number; + unavailableReason?: string; +} + +interface LedgerChangeRelation { + kind: 'rename' | 'copy'; + oldPath: string; + newPath: string; +} + +interface LedgerEvent { + schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + eventId: string; + taskId: string; + taskRef: string; + taskRefKind: 'canonical' | 'display' | 'unknown'; + phase: 'work' | 'review'; + executionSeq: number; + sessionId: string; + agentId?: string; + toolUseId: string; + source: + | 'file_edit' + | 'file_write' + | 'notebook_edit' + | 'bash_simulated_sed' + | 'shell_snapshot' + | 'powershell_snapshot' + | 'post_tool_hook_snapshot'; + operation: 'create' | 'modify' | 'delete'; + confidence: LedgerConfidence; + workspaceRoot: string; + filePath: string; + relativePath: string; + timestamp: string; + toolStatus: 'succeeded' | 'failed' | 'killed' | 'backgrounded'; + before: LedgerContentRef | null; + after: LedgerContentRef | null; + beforeState?: LedgerContentState; + afterState?: LedgerContentState; + relation?: LedgerChangeRelation; + oldString?: string; + newString?: string; + linesAdded?: number; + linesRemoved?: number; + replaceAll?: boolean; + warnings?: string[]; +} + +interface LedgerNotice { + schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + noticeId: string; + taskId: string; + taskRef: string; + taskRefKind: 'canonical' | 'display' | 'unknown'; + phase: 'work' | 'review'; + executionSeq: number; + sessionId: string; + agentId?: string; + toolUseId: string; + timestamp: string; + severity: 'warning'; + message: string; +} + +interface LedgerBundle { + schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + source: 'task-change-ledger'; + taskId: string; + generatedAt: string; + eventCount: number; + files: { + filePath: string; + relativePath: string; + eventIds: string[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + latestAfterHash: string | null; + }[]; + totalLinesAdded: number; + totalLinesRemoved: number; + totalFiles: number; + confidence: 'high' | 'medium' | 'low'; + warnings: string[]; + events: LedgerEvent[]; + notices?: LedgerNotice[]; +} + +export class TaskChangeLedgerReader { + async readTaskChanges(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + includeDetails: boolean; + }): Promise { + const bundle = await this.readBundle(params.projectDir, params.taskId); + if (!bundle) { + return null; + } + + const events = bundle.events + .filter((event) => event.taskId === params.taskId) + .sort((a, b) => { + const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); + return timeDiff === 0 ? a.eventId.localeCompare(b.eventId) : timeDiff; + }); + const notices = (bundle.notices ?? []) + .filter((notice) => notice.taskId === params.taskId) + .sort((a, b) => { + const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); + return timeDiff === 0 ? a.noticeId.localeCompare(b.noticeId) : timeDiff; + }); + if (events.length === 0 && notices.length === 0) { + return null; + } + + const snippets = params.includeDetails + ? await this.buildSnippets(params.projectDir, events) + : []; + const files = params.includeDetails + ? this.aggregateByFile(snippets, params.projectPath, true) + : this.buildSummaryFiles(bundle, params.projectPath); + const scope = this.buildScope(params.taskId, events, files, notices); + const warnings = new Set(bundle.warnings ?? []); + for (const notice of notices) warnings.add(notice.message); + for (const event of events) { + for (const warning of event.warnings ?? []) warnings.add(warning); + if (event.toolStatus === 'failed') { + warnings.add(`Tool ${event.toolUseId} failed after changing files.`); + } + if (event.toolStatus === 'killed') { + warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + } + + return { + teamName: params.teamName, + taskId: params.taskId, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + confidence: bundle.confidence, + computedAt: bundle.generatedAt, + scope, + warnings: [...warnings], + }; + } + + private async readBundle(projectDir: string, taskId: string): Promise { + const bundlePath = path.join( + projectDir, + TASK_CHANGE_LEDGER_DIRNAME, + 'bundles', + `${encodeURIComponent(taskId)}.json` + ); + + try { + const raw = await readFile(bundlePath, 'utf8'); + const parsed = JSON.parse(raw) as LedgerBundle; + if ( + parsed?.schemaVersion !== TASK_CHANGE_LEDGER_SCHEMA_VERSION || + parsed.source !== 'task-change-ledger' || + parsed.taskId !== taskId || + !Array.isArray(parsed.events) + ) { + return null; + } + return parsed; + } catch (error) { + logger.debug(`No task-change ledger bundle for ${taskId}: ${String(error)}`); + return null; + } + } + + private async buildSnippets(projectDir: string, events: LedgerEvent[]): Promise { + return Promise.all( + events.map(async (event) => { + const beforeContent = await this.readContentRef(projectDir, event.before); + const afterContent = await this.readContentRef(projectDir, event.after); + return this.eventToSnippet(event, beforeContent, afterContent); + }) + ); + } + + private async readContentRef( + projectDir: string, + ref: LedgerContentRef | null + ): Promise { + if (!ref?.blobRef) { + return null; + } + try { + return await readFile( + path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef), + 'utf8' + ); + } catch { + return null; + } + } + + private eventToSnippet( + event: LedgerEvent, + beforeContent: string | null, + afterContent: string | null + ): SnippetDiff { + const toolName = this.mapToolName(event.source); + const type = this.mapSnippetType(event); + const source = event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot'; + return { + toolUseId: event.toolUseId, + filePath: event.filePath, + toolName, + type, + oldString: event.oldString ?? beforeContent ?? '', + newString: event.newString ?? afterContent ?? '', + replaceAll: event.replaceAll ?? false, + timestamp: event.timestamp, + isError: false, + ledger: { + eventId: event.eventId, + source, + confidence: event.confidence, + originalFullContent: beforeContent, + modifiedFullContent: afterContent, + beforeHash: event.before?.sha256 ?? null, + afterHash: event.after?.sha256 ?? null, + operation: event.operation, + beforeState: event.beforeState, + afterState: event.afterState, + relation: event.relation, + executionSeq: event.executionSeq, + }, + }; + } + + private mapToolName(eventSource: LedgerEvent['source']): SnippetDiff['toolName'] { + switch (eventSource) { + case 'file_edit': + return 'Edit'; + case 'file_write': + return 'Write'; + case 'notebook_edit': + return 'NotebookEdit'; + case 'bash_simulated_sed': + case 'shell_snapshot': + return 'Bash'; + case 'powershell_snapshot': + return 'PowerShell'; + case 'post_tool_hook_snapshot': + return 'PostToolUse'; + } + } + + private mapSnippetType(event: LedgerEvent): SnippetDiff['type'] { + if (event.source === 'file_write') { + return event.operation === 'create' ? 'write-new' : 'write-update'; + } + if (event.source === 'notebook_edit') { + return 'notebook-edit'; + } + if (event.source === 'shell_snapshot' || event.source === 'powershell_snapshot') { + return 'shell-snapshot'; + } + if (event.source === 'post_tool_hook_snapshot') { + return 'hook-snapshot'; + } + return 'edit'; + } + + private aggregateByFile( + snippets: SnippetDiff[], + projectPath: string | undefined, + includeDetails: boolean + ): FileChangeSummary[] { + const fileMap = new Map< + string, + { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + >(); + for (const snippet of snippets) { + const key = this.fileGroupKey(snippet); + const existing = fileMap.get(key); + if (existing) { + existing.snippets.push(snippet); + existing.isNewFile ||= + snippet.type === 'write-new' || snippet.ledger?.operation === 'create'; + } else { + fileMap.set(key, { + filePath: snippet.filePath, + snippets: [snippet], + isNewFile: snippet.type === 'write-new' || snippet.ledger?.operation === 'create', + }); + } + } + + return [...fileMap.values()].map((entry) => { + let linesAdded = 0; + let linesRemoved = 0; + for (const snippet of entry.snippets) { + const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); + linesAdded += added; + linesRemoved += removed; + } + + const displayFilePath = this.displayFilePathForGroup(entry); + const relation = this.relationForSnippets(entry.snippets); + return { + filePath: displayFilePath, + relativePath: this.relativePath(displayFilePath, projectPath), + snippets: includeDetails ? entry.snippets : [], + linesAdded, + linesRemoved, + isNewFile: relation?.kind === 'rename' ? false : entry.isNewFile, + timeline: includeDetails ? this.buildTimeline(displayFilePath, entry.snippets) : undefined, + }; + }); + } + + private buildSummaryFiles( + bundle: LedgerBundle, + projectPath: string | undefined + ): FileChangeSummary[] { + const eventById = new Map(bundle.events.map((event) => [event.eventId, event])); + const fileMap = new Map< + string, + { + filePath: string; + filePaths: string[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + relation?: LedgerChangeRelation; + } + >(); + + for (const file of bundle.files) { + const relation = file.eventIds + .map((eventId) => eventById.get(eventId)?.relation) + .find((value): value is LedgerChangeRelation => Boolean(value)); + const key = relation + ? `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}` + : this.normalizePathKey(file.filePath); + const displayFilePath = relation?.newPath ?? file.filePath; + const existing = fileMap.get(key); + if (existing) { + existing.filePaths.push(file.filePath); + existing.filePath = relation + ? this.displayFilePathForRelation(relation, existing.filePaths) + : existing.filePath; + existing.linesAdded += file.linesAdded; + existing.linesRemoved += file.linesRemoved; + existing.isNewFile ||= file.isNewFile; + existing.relation ??= relation; + } else { + fileMap.set(key, { + filePath: relation + ? this.displayFilePathForRelation(relation, [file.filePath]) + : displayFilePath, + filePaths: [file.filePath], + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + isNewFile: file.isNewFile, + relation, + }); + } + } + + return [...fileMap.values()].map((file) => ({ + filePath: file.filePath, + relativePath: this.relativePath(file.filePath, projectPath), + snippets: [], + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + isNewFile: file.relation?.kind === 'rename' ? false : file.isNewFile, + })); + } + + private buildScope( + taskId: string, + events: LedgerEvent[], + files: FileChangeSummary[], + notices: LedgerNotice[] = [] + ): TaskChangeScope { + const first = events[0]; + const last = events[events.length - 1]; + const firstNotice = notices[0]; + const lastNotice = notices[notices.length - 1]; + const worstConfidence = events.some((event) => event.confidence !== 'exact') ? 2 : 1; + return { + taskId, + memberName: first?.agentId ?? firstNotice?.agentId ?? '', + startLine: 0, + endLine: 0, + startTimestamp: first?.timestamp ?? firstNotice?.timestamp ?? new Date().toISOString(), + endTimestamp: + last?.timestamp ?? + first?.timestamp ?? + lastNotice?.timestamp ?? + firstNotice?.timestamp ?? + new Date().toISOString(), + toolUseIds: [ + ...new Set([ + ...events.map((event) => event.toolUseId), + ...notices.map((notice) => notice.toolUseId), + ]), + ], + filePaths: files.map((file) => file.filePath), + confidence: { + tier: worstConfidence, + label: worstConfidence === 1 ? 'high' : 'medium', + reason: 'Scoped by orchestrator task-change ledger', + }, + }; + } + + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { + const events: FileEditEvent[] = snippets.map((snippet, index) => { + const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); + return { + toolUseId: snippet.toolUseId, + toolName: snippet.toolName, + timestamp: snippet.timestamp, + summary: this.summaryForSnippet(snippet, added, removed), + linesAdded: added, + linesRemoved: removed, + snippetIndex: index, + }; + }); + const firstMs = Date.parse(events[0]?.timestamp ?? ''); + const lastMs = Date.parse(events[events.length - 1]?.timestamp ?? ''); + return { + filePath, + events, + durationMs: + Number.isFinite(firstMs) && Number.isFinite(lastMs) ? Math.max(0, lastMs - firstMs) : 0, + }; + } + + private summaryForSnippet(snippet: SnippetDiff, added: number, removed: number): string { + if (snippet.type === 'write-new') return `Created file (${added} lines)`; + if (snippet.type === 'write-update') return `Rewrote file (+${added}/-${removed})`; + if (snippet.type === 'shell-snapshot') { + return `${snippet.toolName === 'PowerShell' ? 'PowerShell' : 'Shell'} changed file (+${added}/-${removed})`; + } + if (snippet.type === 'hook-snapshot') return `Hook changed file (+${added}/-${removed})`; + if (snippet.type === 'notebook-edit') return `Edited notebook (+${added}/-${removed})`; + return `Edited file (+${added}/-${removed})`; + } + + private countLineChanges(before: string, after: string): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const change of diffLines(before, after)) { + if (change.added) added += change.count ?? 0; + if (change.removed) removed += change.count ?? 0; + } + return { added, removed }; + } + + private normalizePathKey(filePath: string): string { + return path.normalize(filePath).toLowerCase(); + } + + private fileGroupKey(snippet: SnippetDiff): string { + const relation = snippet.ledger?.relation; + if (relation) { + return `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}`; + } + return this.normalizePathKey(snippet.filePath); + } + + private displayFilePathForGroup(entry: { filePath: string; snippets: SnippetDiff[] }): string { + const relation = this.relationForSnippets(entry.snippets); + if (!relation) { + return entry.filePath; + } + return this.displayFilePathForRelation( + relation, + entry.snippets.map((snippet) => snippet.filePath) + ); + } + + private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { + return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; + } + + private displayFilePathForRelation(relation: LedgerChangeRelation, filePaths: string[]): string { + const expected = relation.newPath.replace(/\\/g, '/'); + const match = filePaths.find((filePath) => { + const normalized = filePath.replace(/\\/g, '/'); + return normalized === expected || normalized.endsWith(`/${expected}`); + }); + return match ?? relation.newPath; + } + + private relativePath(filePath: string, projectPath?: string): string { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); + if (normalizedProjectPath && normalizedFilePath.startsWith(normalizedProjectPath + '/')) { + return normalizedFilePath.slice(normalizedProjectPath.length + 1); + } + return normalizedFilePath.split('/').slice(-3).join('/'); + } +} diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 0f99a0ce..cb23ee30 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -15,6 +15,7 @@ import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; +const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { @@ -288,7 +289,18 @@ export class TeamLogSourceTracker { } if ( changedPath && - this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath) + (this.handleTaskFreshnessSignalChange( + teamName, + current.projectDir, + changedPath, + BOARD_TASK_LOG_FRESHNESS_DIRNAME + ) || + this.handleTaskFreshnessSignalChange( + teamName, + current.projectDir, + changedPath, + BOARD_TASK_CHANGE_FRESHNESS_DIRNAME + )) ) { return; } @@ -311,12 +323,13 @@ export class TeamLogSourceTracker { }); } - private handleTaskLogFreshnessSignalChange( + private handleTaskFreshnessSignalChange( teamName: string, projectDir: string, - changedPath: string + changedPath: string, + signalDirName: string ): boolean { - const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME); + const signalDir = path.join(projectDir, signalDirName); const relativePath = path.relative(signalDir, changedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return path.normalize(changedPath) === path.normalize(signalDir); diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index 596614f7..73d151cd 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -117,6 +117,15 @@ export const FileSectionDiff = ({ const resolvedOriginal = fileContent?.originalFullContent ?? null; const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false; + const hasLedgerManualAction = file.snippets.some( + (snippet) => + !!snippet.ledger && + (snippet.ledger.relation?.kind === 'rename' || + (!!snippet.ledger.beforeState?.unavailableReason && + snippet.ledger.originalFullContent == null) || + (!!snippet.ledger.afterState?.unavailableReason && + snippet.ledger.modifiedFullContent == null)) + ); // Show CodeMirror only when we have a trustworthy original baseline: // - new files: original is legitimately empty @@ -168,8 +177,8 @@ export const FileSectionDiff = ({ original={originalForDiff} modified={resolvedModified} fileName={file.relativePath} - readOnly={false} - showMergeControls={!isMissingOnDisk} + readOnly={hasLedgerManualAction} + showMergeControls={!isMissingOnDisk && !hasLedgerManualAction} collapseUnchanged={collapseUnchanged} usePortionCollapse={true} onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)} diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index acf3d121..6aa415c9 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -9,6 +9,8 @@ import type { FileChangeWithContent, HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; const CONTENT_SOURCE_LABELS: Record = { + 'ledger-exact': 'Ledger Exact', + 'ledger-snapshot': 'Ledger Snapshot', 'file-history': 'File History', 'snippet-reconstruction': 'Reconstructed', 'disk-current': 'Current Disk', @@ -57,6 +59,14 @@ export const FileSectionHeader = ({ }: FileSectionHeaderProps): React.ReactElement => { const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false; const isPreviewOnly = isMissingOnDisk || fileContent?.contentSource === 'unavailable'; + const requiresManualLedgerReview = file.snippets.some( + (snippet) => + !!snippet.ledger && + (!!snippet.ledger.beforeState?.unavailableReason || + !!snippet.ledger.afterState?.unavailableReason) && + (snippet.ledger.originalFullContent == null || snippet.ledger.modifiedFullContent == null) + ); + const rejectDisabled = isPreviewOnly || requiresManualLedgerReview; const restoreContent = fileContent?.modifiedFullContent ?? (() => { @@ -189,6 +199,12 @@ export const FileSectionHeader = ({ )} + {requiresManualLedgerReview && ( + + MANUAL REVIEW + + )} +
{externalChange && onReloadFromDisk && onKeepDraft && (
@@ -242,7 +258,7 @@ export const FileSectionHeader = ({ - {isPreviewOnly && ( + {rejectDisabled && ( - Accept/Reject is disabled while the file is missing on disk. + {requiresManualLedgerReview + ? 'Reject is disabled because this ledger change has binary, large, or unavailable content.' + : 'Accept/Reject is disabled while the file is missing on disk.'} )} diff --git a/src/renderer/components/team/review/ReviewDiffContent.tsx b/src/renderer/components/team/review/ReviewDiffContent.tsx index 4cd22f27..b10d2034 100644 --- a/src/renderer/components/team/review/ReviewDiffContent.tsx +++ b/src/renderer/components/team/review/ReviewDiffContent.tsx @@ -83,7 +83,15 @@ const SnippetDiffView = ({ ? 'Full rewrite' : snippet.type === 'multi-edit' ? 'Multi-edit' - : 'Edit'; + : snippet.type === 'notebook-edit' + ? 'Notebook' + : snippet.type === 'shell-snapshot' + ? snippet.toolName === 'PowerShell' + ? 'PowerShell' + : 'Shell' + : snippet.type === 'hook-snapshot' + ? 'Hook' + : 'Edit'; return (
@@ -135,9 +143,33 @@ const SnippetDiffView = ({ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => { const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]); + const ledgerMetadataRows = useMemo(() => { + const rows = new Set(); + for (const snippet of nonErrorSnippets) { + const relation = snippet.ledger?.relation; + if (relation) { + rows.add( + `${relation.kind === 'rename' ? 'Rename' : 'Copy'}: ${relation.oldPath} -> ${relation.newPath}` + ); + } + const beforeReason = snippet.ledger?.beforeState?.unavailableReason; + const afterReason = snippet.ledger?.afterState?.unavailableReason; + if (beforeReason) rows.add(`Before content metadata only: ${beforeReason}`); + if (afterReason) rows.add(`After content metadata only: ${afterReason}`); + } + return [...rows]; + }, [nonErrorSnippets]); return (
+ {ledgerMetadataRows.length > 0 && ( +
+ {ledgerMetadataRows.map((row) => ( +
{row}
+ ))} +
+ )} + {nonErrorSnippets.map((snippet, index) => ( { expect(content.contentSource).toBe('snippet-reconstruction'); }); + it('maps ledger create original content to empty string without disk reconstruction', async () => { + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-create.txt', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/ledger-create.txt', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: 'created\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: 'created\n', + beforeHash: null, + afterHash: 'hash', + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: 'hash' }, + }, + }, + ]); + + expect(content.originalFullContent).toBe(''); + expect(content.modifiedFullContent).toBe('created\n'); + expect(content.contentSource).toBe('ledger-snapshot'); + }); + + it('maps ledger delete modified content to empty string for diff display', async () => { + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-delete.txt', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/ledger-delete.txt', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: 'deleted\n', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: 'deleted\n', + modifiedFullContent: null, + beforeHash: 'hash', + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: 'hash' }, + afterState: { exists: false }, + }, + }, + ]); + + expect(content.originalFullContent).toBe('deleted\n'); + expect(content.modifiedFullContent).toBe(''); + expect(content.contentSource).toBe('ledger-snapshot'); + }); + it('reuses cached content only when disk bytes and snippets are unchanged', async () => { const fsPromises = await import('fs/promises'); const readFile = fsPromises.readFile as unknown as ReturnType; @@ -184,8 +254,18 @@ describe('FileContentResolver', () => { const resolver = new FileContentResolver(logsFinder as any); - const missing = await resolver.resolveFileContent('team', 'member', '/tmp/missing-vs-empty.txt', []); - const empty = await resolver.resolveFileContent('team', 'member', '/tmp/missing-vs-empty.txt', []); + const missing = await resolver.resolveFileContent( + 'team', + 'member', + '/tmp/missing-vs-empty.txt', + [] + ); + const empty = await resolver.resolveFileContent( + 'team', + 'member', + '/tmp/missing-vs-empty.txt', + [] + ); expect(missing.source).toBe('unavailable'); expect(empty.source).toBe('disk-current'); diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index 113f89b3..c94fae64 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'crypto'; import { structuredPatch } from 'diff'; import type { SnippetDiff } from '@shared/types'; @@ -9,17 +10,23 @@ vi.mock('fs/promises', async (importOriginal) => { const readFile = vi.fn(); const writeFile = vi.fn(); const unlink = vi.fn(); + const mkdir = vi.fn(); return { ...actual, + mkdir, readFile, writeFile, unlink, // ESM interop: some code paths expect a default export - default: { ...actual, readFile, writeFile, unlink }, + default: { ...actual, mkdir, readFile, writeFile, unlink }, }; }); describe('ReviewApplierService', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + it('previewReject avoids write-update snippet-level replacement', async () => { const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); const original = 'hello\nworld\n'; @@ -111,4 +118,471 @@ describe('ReviewApplierService', () => { expect(unlink).toHaveBeenCalledWith(filePath); expect(writeFile).not.toHaveBeenCalled(); }); + + it('ledger create reject deletes only when current hash matches', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + const content = 'created\n'; + readFile.mockResolvedValue(content); + unlink.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/ledger-created.txt'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'ledger-created.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: content, + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: content, + beforeHash: null, + afterHash: sha(content), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(content), sizeBytes: content.length }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + originalFullContent: '', + modifiedFullContent: content, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(unlink).toHaveBeenCalledWith(filePath); + }); + + it('ledger create reject blocks when current hash changed', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + readFile.mockResolvedValue('user changed\n'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/ledger-conflict.txt'; + const ledgerContent = 'created\n'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'ledger-conflict.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: ledgerContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: ledgerContent, + beforeHash: null, + afterHash: sha(ledgerContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(ledgerContent) }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + originalFullContent: '', + modifiedFullContent: ledgerContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.conflicts).toBe(1); + expect(res.errors[0]?.code).toBe('conflict'); + expect(unlink).not.toHaveBeenCalled(); + }); + + it('ledger delete reject restores only when file is missing', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + writeFile.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/deleted.txt'; + const original = 'restore me\n'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'deleted.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: original, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: original, + modifiedFullContent: null, + beforeHash: sha(original), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(original) }, + afterState: { exists: false }, + }, + }, + ], + linesAdded: 0, + linesRemoved: 1, + isNewFile: false, + originalFullContent: original, + modifiedFullContent: '', + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(1); + expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8'); + }); + + it('ledger binary or large unavailable content requires manual review', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + readFile.mockResolvedValue('binary placeholder'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/blob.bin'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'blob.bin', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: null, + beforeHash: null, + afterHash: null, + operation: 'modify', + beforeState: { exists: true, unavailableReason: 'binary file' }, + afterState: { exists: true, unavailableReason: 'binary file' }, + }, + }, + ], + linesAdded: 0, + linesRemoved: 0, + isNewFile: false, + originalFullContent: null, + modifiedFullContent: null, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.errors[0]?.code).toBe('manual-review-required'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('ledger rename reject restores old path and deletes new path with hash guards', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + const mkdir = fsPromises.mkdir as unknown as ReturnType; + + const oldPath = '/repo/src/old.ts'; + const newPath = '/repo/src/new.ts'; + const oldContent = 'old\n'; + const newContent = 'new\n'; + readFile.mockImplementation(async (filePath: string) => { + if (filePath === newPath) return newContent; + if (filePath === oldPath) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + throw new Error(`unexpected read ${filePath}`); + }); + mkdir.mockResolvedValue(undefined); + writeFile.mockResolvedValue(undefined); + unlink.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }, + ], + }, + new Map([ + [ + newPath, + { + filePath: newPath, + relativePath: 'src/new.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath: oldPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: oldContent, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-old', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: oldContent, + modifiedFullContent: null, + beforeHash: sha(oldContent), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(oldContent) }, + afterState: { exists: false }, + relation, + }, + }, + { + toolUseId: 'ledger-1', + filePath: newPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: newContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:01.000Z', + isError: false, + ledger: { + eventId: 'event-new', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: newContent, + beforeHash: null, + afterHash: sha(newContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(newContent) }, + relation, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: oldContent, + modifiedFullContent: newContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(mkdir).toHaveBeenCalledWith('/repo/src', { recursive: true }); + expect(writeFile).toHaveBeenCalledWith(oldPath, oldContent, 'utf8'); + expect(unlink).toHaveBeenCalledWith(newPath); + }); + + it('ledger rename reject blocks when new path hash changed', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + const oldPath = '/repo/src/old.ts'; + const newPath = '/repo/src/new.ts'; + const oldContent = 'old\n'; + const newContent = 'new\n'; + readFile.mockResolvedValue('user changed\n'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }, + ], + }, + new Map([ + [ + newPath, + { + filePath: newPath, + relativePath: 'src/new.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath: oldPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: oldContent, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-old', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: oldContent, + modifiedFullContent: null, + beforeHash: sha(oldContent), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(oldContent) }, + afterState: { exists: false }, + relation, + }, + }, + { + toolUseId: 'ledger-1', + filePath: newPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: newContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:01.000Z', + isError: false, + ledger: { + eventId: 'event-new', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: newContent, + beforeHash: null, + afterHash: sha(newContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(newContent) }, + relation, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: oldContent, + modifiedFullContent: newContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.conflicts).toBe(1); + expect(res.errors[0]?.code).toBe('conflict'); + expect(writeFile).not.toHaveBeenCalled(); + expect(unlink).not.toHaveBeenCalled(); + }); }); + +function sha(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts new file mode 100644 index 00000000..5879b345 --- /dev/null +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -0,0 +1,222 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; + +import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader'; + +const TASK_ID = 'task-1'; + +describe('TaskChangeLedgerReader', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('returns warning-only notice bundles even when no file events exist', async () => { + tmpDir = await makeLedgerBundle({ + events: [], + notices: [ + { + schemaVersion: 1, + noticeId: 'notice-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + timestamp: '2026-03-01T10:00:00.000Z', + severity: 'warning', + message: + 'Task change ledger skipped attribution because multiple task scopes were active.', + }, + ], + }); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + includeDetails: true, + }); + + expect(result).not.toBeNull(); + expect(result?.files).toEqual([]); + expect(result?.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(result?.scope.toolUseIds).toEqual(['tool-1']); + }); + + it('maps ledger state and rename relation into snippets', async () => { + tmpDir = await makeLedgerBundle({ + events: [ + { + schemaVersion: 1, + eventId: 'event-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'modify', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/new.ts', + relativePath: 'src/new.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + beforeState: { exists: true, unavailableReason: 'binary file' }, + afterState: { exists: true, unavailableReason: 'binary file' }, + relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' }, + linesAdded: 0, + linesRemoved: 0, + }, + ], + }); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: true, + }); + + const snippet = result?.files[0]?.snippets[0]; + expect(snippet?.ledger?.beforeState?.unavailableReason).toBe('binary file'); + expect(snippet?.ledger?.relation).toEqual({ + kind: 'rename', + oldPath: 'src/old.ts', + newPath: 'src/new.ts', + }); + expect(result?.files[0]?.relativePath).toBe('src/new.ts'); + }); + + it('groups rename relations in summary-only bundles without losing absolute paths', async () => { + const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + tmpDir = await makeLedgerBundle({ + events: [ + { + schemaVersion: 1, + eventId: 'event-old', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'delete', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/old.ts', + relativePath: 'src/old.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + relation, + linesAdded: 0, + linesRemoved: 2, + }, + { + schemaVersion: 1, + eventId: 'event-new', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'create', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/new.ts', + relativePath: 'src/new.ts', + timestamp: '2026-03-01T10:00:01.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + relation, + linesAdded: 3, + linesRemoved: 0, + }, + ], + }); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.files[0]?.filePath).toBe('/repo/src/new.ts'); + expect(result?.files[0]?.relativePath).toBe('src/new.ts'); + expect(result?.files[0]?.isNewFile).toBe(false); + expect(result?.files[0]?.linesAdded).toBe(3); + expect(result?.files[0]?.linesRemoved).toBe(2); + }); +}); + +async function makeLedgerBundle(params: { + events: unknown[]; + notices?: unknown[]; +}): Promise { + const dir = await fsTempDir(); + const bundleDir = path.join(dir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId: TASK_ID, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: params.events.length, + files: params.events.map((event: any) => ({ + filePath: event.filePath, + relativePath: event.relativePath, + eventIds: [event.eventId], + linesAdded: event.linesAdded ?? 0, + linesRemoved: event.linesRemoved ?? 0, + isNewFile: event.operation === 'create', + latestAfterHash: event.after?.sha256 ?? null, + })), + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: params.events.length, + confidence: 'high', + warnings: [], + events: params.events, + ...(params.notices ? { notices: params.notices } : {}), + }), + 'utf8' + ); + return dir; +} + +async function fsTempDir(): Promise { + return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-')); +} From 7a337b626816384898da4c2da429f2ae982dc739 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 12:45:34 +0300 Subject: [PATCH 02/19] feat(codex): add app-server model catalog --- .../codex-app-server-model-catalog-plan.md | 2372 +++++++++++++++++ .../codex-model-catalog/contracts/dto.ts | 13 + .../codex-model-catalog/contracts/index.ts | 7 + .../normalizeCodexAppServerModel.test.ts | 95 + .../core/domain/codexModelCatalogFallback.ts | 61 + .../core/domain/codexReasoningEffort.ts | 24 + .../domain/normalizeCodexAppServerModel.ts | 151 ++ src/features/codex-model-catalog/index.ts | 9 + .../createCodexModelCatalogFeature.ts | 357 +++ .../codex-model-catalog/main/index.ts | 5 + .../CodexModelCatalogAppServerClient.ts | 159 ++ .../InMemoryCodexModelCatalogCache.ts | 37 + .../CodexModelCatalogAppServerClient.test.ts | 88 + src/main/http/teams.ts | 21 +- src/main/index.ts | 13 + src/main/ipc/teams.ts | 92 +- .../infrastructure/CliInstallerService.ts | 4 + .../codexAppServer/CodexBinaryResolver.ts | 35 + .../codexAppServer/JsonRpcStdioClient.ts | 50 +- .../__tests__/JsonRpcStdioClient.test.ts | 79 + .../infrastructure/codexAppServer/index.ts | 9 +- .../infrastructure/codexAppServer/protocol.ts | 50 + .../runtime/ClaudeMultimodelBridgeService.ts | 44 + .../runtime/ProviderConnectionService.ts | 55 +- src/main/services/team/TeamDataService.ts | 18 +- src/main/services/team/TeamMemberResolver.ts | 4 +- .../services/team/TeamMembersMetaStore.ts | 6 +- src/main/services/team/TeamMetaStore.ts | 69 + .../services/team/TeamProvisioningService.ts | 386 ++- .../team/dialogs/EffortLevelSelector.tsx | 144 +- .../team/dialogs/LaunchTeamDialog.tsx | 2 + .../team/dialogs/TeamModelSelector.tsx | 1 + .../team/dialogs/editTeamRuntimeChanges.ts | 12 +- .../components/team/members/LeadModelRow.tsx | 2 + .../team/members/MemberDraftRow.tsx | 2 + .../team/members/MembersEditorSection.tsx | 11 +- .../team/members/membersEditorUtils.ts | 6 +- .../services/createTeamDraftStorage.ts | 11 +- ...teamModelAvailability.codexCatalog.test.ts | 161 ++ src/renderer/utils/teamModelAvailability.ts | 77 +- src/renderer/utils/teamModelCatalog.ts | 26 +- src/shared/types/cliInstaller.ts | 53 + src/shared/types/team.ts | 15 +- src/shared/utils/effortLevels.ts | 57 + .../team/TeamProvisioningService.test.ts | 40 + .../TeamProvisioningServicePrompts.test.ts | 108 +- 46 files changed, 4873 insertions(+), 168 deletions(-) create mode 100644 docs/research/codex-app-server-model-catalog-plan.md create mode 100644 src/features/codex-model-catalog/contracts/dto.ts create mode 100644 src/features/codex-model-catalog/contracts/index.ts create mode 100644 src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts create mode 100644 src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts create mode 100644 src/features/codex-model-catalog/core/domain/codexReasoningEffort.ts create mode 100644 src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts create mode 100644 src/features/codex-model-catalog/index.ts create mode 100644 src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts create mode 100644 src/features/codex-model-catalog/main/index.ts create mode 100644 src/features/codex-model-catalog/main/infrastructure/CodexModelCatalogAppServerClient.ts create mode 100644 src/features/codex-model-catalog/main/infrastructure/InMemoryCodexModelCatalogCache.ts create mode 100644 src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts create mode 100644 src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts create mode 100644 src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts create mode 100644 src/shared/utils/effortLevels.ts diff --git a/docs/research/codex-app-server-model-catalog-plan.md b/docs/research/codex-app-server-model-catalog-plan.md new file mode 100644 index 00000000..84ad8210 --- /dev/null +++ b/docs/research/codex-app-server-model-catalog-plan.md @@ -0,0 +1,2372 @@ +# Codex App-Server Model Catalog Plan + +**Date**: 2026-04-21 +**Status**: implementation complete in feature worktrees, pending final review/commit +**Worktree**: `/Users/belief/dev/projects/claude/claude_team_codex_model_catalog_plan` +**Branch**: `spike/codex-model-catalog-plan` +**Primary repo**: `claude_team` +**Secondary repo worktree**: `/Users/belief/dev/projects/claude/agent_teams_orchestrator_codex_native_spike` +**Architecture reference**: [FEATURE_ARCHITECTURE_STANDARD.md](../FEATURE_ARCHITECTURE_STANDARD.md) + +## Executive Summary + +Codex model selection should move from hardcoded local lists to the official Codex app-server `model/list` catalog. + +Chosen implementation: + +- Add a dedicated `src/features/codex-model-catalog` feature in `claude_team`. +- Use `codex app-server` JSON-RPC `model/list` as the primary source for Codex models. +- Keep the existing static Codex catalog only as a bounded fallback when app-server is unavailable. +- Add rich, additive model metadata to `CliProviderStatus` while keeping `models: string[]` for backwards compatibility. +- Use per-model `supportedReasoningEfforts` and `defaultReasoningEffort` for the Codex model picker and launch validation. +- Keep Anthropic and Gemini behavior unchanged by default. +- Update `agent_teams_orchestrator` so Codex launches pass reasoning effort through Codex config key `model_reasoning_effort`, not through an invented `--effort` flag. + +Decision score: + +- `🎯 9 🛡️ 9 🧠 6` +- estimated implementation size: `1200-2400` lines across `claude_team` and `agent_teams_orchestrator` + +Why this is the safest path: + +- It follows the real Codex client contract instead of chasing static releases. +- It solves future model releases like `gpt-5.5` without an app release, as long as Codex app-server already exposes the model. +- It avoids breaking Anthropic by making the new catalog contract additive and provider-scoped. +- It handles `xhigh` correctly as Codex-specific reasoning effort, not as Anthropic `max`. + +Current implementation state: + +- `claude_team` has the dedicated Codex model catalog feature, app-server JSON-RPC client, static fallback, provider status integration, Codex model picker integration, provider-aware effort UI, launch validation, launch identity persistence, and targeted tests. +- `agent_teams_orchestrator_codex_native_spike` exposes runtime capabilities for dynamic Codex models and Codex reasoning config pass-through, and its Codex native exec runner passes effort through `-c model_reasoning_effort="value"`. +- Anthropic remains isolated from Codex-only effort values. Anthropic launch UI still uses `low | medium | high`; Codex can use per-model `minimal | low | medium | high | xhigh` only where catalog/runtime policy allows it. +- Future Codex app-server models can appear immediately in UI. Launch is allowed only when the local runtime declares dynamic Codex model support; otherwise they remain visible with upgrade/policy copy instead of failing late during spawn. +- `Default` Codex selection is resolved to a concrete model immediately before provisioning and stored as additive launch identity metadata. +- The remaining work before merge is review/signoff, not more architecture discovery. + +## Sources And Verification + +Official sources checked: + +- [Codex App Server](https://developers.openai.com/codex/app-server) +- [Codex CLI command line options](https://developers.openai.com/codex/cli/reference) +- [Codex Configuration Reference](https://developers.openai.com/codex/config-reference) + +Important official facts: + +- `model/list` is explicitly intended for rendering model and personality selectors. +- `model/list` returns `id`, `model`, `displayName`, `hidden`, `defaultReasoningEffort`, `supportedReasoningEfforts`, `inputModalities`, `supportsPersonality`, `isDefault`, `upgrade`, and `upgradeInfo`. +- `includeHidden: false` returns picker-visible models by default. +- `codex exec` has `--model` and `-c, --config key=value`. +- `codex exec` does not expose a first-class `--effort` flag. +- Codex config key `model_reasoning_effort` supports `minimal | low | medium | high | xhigh`. +- `xhigh` is model-dependent. +- `config/read` exists in app-server and returns effective configuration after configuration layering. +- Codex loads user config from `~/.codex/config.toml` and can also load project-scoped `.codex/config.toml` only for trusted projects. +- `model_catalog_json` can override the model catalog, including profile-level overrides. +- `codex exec` supports `--cd` and `--profile`, and `-c key=value` overrides take precedence for one invocation. + +Local probe: + +- binary: `codex-cli 0.117.0` +- method: `codex app-server` over JSON-RPC stdio +- transport: newline-delimited JSON-RPC over stdio, not `Content-Length` framing +- request: `model/list` with `{ "limit": 20, "includeHidden": false }` +- result: 8 visible models, `gpt-5.4` marked default, `nextCursor: null` +- visible models returned: `gpt-5.4`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.1-codex-mini` +- `xhigh` is already returned for most models. +- `gpt-5.1-codex-mini` only returned `medium | high`, so effort options must be per-model. +- `gpt-5.3-codex-spark` returned default effort `high`, so default effort must not be global. +- `codex exec --help` locally confirms `--cd`, `--profile`, `--model`, `--oss`, `--local-provider`, and repeatable `-c key=value`. +- local help confirms `--oss` is equivalent to `-c model_provider=oss`, so provider scope can differ from subscription-backed OpenAI Codex if not guarded. +- live `config/read` probe returned `{ config, origins }`. +- live `config/read` probe requires `params` object; missing `params` returns JSON-RPC error `-32600`. +- live `config/read` probe accepted `{ cwd }` and `{ profile }` without error, so the implementation should feature-detect and test scoped reads instead of assuming only global config. +- final live smoke on this worktree confirmed `model/list` returns 8 visible models, default `gpt-5.4`, `xhigh` for most models, and `medium | high` only for `gpt-5.1-codex-mini`. + +Combined app-server session probe: + +- one initialized app-server process successfully handled `account/read`, `account/rateLimits/read`, and `model/list` sequentially +- `account/read` returned a ChatGPT account shape in the local environment +- `account/rateLimits/read` returned `primary.windowDurationMins = 300` and `secondary.windowDurationMins = 10080` +- `model/list` returned the same 8 visible models in that same session +- conclusion: provider refresh should use a combined control-plane session when it needs account, limits, and catalog truth + +## Lowest-Confidence Areas And Decisions + +### 1. Auth-scoped catalog truth + +`🎯 7 🛡️ 9 🧠 6` +Estimated implementation impact: `180-350` lines + +Uncertainty: + +- app-server `model/list` may return different catalogs depending on active Codex auth state, account plan, org policy, API-key mode, or future Codex rollout flags. +- The local probe only proves one logged-in environment, not all account modes. + +Decision: + +- treat Codex model catalog as auth-scoped, not global +- cache key must include binary path, Codex home, preferred auth mode, effective auth mode, managed account stable identity when available, and API-key availability source +- never reuse a ChatGPT-account catalog as API-key-mode catalog +- never reuse an API-key-mode catalog as ChatGPT-account catalog +- when auth mode changes, keep previous catalog visible only as stale UI while refresh is in flight, then replace it + +Implementation rule: + +```text +catalogCacheKey = + binaryPath + + binaryVersion + + codexHome + + preferredAuthMode + + effectiveAuthMode + + managedAccountHash or "no-chatgpt-account" + + apiKey.source or "no-api-key" +``` + +The hash should use a per-process salt and should not be persisted. Do not persist raw email solely for catalog cache. + +### 2. Default model determinism + +`🎯 8 🛡️ 9 🧠 6` +Estimated implementation impact: `220-420` lines + +Uncertainty: + +- current UI can represent model as empty string meaning `Default` +- Codex app-server default can change after a Codex release +- launch logs, relaunch, replay, and team metadata need to stay explainable + +Decision: + +- keep `Default` as a UI selection +- resolve `Default` to a concrete `resolvedLaunchModel` immediately before launch +- persist both user selection and resolved runtime truth in launch metadata +- never silently rewrite old team config from one concrete model to another +- if a team stored `Default`, relaunch should show that it will resolve to the current Codex default before launch + +Required persisted launch identity: + +```ts +export interface ProviderModelLaunchIdentity { + providerId: TeamProviderId; + providerBackendId: TeamProviderBackendId | null; + selectedModel: string | null; + selectedModelKind: 'default' | 'explicit'; + resolvedLaunchModel: string; + catalogId: string | null; + catalogSource: 'app-server' | 'static-fallback' | 'unavailable'; + catalogFetchedAt: string | null; + selectedEffort: string | null; + resolvedEffort: string | null; +} +``` + +This identity should be written into exact logs and launch-derived metadata. It should not replace existing fields in Phase 1, but it should become the canonical explanation layer for Codex relaunch/replay. + +### 3. Effort transport through orchestrator + +`🎯 8 🛡️ 9 🧠 5` +Estimated implementation impact: `180-320` lines + +Uncertainty: + +- Agent Teams exposes a generic `--effort` concept today +- Codex CLI does not expose `--effort` +- Codex uses config key `model_reasoning_effort` + +Decision: + +- UI and main process may accept provider-aware effort strings +- orchestrator public Agent Teams CLI can continue accepting `--effort` +- Codex executor must translate Codex effort to `codex exec -c model_reasoning_effort='"value"'` +- Anthropic executor must not see Codex-only effort values +- Codex executor must not see Anthropic `max` + +No implementation phase may ship `xhigh` as selectable until this pass-through is tested. + +### 4. Catalog availability vs team-agent safety policy + +`🎯 8 🛡️ 9 🧠 5` +Estimated implementation impact: `160-280` lines + +Uncertainty: + +- app-server `model/list` says a model is available to Codex +- our team-agent contract can still make a model unsafe for Agent Teams if it breaks task/reply/bootstrap conventions +- current UI has local disabled policy for `gpt-5.3-codex-spark`, `gpt-5.2-codex`, and `gpt-5.1-codex-mini` + +Decision: + +- model catalog answers "can Codex offer this model" +- team policy answers "can Agent Teams safely launch this model" +- keep these as separate layers +- do not remove current disabled policies just because app-server returns a model +- show clear disabled copy: `Available in Codex, disabled for Agent Teams` +- disabled models can still display catalog metadata and effort metadata for transparency + +### 5. Codex binary version and app-server method compatibility + +`🎯 7 🛡️ 9 🧠 6` +Estimated implementation impact: `220-420` lines + +Uncertainty: + +- `codex app-server` is documented as an app-server integration surface, but local users can have older Codex binaries. +- `model/list` may be missing, renamed, or return a narrower shape in older binaries. +- Current `JsonRpcStdioClient` collapses JSON-RPC errors to `Error(message)`, which loses method, code, and structured details needed to distinguish `method not found` from auth/network/timeout. +- Current `CodexBinaryResolver` caches only binary path, not binary version. + +Decision: + +- make binary version part of catalog cache identity +- add structured JSON-RPC error metadata before implementing catalog fallback +- treat `method not found` as `static-fallback`, not as account failure +- treat malformed model rows as catalog degradation, not app-server runtime failure +- clear catalog cache when resolved Codex binary path or version changes + +Required implementation detail: + +```ts +export class JsonRpcRequestError extends Error { + readonly method: string; + readonly code: number | null; + readonly details: unknown; +} +``` + +The app-server model client should classify: + +- `method_not_found`: fallback to static catalog and show upgrade hint +- `timeout`: stale cache if available, then fallback +- `malformed_response`: fallback plus diagnostics +- `process_exit`: shared app-server failure for all sub-results in combined snapshot +- `auth_required`: account/read decides auth truth; model/list must not invent auth truth + +### 6. `auto` auth resolution for model catalog + +`🎯 7 🛡️ 9 🧠 6` +Estimated implementation impact: `180-320` lines + +Uncertainty: + +- UI lets users pick `auto`, `chatgpt`, or `api_key`. +- Catalog can differ between ChatGPT subscription and API key. +- The model picker must preview the catalog for the mode that launch will actually use, not only the configured preference. + +Decision: + +- `preferredAuthMode=auto` is not a catalog scope by itself +- resolve `auto` into `effectiveAuthMode` using the same readiness logic as launch +- catalog request should be scoped to the effective launch mode +- Provider Settings can show both preference and effective catalog scope when they differ +- if effective mode flips from ChatGPT to API key because ChatGPT becomes unavailable, keep stale ChatGPT catalog visually stale and refresh API-key catalog + +UX copy rule: + +- do not say `Detected from OPENAI_API_KEY` as the primary model catalog source when ChatGPT account is the effective mode +- show API-key availability only as fallback/secondary when selected auth is ChatGPT or auto resolves to ChatGPT + +### 7. App-server notifications and refresh cadence + +`🎯 8 🛡️ 8 🧠 5` +Estimated implementation impact: `160-260` lines + +Uncertainty: + +- account login flow has notifications +- current docs and local probe do not establish a dedicated model-catalog changed notification +- keeping a long-lived app-server just for model catalog would increase lifecycle complexity + +Decision: + +- do not introduce a long-lived model catalog subscription in this rollout +- use short-lived app-server sessions for refresh +- trigger catalog refresh after login success, logout, auth mode change, API-key source change, manual refresh, and provider status refresh +- do not poll `model/list` aggressively from renderer +- use `10 minute` success TTL and stale cache for UI continuity + +If a future app-server release adds model catalog notifications, integrate them later behind the catalog feature port without changing renderer contracts. + +### 8. Backup, restore, and relaunch compatibility + +`🎯 7 🛡️ 9 🧠 7` +Estimated implementation impact: `240-520` lines + +Uncertainty: + +- team launch metadata already persisted provider/model/effort/backend in several places +- adding dynamic defaults and resolved model identity can make old backups ambiguous +- old teams may contain no `modelCatalog` metadata + +Decision: + +- new `ProviderModelLaunchIdentity` is additive +- old teams without it remain readable +- relaunch derives missing identity from existing provider/model/effort/backend fields +- restore does not require the old catalog to be available +- if restored explicit model is missing from current catalog, UI preserves the explicit model with a warning instead of silently replacing it with current default +- if restored model was `Default`, relaunch preview resolves it against current catalog and says so before launch + +Migration rule: + +```text +old explicit model -> selectedModelKind="explicit", resolvedLaunchModel=old model +old empty model -> selectedModelKind="default", resolvedLaunchModel=current default at next launch +missing effort -> selectedEffort=null, resolvedEffort=current model default at next launch +``` + +### 9. UI and orchestrator version skew + +`🎯 7 🛡️ 10 🧠 7` +Estimated implementation impact: `280-620` lines + +Uncertainty: + +- `claude_team` and `agent_teams_orchestrator` can be updated at different times. +- UI can learn about `xhigh`, `minimal`, or a future model like `gpt-5.5` before the installed orchestrator can launch it safely. +- The current orchestrator static Codex helpers can reject a model that Codex app-server already exposed. + +Decision: + +- catalog visibility and launch capability are separate contracts +- UI may display app-server catalog metadata as soon as it is available +- UI must not enable launch controls that require new orchestrator behavior until runtime capability says that behavior exists +- provider-explicit Codex model strings can be accepted only after orchestrator declares dynamic Codex model support +- Codex `xhigh` can be shown as metadata before Phase 4, but it is disabled for launch until Codex effort pass-through is available + +Required runtime capability contract: + +```ts +export interface ProviderRuntimeCapabilities { + providerId: TeamProviderId; + codex?: { + supportsDynamicAppServerModels: boolean; + supportsCodexReasoningEffortConfig: boolean; + supportedCodexReasoningEfforts: Array<'minimal' | 'low' | 'medium' | 'high' | 'xhigh'>; + acceptsProviderExplicitFutureModels: boolean; + }; +} +``` + +Compatibility rule: + +```text +catalog says model/effort exists ++ team policy says model is not disabled ++ runtime capability says launch path supports it += launch control enabled +``` + +If any part is missing, the picker can still display the model, but launch must be disabled with explicit copy. + +Recommended copy: + +- `Available in Codex, waiting for Agent Teams runtime support` +- `This Codex effort is visible in Codex, but this Agent Teams runtime cannot launch it yet` +- `Upgrade the Agent Teams runtime to use this model` + +This avoids a bad state where the user selects `xhigh` successfully in UI and then gets a late `codex exec` failure. + +### 10. Future model policy, including `gpt-5.5` + +`🎯 8 🛡️ 8 🧠 6` +Estimated implementation impact: `240-520` lines + +Uncertainty: + +- app-server can expose a new model immediately after OpenAI releases it. +- the user goal is that new Codex models appear without us shipping a new static list. +- Agent Teams still needs a safety layer so one unexpected model row does not break team launch flows. + +Top 3 policies: + +1. Allow every app-server-visible model immediately: `🎯 8 🛡️ 5 🧠 3`, `80-180` lines. This best solves future releases, but it can route unverified models into team launch without product copy or rollback clarity. +2. Show every app-server-visible model immediately, launch with capability gate plus "new model" warning: `🎯 9 🛡️ 8 🧠 5`, `240-520` lines. This keeps future models visible without app releases, but still blocks only real launch incompatibilities. +3. Hide or disable unknown models until a code release updates policy: `🎯 4 🛡️ 9 🧠 2`, `60-120` lines. This is safe but defeats the reason to use `model/list`. + +Chosen policy: option 2. + +Implementation rule: + +- app-server-visible, non-hidden models appear in the picker immediately +- known disabled Agent Teams models remain disabled +- new unknown models are selectable only if runtime capabilities support dynamic Codex models +- new unknown models get a `New from Codex catalog` note until a successful launch or explicit policy promotion marks them `verified` +- if the new model does not expose usable text input or any supported effort we can launch, it is shown but disabled +- hidden models are never introduced into new-team pickers by default + +Policy statuses: + +```ts +export type CodexTeamModelPolicyStatus = + | 'verified' + | 'new-from-codex-catalog' + | 'disabled-for-agent-teams' + | 'requires-runtime-upgrade' + | 'missing-from-current-catalog'; +``` + +This means `gpt-5.5` can appear the day app-server returns it, but the UI will not pretend the full Agent Teams launch path is verified unless the local runtime can actually handle provider-explicit dynamic Codex models. + +### 11. Hidden, upgraded, and persisted models + +`🎯 8 🛡️ 9 🧠 5` +Estimated implementation impact: `160-340` lines + +Uncertainty: + +- official docs say `includeHidden: false` returns picker-visible models by default. +- persisted teams can reference a model that later becomes hidden, upgraded, renamed, or unavailable. +- app-server exposes `upgrade` and `upgradeInfo`, but we do not know every future migration shape. + +Decision: + +- normal picker uses `includeHidden: false` +- if a persisted explicit Codex model is not found in the visible catalog, run one scoped refresh with `includeHidden: true` +- if hidden lookup finds the model, show it as `Hidden in Codex catalog` and keep relaunch possible only if runtime capability and team policy allow it +- if `upgrade` points to a visible replacement, show a non-destructive migration suggestion +- never auto-rewrite persisted model ids during restore or relaunch + +Relaunch behavior: + +```text +visible model found -> normal relaunch +hidden model found -> relaunch allowed only with warning and policy pass +upgrade available -> show "Switch to recommended model" action +missing model -> keep value visible, require user to choose another model before launch +``` + +This avoids both failure modes: silently changing a user's team model, or breaking old teams because a model moved out of the default picker. + +### 12. Stored effort schema and non-dialog launch paths + +`🎯 7 🛡️ 9 🧠 7` +Estimated implementation impact: `320-760` lines + +Uncertainty: + +- effort is not used only in launch dialogs. +- team metadata, member metadata, backup/restore, draft retry, localStorage launch params, and scheduled/provisioned flows can all carry `effort`. +- current normalizers in team data paths may silently discard anything outside `low | medium | high`. + +Decision: + +- provider-aware effort parsing must be added at every inbound boundary, not only in React components +- old persisted `low | medium | high` values stay valid +- new Codex-specific values are preserved only with provider/model context +- if provider context is missing, parse as legacy effort and do not invent Codex-specific meaning +- scheduled launches and automation-like flows must either be updated in the same phase or explicitly block Codex-only efforts until updated + +High-risk code paths to audit during implementation: + +- `src/main/services/team/TeamMembersMetaStore.ts` +- `src/main/services/team/TeamDataService.ts` +- `src/main/services/team/TeamBackupService.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/shared/types/schedule.ts` +- `src/main/ipc/teams.ts` +- `src/main/http/teams.ts` +- renderer launch prefill and draft retry localStorage state + +Migration rule: + +```text +legacy effort with no provider context -> keep if low | medium | high +codex effort with provider=codex -> validate against selected model catalog +codex effort with provider missing -> store as selected string only, resolve before launch +unsupported restored effort -> show warning, do not silently downgrade +``` + +### 13. Renderer stale state, HMR, and out-of-order refreshes + +`🎯 8 🛡️ 9 🧠 5` +Estimated implementation impact: `180-380` lines + +Uncertainty: + +- previous provider settings work showed transient wrong states after HMR and slow refreshes. +- catalog, account, and rate limits can refresh with different timings. +- a stale app-server response can arrive after a newer auth-mode change. + +Decision: + +- every provider status refresh should carry a monotonic `requestId` or `snapshotVersion` +- renderer stores the latest accepted version per provider +- responses older than the latest accepted version are ignored +- `modelCatalog.schemaVersion` is required and future versions are treated as degraded, not fatal +- HMR should keep last ready provider status visible while a refresh is in flight +- a catalog refresh cannot overwrite account connected state unless it came from the same combined snapshot + +Required stale-write guard: + +```text +if incoming.providerId != current.providerId -> reject +if incoming.requestId < current.requestId -> reject +if incoming.authScope != current.authScope and incoming.status is not from current auth selection -> keep as stale diagnostics only +``` + +This directly targets flicker like `Codex native unavailable` followed by ready state, or fallback API-key copy appearing while ChatGPT account mode is selected. + +### 14. Privacy, logs, and diagnostics + +`🎯 8 🛡️ 9 🧠 4` +Estimated implementation impact: `120-260` lines + +Uncertainty: + +- account-scoped cache keys need stable identity, but raw email should not leak into exact logs, runtime snapshots, or persistent diagnostics. +- API-key source is useful for UX, but no secret or env value should be logged. + +Decision: + +- hash managed account identity in memory for cache keys +- use a per-process salt for volatile cache keys +- do not persist raw account email solely for model catalog cache +- exact logs can record `authScope=chatgpt` or `authScope=api_key`, not raw account identity +- diagnostics can record `apiKeySource=OPENAI_API_KEY` but never the value +- error messages preserve method/code/timeout, but redact command env and tokens + +Required diagnostic fields: + +```ts +export interface CodexModelCatalogDiagnostics { + source: 'app-server' | 'static-fallback' | 'unavailable'; + status: 'ready' | 'stale' | 'degraded' | 'unavailable'; + method?: 'model/list'; + errorCode?: string | number | null; + errorCategory?: string | null; + binaryVersion?: string | null; + effectiveAuthMode?: 'chatgpt' | 'api_key' | null; + cacheAgeMs?: number | null; +} +``` + +No UI surface should show `Unknown error` for catalog failures after this feature. + +### 15. Rollout ordering across repos + +`🎯 8 🛡️ 10 🧠 6` +Estimated implementation impact: `120-260` lines + +Uncertainty: + +- `claude_team` can ship UI before the user has a compatible `agent_teams_orchestrator` runtime in cache. +- the app can point to `CLAUDE_DEV_RUNTIME_ROOT`, bundled runtime cache, or a user-installed runtime binary. + +Decision: + +- implement orchestrator support first or behind a UI capability gate +- Provider Settings can show catalog metadata before launch support exists +- Create/Launch dialogs must consult runtime capabilities before enabling new Codex models or new Codex efforts +- the runtime health check should expose a version/capability payload, not force UI to infer support from binary version strings +- if capabilities are unavailable, default to safe: display metadata, disable launch-only features + +Rollout sequence: + +1. Add orchestrator dynamic Codex model and effort capability support. +2. Add `claude_team` catalog feature and provider status metadata. +3. Show catalog in UI with capability gates. +4. Enable launch when capability and catalog agree. +5. Remove any temporary guard only after bundled runtime and dev runtime both report capabilities in CI/smoke. + +This is the cleanest way to avoid UI and runtime getting out of sync. + +### 16. Codex config/profile/cwd catalog mismatch + +`🎯 6 🛡️ 10 🧠 8` +Estimated implementation impact: `360-900` lines + +Uncertainty: + +- official config docs allow `model_catalog_json`, and profile-level `profiles..model_catalog_json` can override it. +- Codex loads project-scoped `.codex/config.toml` only when a project or worktree is trusted. +- `codex exec` can run with a different `cwd`, profile, and inline `-c` overrides than the short-lived app-server preview session. +- current `CodexAppServerSessionFactory` starts `codex app-server` without an explicit `cwd` or profile. + +Failure mode: + +- Provider Settings shows catalog A from global config. +- Launch runs `codex exec` in project cwd with project-scoped or profile config and effectively uses catalog B. +- The user selects a model that preview says is valid, but launch resolves against a different provider/catalog. + +Top 3 policies: + +1. Global-only catalog preview: `🎯 7 🛡️ 5 🧠 3`, `80-180` lines. Fast and simple, but wrong for project-scoped Codex configs. +2. Project-scoped catalog preview for launch flows, global preview for dashboard: `🎯 9 🛡️ 9 🧠 7`, `360-900` lines. More work, but it matches actual `codex exec` launch context. +3. Ignore config and force a static OpenAI Codex provider always: `🎯 5 🛡️ 8 🧠 4`, `200-420` lines. Safer than mismatch, but it discards legitimate user Codex config and can surprise power users. + +Chosen policy: option 2. + +Decision: + +- dashboard/provider card can show a global Codex catalog snapshot +- Create/Launch dialogs must fetch or resolve catalog for the selected launch `cwd` +- if profile selection exists or is introduced, catalog cache key must include profile name +- if we pass inline config overrides to `codex exec`, equivalent preview scope must include those overrides or launch must be marked "not preview-verified" +- if project trust/config cannot be resolved, launch UI falls back to global catalog but shows `Catalog may differ for this project` + +Required preview scope: + +```ts +export interface CodexModelCatalogScope { + codexHome: string; + binaryPath: string; + binaryVersion: string | null; + cwd: string | null; + projectTrust: 'trusted' | 'untrusted' | 'unknown'; + profileName: string | null; + configFingerprint: string | null; + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' | null; + effectiveAuthMode: 'chatgpt' | 'api_key' | null; + launchOverridesFingerprint: string | null; +} +``` + +Cache key correction: + +```text +catalogCacheKey = + binaryPath + + binaryVersion + + codexHome + + cwd or "global" + + projectTrust + + profileName or "default-profile" + + configFingerprint or "unknown-config" + + launchOverridesFingerprint or "no-launch-overrides" + + preferredAuthMode + + effectiveAuthMode + + forcedLoginMethod or "no-forced-login-method" + + forcedWorkspaceHash or "no-forced-workspace" + + managedAccountHash or "no-chatgpt-account" + + apiKey.source or "no-api-key" +``` + +Implementation notes: + +- use app-server `config/read` when available to get effective config fingerprints for the same scope that launch will use +- do not parse arbitrary TOML as the primary config source if app-server can resolve effective configuration +- if app-server cannot scope `config/read` by cwd/profile, keep that uncertainty visible in diagnostics +- do not use raw config file contents as a cache key or log payload; hash only the relevant effective keys + +Relevant effective keys: + +- `model` +- `model_provider` +- `model_catalog_json` +- `profiles..model_catalog_json` +- `model_reasoning_effort` +- `forced_login_method` +- `forced_chatgpt_workspace_id` +- `openai_base_url` +- `model_providers.*` only as a redacted structural fingerprint +- `projects..trust_level` + +Acceptance: + +- a team launch from project A and project B can have different Codex catalog cache entries +- a trusted project `.codex/config.toml` changing `model_catalog_json` invalidates preview for that project +- global dashboard status does not claim to be launch-exact for every project +- exact logs record the catalog scope fingerprint, not raw config values + +### 17. Built-in OpenAI Codex provider vs custom/OSS Codex config + +`🎯 7 🛡️ 9 🧠 7` +Estimated implementation impact: `260-620` lines + +Uncertainty: + +- Codex config supports `model_provider`, custom providers, `oss_provider`, and provider auth settings. +- Agent Teams "Codex" provider is intended to mean native Codex through OpenAI/ChatGPT subscription or API-key billing, not arbitrary custom provider execution. +- app-server `model/list` can be influenced by configuration, but our product copy currently talks about Codex subscription. + +Decision: + +- this cutover should keep Agent Teams Codex scoped to the built-in OpenAI Codex provider +- custom provider and OSS provider support should be a separate provider feature, not silently mixed into `provider=codex` +- if effective config says `model_provider` is not built-in OpenAI for the launch scope, show a clear warning and block subscription-mode launch unless the user intentionally switches to a future custom-provider flow +- when launching Agent Teams Codex, pass or enforce provider config consistently so `codex exec` uses the same provider class previewed by the catalog + +Recommended launch guard: + +```text +if provider=codex and effective model_provider is neither missing nor "openai": + status = degraded + launch = blocked + copy = "This project config points Codex at a custom/local provider. Agent Teams Codex currently supports the built-in OpenAI Codex provider only." +``` + +If the team wants to support custom providers later: + +- add a separate `provider=codex-custom` or generic OpenAI-compatible provider +- do not reuse subscription UX or rate-limit UI +- do not show ChatGPT account limits for custom provider launches + +This prevents a confusing case where UI says "Codex subscription" but runtime actually routes to local OSS or a custom endpoint. + +### 18. Modalities and personality support + +`🎯 8 🛡️ 9 🧠 4` +Estimated implementation impact: `120-280` lines + +Uncertainty: + +- app-server model rows expose `inputModalities` and `supportsPersonality`. +- Agent Teams launch prompts are text-first today, but future UI can attach images or personality-like instructions. +- older model catalogs can omit `inputModalities`, and docs say missing modalities should be treated as `["text", "image"]` for backward compatibility. + +Decision: + +- launchability requires `text` input support +- image support is displayed as capability metadata, not required for normal team launch +- `supportsPersonality=false` must not disable normal team launch, but the UI must not claim `/personality` or personality-specific behavior for that model +- missing `inputModalities` uses the documented backward-compatible default + +Validation rule: + +```text +if inputModalities exists and does not include "text": + show model, disable launch, copy "This Codex model is not text-launch compatible for Agent Teams" + +if supportsPersonality=false: + hide personality controls for this model if those controls exist +``` + +This keeps model picker truthful without overfitting to the current text-only launch flow. + +### 19. Stable app-server surface vs experimental fields + +`🎯 8 🛡️ 9 🧠 4` +Estimated implementation impact: `80-180` lines + +Uncertainty: + +- app-server has an `experimentalApi` capability. +- `model/list` itself is documented on the stable API overview, but adjacent methods and future richer fields can be experimental. +- opting into experimental API globally can change response surface and error behavior. + +Decision: + +- keep `experimentalApi=false` for the model catalog rollout +- rely only on stable `model/list` fields listed in the docs +- treat extra fields as diagnostics only +- add a later explicit spike before using experimental catalog, plugin, or app-server thread features in this path + +Acceptance: + +- catalog tests run with `experimentalApi=false` +- no Phase 1-5 task depends on experimental fields +- if a future field appears, normalization ignores it unless we add a typed, tested use case + +### 20. App-server preview vs native exec signoff + +`🎯 8 🛡️ 10 🧠 6` +Estimated implementation impact: `180-420` lines + +Uncertainty: + +- `model/list` is the correct picker source, but the actual launch surface remains `codex exec --json`. +- a model can appear in app-server before `codex exec` in the installed binary handles it correctly. +- effort config can be accepted syntactically but rejected by the model/provider at runtime. + +Decision: + +- app-server catalog is necessary for UI, but not the only release gate for enabling new launch capability +- Phase 4 must include a live or mocked native-exec compatibility probe for the selected launch path +- native exec signoff should test model, provider scope, cwd, profile, and non-default effort together +- if live signoff is not available in CI, use a fixture-based unit test plus one documented local smoke command before merging + +Required signoff matrix: + +```text +default model + default effort + selected cwd +explicit gpt-5.4 + xhigh + selected cwd +gpt-5.1-codex-mini + high + selected cwd +gpt-5.1-codex-mini + xhigh -> blocked before exec +synthetic future model + capability disabled -> blocked before exec +synthetic future model + capability enabled -> argv accepted by orchestrator test +custom model_provider config -> blocked or explicit custom-provider copy +``` + +This prevents the plan from treating app-server catalog presence as proof that the full Agent Teams runtime path is healthy. + +### 21. `config/read` scope contract is only partially documented + +`🎯 7 🛡️ 9 🧠 6` +Estimated implementation impact: `180-420` lines + +Uncertainty: + +- docs list `config/read`, but the detailed request/response shape is not as explicit as `model/list`. +- local probe confirms `config/read` returns `{ config, origins }` and accepts `params`. +- local probe confirms missing `params` returns `-32600`, so callers must always send `{}` at minimum. +- local probe confirms `{ cwd }` and `{ profile }` are accepted, but we still need tests around whether they fully mirror `codex exec --cd/--profile` in all installations. + +Decision: + +- treat `config/read` as a feature-detected helper, not as a hard dependency for model catalog availability +- always call `config/read` with an object, never with missing params +- include `config/read` method/code/details in diagnostics +- if scoped `config/read` fails but global succeeds, mark launch catalog as `scope_unverified`, not `ready` +- if `config/read` is missing on older binaries, fall back to global catalog and require runtime capability plus explicit degraded copy before launch enablement + +Recommended DTO: + +```ts +export interface CodexAppServerConfigReadParams { + cwd?: string | null; + profile?: string | null; +} + +export interface CodexAppServerConfigReadResponse { + config: Record; + origins: Record; +} +``` + +Feature-detect result: + +```ts +export type CodexConfigReadSupport = + | 'supported-scoped' + | 'supported-global-only' + | 'method-missing' + | 'failed'; +``` + +Acceptance: + +- unit tests cover missing `params`, method-not-found, global success, scoped success, and scoped failure +- `config/read` failure never breaks account/rate-limit/model-list reads +- launch UI does not present a project-scoped catalog as verified unless config scope was actually checked + +### 22. Forced login method and ChatGPT workspace scope + +`🎯 7 🛡️ 10 🧠 6` +Estimated implementation impact: `180-420` lines + +Uncertainty: + +- effective Codex config can include `forced_login_method`. +- effective Codex config can include `forced_chatgpt_workspace_id`. +- workspace/account policy can affect available models, rate limits, and whether ChatGPT subscription mode is valid. +- previous Codex account work already had a real bug around forced login method, so this is not theoretical. + +Decision: + +- auth scope must include forced login method and forced workspace identity when present +- if UI-selected auth mode conflicts with `forced_login_method`, effective auth mode wins and UI must explain why +- forced workspace id must be hashed before cache/log usage +- rate-limit, account, and model catalog snapshots must be scoped together so workspace changes cannot reuse stale catalog + +Auth scope correction: + +```ts +export interface CodexCatalogAuthScope { + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' | null; + effectiveAuthMode: 'chatgpt' | 'api_key' | null; + forcedLoginMethod: 'chatgpt' | 'api_key' | null; + managedAccountHash: string | null; + forcedWorkspaceHash: string | null; + apiKeySource: string | null; +} +``` + +UX rules: + +- if user selected ChatGPT but config forces API key, show `Codex config forces API key mode for this scope` +- if user selected API key but config forces ChatGPT, show `Codex config forces ChatGPT account mode for this scope` +- if workspace id changes, show `Codex workspace changed, refreshing subscription limits and model catalog` +- never show raw workspace id in UI unless Codex app-server provides a display name that is intended for users + +Cache invalidation: + +- forced login method change invalidates both auth and catalog cache +- forced workspace hash change invalidates ChatGPT-scoped rate limits and catalog +- account logout clears all ChatGPT workspace-scoped entries + +### 23. Model catalog file trust and local file changes + +`🎯 6 🛡️ 9 🧠 7` +Estimated implementation impact: `220-520` lines + +Uncertainty: + +- `model_catalog_json` can point to a local JSON file. +- app-server resolves effective config, but our app may not know if that JSON file changed unless config fingerprint includes enough origin data. +- project-scoped `.codex/config.toml` only applies for trusted projects, so a file can exist but not be active. + +Decision: + +- treat `model_catalog_json` as part of effective config, not as a file we parse directly by default +- if `config/read.origins` exposes enough origin/path data, hash only path and mtime for invalidation, not file contents +- if origin/path data is unavailable, rely on manual refresh and short TTL +- never read arbitrary `model_catalog_json` file contents into logs or diagnostics +- do not apply project-scoped model catalog unless Codex effective config says the project is trusted and the catalog is active + +Top 3 invalidation policies: + +1. TTL/manual-refresh only: `🎯 7 🛡️ 6 🧠 2`, `40-100` lines. Simple but stale after local file edits. +2. Hash effective config plus optional mtime for active catalog file: `🎯 8 🛡️ 9 🧠 5`, `220-520` lines. Best balance without parsing arbitrary catalog files ourselves. +3. Parse and watch every possible catalog file: `🎯 5 🛡️ 7 🧠 8`, `500-1000` lines. Too much responsibility and security surface for this feature. + +Chosen policy: option 2. + +Acceptance: + +- active `model_catalog_json` path change invalidates cache +- active catalog file mtime change invalidates cache when path is available +- inactive untrusted project `.codex/config.toml` does not affect the trusted/global catalog + +## Top 3 Implementation Options + +### 1. Dedicated Codex model catalog feature - chosen + +`🎯 9 🛡️ 9 🧠 6` +Estimated size: `1200-2400` lines + +Core idea: + +- create `src/features/codex-model-catalog` +- keep model catalog rules isolated from account UI, provider status plumbing, and Electron transport +- reuse existing `CodexAppServerSessionFactory` +- expose a small feature facade to provider status and renderer model picker +- update orchestrator only where runtime status and launch effort transport require it + +Why it wins: + +- best SOLID alignment +- clean domain rules for model visibility, effort validation, fallback, and default selection +- does not make `codex-account` responsible for model policy +- least risk to Anthropic +- easiest to test without full app startup + +Main tradeoff: + +- needs small integration glue in existing provider status and team launch flows + +### 2. Fold catalog into `codex-account` + +`🎯 7 🛡️ 7 🧠 5` +Estimated size: `800-1600` lines + +Core idea: + +- extend `src/features/codex-account` with `model/list` +- use account snapshot as the only Codex control-plane snapshot +- merge account, rate limits, and model catalog in one feature + +Why it is tempting: + +- fewer new folders +- account feature already owns app-server account/rate-limit reads +- easier to fetch account plus model catalog in one app-server session + +Why I do not recommend it: + +- model catalog is not account management +- the feature becomes a broad Codex control-plane catch-all +- future provider catalog work would have to pull model rules back out +- more risk of account UI churn when only model picker changes are needed + +### 3. Full provider model catalog for all providers now + +`🎯 7 🛡️ 8 🧠 9` +Estimated size: `2500-4500` lines + +Core idea: + +- build one provider-agnostic model catalog for Anthropic, Codex, Gemini, and future providers +- move static renderer catalog policy into a shared feature +- expose one rich contract for all provider model pickers + +Why it is attractive: + +- cleanest long-term abstraction +- one UI model for labels, availability, capabilities, and efforts +- reduces future duplication + +Why not now: + +- too much surface area while Codex runtime cutover is still fresh +- Anthropic model behavior is already stable and should not be reworked for a Codex catalog issue +- would delay the concrete Codex model release problem + +## Current Code Reality + +### `claude_team` + +Existing app-server infrastructure: + +- `src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts` +- `src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts` +- `src/main/services/infrastructure/codexAppServer/protocol.ts` +- `src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts` + +Current account client behavior: + +- `readAccount()` opens one app-server session. +- `readRateLimits()` opens another app-server session. +- `logout()` opens another app-server session. +- no `model/list` protocol types exist yet. +- `CodexAppServerSessionFactory` starts `codex app-server` with no explicit `cwd` or profile option. +- app-server initialize response includes `codexHome`, but the current protocol types do not expose effective config or config fingerprint. + +Current shared provider status: + +- `CliProviderStatus.models` is only `string[]`. +- `CliProviderStatus.modelAvailability` has per-model verification status but no rich model metadata. +- renderer model selector can already prefer runtime-provided `providerStatus.models`. + +Current effort type: + +```ts +export type EffortLevel = 'low' | 'medium' | 'high'; +``` + +Risk: + +- adding `xhigh` directly without provider-specific validation would let Anthropic UI accidentally offer unsupported choices. + +Current persistence and non-dialog launch paths: + +- team metadata and member metadata normalize launch-derived provider/model/effort in multiple services. +- backup/restore copies metadata but restore-time launch preview must still tolerate missing catalog metadata. +- draft retry and launch prefill can reuse old localStorage state. +- scheduled launch types can reference the shared effort type. + +Risk: + +- updating only the visible launch dialogs would leave hidden paths that silently drop Codex-only efforts or relaunch with stale default semantics. + +### `agent_teams_orchestrator` + +Current Codex model catalog: + +- `src/utils/model/codex.ts` +- static `CODEX_MODELS` +- static `DEFAULT_CODEX_MODEL` +- `isCodexModel()` checks only static ids + +Current runtime status: + +- `getUnifiedRuntimeStatusPayload('codex')` returns static Codex model ids. + +Current CLI effort: + +- top-level `--effort ` currently accepts `low | medium | high | max`. +- Codex native execution is ultimately `codex exec --json`. +- installed `codex exec --help` shows no `--effort` flag. + +Risk: + +- if we send `--effort xhigh` through current orchestrator, it fails before Codex can use it. +- if we map Anthropic `max` to Codex `xhigh`, the semantics are wrong. +- if we show `xhigh` in UI before the launch path supports it, the picker becomes misleading. + +## Target Architecture + +### Feature folder + +```text +src/features/codex-model-catalog/ + contracts/ + codexModelCatalog.dto.ts + index.ts + core/ + domain/ + codexModelCatalog.ts + codexReasoningEffort.ts + codexModelCatalogFallback.ts + normalizeCodexAppServerModel.ts + application/ + GetCodexModelCatalogUseCase.ts + CodexModelCatalogPorts.ts + main/ + composition/ + createCodexModelCatalogFeature.ts + adapters/ + output/ + CodexAppServerModelCatalogSource.ts + StaticCodexModelCatalogSource.ts + infrastructure/ + CodexModelCatalogAppServerClient.ts + InMemoryCodexModelCatalogCache.ts + preload/ + index.ts + renderer/ + adapters/ + codexModelCatalogViewModel.ts + hooks/ + useCodexModelCatalog.ts + ui/ + CodexModelEffortHint.tsx +``` + +Rules: + +- `core/domain` has all normalization and validation rules. +- `main/infrastructure` is the only layer that knows JSON-RPC method names. +- renderer never receives raw app-server rows. +- app shell imports only public feature entrypoints. + +### App-server lifecycle + +Use the existing `CodexAppServerSessionFactory`. + +Request sequence: + +1. Spawn `codex app-server`. +2. Send `initialize` with `clientInfo` and capabilities. +3. Send `initialized`. +4. Request `model/list`. +5. Drain or ignore notifications safely. +6. Close stdin and terminate the process on completion or timeout. + +Recommended timeouts: + +- initialize: `6000ms` +- `model/list`: `4500ms` +- total model catalog read: `9000ms` + +Recommended pagination: + +- request `limit: 100`, `includeHidden: false` for normal UI +- follow `nextCursor` until `null` +- hard-stop after 5 pages to avoid runaway loops +- log a degraded catalog warning if the hard-stop is hit + +### Single-session snapshot policy + +Provider status currently risks multiple sequential app-server starts: + +- account read +- rate limits read +- future model list read + +This caused slow provider loading in earlier UI work, so the plan should not add another app-server spawn in the hot path. + +Preferred design: + +- keep `codex-model-catalog` as a separate feature for ownership +- add an optional combined Codex control-plane read in composition +- when provider status refresh needs account plus rate limits plus model catalog, use one app-server session and issue all three requests inside it +- each sub-result has independent soft-failure state +- total snapshot can be partially healthy + +Snapshot shape: + +```ts +export interface CodexControlPlaneSnapshot { + binary: { + path: string; + version: string | null; + }; + account: CodexAccountSnapshotResult; + rateLimits: CodexRateLimitsSnapshotResult; + modelCatalog: CodexModelCatalogSnapshotResult; + configScope: { + cwd: string | null; + profileName: string | null; + projectTrust: 'trusted' | 'untrusted' | 'unknown'; + configReadSupport: CodexConfigReadSupport; + effectiveConfigFingerprint: string | null; + launchOverridesFingerprint: string | null; + activeModelCatalogFileFingerprint: string | null; + }; + initialize: { + codexHome: string; + platformFamily: string; + platformOs: string; + }; + fetchedAt: string; +} +``` + +Soft-failure rules: + +- account failure must not erase a fresh cached model catalog +- model catalog failure must not mark ChatGPT account disconnected +- rate-limit failure must not hide model picker options +- if app-server initialize fails, all three sub-results are degraded from the same root cause + +Required correction to the existing account flow: + +- current `CodexAccountAppServerClient.readAccount()` and `readRateLimits()` each open their own app-server process +- adding a third standalone `readModelCatalog()` would be a Provider Settings latency regression +- implement a combined app-server read path before wiring catalog into provider refresh +- keep separate methods for mutations and focused tests, but use the combined path for normal status refresh +- enrich `JsonRpcStdioClient` errors before catalog integration so the combined reader can classify `model/list` method failures without losing account truth + +Recommended application service shape: + +```ts +export interface CodexControlPlaneReader { + readSnapshot(options: CodexControlPlaneReadOptions): Promise; +} +``` + +This can live in `codex-model-catalog` composition or in a small shared Codex control-plane composition module. Do not put model normalization inside `codex-account`. + +Read scope: + +- Provider Settings global refresh can pass `cwd=null`. +- Create/Launch dialogs should pass the selected absolute `cwd`. +- Relaunch/restore should pass the team's persisted project path. +- Scheduled launch validation should pass `schedule.launchConfig.cwd`. +- If a future UI supports Codex profile selection, the same profile must be passed to preview and launch. + +## Contracts + +### App-server protocol types + +Add protocol DTOs to `src/main/services/infrastructure/codexAppServer/protocol.ts`: + +```ts +export type CodexAppServerReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh'; + +export interface CodexAppServerReasoningEffortOption { + reasoningEffort: CodexAppServerReasoningEffort; + description?: string | null; +} + +export type CodexAppServerInputModality = 'text' | 'image' | string; + +export interface CodexAppServerModel { + id: string; + model: string; + displayName: string; + description?: string | null; + hidden: boolean; + supportedReasoningEfforts: CodexAppServerReasoningEffortOption[]; + defaultReasoningEffort: CodexAppServerReasoningEffort; + inputModalities?: CodexAppServerInputModality[] | null; + supportsPersonality?: boolean | null; + isDefault: boolean; + upgrade?: string | null; + upgradeInfo?: unknown; + availabilityNux?: unknown; +} + +export interface CodexAppServerModelListParams { + cursor?: string | null; + limit?: number | null; + includeHidden?: boolean | null; +} + +export interface CodexAppServerModelListResponse { + data: CodexAppServerModel[]; + nextCursor: string | null; +} + +export interface CodexAppServerConfigReadParams { + cwd?: string | null; + profile?: string | null; +} + +export interface CodexAppServerConfigReadResponse { + config: Record; + origins: Record; +} +``` + +`config/read` caller rule: + +- always pass a params object, even when empty +- call global config as `config/read` with `{}` +- call project scope as `config/read` with `{ cwd }` +- call profile scope as `config/read` with `{ profile }` +- if both cwd and profile are needed, test `{ cwd, profile }` in Phase 1 and record the behavior before enabling profile-aware UI + +### Domain model + +Use separate ids: + +- `catalogId`: app-server `id`, stable identity for React keys, telemetry, and dedupe +- `launchModel`: app-server `model` when non-empty, otherwise `id` + +Reason: + +- local probe currently returned equal values, but official schema exposes both fields, so they can diverge later. +- using `id` for launch would be a latent bug if Codex introduces a display/catalog alias. + +```ts +export interface CodexCatalogModel { + catalogId: string; + launchModel: string; + displayName: string; + description: string | null; + hidden: boolean; + isDefault: boolean; + supportedReasoningEfforts: CodexReasoningEffort[]; + defaultReasoningEffort: CodexReasoningEffort | null; + inputModalities: CodexInputModality[]; + supportsPersonality: boolean; + upgrade: string | null; + source: 'app-server' | 'static-fallback'; +} +``` + +Normalization rules: + +- reject rows without a usable `id` +- derive `launchModel` from `model || id` +- default missing `inputModalities` to `['text', 'image']` for older catalogs +- default missing `supportsPersonality` to `false` +- accept documented `supportedReasoningEfforts` objects with `reasoningEffort` +- defensively accept string effort entries in tests, because older generated local types and live clients can drift +- drop duplicate `catalogId` rows after the first visible row +- drop duplicate `launchModel` rows after the first visible row unless a hidden row is the only available row +- keep unknown effort strings out of the selectable UI, but preserve them in diagnostics +- if no model is marked `isDefault`, choose static fallback default only as degraded fallback and label it as such + +### Provider status contract + +Add an optional rich catalog to `CliProviderStatus`: + +```ts +export interface CliProviderModelCatalog { + schemaVersion: 1; + source: 'app-server' | 'static-fallback' | 'unavailable'; + status: 'ready' | 'stale' | 'degraded' | 'unavailable'; + fetchedAt: string | null; + staleAt: string | null; + binary?: { + path: string | null; + version: string | null; + }; + authScope?: { + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' | null; + effectiveAuthMode: 'chatgpt' | 'api_key' | null; + forcedLoginMethod?: 'chatgpt' | 'api_key' | null; + managedAccountHash?: string | null; + forcedWorkspaceHash?: string | null; + apiKeySource?: string | null; + }; + launchScope?: { + cwd: string | null; + profileName: string | null; + projectTrust: 'trusted' | 'untrusted' | 'unknown'; + configFingerprint: string | null; + launchOverridesFingerprint: string | null; + }; + errorMessage?: string | null; + defaultModelId?: string | null; + defaultLaunchModel?: string | null; + models: CliProviderModelInfo[]; +} + +export interface CliProviderModelInfo { + catalogId: string; + launchModel: string; + displayName: string; + description?: string | null; + hidden?: boolean; + isDefault?: boolean; + supportedReasoningEfforts?: CliProviderReasoningEffort[]; + defaultReasoningEffort?: CliProviderReasoningEffort | null; + inputModalities?: string[]; + supportsPersonality?: boolean; + upgrade?: string | null; +} + +export type CliProviderReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; + +export interface CliProviderRuntimeCapabilities { + schemaVersion: 1; + codex?: { + supportsDynamicAppServerModels: boolean; + supportsCodexReasoningEffortConfig: boolean; + supportedCodexReasoningEfforts: Array<'minimal' | 'low' | 'medium' | 'high' | 'xhigh'>; + acceptsProviderExplicitFutureModels: boolean; + }; +} +``` + +Backwards compatibility: + +- keep `CliProviderStatus.models: string[]` +- add `CliProviderStatus.runtimeCapabilities?: CliProviderRuntimeCapabilities` +- for Codex, derive `models` from `modelCatalog.models.map(model => model.launchModel)` +- for Anthropic and Gemini, do not require `modelCatalog` +- old renderers continue to work from `models` +- new renderers prefer `modelCatalog` when present +- never put team-agent disabled policy directly into `CliProviderModelCatalog`; catalog describes Codex availability, while Agent Teams policy is applied by renderer and launch validators +- never infer launch capability only from catalog presence + +Renderer integration hotspot: + +- update `TeamModelRuntimeProviderStatus` in `src/renderer/utils/teamModelAvailability.ts` to include `modelCatalog` +- update `getRuntimeSelectorModels()` to use `modelCatalog.models[*].launchModel` for Codex +- update `getAvailableTeamProviderModelOptions()` to map rich Codex options with display labels, default badge, and catalog diagnostics +- keep Anthropic path on `getFallbackTeamProviderModelOptions()` +- keep Gemini path on existing `models: string[]` until Gemini has a richer catalog + +### Team launch effort contract + +Do not add a separate per-provider lane for this feature. + +Use existing team-level model/provider selection, but make effort provider-aware. + +Recommended implementation: + +- keep persisted field name `effort` +- widen internal effort type to `ProviderReasoningEffort` +- add provider/model validators at every launch boundary +- Anthropic UI only shows `low | medium | high` +- Codex UI shows only the selected model's `supportedReasoningEfforts` +- orchestrator accepts `minimal | low | medium | high | xhigh` for Codex and `low | medium | high | max` for Anthropic paths + +Existing validator hotspots: + +- `src/shared/types/team.ts` currently defines `EffortLevel = 'low' | 'medium' | 'high'` +- `src/main/ipc/teams.ts` currently validates only `low | medium | high` +- `src/main/http/teams.ts` currently validates only `low | medium | high` +- `src/renderer/components/team/dialogs/EffortLevelSelector.tsx` currently hardcodes only `Default | Low | Medium | High` +- `LaunchTeamDialog`, `CreateTeamDialog`, member draft rows, and member editor utilities currently cast strings with `as EffortLevel` + +Required migration: + +- replace unsafe `as EffortLevel` casts with a provider-aware normalization function +- parse provider before parsing effort in IPC and HTTP paths +- validate lead effort against lead provider/model +- validate member effort against each member's resolved provider/model +- keep old persisted `low | medium | high` values readable without migration + +Validation rule: + +```text +provider=codex: + effort must be in selectedModel.supportedReasoningEfforts + +provider=anthropic: + effort must be low | medium | high + +provider=gemini: + keep current behavior unless Gemini gets a richer effort contract +``` + +Important: + +- do not map Anthropic `max` to Codex `xhigh` +- do not map Codex `xhigh` to Anthropic `max` +- if selected Codex model changes and old effort is unsupported, reset to the new model's `defaultReasoningEffort` +- if catalog is unavailable, only allow static fallback efforts that are proven launchable + +Launch identity rule: + +- `effort` is user selection +- `resolvedEffort` is what launch sends to runtime +- if user selection is empty/default, `resolvedEffort` comes from app-server `defaultReasoningEffort` +- if resolved effort equals app-server default, runtime transport may omit `model_reasoning_effort`, but exact logs still record the resolved value + +## Runtime Launch Transport + +This was the highest-risk area in the earlier plan. The corrected plan is explicit. + +Facts: + +- `codex exec` has `--model`. +- `codex exec` has `-c, --config key=value`. +- `codex exec` has no documented `--effort`. +- Codex config has `model_reasoning_effort`. +- `model_reasoning_effort` supports `minimal | low | medium | high | xhigh`. + +Therefore: + +- Codex native launch must not pass `--effort xhigh` to Codex CLI. +- Orchestrator may keep accepting `--effort` as its public Agent Teams flag. +- When provider is Codex native, orchestrator must translate accepted effort into `codex exec -c model_reasoning_effort="value"`. +- When no effort is selected, omit `model_reasoning_effort` and let Codex use its model default. +- When effort equals the selected model's app-server default, either omit it or pass it consistently, but pick one policy and test it. + +Recommended policy: + +- omit effort when it equals app-server `defaultReasoningEffort` +- pass effort only when user explicitly selected a non-default value + +Reason: + +- this tracks Codex defaults as Codex evolves +- exact logs remain cleaner +- future app-server default changes are not blocked by stale persisted values + +Live signoff command shape: + +```bash +codex exec --json --model gpt-5.4 -c model_reasoning_effort='"xhigh"' --skip-git-repo-check --ephemeral "Return only: ok" +``` + +Quoting requirement: + +- command builder must pass `-c` and `model_reasoning_effort="xhigh"` as separate argv entries +- shell-rendered exact logs can show `-c model_reasoning_effort='"xhigh"'` +- tests should assert argv arrays, not only shell strings +- never concatenate user-controlled effort into a shell string without argv escaping + +Prelaunch validation must block: + +- `gpt-5.1-codex-mini` with `low` +- `gpt-5.1-codex-mini` with `xhigh` +- unknown effort strings from app-server until explicitly supported by our UI and orchestrator type + +## Static Fallback + +Fallback stays necessary because: + +- user may have an older Codex binary +- app-server may fail to initialize +- app-server may start but not support `model/list` +- offline usage should not make the entire model picker empty +- tests should not depend on live Codex availability + +Fallback rules: + +- fallback source is explicitly marked `static-fallback` +- fallback never claims to be current +- fallback has a short visible warning in Provider Settings only when user is choosing Codex models +- fallback model list should be minimal and conservative +- fallback must not include newly guessed future models +- fallback caused by missing `model/list` should include an upgrade hint tied to the detected Codex binary version when available + +Recommended fallback models: + +- `gpt-5.4` +- `gpt-5.4-mini` +- `gpt-5.3-codex` +- `gpt-5.2` +- `gpt-5.1-codex-mini` + +Fallback effort rules: + +- use `medium | high` for `gpt-5.1-codex-mini` +- use `low | medium | high | xhigh` for known models only if live signoff confirms `model_reasoning_effort` pass-through +- otherwise fallback UI can show richer metadata but disable non-launchable options + +API-key mode note: + +- do not use OpenAI `/v1/models` as the primary Codex picker for subscription-backed Codex +- optional API `/v1/models` fallback is allowed only for explicit API-key mode diagnostics +- if API `/v1/models` disagrees with Codex app-server `model/list`, Codex app-server wins for native Codex execution +- reason: the actual runtime surface is `codex exec`, and app-server describes what Codex clients should show + +## Cache And Refresh + +Goal: + +- make model updates feel fresh without making Provider Settings slow or flaky. + +Main-process cache: + +- key: Codex binary path plus Codex binary version plus Codex home plus launch cwd/profile/config fingerprint plus preferred auth mode plus effective auth mode plus managed account hash plus API-key source +- success TTL: `10 minutes` +- stale TTL: `24 hours` +- in-flight dedupe: one live `model/list` request per key +- manual refresh bypasses success TTL but still dedupes in-flight work +- auth mode change invalidates the ready cache for UI selection purposes +- `forced_login_method` and forced workspace changes invalidate the affected auth/catalog scope +- logout clears ChatGPT-scoped catalog cache +- API key source change clears API-key-scoped catalog cache +- project `.codex/config.toml`, global `config.toml`, or `model_catalog_json` changes clear the affected scope when detected by fingerprint change +- binary path or version change clears all Codex model catalog cache entries + +Renderer cache: + +- consume `CliProviderStatus.modelCatalog` +- no independent polling loop in the model picker +- refresh through existing provider status refresh action + +Dashboard policy: + +- do not run `model/list` on every dashboard render +- use existing provider status refresh cadence +- model catalog stale state can be shown only inside settings/model picker, not as a scary dashboard error +- dashboard catalog is a global/default-scope summary, not a promise that every project cwd has the same catalog + +Provider Settings policy: + +- open dialog with cached provider status immediately +- refresh in background +- show `Checking...` only for the area still being refreshed +- never replace a ready catalog with empty state during a refresh + +Avoid this bug: + +- do not set global provider status to `unavailable` while only the model catalog refresh is pending +- do not replace a ChatGPT-ready account state with a catalog timeout +- do not show generic `Unknown error`; preserve app-server method, timeout, and fallback source in diagnostics +- if `auto` resolves to ChatGPT, API-key detection copy stays secondary +- if `auto` resolves to API key because ChatGPT is unavailable, show why ChatGPT was skipped before showing API-key catalog + +## UI Behavior + +### Model picker + +When `provider=codex`: + +- prefer `providerStatus.modelCatalog.models` +- option value is `launchModel` +- React key can use `catalogId` +- label uses `displayName` +- default badge uses `isDefault` +- hidden app-server models are excluded from normal selector unless already persisted in a team +- disabled state uses existing Agent Teams policy plus app-server `upgrade` hints +- runtime-capability state controls whether a visible model is launchable +- fallback badge says `Using fallback catalog` only when source is fallback +- if app-server says a model is available but Agent Teams disables it, show `Available in Codex, disabled for Agent Teams` +- if app-server says a future model exists but runtime capability is missing, show `Available in Codex, waiting for Agent Teams runtime support` +- if a persisted model is missing from current catalog, show it as `Unavailable in current Codex catalog` and require user confirmation before relaunch +- if the dialog has a selected cwd and only a global catalog is available, show global options as provisional until project-scoped catalog finishes +- if project-scoped catalog differs from global catalog, keep the user's explicit selection only if it exists in the project-scoped catalog or is a preserved persisted value + +When catalog is loading: + +- keep previous options visible +- show a subtle "Refreshing models" state +- do not show an empty Codex picker unless no cached or fallback models exist +- label provisional global catalog rows as `Checking this project...` when launch cwd is known + +When catalog fails: + +- use stale cache if present +- otherwise use static fallback +- show the app-server error in diagnostics, not as a generic unknown error + +### Effort selector + +When `provider=codex` and selected model has catalog metadata: + +- show efforts from `supportedReasoningEfforts` +- mark `defaultReasoningEffort` as default +- include `xhigh` if returned by app-server and runtime capability says Codex effort config pass-through is supported +- if runtime capability is missing, show Codex-only efforts as metadata or disabled rows, not selectable launch values +- if selected effort is no longer valid, reset to default with a small explanation +- if model is Agent Teams-disabled, keep effort selector read-only or disabled to avoid suggesting launchability + +When selected model has no catalog metadata: + +- show only safe fallback efforts +- do not show `xhigh` unless launch pass-through is implemented and tested + +When `provider=anthropic`: + +- keep current selector behavior +- do not show Codex-only `minimal`, `none`, or `xhigh` +- do not change Anthropic copy + +### Default model + +Recommended behavior: + +- app-server `isDefault` defines the Codex default in UI +- "Default" label can render as `Default (gpt-5.4)` or `Default (GPT-5.4)` when catalog is ready +- new Codex teams can display `Default`, but launch must resolve it to a concrete `resolvedLaunchModel` +- existing teams keep their persisted model unless user changes it +- do not rewrite old team metadata just because app-server default changed +- exact logs and team metadata should record both selected `Default` and concrete resolved model + +Reason: + +- new teams benefit from current Codex defaults +- existing teams remain explainable even if Codex default changes later + +## Orchestrator Changes + +### Model status + +Short term: + +- keep static `CODEX_MODELS` for standalone fallback and non-app UI compatibility +- add richer status only if orchestrator can read app-server directly without slowing CLI startup + +Recommended first cut: + +- `claude_team` owns app-server model catalog for UI +- orchestrator keeps static runtime status until a dedicated orchestrator catalog source is added +- launch validation accepts provider-explicit Codex model strings even if not in static `CODEX_MODELS` +- orchestrator exposes runtime capabilities for dynamic Codex model ids and Codex reasoning effort config pass-through + +Reason: + +- UI is where the dynamic picker is needed immediately +- orchestrator should not reject a future model that Codex app-server already exposed and `claude_team` selected +- UI should not guess whether the current runtime can launch that future model + +### Validation + +Update validation so: + +- provider-explicit `codex` launches can use model strings from app-server catalog +- unknown model strings are not guessed as Codex without provider context +- static `isCodexModel()` remains valid for generic detection, not authoritative for provider-explicit launches +- if provider context is missing, keep existing conservative static validation + +### Effort transport + +Update orchestrator: + +- accept Codex efforts `minimal | low | medium | high | xhigh` +- preserve Anthropic `max` +- in Codex native executor, convert Codex effort to `-c model_reasoning_effort='"value"'` +- do not pass unsupported effort values to `codex exec` +- exact logs should show the selected effort as normalized Agent Teams metadata and the actual Codex config override + +Required tests: + +- Codex native `xhigh` becomes `-c model_reasoning_effort='"xhigh"'` +- no effort omits `model_reasoning_effort` +- Anthropic `max` remains Anthropic-only +- Codex `max` is rejected +- Anthropic `xhigh` is rejected + +## Concrete Implementation Touchpoints + +`claude_team`: + +- `src/main/services/infrastructure/codexAppServer/protocol.ts` - add app-server model DTOs +- `src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts` - preserve JSON-RPC error code, method, and details +- `src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts` or a nearby service - expose binary version for cache invalidation +- `src/features/codex-model-catalog` - new feature for catalog domain, use case, app-server source, fallback source, and cache +- `src/features/codex-account/main/composition/createCodexAccountFeature.ts` - coordinate combined control-plane snapshot or delegate to shared reader +- `src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts` - preserve account truth while merging model catalog truth +- `src/shared/types/cliInstaller.ts` - add optional provider model catalog +- `src/shared/types/team.ts` - widen provider-aware effort types without breaking old persisted values +- `src/shared/types/schedule.ts` - prevent scheduled launches from dropping Codex-specific efforts +- `src/main/services/team/TeamDataService.ts` - preserve provider-aware effort and launch identity when reconstructing team state +- `src/main/services/team/TeamMembersMetaStore.ts` - stop filtering Codex efforts down to legacy `low | medium | high` +- `src/main/services/team/TeamBackupService.ts` and restore paths - preserve additive launch identity and tolerate old backups +- `src/main/services/runtime/CliProviderModelAvailabilityService.ts` - keep runtime verification compatible with `launchModel` values and do not verify hidden/catalog-only rows by accident +- `src/main/ipc/teams.ts` and `src/main/http/teams.ts` - parse provider first, then validate effort +- `src/renderer/utils/teamModelAvailability.ts` - consume rich Codex catalog +- `src/renderer/utils/teamModelCatalog.ts` - demote Codex static list to fallback and labels only +- `src/renderer/components/team/dialogs/EffortLevelSelector.tsx` - make options provider/model-aware +- `src/renderer/components/team/dialogs/LaunchTeamDialog.tsx` and `CreateTeamDialog.tsx` - remove unsafe effort casts and persist resolved launch identity +- member draft/editor components - validate per-member resolved provider/model/effort +- renderer launch prefill and draft retry storage - add a versioned launch identity payload and tolerate old entries + +`agent_teams_orchestrator`: + +- `src/entrypoints/sdk/runtimeTypes.ts` - add provider-aware Codex effort support +- `src/main.tsx` - update `--effort` parser or provider-specific validation path +- `src/utils/effort.ts` and `src/utils/providerEffort.ts` - separate Anthropic `max` from Codex `xhigh` +- Codex native executor path - convert effort to `-c model_reasoning_effort` +- `src/utils/model/codex.ts` - rename static list semantics to fallback/static detection +- `src/utils/model/validateModel.ts` - allow provider-explicit Codex app-catalog models +- runtime status/capability endpoint - expose dynamic Codex model and effort pass-through support +- exact-log/runtime status code - record selected model, resolved model, selected effort, resolved effort, and config override + +## Phased Implementation + +### Phase 0 - contracts and live spike + +Commit boundary: `docs(codex): plan app-server model catalog` + +Tasks: + +- add this plan +- keep live probe output in signoff notes or test fixture +- confirm installed Codex supports `model/list` +- confirm one app-server session can read account, rate limits, and model catalog +- confirm docs support `model_reasoning_effort` +- decide exact shell quoting for `-c model_reasoning_effort` +- capture fixtures for at least two catalog shapes: current live shape and synthetic `id !== model` +- capture current Codex binary version and document cache invalidation expectations + +Acceptance: + +- plan exists in the dedicated worktree +- no code behavior changes +- weak areas are explicitly called out + +### Phase 1 - app-server model catalog feature + +Commit boundary: `feat(codex): add app-server model catalog source` + +Tasks: + +- add structured JSON-RPC request errors with method/code/details +- expose or probe Codex binary version for catalog cache keys +- add effective config fingerprint support using app-server `config/read` when available +- add `config/read` support detection and always send `{}` params at minimum +- add `src/features/codex-model-catalog` +- add app-server protocol types +- add `CodexModelCatalogAppServerClient` +- add normalization domain rules +- add static fallback source +- add in-memory cache with TTL and in-flight dedupe +- include launch scope fields in cache keys: cwd, profile, trust, config fingerprint, launch override fingerprint +- include forced login method and forced workspace hash in auth-scoped cache keys +- normalize both documented effort option objects and defensive string effort values +- classify `method not found`, timeout, malformed response, and empty catalog separately +- add structured diagnostics without raw account email or secret-bearing env values +- expose feature facade from main composition + +Acceptance: + +- JSON-RPC `method not found` can be detected in tests +- binary version changes invalidate catalog cache +- config fingerprint changes invalidate catalog cache for that scope +- forced login/workspace changes invalidate account, limits, and catalog cache for that scope +- unit tests cover normalization, fallback, pagination, duplicate ids, missing modalities, unknown effort strings, and `id !== model` +- app-server client tests cover `model/list` request params and timeout labels +- method-not-found falls back without marking account disconnected +- diagnostics include source, status, method, error category, binary version, effective auth mode, and cache age +- no renderer behavior changes yet + +### Phase 2 - provider status integration + +Commit boundary: `feat(runtime): expose codex model catalog metadata` + +Tasks: + +- add optional `modelCatalog` to `CliProviderStatus` +- add optional `runtimeCapabilities` to `CliProviderStatus` +- merge Codex model catalog into provider status +- keep `models: string[]` derived from `launchModel` +- make provider refresh use cached, auth-scoped catalog +- implement combined account/rate-limits/catalog app-server read for normal refresh +- avoid extra app-server session in hot paths where account snapshot already refreshes +- clear ChatGPT-scoped catalog on logout and API-key-scoped catalog when API key source changes +- clear all catalog entries when Codex binary path or version changes +- ensure `auto` catalog scope follows effective launch auth mode, not just configured preference +- add request/snapshot versioning so stale refresh responses cannot overwrite newer auth state +- support global provider refresh and project-scoped launch refresh as different catalog scopes +- preserve Anthropic provider status shape + +Acceptance: + +- Codex provider status includes `modelCatalog` +- Codex provider status includes runtime capability metadata when available +- old `models` still works +- `auto` with ChatGPT ready uses ChatGPT-scoped catalog even if API key is detected +- `auto` with ChatGPT unavailable and API key ready uses API-key-scoped catalog with clear degraded copy +- forced login method overrides are reflected in effective auth copy and cache scope +- one normal Codex provider refresh does not spawn separate app-server processes for account, limits, and catalog +- Anthropic snapshots are byte-for-byte equivalent except ordering noise already present +- provider dashboard does not block on a slow catalog refresh when stale cache exists +- older refresh results are ignored after auth mode or runtime capability changes +- global dashboard catalog and project launch catalog do not overwrite each other + +### Phase 3 - dynamic UI model picker and effort selector + +Commit boundary: `feat(codex): use dynamic model catalog in team launch UI` + +Tasks: + +- update Codex model picker to prefer rich catalog +- show app-server labels, default badge, and fallback source state +- update effort selector to be provider/model-aware +- show `xhigh` metadata only for Codex models that return it +- make `xhigh` selectable only when runtime capability says Codex effort config pass-through is supported +- hide Codex-only efforts for Anthropic +- reset invalid effort on model change +- preserve missing persisted models as visible warning rows instead of silently clearing selection +- keep Agent Teams disabled policy separate from Codex app-server availability +- show future app-server models immediately, with `New from Codex catalog` status when policy has not verified them yet +- when cwd is selected, refresh project-scoped Codex catalog before enabling launch-only controls + +Acceptance: + +- `gpt-5.1-codex-mini` shows only `medium | high` +- `gpt-5.3-codex-spark` defaults to `high` +- `gpt-5.4` shows `low | medium | high | xhigh` as catalog metadata +- `xhigh` is disabled with runtime-upgrade copy until capability support is present +- app-server-visible but Agent Teams-disabled model shows disabled copy, not unavailable copy +- synthetic future `gpt-5.5` fixture appears without touching static catalog +- persisted model missing from current catalog is visible with a warning +- Anthropic UI remains `low | medium | high` +- static fallback still renders when app-server is unavailable +- global catalog can be displayed provisionally, but launch enablement waits for project-scoped catalog or explicit degraded confirmation + +### Phase 4 - launch validation and Codex effort pass-through + +Commit boundary: `feat(runtime): pass codex reasoning effort through native exec` + +Tasks: + +- widen team launch effort validation with provider-specific rules +- update IPC and HTTP validators +- update `TeamProvisioningService` request shaping +- persist additive `ProviderModelLaunchIdentity` into team metadata, exact-log metadata, and backup/restore payloads where launch identity is reconstructed +- update orchestrator parser and runtime types +- expose orchestrator runtime capability metadata for dynamic Codex models and Codex effort config +- translate Codex effort to argv entries `['-c', 'model_reasoning_effort="value"']` +- keep Anthropic `max` separate +- add exact-log metadata for selected model, resolved launch model, catalog source, selected effort, and resolved effort +- resolve `Default` to concrete launch model before provisioning +- update scheduled/provisioned launch paths or block Codex-only efforts in those paths until updated +- enforce built-in OpenAI Codex provider scope or block custom/OSS provider configs with clear copy +- pass profile/cwd/config overrides consistently between preview and `codex exec` + +Acceptance: + +- Codex `xhigh` launch reaches `codex exec` as `model_reasoning_effort` +- Codex `max` is rejected before launch +- Anthropic `xhigh` is rejected before launch +- unsupported model-effort pairs are blocked before provisioning +- provider-explicit synthetic future model is accepted only when runtime capability says dynamic Codex models are supported +- member metadata, team metadata, draft retry, and backup/restore preserve provider-aware effort +- replay/exact logs show what was selected, what default resolved to, and what was passed to Codex +- exact logs include catalog scope fingerprint and provider scope, but not raw config values + +### Phase 5 - cleanup and fallback tightening + +Commit boundary: `refactor(codex): demote static model catalog to fallback` + +Tasks: + +- rename static Codex catalog helpers to make fallback status explicit +- remove UI assumptions that static list is authoritative +- make future provider-explicit Codex ids launchable when selected from app-server catalog +- add diagnostics for catalog source and staleness +- document fallback behavior +- add a fixture/test with synthetic future model `gpt-5.5` +- remove any remaining hardcoded Codex model order from the primary Codex UI path +- add hidden-model fixture and upgrade-suggestion fixture +- add one migration test for old localStorage launch prefill without provider model launch identity +- add project-scoped catalog fixture with `model_catalog_json` +- add custom-provider config fixture +- add forced login method and forced workspace fixtures +- add `config/read` method-missing and invalid-params fixtures + +Acceptance: + +- new app-server model can appear in UI without code changes +- static fallback is visible as fallback in diagnostics +- no code path treats static `CODEX_MODELS` as the only valid Codex provider model list +- synthetic `gpt-5.5` appears through app-server fixture and can be selected without touching static catalog +- hidden persisted model is preserved with warning and is not introduced into new-team picker +- project-scoped catalog differences are visible and do not corrupt global provider status +- forced login method changes are visible and do not reuse stale catalog/rate-limit scope + +## Test Plan + +### `claude_team` unit tests + +Add tests for: + +- structured JSON-RPC error classification +- binary version cache invalidation +- effective config fingerprint cache invalidation +- `config/read` support detection, including invalid missing params +- project-scoped `model_catalog_json` fixture +- app-server model normalization +- `id` vs `model` split +- default model selection +- per-model effort options +- unknown effort filtering +- auth-scoped catalog cache keys +- `auto` auth resolving to ChatGPT vs API-key catalog scope +- combined app-server snapshot partial failures +- method-not-found fallback for older Codex app-server +- fallback catalog source +- stale cache behavior +- stale refresh response is ignored after newer auth-scope request +- global catalog and project-scoped catalog use separate cache entries +- forced login method and forced workspace hash use separate cache entries +- custom/OSS `model_provider` config is blocked or marked unsupported for Agent Teams Codex +- raw managed account email does not appear in catalog diagnostics or exact-log metadata +- provider status `models` compatibility +- provider status runtime capabilities compatibility +- provider model availability uses `launchModel`, not `catalogId` +- renderer model picker with rich catalog +- renderer effort selector with Codex and Anthropic providers +- renderer disables Codex-only efforts when runtime capability is missing +- renderer shows synthetic future model as `New from Codex catalog` +- renderer preserves hidden persisted model after `includeHidden: true` recovery +- persisted missing model warning row +- Agent Teams disabled policy overlay for app-server-visible models +- backup/restore reads old metadata and preserves new launch identity when present +- draft retry and launch prefill read old localStorage entries without dropping provider/model identity +- scheduled launch validation either supports Codex-specific effort or blocks it with explicit error +- launch preview with selected cwd does not enable launch from global-only catalog when project-scoped catalog is still unknown + +Suggested commands: + +```bash +pnpm vitest run \ + test/features/codex-model-catalog \ + test/features/codex-account \ + test/renderer/components/team \ + test/renderer/utils/teamModelCatalog.test.ts +``` + +### `agent_teams_orchestrator` tests + +Add tests for: + +- provider-explicit Codex model validation +- Codex effort parser accepts `minimal | low | medium | high | xhigh` +- Anthropic effort parser keeps existing behavior +- Codex native executor emits `-c model_reasoning_effort` +- Codex native executor builds argv entries, not unsafe shell concatenation +- no effort omits Codex effort config +- `max` is not accepted for Codex +- synthetic `gpt-5.5` passes when provider is explicitly Codex and model came from app catalog +- capability payload reports dynamic Codex model support and effort config support +- provider-explicit future model fails closed when capability is disabled +- Codex native exec argv includes cwd/profile/config override semantics that match preview scope +- custom provider config is not silently routed through subscription Codex UX + +Suggested command: + +```bash +pnpm test -- runtimeBackends providerEffort spawnMultiAgent codex +``` + +### live smoke + +Run only when developer has Codex login/API available: + +```bash +codex app-server +``` + +JSON-RPC smoke: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "model/list", "params": { "limit": 20, "includeHidden": false } } +``` + +Native exec effort smoke: + +```bash +codex exec --json --model gpt-5.4 -c model_reasoning_effort='"xhigh"' --skip-git-repo-check --ephemeral "Return only: ok" +``` + +Failure smoke: + +```bash +codex exec --json --model gpt-5.1-codex-mini -c model_reasoning_effort='"xhigh"' --skip-git-repo-check --ephemeral "Return only: ok" +``` + +Expected: + +- our app should block the second case before launch once catalog metadata is available +- if run manually, Codex may return model/provider-specific error, but product UX should not rely on that late failure + +## Risks And Mitigations + +### Risk 1 - app-server startup slows provider settings + +`🎯 8 🛡️ 8 🧠 5` + +Mitigation: + +- cache model catalog in main process +- dedupe in-flight refreshes +- use stale cache while refreshing +- combine account/rate-limit/catalog reads where possible +- never clear ready UI while refresh is pending + +### Risk 2 - effort values leak into Anthropic + +`🎯 9 🛡️ 9 🧠 4` + +Mitigation: + +- provider-specific effort validation +- renderer selector branches by provider and selected model +- tests for Anthropic not showing `xhigh`, `minimal`, or `none` +- orchestrator rejects invalid provider-effort pairs + +### Risk 3 - `id` and `model` diverge later + +`🎯 8 🛡️ 9 🧠 3` + +Mitigation: + +- use `catalogId` for identity +- use `launchModel` for runtime +- tests with fixture where `id !== model` + +### Risk 4 - app-server catalog has unknown fields or new efforts + +`🎯 8 🛡️ 8 🧠 5` + +Mitigation: + +- tolerant protocol DTOs +- unknown efforts preserved in diagnostics but not selectable +- add one small allow-list update when product intentionally supports a new effort +- no hard crash on unknown `inputModalities` + +### Risk 5 - static fallback becomes accidentally authoritative again + +`🎯 7 🛡️ 8 🧠 4` + +Mitigation: + +- name fallback helpers clearly +- include `source` in model catalog +- tests assert app-server source wins over fallback +- UI diagnostics expose fallback source + +### Risk 6 - launch path accepts model from UI but orchestrator rejects it + +`🎯 8 🛡️ 8 🧠 6` + +Mitigation: + +- provider-explicit Codex launch validation should trust `provider=codex` plus app-server-selected model +- static `isCodexModel()` remains only a generic detector +- exact tests with a future-model fixture like `gpt-5.5` + +### Risk 7 - auth-scoped catalog leaks between modes + +`🎯 7 🛡️ 9 🧠 6` + +Mitigation: + +- include auth scope in catalog cache key +- clear scoped cache on logout and API-key source changes +- tests for ChatGPT catalog not being reused in API-key mode +- UI labels catalog source and auth scope in diagnostics + +### Risk 8 - Default becomes nondeterministic across relaunch + +`🎯 8 🛡️ 9 🧠 6` + +Mitigation: + +- persist selected model kind and resolved launch model in launch identity +- exact logs record both `Default` and concrete model +- relaunch preview shows current default resolution before launch +- do not silently rewrite old explicit models + +### Risk 9 - older Codex binary lacks `model/list` + +`🎯 7 🛡️ 9 🧠 5` + +Mitigation: + +- preserve JSON-RPC error code and method +- classify method-not-found separately from app-server failure +- show static fallback with Codex upgrade hint +- cache key includes binary version so upgrades refresh the catalog + +### Risk 10 - `auto` auth shows the wrong catalog + +`🎯 7 🛡️ 9 🧠 6` + +Mitigation: + +- resolve effective auth mode before catalog scope +- keep ChatGPT and API-key catalogs separate +- UI copy distinguishes selected preference, effective launch mode, and fallback credentials +- tests cover ChatGPT-ready + API-key-present and ChatGPT-missing + API-key-ready cases + +### Risk 11 - UI enables a capability the installed runtime cannot launch + +`🎯 7 🛡️ 10 🧠 7` + +Mitigation: + +- add explicit runtime capability metadata +- display catalog metadata separately from launch enablement +- fail closed when capability is missing or stale +- test Phase 3 UI against a pre-Phase-4 runtime fixture + +### Risk 12 - future models appear but break team-agent behavior + +`🎯 8 🛡️ 8 🧠 6` + +Mitigation: + +- split Codex catalog availability from Agent Teams policy status +- show new models as `New from Codex catalog` +- block only hard incompatibilities: runtime capability missing, unsupported modality, disabled policy, unsupported effort +- exact logs record new-model status for later debugging + +### Risk 13 - hidden or upgraded persisted models are silently lost + +`🎯 8 🛡️ 9 🧠 5` + +Mitigation: + +- run one `includeHidden: true` lookup for persisted explicit models missing from visible catalog +- preserve model value during restore and relaunch preview +- show upgrade suggestions without auto-rewriting metadata +- test hidden-model and upgrade fixtures + +### Risk 14 - non-dialog launch path drops Codex effort + +`🎯 7 🛡️ 9 🧠 7` + +Mitigation: + +- audit team metadata, members metadata, backup/restore, draft retry, launch prefill, and schedule types +- parse provider before parsing effort at every main-process boundary +- block Codex-only effort in any path not updated in the same phase +- add tests outside React launch dialogs + +### Risk 15 - HMR or slow refresh overwrites correct provider state + +`🎯 8 🛡️ 9 🧠 5` + +Mitigation: + +- add request/snapshot versioning +- ignore out-of-order provider status responses +- do not let catalog failures overwrite account truth +- keep last ready state visible while a refresh is pending + +### Risk 16 - global catalog preview differs from project launch catalog + +`🎯 6 🛡️ 10 🧠 8` + +Mitigation: + +- include cwd, profile, trust, config fingerprint, and launch override fingerprint in catalog scope +- use app-server `config/read` when available to derive effective config +- keep dashboard/global catalog separate from launch/project catalog +- require project-scoped catalog before enabling launch-only controls when cwd is known + +### Risk 17 - custom or OSS Codex config is mistaken for subscription Codex + +`🎯 7 🛡️ 9 🧠 7` + +Mitigation: + +- keep Agent Teams Codex scoped to built-in OpenAI Codex provider +- detect effective `model_provider` when possible +- block or degrade custom/OSS provider configs with explicit copy +- do not show ChatGPT account limits for custom provider execution + +### Risk 18 - non-text model row appears in catalog + +`🎯 8 🛡️ 9 🧠 4` + +Mitigation: + +- require `text` input modality for Agent Teams launch +- treat missing `inputModalities` with the documented backward-compatible default +- do not claim personality support when `supportsPersonality=false` + +### Risk 19 - experimental app-server surface changes behavior + +`🎯 8 🛡️ 9 🧠 4` + +Mitigation: + +- keep `experimentalApi=false` +- rely only on documented stable `model/list` fields +- ignore unknown fields unless a typed use case is added + +### Risk 20 - app-server catalog passes but native exec fails + +`🎯 8 🛡️ 10 🧠 6` + +Mitigation: + +- treat app-server catalog as picker truth, not full launch proof +- require Phase 4 native exec argv tests and live smoke where possible +- test model, effort, cwd, profile, and provider scope together +- block unsupported model-effort pairs before `codex exec` + +### Risk 21 - `config/read` behavior differs across Codex versions + +`🎯 7 🛡️ 9 🧠 6` + +Mitigation: + +- feature-detect `config/read` +- always send `{}` params at minimum +- classify method-missing, invalid-params, scoped-failure, and global-success separately +- never make config-read failure disconnect the Codex account + +### Risk 22 - forced login/workspace reuses stale catalog + +`🎯 7 🛡️ 10 🧠 6` + +Mitigation: + +- include forced login method and forced workspace hash in auth scope +- invalidate account, limits, and catalog together when either changes +- display forced auth copy instead of showing conflicting selected auth copy +- redact workspace ids in logs and diagnostics + +### Risk 23 - local `model_catalog_json` changes without config change + +`🎯 6 🛡️ 9 🧠 7` + +Mitigation: + +- hash effective config and optionally active catalog file mtime when app-server exposes enough origin data +- keep TTL/manual refresh fallback when origin data is unavailable +- do not parse or log arbitrary catalog file contents +- do not apply untrusted project-scoped catalog files unless effective config says they are active + +## Definition Of Done + +The feature is done when: + +- Codex model picker uses app-server `model/list` when available. +- New app-server-visible Codex models appear without app code changes. +- `supportedReasoningEfforts` and `defaultReasoningEffort` drive Codex effort UI. +- `xhigh` appears only where Codex reports it. +- Anthropic UI and launch behavior are unchanged. +- Codex launches pass effort through `model_reasoning_effort`. +- UI launch controls are gated by runtime capabilities, not by catalog metadata alone. +- Future app-server-visible models appear without code changes and are marked as new until policy/runtime support is clear. +- `Default` Codex selection resolves to concrete launch identity before provisioning. +- Auth changes do not reuse stale model catalogs across ChatGPT and API-key modes. +- Project-scoped Codex config and `model_catalog_json` cannot make launch use a different catalog than preview without explicit degraded copy. +- Custom or OSS Codex provider config is not silently presented as ChatGPT subscription-backed Agent Teams Codex. +- `config/read` compatibility is feature-detected and never breaks account truth on older binaries. +- Forced login method and forced workspace changes cannot reuse stale account, rate-limit, or catalog cache. +- Codex binary upgrades invalidate stale catalog cache and retry `model/list`. +- Older Codex binaries without `model/list` fall back without breaking account state. +- Static Codex catalog is clearly fallback, not primary truth. +- Hidden persisted models are preserved with explicit warnings. +- Backup/restore, draft retry, launch prefill, member metadata, and scheduled paths do not drop provider-aware effort. +- Exact logs and diagnostics do not persist raw account identifiers or secret values. +- Exact logs include catalog scope and provider scope fingerprints for debugging preview vs launch mismatch. +- HMR and out-of-order refreshes do not replace ready provider status with stale fallback/error state. +- Provider Settings remains fast and does not show transient empty/error states during refresh. +- Tests cover catalog source, fallback, effort validation, and launch pass-through. + +## Final Signoff And Handoff + +The implementation is now ready for review after these checks stay green: + +1. `claude_team`: `pnpm typecheck` +2. `claude_team`: targeted catalog/runtime/team provisioning Vitest suites +3. `agent_teams_orchestrator_codex_native_spike`: targeted Codex native exec and runtime capability Bun suites +4. Live `codex app-server model/list` smoke against the installed Codex binary +5. Optional UI smoke with `CLAUDE_DEV_RUNTIME_ROOT=/Users/belief/dev/projects/claude/agent_teams_orchestrator_codex_native_spike` + +Merge requirement: + +- merge/pair the `claude_team` branch with the `agent_teams_orchestrator_codex_native_spike` runtime capability change. +- if the UI branch is merged without the runtime capability change, the feature remains safe but conservative: dynamic future Codex models and `xhigh` are visible as catalog metadata but blocked for launch. +- if the runtime capability change is merged without the UI branch, existing Codex native behavior remains unchanged except for the explicit runtime status payload and `xhigh` exact argv support already covered by tests. + +Recommended final manual smoke: + +```bash +CLAUDE_DEV_RUNTIME_ROOT=/Users/belief/dev/projects/claude/agent_teams_orchestrator_codex_native_spike pnpm dev +``` + +Then verify: + +- Provider Settings Codex model list is populated from app-server catalog. +- `gpt-5.1-codex-mini` shows only `medium | high`. +- `gpt-5.4` shows `low | medium | high | xhigh`. +- Anthropic does not show `minimal`, `none`, or `xhigh`. +- A synthetic or newly released Codex model is not silently hidden by static UI code. +- Launch logs include selected model, resolved launch model, selected effort, resolved effort, catalog source, and runtime capability truth. diff --git a/src/features/codex-model-catalog/contracts/dto.ts b/src/features/codex-model-catalog/contracts/dto.ts new file mode 100644 index 00000000..10b65283 --- /dev/null +++ b/src/features/codex-model-catalog/contracts/dto.ts @@ -0,0 +1,13 @@ +import type { + CliProviderModelCatalog, + CliProviderModelCatalogItem, + CliProviderModelCatalogSource, + CliProviderModelCatalogStatus, + CliProviderReasoningEffort, +} from '@shared/types'; + +export type CodexModelCatalogDto = CliProviderModelCatalog; +export type CodexModelCatalogItemDto = CliProviderModelCatalogItem; +export type CodexModelCatalogSourceDto = CliProviderModelCatalogSource; +export type CodexModelCatalogStatusDto = CliProviderModelCatalogStatus; +export type CodexModelReasoningEffortDto = CliProviderReasoningEffort; diff --git a/src/features/codex-model-catalog/contracts/index.ts b/src/features/codex-model-catalog/contracts/index.ts new file mode 100644 index 00000000..894516e9 --- /dev/null +++ b/src/features/codex-model-catalog/contracts/index.ts @@ -0,0 +1,7 @@ +export type { + CodexModelCatalogDto, + CodexModelCatalogItemDto, + CodexModelCatalogSourceDto, + CodexModelCatalogStatusDto, + CodexModelReasoningEffortDto, +} from './dto'; diff --git a/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts b/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts new file mode 100644 index 00000000..ed7f325c --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeCodexAppServerModels } from '../normalizeCodexAppServerModel'; + +describe('normalizeCodexAppServerModels', () => { + it('keeps app-server model metadata required by the UI picker', () => { + const result = normalizeCodexAppServerModels([ + { + id: 'gpt-5.5', + displayName: 'GPT-5.5', + supportedReasoningEfforts: [ + { reasoningEffort: 'low' }, + { reasoningEffort: 'medium' }, + { reasoningEffort: 'high' }, + { reasoningEffort: 'xhigh' }, + ], + defaultReasoningEffort: 'xhigh', + inputModalities: ['text', 'image'], + supportsPersonality: true, + isDefault: true, + }, + ]); + + expect(result.defaultModelId).toBe('gpt-5.5'); + expect(result.models).toEqual([ + expect.objectContaining({ + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'xhigh', + inputModalities: ['text', 'image'], + supportsPersonality: true, + isDefault: true, + source: 'app-server', + }), + ]); + }); + + it('filters hidden models unless the caller explicitly asks for them', () => { + const result = normalizeCodexAppServerModels([ + { id: 'gpt-visible', hidden: false }, + { id: 'gpt-hidden', hidden: true }, + ]); + + expect(result.models.map((model) => model.id)).toEqual(['gpt-visible']); + + const withHidden = normalizeCodexAppServerModels( + [ + { id: 'gpt-visible', hidden: false }, + { id: 'gpt-hidden', hidden: true }, + ], + { includeHidden: true } + ); + + expect(withHidden.models.map((model) => model.id)).toEqual(['gpt-visible', 'gpt-hidden']); + }); + + it('drops unknown effort values instead of leaking them into launch options', () => { + const result = normalizeCodexAppServerModels([ + { + id: 'gpt-5.4', + supportedReasoningEfforts: ['none', 'medium', { reasoningEffort: 'future-effort' }], + defaultReasoningEffort: 'future-effort', + }, + ]); + + expect(result.models[0]?.supportedReasoningEfforts).toEqual(['medium']); + expect(result.models[0]?.defaultReasoningEffort).toBe('medium'); + }); + + it('uses model as the launch value and de-duplicates duplicate launch models', () => { + const result = normalizeCodexAppServerModels([ + { + id: 'catalog-alias', + model: 'gpt-5.5', + displayName: 'GPT-5.5 Alias', + }, + { + id: 'catalog-duplicate', + model: 'gpt-5.5', + displayName: 'Duplicate GPT-5.5 Alias', + }, + ]); + + expect(result.models).toEqual([ + expect.objectContaining({ + id: 'catalog-alias', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5 Alias', + }), + ]); + expect(result.diagnostics).toContain('model/list returned duplicate launch model gpt-5.5.'); + }); +}); diff --git a/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts new file mode 100644 index 00000000..5f9c724c --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts @@ -0,0 +1,61 @@ +import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types'; + +const DEFAULT_CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'] as const; +const MINI_CODEX_EFFORTS = ['medium', 'high'] as const; + +function createFallbackModel(options: { + id: string; + displayName: string; + badgeLabel: string; + isDefault?: boolean; + efforts?: readonly CliProviderReasoningEffort[]; + defaultEffort?: CliProviderReasoningEffort; +}): CliProviderModelCatalogItem { + const efforts = [...(options.efforts ?? DEFAULT_CODEX_EFFORTS)]; + return { + id: options.id, + launchModel: options.id, + displayName: options.displayName, + hidden: false, + supportedReasoningEfforts: efforts, + defaultReasoningEffort: options.defaultEffort ?? 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: options.isDefault === true, + upgrade: false, + source: 'static-fallback', + badgeLabel: options.badgeLabel, + }; +} + +export function createStaticCodexModelCatalogModels(): CliProviderModelCatalogItem[] { + return [ + createFallbackModel({ + id: 'gpt-5.4', + displayName: 'GPT-5.4', + badgeLabel: '5.4', + isDefault: true, + }), + createFallbackModel({ + id: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + badgeLabel: '5.4-mini', + }), + createFallbackModel({ + id: 'gpt-5.3-codex', + displayName: 'GPT-5.3 Codex', + badgeLabel: '5.3-codex', + }), + createFallbackModel({ + id: 'gpt-5.2', + displayName: 'GPT-5.2', + badgeLabel: '5.2', + }), + createFallbackModel({ + id: 'gpt-5.1-codex-mini', + displayName: 'GPT-5.1 Codex Mini', + badgeLabel: '5.1-codex-mini', + efforts: MINI_CODEX_EFFORTS, + }), + ]; +} diff --git a/src/features/codex-model-catalog/core/domain/codexReasoningEffort.ts b/src/features/codex-model-catalog/core/domain/codexReasoningEffort.ts new file mode 100644 index 00000000..e817c6c0 --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/codexReasoningEffort.ts @@ -0,0 +1,24 @@ +import type { CliProviderReasoningEffort } from '@shared/types'; + +export const CODEX_REASONING_EFFORTS = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const satisfies readonly CliProviderReasoningEffort[]; + +const CODEX_REASONING_EFFORT_SET = new Set(CODEX_REASONING_EFFORTS); + +export function isCodexReasoningEffort(value: unknown): value is CliProviderReasoningEffort { + return typeof value === 'string' && CODEX_REASONING_EFFORT_SET.has(value); +} + +export function normalizeCodexReasoningEffort(value: unknown): CliProviderReasoningEffort | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + return isCodexReasoningEffort(normalized) ? normalized : null; +} diff --git a/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts new file mode 100644 index 00000000..075c81e4 --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts @@ -0,0 +1,151 @@ +import { normalizeCodexReasoningEffort, CODEX_REASONING_EFFORTS } from './codexReasoningEffort'; + +import type { CodexAppServerModel } from '@main/services/infrastructure/codexAppServer'; +import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types'; + +export interface NormalizedCodexModelCatalogResult { + models: CliProviderModelCatalogItem[]; + defaultModelId: string | null; + diagnostics: string[]; +} + +function normalizeModelId(model: CodexAppServerModel): string | null { + const id = model.id?.trim() || model.model?.trim() || null; + return id && id.length > 0 ? id : null; +} + +function normalizeEffortOption(option: unknown): CliProviderReasoningEffort | null { + if (typeof option === 'string') { + return normalizeCodexReasoningEffort(option); + } + + if (option && typeof option === 'object' && 'reasoningEffort' in option) { + return normalizeCodexReasoningEffort((option as { reasoningEffort?: unknown }).reasoningEffort); + } + + return null; +} + +function normalizeEfforts(model: CodexAppServerModel): CliProviderReasoningEffort[] { + const efforts = model.supportedReasoningEfforts?.flatMap((option) => { + const normalized = normalizeEffortOption(option); + return normalized ? [normalized] : []; + }); + + if (!efforts || efforts.length === 0) { + return ['low', 'medium', 'high']; + } + + return CODEX_REASONING_EFFORTS.filter((effort) => efforts.includes(effort)); +} + +function normalizeDefaultEffort( + defaultEffort: unknown, + supportedEfforts: readonly CliProviderReasoningEffort[] +): CliProviderReasoningEffort | null { + const normalized = normalizeCodexReasoningEffort(defaultEffort); + if (!normalized) { + return supportedEfforts.includes('medium') ? 'medium' : (supportedEfforts[0] ?? null); + } + + return supportedEfforts.includes(normalized) + ? normalized + : supportedEfforts.includes('medium') + ? 'medium' + : (supportedEfforts[0] ?? null); +} + +function normalizeModalities(value: unknown): string[] { + if (!Array.isArray(value)) { + return ['text', 'image']; + } + + const seen = new Set(); + const modalities: string[] = []; + for (const item of value) { + if (typeof item !== 'string') { + continue; + } + const normalized = item.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + modalities.push(normalized); + } + + return modalities.length > 0 ? modalities : ['text', 'image']; +} + +function asBadgeLabel(modelId: string): string { + return modelId.replace(/^gpt-/, ''); +} + +export function normalizeCodexAppServerModels( + models: readonly CodexAppServerModel[] | undefined, + options: { + includeHidden?: boolean; + } = {} +): NormalizedCodexModelCatalogResult { + const diagnostics: string[] = []; + const seen = new Set(); + const seenLaunchModels = new Set(); + const normalizedModels: CliProviderModelCatalogItem[] = []; + + for (const model of models ?? []) { + const id = normalizeModelId(model); + if (!id) { + diagnostics.push('model/list returned a model without id/model.'); + continue; + } + + if (seen.has(id)) { + diagnostics.push(`model/list returned duplicate model id ${id}.`); + continue; + } + seen.add(id); + + const hidden = model.hidden === true; + if (hidden && options.includeHidden !== true) { + continue; + } + + const launchModel = model.model?.trim() || id; + if (seenLaunchModels.has(launchModel)) { + diagnostics.push(`model/list returned duplicate launch model ${launchModel}.`); + continue; + } + seenLaunchModels.add(launchModel); + + const supportedReasoningEfforts = normalizeEfforts(model); + normalizedModels.push({ + id, + launchModel, + displayName: model.displayName?.trim() || id, + hidden, + supportedReasoningEfforts, + defaultReasoningEffort: normalizeDefaultEffort( + model.defaultReasoningEffort, + supportedReasoningEfforts + ), + inputModalities: normalizeModalities(model.inputModalities), + supportsPersonality: model.supportsPersonality === true, + isDefault: model.isDefault === true, + upgrade: Boolean(model.upgrade), + source: 'app-server', + badgeLabel: asBadgeLabel(id), + }); + } + + const defaultModel = + normalizedModels.find((model) => model.isDefault) ?? + normalizedModels.find((model) => !model.hidden) ?? + normalizedModels[0] ?? + null; + + return { + models: normalizedModels, + defaultModelId: defaultModel?.id ?? null, + diagnostics, + }; +} diff --git a/src/features/codex-model-catalog/index.ts b/src/features/codex-model-catalog/index.ts new file mode 100644 index 00000000..5296c495 --- /dev/null +++ b/src/features/codex-model-catalog/index.ts @@ -0,0 +1,9 @@ +export type { + CodexModelCatalogDto, + CodexModelCatalogItemDto, + CodexModelCatalogSourceDto, + CodexModelCatalogStatusDto, + CodexModelReasoningEffortDto, +} from './contracts'; +export type { CodexModelCatalogFeatureFacade, CodexModelCatalogRequest } from './main'; +export { createCodexModelCatalogFeature } from './main'; diff --git a/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts b/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts new file mode 100644 index 00000000..5fee0da6 --- /dev/null +++ b/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts @@ -0,0 +1,357 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; +import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; +import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder'; +import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback'; +import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel'; +import { + CodexAppServerSessionFactory, + CodexBinaryResolver, + JsonRpcRequestError, + JsonRpcStdioClient, +} from '@main/services/infrastructure/codexAppServer'; + +import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient'; +import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache'; + +import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts'; +import type { Logger } from '@shared/utils/logger'; + +type LoggerPort = Pick; + +const CATALOG_CACHE_TTL_MS = 10 * 60_000; +const CATALOG_STALE_TTL_MS = 24 * 60 * 60_000; +const HASH_SALT = randomBytes(16).toString('hex'); + +export interface CodexModelCatalogRequest { + cwd?: string | null; + profile?: string | null; + includeHidden?: boolean; + forceRefresh?: boolean; +} + +export interface CodexModelCatalogFeatureFacade { + getCatalog(options?: CodexModelCatalogRequest): Promise; + invalidate(): void; + dispose(): Promise; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function staleAtIso(): string { + return new Date(Date.now() + CATALOG_CACHE_TTL_MS).toISOString(); +} + +function hashValue(value: unknown): string { + return createHash('sha256') + .update(HASH_SALT) + .update(JSON.stringify(value ?? null)) + .digest('hex') + .slice(0, 16); +} + +function classifyAppServerFailure(error: unknown): { + appServerState: CodexModelCatalogDto['diagnostics']['appServerState']; + message: string; + code: string | null; +} { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + const rpcCode = + error instanceof JsonRpcRequestError && error.code !== null ? String(error.code) : null; + + if ( + lower.includes('unknown method') || + lower.includes('method not found') || + lower.includes('unknown command') || + lower.includes('no such command') || + rpcCode === '-32601' + ) { + return { + appServerState: 'incompatible', + message: 'The installed Codex binary does not support app-server model/list yet.', + code: rpcCode ?? 'method-not-found', + }; + } + + return { + appServerState: 'degraded', + message, + code: rpcCode, + }; +} + +function createCacheKey(options: { + binaryPath: string | null; + binaryVersion: string | null; + accountSnapshot: CodexAccountSnapshotDto; + cwd?: string | null; + profile?: string | null; + configFingerprint?: string | null; + includeHidden?: boolean; +}): string { + return hashValue({ + binaryPath: options.binaryPath, + binaryVersion: options.binaryVersion, + preferredAuthMode: options.accountSnapshot.preferredAuthMode, + effectiveAuthMode: options.accountSnapshot.effectiveAuthMode, + managedAccount: options.accountSnapshot.managedAccount + ? { + type: options.accountSnapshot.managedAccount.type, + planType: options.accountSnapshot.managedAccount.planType, + emailHash: hashValue(options.accountSnapshot.managedAccount.email), + } + : null, + apiKeySource: options.accountSnapshot.apiKey.source, + cwd: options.cwd?.trim() || null, + profile: options.profile?.trim() || null, + configFingerprint: options.configFingerprint ?? null, + includeHidden: options.includeHidden === true, + codexHome: process.env.CODEX_HOME?.trim() || null, + }); +} + +function setCatalogCacheEntries( + cache: InMemoryCodexModelCatalogCache, + keys: readonly string[], + catalog: CodexModelCatalogDto +): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + cache.set(key, catalog); + } +} + +function createFallbackCatalog(options: { + sourceMessage: string; + appServerState: CodexModelCatalogDto['diagnostics']['appServerState']; + status?: CodexModelCatalogDto['status']; + code?: string | null; +}): CodexModelCatalogDto { + const models = createStaticCodexModelCatalogModels(); + const defaultModel = models.find((model) => model.isDefault) ?? models[0] ?? null; + return { + schemaVersion: 1, + providerId: 'codex', + source: 'static-fallback', + status: options.status ?? 'degraded', + fetchedAt: nowIso(), + staleAt: staleAtIso(), + defaultModelId: defaultModel?.id ?? null, + defaultLaunchModel: defaultModel?.launchModel ?? null, + models, + diagnostics: { + configReadState: 'skipped', + appServerState: options.appServerState, + message: options.sourceMessage, + code: options.code ?? null, + }, + }; +} + +function markCatalogStale( + catalog: CodexModelCatalogDto, + diagnostics: CodexModelCatalogDto['diagnostics'] +): CodexModelCatalogDto { + return { + ...catalog, + status: 'stale', + diagnostics, + }; +} + +export function createCodexModelCatalogFeature(options: { + logger: LoggerPort; + codexAccountFeature: Pick; +}): CodexModelCatalogFeatureFacade { + const envBuilder = new CodexAccountEnvBuilder(); + const cache = new InMemoryCodexModelCatalogCache(); + const inFlightRefreshes = new Map>(); + let cacheGeneration = 0; + const client = new CodexModelCatalogAppServerClient( + new CodexAppServerSessionFactory(new JsonRpcStdioClient(options.logger)) + ); + + async function getCatalog(request: CodexModelCatalogRequest = {}): Promise { + const accountSnapshot = await options.codexAccountFeature.getSnapshot(); + const binaryPath = await CodexBinaryResolver.resolve(); + const binaryVersion = await CodexBinaryResolver.resolveVersion(binaryPath); + + if (!binaryPath) { + return createFallbackCatalog({ + sourceMessage: 'Codex CLI was not found. Showing static fallback model list.', + appServerState: 'runtime-missing', + status: 'unavailable', + }); + } + + const env = envBuilder.buildControlPlaneEnv({ binaryPath }); + const preflightCacheKey = createCacheKey({ + binaryPath, + binaryVersion, + accountSnapshot, + cwd: request.cwd, + profile: request.profile, + configFingerprint: null, + includeHidden: request.includeHidden, + }); + + if (request.forceRefresh !== true) { + const cached = cache.get(preflightCacheKey, CATALOG_CACHE_TTL_MS); + if (cached) { + return cached; + } + } + + const existingRefresh = inFlightRefreshes.get(preflightCacheKey); + if (existingRefresh) { + return existingRefresh; + } + + const refreshGeneration = cacheGeneration; + const refreshPromise = (async (): Promise => { + let configFingerprint: string | null = null; + let configReadState: CodexModelCatalogDto['diagnostics']['configReadState'] = 'skipped'; + let configReadMessage: string | null = null; + let cacheKey = preflightCacheKey; + + try { + const payload = await client.readModelCatalogWithConfig({ + binaryPath, + env, + includeHidden: request.includeHidden, + cwd: request.cwd, + profile: request.profile, + }); + + if (payload.config.ok) { + configReadState = 'ready'; + configFingerprint = hashValue(payload.config.value); + } else { + configReadState = + payload.config.error instanceof JsonRpcRequestError && + payload.config.error.code === -32601 + ? 'unsupported' + : 'failed'; + configReadMessage = + payload.config.error instanceof Error + ? payload.config.error.message + : String(payload.config.error); + } + + cacheKey = createCacheKey({ + binaryPath, + binaryVersion, + accountSnapshot, + cwd: request.cwd, + profile: request.profile, + configFingerprint, + includeHidden: request.includeHidden, + }); + + const normalized = normalizeCodexAppServerModels( + payload.modelCatalog.models ?? payload.modelCatalog.data, + { + includeHidden: request.includeHidden, + } + ); + + const defaultModel = + normalized.models.find((model) => model.id === normalized.defaultModelId) ?? + normalized.models.find((model) => model.isDefault) ?? + normalized.models[0] ?? + null; + const diagnostics = [ + ...normalized.diagnostics, + configReadMessage ? `config/read: ${configReadMessage}` : null, + payload.modelCatalog.truncated + ? 'model/list pagination reached the safety page limit; some Codex models may be omitted.' + : null, + ].filter(Boolean); + const catalog: CodexModelCatalogDto = { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: nowIso(), + staleAt: staleAtIso(), + defaultModelId: defaultModel?.id ?? null, + defaultLaunchModel: defaultModel?.launchModel ?? null, + models: normalized.models, + diagnostics: { + configReadState, + appServerState: 'healthy', + message: diagnostics.length > 0 ? diagnostics.join(' ') : null, + code: null, + }, + }; + + if (normalized.models.length === 0) { + throw new Error('Codex app-server model/list returned no visible models.'); + } + + if (refreshGeneration === cacheGeneration) { + setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], catalog); + } + return catalog; + } catch (error) { + const failure = classifyAppServerFailure(error); + const stale = + cache.getLatest(cacheKey) ?? + (cacheKey === preflightCacheKey ? null : cache.getLatest(preflightCacheKey)); + if (stale && Date.parse(stale.fetchedAt) + CATALOG_STALE_TTL_MS > Date.now()) { + return markCatalogStale(stale, { + configReadState, + appServerState: failure.appServerState, + message: failure.message, + code: failure.code, + }); + } + + options.logger.warn('codex model catalog refresh failed', { + error: failure.message, + code: failure.code, + }); + const fallback = createFallbackCatalog({ + sourceMessage: failure.message, + appServerState: failure.appServerState, + code: failure.code, + }); + if (refreshGeneration === cacheGeneration) { + setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], fallback); + } + return fallback; + } + })(); + + inFlightRefreshes.set(preflightCacheKey, refreshPromise); + try { + return await refreshPromise; + } finally { + if (inFlightRefreshes.get(preflightCacheKey) === refreshPromise) { + inFlightRefreshes.delete(preflightCacheKey); + } + } + } + + return { + getCatalog, + invalidate: () => { + cacheGeneration += 1; + cache.clear(); + inFlightRefreshes.clear(); + }, + dispose: async () => { + cacheGeneration += 1; + cache.clear(); + inFlightRefreshes.clear(); + }, + }; +} diff --git a/src/features/codex-model-catalog/main/index.ts b/src/features/codex-model-catalog/main/index.ts new file mode 100644 index 00000000..0d29b79f --- /dev/null +++ b/src/features/codex-model-catalog/main/index.ts @@ -0,0 +1,5 @@ +export type { + CodexModelCatalogFeatureFacade, + CodexModelCatalogRequest, +} from './composition/createCodexModelCatalogFeature'; +export { createCodexModelCatalogFeature } from './composition/createCodexModelCatalogFeature'; diff --git a/src/features/codex-model-catalog/main/infrastructure/CodexModelCatalogAppServerClient.ts b/src/features/codex-model-catalog/main/infrastructure/CodexModelCatalogAppServerClient.ts new file mode 100644 index 00000000..42b46702 --- /dev/null +++ b/src/features/codex-model-catalog/main/infrastructure/CodexModelCatalogAppServerClient.ts @@ -0,0 +1,159 @@ +import type { + CodexAppServerListModelsParams, + CodexAppServerListModelsResponse, + CodexAppServerReadConfigParams, + CodexAppServerReadConfigResponse, + CodexAppServerSession, + CodexAppServerSessionFactory, +} from '@main/services/infrastructure/codexAppServer'; + +const MODEL_LIST_PAGE_LIMIT = 100; +const MODEL_LIST_MAX_PAGES = 5; +const MODEL_LIST_TIMEOUT_MS = 4_500; +const CONFIG_READ_TIMEOUT_MS = 3_500; +const INITIALIZE_TIMEOUT_MS = 6_000; +const TOTAL_TIMEOUT_MS = 9_000; + +export class CodexModelCatalogAppServerClient { + constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {} + + async readModelCatalogWithConfig(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + includeHidden?: boolean; + cwd?: string | null; + profile?: string | null; + }): Promise<{ + modelCatalog: CodexAppServerListModelsResponse; + config: { ok: true; value: CodexAppServerReadConfigResponse } | { ok: false; error: unknown }; + }> { + const configParams = this.buildConfigReadParams(options); + + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: MODEL_LIST_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server model/list with config/read', + experimentalApi: false, + }, + async (session) => { + const configPromise = session + .request( + 'config/read', + configParams, + CONFIG_READ_TIMEOUT_MS + ) + .then((value) => ({ ok: true as const, value })) + .catch((error: unknown) => ({ ok: false as const, error })); + const modelCatalogPromise = this.readModelCatalogPages(session, { + includeHidden: options.includeHidden, + }); + const [config, modelCatalog] = await Promise.all([configPromise, modelCatalogPromise]); + return { + config, + modelCatalog, + }; + } + ); + } + + async readModelCatalog(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + includeHidden?: boolean; + }): Promise { + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: MODEL_LIST_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server model/list', + experimentalApi: false, + }, + async (session) => + this.readModelCatalogPages(session, { + includeHidden: options.includeHidden, + }) + ); + } + + async readConfig(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + cwd?: string | null; + profile?: string | null; + }): Promise { + const params = this.buildConfigReadParams(options); + + return this.sessionFactory.withSession( + { + binaryPath: options.binaryPath, + env: options.env, + requestTimeoutMs: CONFIG_READ_TIMEOUT_MS, + initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: TOTAL_TIMEOUT_MS, + label: 'codex app-server config/read', + experimentalApi: false, + }, + async (session) => + session.request( + 'config/read', + params, + CONFIG_READ_TIMEOUT_MS + ) + ); + } + + private buildConfigReadParams(options: { + cwd?: string | null; + profile?: string | null; + }): CodexAppServerReadConfigParams { + const params: CodexAppServerReadConfigParams = {}; + if (options.cwd?.trim()) { + params.cwd = options.cwd.trim(); + } + if (options.profile?.trim()) { + params.profile = options.profile.trim(); + } + return params; + } + + private async readModelCatalogPages( + session: CodexAppServerSession, + options: { includeHidden?: boolean } + ): Promise { + const data: NonNullable = []; + let cursor: string | null = null; + let nextCursor: string | null = null; + + for (let page = 0; page < MODEL_LIST_MAX_PAGES; page += 1) { + const payload: CodexAppServerListModelsResponse = + await session.request( + 'model/list', + { + cursor, + limit: MODEL_LIST_PAGE_LIMIT, + includeHidden: options.includeHidden === true, + } satisfies CodexAppServerListModelsParams, + MODEL_LIST_TIMEOUT_MS + ); + data.push(...(payload.data ?? payload.models ?? [])); + nextCursor = payload.nextCursor ?? null; + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return { + data, + nextCursor, + truncated: nextCursor !== null, + }; + } +} diff --git a/src/features/codex-model-catalog/main/infrastructure/InMemoryCodexModelCatalogCache.ts b/src/features/codex-model-catalog/main/infrastructure/InMemoryCodexModelCatalogCache.ts new file mode 100644 index 00000000..6c8c4e9e --- /dev/null +++ b/src/features/codex-model-catalog/main/infrastructure/InMemoryCodexModelCatalogCache.ts @@ -0,0 +1,37 @@ +import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts'; + +interface CacheEntry { + value: CodexModelCatalogDto; + observedAt: number; +} + +export class InMemoryCodexModelCatalogCache { + private readonly entries = new Map(); + + get(key: string, maxAgeMs: number): CodexModelCatalogDto | null { + const entry = this.entries.get(key); + if (!entry) { + return null; + } + if (Date.now() - entry.observedAt > maxAgeMs) { + return null; + } + return structuredClone(entry.value); + } + + getLatest(key: string): CodexModelCatalogDto | null { + const entry = this.entries.get(key); + return entry ? structuredClone(entry.value) : null; + } + + set(key: string, value: CodexModelCatalogDto): void { + this.entries.set(key, { + value: structuredClone(value), + observedAt: Date.now(), + }); + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts b/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts new file mode 100644 index 00000000..c8217d87 --- /dev/null +++ b/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { CodexModelCatalogAppServerClient } from '../CodexModelCatalogAppServerClient'; + +import type { + CodexAppServerSession, + CodexAppServerSessionFactory, +} from '@main/services/infrastructure/codexAppServer'; + +describe('CodexModelCatalogAppServerClient', () => { + it('reads config and paginated model/list in one app-server session', async () => { + const requests: Array<{ method: string; params: unknown }> = []; + let sessionCount = 0; + const session: CodexAppServerSession = { + initializeResponse: { + userAgent: 'codex-cli 0.117.0', + codexHome: '/Users/me/.codex', + platformFamily: 'macos', + platformOs: 'darwin', + }, + request: async (method: string, params?: unknown): Promise => { + requests.push({ method, params }); + if (method === 'config/read') { + return { config: { model: 'gpt-5.4' }, origins: {} } as TResult; + } + if (method === 'model/list') { + const cursor = (params as { cursor?: string | null }).cursor ?? null; + if (cursor === null) { + return { + data: [{ id: 'gpt-5.4', model: 'gpt-5.4' }], + nextCursor: 'page-2', + } as TResult; + } + return { + data: [{ id: 'gpt-5.5', model: 'gpt-5.5' }], + nextCursor: null, + } as TResult; + } + throw new Error(`Unexpected method ${method}`); + }, + notify: async () => undefined, + onNotification: () => () => undefined, + close: async () => undefined, + }; + const factory = { + withSession: async ( + _options: unknown, + handler: (session: CodexAppServerSession) => Promise + ): Promise => { + sessionCount += 1; + return handler(session); + }, + } as unknown as CodexAppServerSessionFactory; + + const client = new CodexModelCatalogAppServerClient(factory); + const result = await client.readModelCatalogWithConfig({ + binaryPath: '/usr/local/bin/codex', + env: {}, + cwd: '/repo', + profile: 'work', + }); + + expect(sessionCount).toBe(1); + expect(result.config).toEqual({ + ok: true, + value: { config: { model: 'gpt-5.4' }, origins: {} }, + }); + expect(result.modelCatalog).toEqual({ + data: [ + { id: 'gpt-5.4', model: 'gpt-5.4' }, + { id: 'gpt-5.5', model: 'gpt-5.5' }, + ], + nextCursor: null, + truncated: false, + }); + expect(requests).toEqual([ + { method: 'config/read', params: { cwd: '/repo', profile: 'work' } }, + { + method: 'model/list', + params: { cursor: null, limit: 100, includeHidden: false }, + }, + { + method: 'model/list', + params: { cursor: 'page-2', limit: 100, includeHidden: false }, + }, + ]); + }); +}); diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 5495aef5..afebd5d1 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -1,5 +1,9 @@ import { validateTeamName } from '@main/ipc/guards'; import { getErrorMessage } from '@shared/utils/errorHandling'; +import { + formatEffortLevelListForProvider, + isTeamEffortLevelForProvider, +} from '@shared/utils/effortLevels'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isAbsolute } from 'path'; @@ -12,8 +16,6 @@ const logger = createLogger('HTTP:teams'); type LaunchBody = Omit; -const EFFORT_LEVELS = new Set(['low', 'medium', 'high']); - class HttpBadRequestError extends Error {} class HttpFeatureUnavailableError extends Error {} @@ -76,16 +78,21 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und return value; } -function assertOptionalEffort(value: unknown): EffortLevel | undefined { +function assertOptionalEffort( + value: unknown, + providerId: TeamLaunchRequest['providerId'] +): EffortLevel | undefined { if (value == null) { return undefined; } - if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) { - throw new HttpBadRequestError('effort must be one of: low, medium, high'); + if (!isTeamEffortLevelForProvider(value, providerId)) { + throw new HttpBadRequestError( + `effort must be one of: ${formatEffortLevelListForProvider(providerId)}` + ); } - return value as EffortLevel; + return value; } function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { @@ -109,7 +116,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest ); } const model = assertOptionalString(payload.model, 'model'); - const effort = assertOptionalEffort(payload.effort); + const effort = assertOptionalEffort(payload.effort, providerId); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); const worktree = assertOptionalString(payload.worktree, 'worktree'); diff --git a/src/main/index.ts b/src/main/index.ts index a89d959e..7a6c373f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,10 @@ import { registerCodexAccountIpc, removeCodexAccountIpc, } from '@features/codex-account/main'; +import { + createCodexModelCatalogFeature, + type CodexModelCatalogFeatureFacade, +} from '@features/codex-model-catalog/main'; import { createRecentProjectsFeature, type RecentProjectsFeatureFacade, @@ -422,6 +426,7 @@ let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; let codexAccountFeature: CodexAccountFeatureFacade | null = null; +let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; @@ -988,6 +993,11 @@ async function initializeServices(): Promise { configManager, }); providerConnectionService.setCodexAccountFeature(codexAccountFeature); + codexModelCatalogFeature = createCodexModelCatalogFeature({ + logger: createLogger('Feature:CodexModelCatalog'), + codexAccountFeature, + }); + providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -1185,7 +1195,10 @@ function shutdownServices(): void { } void skillsWatcherService?.stopAll(); + providerConnectionService.setCodexModelCatalogFeature(null); providerConnectionService.setCodexAccountFeature(null); + void codexModelCatalogFeature?.dispose(); + codexModelCatalogFeature = null; void codexAccountFeature?.dispose(); codexAccountFeature = null; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a01e1d5c..11f34910 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -91,6 +91,10 @@ import { PROTECTED_CLI_FLAGS, } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; +import { + formatEffortLevelListForProvider, + isTeamEffortLevelForProvider, +} from '@shared/utils/effortLevels'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { @@ -1112,10 +1116,8 @@ function isProvisioningTeamName(teamName: string): boolean { return parts.every((p) => /^[a-z0-9]+$/.test(p)); } -const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high']; - -function isValidEffort(value: unknown): value is EffortLevel { - return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); +function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel { + return isTeamEffortLevelForProvider(value, providerId); } function parseOptionalMemberProviderId( @@ -1165,15 +1167,35 @@ function parseOptionalProviderBackendId( } function parseOptionalMemberEffort( - value: unknown + value: unknown, + providerId?: TeamProviderId | null ): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { if (value === undefined || value === null || value === '') { return { valid: true, value: undefined }; } - if (isValidEffort(value)) { + if (isValidEffort(value, providerId)) { return { valid: true, value }; } - return { valid: false, error: 'member effort must be low, medium, or high' }; + return { + valid: false, + error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`, + }; +} + +function parseOptionalTeamEffort( + value: unknown, + providerId?: TeamProviderId | null +): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isValidEffort(value, providerId)) { + return { valid: true, value }; + } + return { + valid: false, + error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`, + }; } async function validateProvisioningRequest( @@ -1202,6 +1224,12 @@ async function validateProvisioningRequest( if (!Array.isArray(payload.members)) { return { valid: false, error: 'members must be an array' }; } + const providerId = + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : 'anthropic'; const seenNames = new Set(); const members: TeamCreateRequest['members'] = []; @@ -1237,12 +1265,20 @@ async function validateProvisioningRequest( if (model !== undefined && typeof model !== 'string') { return { valid: false, error: 'member model must be string' }; } + const effortValidation = parseOptionalMemberEffort( + (member as { effort?: unknown }).effort, + providerValidation.value ?? providerId + ); + if (!effortValidation.valid) { + return { valid: false, error: effortValidation.error }; + } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, providerId: providerValidation.value, model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: effortValidation.value, }); } @@ -1257,12 +1293,6 @@ async function validateProvisioningRequest( if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { return { valid: false, error: 'prompt must be a string' }; } - const providerId = - payload.providerId === 'codex' - ? 'codex' - : payload.providerId === 'gemini' - ? 'gemini' - : 'anthropic'; const providerBackendValidation = parseOptionalProviderBackendId( payload.providerBackendId, providerId @@ -1270,6 +1300,10 @@ async function validateProvisioningRequest( if (!providerBackendValidation.valid) { return { valid: false, error: providerBackendValidation.error }; } + const effortValidation = parseOptionalTeamEffort(payload.effort, providerId); + if (!effortValidation.valid) { + return { valid: false, error: effortValidation.error }; + } try { await fs.promises.mkdir(cwd, { recursive: true }); @@ -1324,7 +1358,7 @@ async function validateProvisioningRequest( providerId, providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, - effort: isValidEffort(payload.effort) ? payload.effort : undefined, + effort: effortValidation.value, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, worktree: @@ -1474,6 +1508,10 @@ async function handleLaunchTeam( : meta?.providerId === 'gemini' ? 'gemini' : 'anthropic'; + const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } const createRequest: TeamCreateRequest = { teamName: tn, @@ -1488,7 +1526,7 @@ async function handleLaunchTeam( providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId ), model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, - effort: isValidEffort(payload.effort) ? payload.effort : undefined, + effort: effortValidation.value, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -1520,6 +1558,11 @@ async function handleLaunchTeam( ); } + const effortValidation = parseOptionalTeamEffort(payload.effort, providerId); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } + return wrapTeamHandler('launch', () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); return getTeamProvisioningService().launchTeam( @@ -1530,7 +1573,7 @@ async function handleLaunchTeam( providerId, providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, - effort: isValidEffort(payload.effort) ? payload.effort : undefined, + effort: effortValidation.value, clearContext: payload.clearContext === true ? true : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -2652,7 +2695,10 @@ async function handleCreateConfig( if (model !== undefined && typeof model !== 'string') { return { success: false, error: 'member model must be string' }; } - const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort); + const effortValidation = parseOptionalMemberEffort( + (member as { effort?: unknown }).effort, + providerValidation.value + ); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } @@ -3090,7 +3136,10 @@ async function handleAddMember( if (model !== undefined && typeof model !== 'string') { return { success: false, error: 'model must be a string' }; } - const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort); + const effortValidation = parseOptionalMemberEffort( + (payload as { effort?: unknown }).effort, + providerValidation.value + ); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } @@ -3162,7 +3211,7 @@ async function handleReplaceMembers( workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - effort?: 'low' | 'medium' | 'high'; + effort?: EffortLevel; }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { @@ -3196,7 +3245,10 @@ async function handleReplaceMembers( if (m.model !== undefined && typeof m.model !== 'string') { return { success: false, error: 'member model must be string' }; } - const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort); + const effortValidation = parseOptionalMemberEffort( + (m as { effort?: unknown }).effort, + providerValidation.value + ); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 24254cc4..d5fc7beb 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -152,7 +152,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat providers: status.providers.map((provider) => ({ ...provider, modelVerificationState: provider.modelVerificationState ?? 'idle', + modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null, modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], + runtimeCapabilities: provider.runtimeCapabilities + ? structuredClone(provider.runtimeCapabilities) + : null, capabilities: { ...provider.capabilities, extensions: { diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 7b7184bf..71627c13 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -2,11 +2,15 @@ import { constants as fsConstants } from 'node:fs'; import * as fsp from 'node:fs/promises'; import path from 'node:path'; +import { execCli } from '@main/utils/childProcess'; + const CACHE_VERIFY_TTL_MS = 30_000; +const VERSION_CACHE_TTL_MS = 30_000; let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; let resolveInFlight: Promise | null = null; +const versionCache = new Map(); async function fileExists(filePath: string): Promise { try { @@ -69,6 +73,7 @@ export class CodexBinaryResolver { cachedBinaryPath = undefined; cacheVerifiedAt = 0; resolveInFlight = null; + versionCache.clear(); } static async resolve(): Promise { @@ -117,4 +122,34 @@ export class CodexBinaryResolver { cacheVerifiedAt = Date.now(); return null; } + + static async resolveVersion(binaryPath: string | null | undefined): Promise { + const normalizedPath = binaryPath?.trim(); + if (!normalizedPath) { + return null; + } + + const cached = versionCache.get(normalizedPath); + if (cached && Date.now() - cached.observedAt <= VERSION_CACHE_TTL_MS) { + return cached.version; + } + + try { + const result = await execCli(normalizedPath, ['--version'], { + timeout: 3_000, + }); + const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null; + versionCache.set(normalizedPath, { + version, + observedAt: Date.now(), + }); + return version; + } catch { + versionCache.set(normalizedPath, { + version: null, + observedAt: Date.now(), + }); + return null; + } + } } diff --git a/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts index 4b2bf21c..fd7d29a6 100644 --- a/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts +++ b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts @@ -10,6 +10,7 @@ interface JsonRpcLogger { interface JsonRpcErrorPayload { code?: number; message?: string; + data?: unknown; } interface JsonRpcResponse { @@ -49,6 +50,22 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +export class JsonRpcRequestError extends Error { + readonly code: number | null; + readonly data: unknown; + readonly details: unknown; + readonly method: string; + + constructor(method: string, payload: JsonRpcErrorPayload) { + super(payload.message ?? 'Unknown JSON-RPC error'); + this.name = 'JsonRpcRequestError'; + this.method = method; + this.code = typeof payload.code === 'number' ? payload.code : null; + this.data = payload.data; + this.details = payload.data; + } +} + export class JsonRpcStdioClient { constructor(private readonly logger: JsonRpcLogger) {} @@ -93,6 +110,7 @@ export class JsonRpcStdioClient { const pending = new Map< number, { + method: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timeoutId: ReturnType; @@ -149,7 +167,7 @@ export class JsonRpcStdioClient { pending.delete(message.id); if (message.error) { - entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); + entry.reject(new JsonRpcRequestError(entry.method, message.error)); return; } @@ -222,17 +240,25 @@ export class JsonRpcStdioClient { reject(new Error(`JSON-RPC request timed out: ${method}`)); }, timeoutMs); - pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId }); - - child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { - if (!error) { - return; - } - - clearTimeout(timeoutId); - pending.delete(id); - reject(error instanceof Error ? error : new Error(String(error))); + pending.set(id, { + method, + resolve: resolve as (value: unknown) => void, + reject, + timeoutId, }); + + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, + (error) => { + if (!error) { + return; + } + + clearTimeout(timeoutId); + pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } + ); }), notify: async (method: string, params?: unknown): Promise => { @@ -241,7 +267,7 @@ export class JsonRpcStdioClient { } await new Promise((resolve, reject) => { - child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => { + child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`, (error) => { if (error) { reject(error instanceof Error ? error : new Error(String(error))); return; diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts new file mode 100644 index 00000000..5e6e308b --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { JsonRpcStdioClient } from '../JsonRpcStdioClient'; + +const tempDirs: string[] = []; + +function createStrictJsonRpcServerScript(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-')); + tempDirs.push(tempDir); + const scriptPath = path.join(tempDir, 'server.cjs'); + fs.writeFileSync( + scriptPath, + ` +const readline = require('node:readline'); +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + const message = JSON.parse(line); + if (message.jsonrpc !== '2.0') { + return; + } + if (message.method === 'fail') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: 'No such method', data: { method: message.method } }, + }) + '\\n'); + return; + } + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { ok: true, params: message.params }, + }) + '\\n'); +}); +`, + 'utf8' + ); + return scriptPath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('JsonRpcStdioClient', () => { + it('sends JSON-RPC 2.0 framed requests and preserves structured errors', async () => { + const scriptPath = createStrictJsonRpcServerScript(); + const client = new JsonRpcStdioClient({ warn: () => undefined }); + + await client.withSession( + { + binaryPath: process.execPath, + args: [scriptPath], + label: 'strict json-rpc smoke', + requestTimeoutMs: 1_000, + totalTimeoutMs: 2_000, + }, + async (session) => { + await expect(session.request('ping', { value: 1 })).resolves.toEqual({ + ok: true, + params: { value: 1 }, + }); + + await expect(session.request('fail')).rejects.toMatchObject({ + method: 'fail', + code: -32601, + data: { method: 'fail' }, + details: { method: 'fail' }, + }); + } + ); + }); +}); diff --git a/src/main/services/infrastructure/codexAppServer/index.ts b/src/main/services/infrastructure/codexAppServer/index.ts index 63b3b013..a875f880 100644 --- a/src/main/services/infrastructure/codexAppServer/index.ts +++ b/src/main/services/infrastructure/codexAppServer/index.ts @@ -5,7 +5,7 @@ export { } from './CodexAppServerSessionFactory'; export { CodexBinaryResolver } from './CodexBinaryResolver'; export type { JsonRpcSession } from './JsonRpcStdioClient'; -export { JsonRpcStdioClient } from './JsonRpcStdioClient'; +export { JsonRpcRequestError, JsonRpcStdioClient } from './JsonRpcStdioClient'; export type { CodexAppServerAccount, CodexAppServerAccountLoginCompletedNotification, @@ -20,10 +20,17 @@ export type { CodexAppServerGetAccountRateLimitsResponse, CodexAppServerGetAccountResponse, CodexAppServerInitializeResponse, + CodexAppServerListModelsParams, + CodexAppServerListModelsResponse, CodexAppServerLoginAccountParams, CodexAppServerLoginAccountResponse, CodexAppServerLogoutAccountResponse, + CodexAppServerModel, CodexAppServerPlanType, CodexAppServerRateLimitSnapshot, CodexAppServerRateLimitWindow, + CodexAppServerReadConfigParams, + CodexAppServerReadConfigResponse, + CodexAppServerReasoningEffort, + CodexAppServerReasoningEffortOption, } from './protocol'; diff --git a/src/main/services/infrastructure/codexAppServer/protocol.ts b/src/main/services/infrastructure/codexAppServer/protocol.ts index 4fd8fbf1..7ca4b714 100644 --- a/src/main/services/infrastructure/codexAppServer/protocol.ts +++ b/src/main/services/infrastructure/codexAppServer/protocol.ts @@ -111,3 +111,53 @@ export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound'; export interface CodexAppServerCancelLoginAccountResponse { status: CodexAppServerCancelLoginAccountStatus; } + +export type CodexAppServerReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh'; + +export interface CodexAppServerReasoningEffortOption { + reasoningEffort?: string; + description?: string | null; +} + +export interface CodexAppServerModel { + id?: string; + model?: string; + displayName?: string; + hidden?: boolean; + supportedReasoningEfforts?: (string | CodexAppServerReasoningEffortOption)[]; + defaultReasoningEffort?: string | null; + inputModalities?: string[] | null; + supportsPersonality?: boolean; + isDefault?: boolean; + upgrade?: boolean | string | null; + upgradeInfo?: unknown; +} + +export interface CodexAppServerListModelsParams { + cursor?: string | null; + limit?: number | null; + includeHidden?: boolean; +} + +export interface CodexAppServerListModelsResponse { + data?: CodexAppServerModel[]; + models?: CodexAppServerModel[]; + nextCursor?: string | null; + truncated?: boolean; +} + +export interface CodexAppServerReadConfigParams { + cwd?: string; + profile?: string; +} + +export interface CodexAppServerReadConfigResponse { + config?: Record; + origins?: Record; +} diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 004869a8..87489e8b 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -30,6 +30,18 @@ interface RuntimeExtensionCapabilitiesResponse { apiKeys?: RuntimeExtensionCapabilityResponse; } +interface RuntimeProviderCapabilitiesResponse { + modelCatalog?: { + dynamic?: boolean; + source?: 'app-server' | 'static-fallback' | 'runtime'; + }; + reasoningEffort?: { + supported?: boolean; + values?: string[]; + configPassthrough?: boolean; + }; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; providers?: Record< @@ -53,6 +65,7 @@ interface ProviderStatusCommandResponse { projectId?: string | null; authMethodDetail?: string | null; } | null; + runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; } >; } @@ -119,6 +132,7 @@ interface UnifiedRuntimeStatusResponse { projectId?: string | null; authMethodDetail?: string | null; } | null; + runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; } >; } @@ -164,6 +178,8 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat externalRuntimeDiagnostics: [], backend: null, connection: null, + modelCatalog: null, + runtimeCapabilities: null, }; } @@ -301,6 +317,34 @@ export class ClaudeMultimodelBridgeService { authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null, } : null, + runtimeCapabilities: runtimeStatus.runtimeCapabilities + ? { + modelCatalog: runtimeStatus.runtimeCapabilities.modelCatalog + ? { + dynamic: runtimeStatus.runtimeCapabilities.modelCatalog.dynamic === true, + source: runtimeStatus.runtimeCapabilities.modelCatalog.source, + } + : undefined, + reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort + ? { + supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true, + values: + runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) => + value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' + ? [value] + : [] + ) ?? [], + configPassthrough: + runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true, + } + : undefined, + } + : null, }; } diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 5581bc29..05133652 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -11,10 +11,12 @@ import type { CodexAccountSnapshotDto, } from '@features/codex-account/contracts'; import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; +import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main'; import type { CliProviderAuthMode, CliProviderConnectionInfo, CliProviderId, + CliProviderReasoningEffort, CliProviderStatus, } from '@shared/types'; @@ -77,6 +79,8 @@ function buildCodexForcedLoginLaunchArgs( export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; private codexAccountFeature: Pick | null = null; + private codexModelCatalogFeature: Pick | null = + null; constructor( private readonly apiKeyService = new ApiKeyService(), @@ -92,6 +96,12 @@ export class ProviderConnectionService { this.codexAccountFeature = feature; } + setCodexModelCatalogFeature( + feature: Pick | null + ): void { + this.codexModelCatalogFeature = feature; + } + getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null { if (providerId === 'anthropic') { return this.configManager.getConfig().providerConnections.anthropic.authMode; @@ -353,10 +363,53 @@ export class ProviderConnectionService { } async enrichProviderStatus(provider: CliProviderStatus): Promise { - return { + const withConnection = { ...provider, connection: await this.getConnectionInfo(provider.providerId), }; + + if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) { + return withConnection; + } + + try { + const catalog = await this.codexModelCatalogFeature.getCatalog(); + const models = catalog.models + .filter((model) => !model.hidden) + .map((model) => model.launchModel.trim()) + .filter(Boolean); + const reasoningEfforts = Array.from( + new Set( + catalog.models.flatMap( + (model) => model.supportedReasoningEfforts + ) + ) + ); + const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort; + const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog; + return { + ...withConnection, + models: models.length > 0 ? models : withConnection.models, + modelCatalog: catalog, + runtimeCapabilities: { + ...withConnection.runtimeCapabilities, + modelCatalog: { + dynamic: runtimeModelCatalogCapability?.dynamic === true, + source: catalog.source, + }, + reasoningEffort: { + supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0, + values: + runtimeReasoningCapability?.values && runtimeReasoningCapability.values.length > 0 + ? runtimeReasoningCapability.values + : (['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[]), + configPassthrough: runtimeReasoningCapability?.configPassthrough === true, + }, + }, + }; + } catch { + return withConnection; + } } async enrichProviderStatuses(providers: CliProviderStatus[]): Promise { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5807ea51..9bdf17b9 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -8,6 +8,7 @@ import { wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; @@ -1258,10 +1259,7 @@ export class TeamDataService { ? request.providerId : undefined, model: request.model?.trim() || undefined, - effort: - request.effort === 'low' || request.effort === 'medium' || request.effort === 'high' - ? request.effort - : undefined, + effort: isTeamEffortLevel(request.effort) ? request.effort : undefined, agentType: 'general-purpose', joinedAt: Date.now(), }; @@ -1297,7 +1295,7 @@ export class TeamDataService { workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - effort?: 'low' | 'medium' | 'high'; + effort?: TeamMember['effort']; }[]; } ): Promise { @@ -1339,10 +1337,7 @@ export class TeamDataService { workflow: member.workflow?.trim() || undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, agentType: prev?.agentType ?? 'general-purpose', agentId: isSameActiveMember ? prev?.agentId : undefined, color: prev?.color, @@ -2418,10 +2413,7 @@ export class TeamDataService { workflow: member.workflow?.trim() || undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, agentType: 'general-purpose' as const, joinedAt, })) diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 62bc8f06..9e819f66 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -123,7 +123,7 @@ export class TeamMemberResolver { workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - effort?: 'low' | 'medium' | 'high'; + effort?: TeamMember['effort']; color?: string; cwd?: string; } @@ -166,7 +166,7 @@ export class TeamMemberResolver { workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - effort?: 'low' | 'medium' | 'high'; + effort?: TeamMember['effort']; color?: string; removedAt?: number; } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 064b0273..7a316a15 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,5 +1,6 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as fs from 'fs'; @@ -36,10 +37,7 @@ function normalizeMember(member: TeamMember): TeamMember | null { workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 2f9553a0..71170eff 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -6,6 +6,8 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; +import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types'; + /** * Persisted team-level metadata saved by the UI before CLI provisioning. * CLI does not know about this file — it only reads/writes config.json. @@ -27,6 +29,7 @@ export interface TeamMetaFile { worktree?: string; extraCliArgs?: string; limitContext?: boolean; + launchIdentity?: ProviderModelLaunchIdentity; createdAt: number; } @@ -40,6 +43,70 @@ function normalizeOptionalBackendId(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function normalizeProviderId(value: unknown): TeamProviderId | undefined { + return value === 'anthropic' || value === 'codex' || value === 'gemini' ? value : undefined; +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const raw = value as Partial; + const providerId = normalizeProviderId(raw.providerId); + const selectedModelKind = + raw.selectedModelKind === 'default' || raw.selectedModelKind === 'explicit' + ? raw.selectedModelKind + : null; + if (!providerId || !selectedModelKind) { + return undefined; + } + + const catalogSource = + raw.catalogSource === 'app-server' || + raw.catalogSource === 'static-fallback' || + raw.catalogSource === 'runtime' || + raw.catalogSource === 'unavailable' + ? raw.catalogSource + : 'unavailable'; + const selectedEffort = + raw.selectedEffort === 'none' || + raw.selectedEffort === 'minimal' || + raw.selectedEffort === 'low' || + raw.selectedEffort === 'medium' || + raw.selectedEffort === 'high' || + raw.selectedEffort === 'xhigh' + ? raw.selectedEffort + : null; + const resolvedEffort = + raw.resolvedEffort === 'none' || + raw.resolvedEffort === 'minimal' || + raw.resolvedEffort === 'low' || + raw.resolvedEffort === 'medium' || + raw.resolvedEffort === 'high' || + raw.resolvedEffort === 'xhigh' + ? raw.resolvedEffort + : null; + + return { + providerId, + providerBackendId: + migrateProviderBackendId(providerId, normalizeOptionalString(raw.providerBackendId)) ?? null, + selectedModel: normalizeOptionalString(raw.selectedModel), + selectedModelKind, + resolvedLaunchModel: normalizeOptionalString(raw.resolvedLaunchModel), + catalogId: normalizeOptionalString(raw.catalogId), + catalogSource, + catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt), + selectedEffort, + resolvedEffort, + }; +} + export class TeamMetaStore { private getMetaPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'team.meta.json'); @@ -110,6 +177,7 @@ export class TeamMetaStore { extraCliArgs: typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined, limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined, + launchIdentity: normalizeLaunchIdentity(file.launchIdentity), createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(), }; } @@ -133,6 +201,7 @@ export class TeamMetaStore { worktree: data.worktree?.trim() || undefined, extraCliArgs: data.extraCliArgs?.trim() || undefined, limitContext: data.limitContext, + launchIdentity: normalizeLaunchIdentity(data.launchIdentity), createdAt: data.createdAt, }; await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2)); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 06ba0121..312f039a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -40,6 +40,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { isInboxNoiseMessage, @@ -169,6 +170,7 @@ interface RelayInboxMessageView { import type { ActiveToolCall, + CliProviderRuntimeCapabilities, CrossTeamSendResult, EffortLevel, InboxMessage, @@ -179,6 +181,7 @@ import type { MemberSpawnStatusEntry, PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, + ProviderModelLaunchIdentity, TeamAgentRuntimeBackendType, TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot, @@ -320,6 +323,21 @@ interface ProviderModelListCommandResponse { >; } +interface RuntimeStatusCommandResponse { + providers?: Record< + string, + { + runtimeCapabilities?: CliProviderRuntimeCapabilities | null; + } + >; +} + +interface RuntimeProviderLaunchFacts { + defaultModel: string | null; + modelIds: Set; + runtimeCapabilities: CliProviderRuntimeCapabilities | null; +} + function extractJsonObjectFromCli(raw: string): T { const trimmed = raw.trim(); try { @@ -334,6 +352,65 @@ function extractJsonObjectFromCli(raw: string): T { } } +function getExplicitLaunchModelSelection(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed || isDefaultProviderModelSelection(trimmed)) { + return undefined; + } + return trimmed; +} + +function getLaunchModelArg( + providerId: TeamProviderId, + model: string | undefined, + launchIdentity?: ProviderModelLaunchIdentity | null +): string | undefined { + const explicitModel = getExplicitLaunchModelSelection(model); + if (explicitModel) { + return explicitModel; + } + + if ( + providerId === 'codex' && + launchIdentity?.selectedModelKind === 'default' && + launchIdentity.resolvedLaunchModel + ) { + return launchIdentity.resolvedLaunchModel; + } + + return undefined; +} + +function normalizeProviderModelListModels( + provider: NonNullable[string] | undefined +): Set { + const models = new Set(); + for (const entry of provider?.models ?? []) { + const modelId = typeof entry === 'string' ? entry : entry.id; + const trimmed = modelId?.trim(); + if (trimmed) { + models.add(trimmed); + } + } + return models; +} + +function isLegacySafeEffort(effort: EffortLevel): boolean { + return effort === 'low' || effort === 'medium' || effort === 'high'; +} + +function isCodexEffortRuntimeSupported( + effort: EffortLevel, + capabilities: CliProviderRuntimeCapabilities | null +): boolean { + if (isLegacySafeEffort(effort)) { + return true; + } + + const reasoning = capabilities?.reasoningEffort; + return reasoning?.configPassthrough === true && reasoning.values.includes(effort); +} + function isProbeTimeoutMessage(message: string): boolean { const lower = message.toLowerCase(); return ( @@ -476,6 +553,7 @@ function logRuntimeLaunchSnapshot( geminiRuntimeAuth?: GeminiRuntimeAuthState | null; promptSize?: PromptSizeSummary | null; expectedMembersCount?: number; + launchIdentity?: ProviderModelLaunchIdentity | null; } ): void { const providerId = resolveTeamProviderId(request.providerId); @@ -489,6 +567,7 @@ function logRuntimeLaunchSnapshot( getConfiguredRuntimeBackend(providerId), promptSize: options?.promptSize ?? null, expectedMembersCount: options?.expectedMembersCount ?? null, + launchIdentity: options?.launchIdentity ?? null, geminiRuntimeAuth: providerId === 'gemini' ? { @@ -1257,17 +1336,22 @@ function buildEffectiveTeamMemberSpec( const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; const model = - member.model?.trim() || + getExplicitLaunchModelSelection(member.model) || (memberProviderId == null || memberProviderId === defaultProviderId - ? defaults.model?.trim() + ? getExplicitLaunchModelSelection(defaults.model) : undefined) || undefined; + const effort = + member.effort ?? + (memberProviderId == null || memberProviderId === defaultProviderId + ? defaults.effort + : undefined); return { ...member, providerId: effectiveProviderId, model, - effort: member.effort ?? defaults.effort, + effort, }; } @@ -2859,6 +2943,214 @@ export class TeamProvisioningService { this.controlApiBaseUrlResolver = resolver; } + private async readRuntimeProviderLaunchFacts(params: { + claudePath: string; + cwd: string; + providerId: TeamProviderId; + env: NodeJS.ProcessEnv; + limitContext?: boolean; + }): Promise { + if (params.providerId === 'anthropic') { + return { + defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true), + modelIds: new Set(), + runtimeCapabilities: null, + }; + } + + const modelListPromise = execCli( + params.claudePath, + ['model', 'list', '--json', '--provider', params.providerId], + { + cwd: params.cwd, + env: params.env, + timeout: 10_000, + } + ); + const runtimeStatusPromise = + params.providerId === 'codex' + ? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], { + cwd: params.cwd, + env: params.env, + timeout: 8_000, + }) + : null; + + const [modelListResult, runtimeStatusResult] = await Promise.allSettled([ + modelListPromise, + runtimeStatusPromise, + ]); + + let defaultModel: string | null = null; + let modelIds = new Set(); + if (modelListResult.status === 'fulfilled') { + try { + const parsed = extractJsonObjectFromCli( + modelListResult.value.stdout + ); + const provider = parsed.providers?.[params.providerId]; + defaultModel = + typeof provider?.defaultModel === 'string' && provider.defaultModel.trim().length > 0 + ? provider.defaultModel.trim() + : null; + modelIds = normalizeProviderModelListModels(provider); + } catch (error) { + logger.warn( + `[${params.providerId}] Failed to parse runtime model list for launch validation: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null; + if ( + runtimeStatusResult.status === 'fulfilled' && + runtimeStatusResult.value && + typeof runtimeStatusResult.value.stdout === 'string' + ) { + try { + const parsed = extractJsonObjectFromCli( + runtimeStatusResult.value.stdout + ); + runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null; + } catch (error) { + logger.warn( + `[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + return { + defaultModel, + modelIds, + runtimeCapabilities, + }; + } + + private buildProviderModelLaunchIdentity(params: { + request: Pick; + facts: RuntimeProviderLaunchFacts; + }): ProviderModelLaunchIdentity { + const providerId = resolveTeamProviderId(params.request.providerId); + const explicitModel = getExplicitLaunchModelSelection(params.request.model); + const resolvedLaunchModel = explicitModel ?? params.facts.defaultModel; + const resolvedEffort = params.request.effort ?? null; + + return { + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null, + selectedModel: explicitModel ?? null, + selectedModelKind: explicitModel ? 'explicit' : 'default', + resolvedLaunchModel, + catalogId: resolvedLaunchModel, + catalogSource: 'runtime', + catalogFetchedAt: null, + selectedEffort: params.request.effort ?? null, + resolvedEffort, + }; + } + + private validateRuntimeLaunchSelection(params: { + actorLabel: string; + providerId: TeamProviderId; + model?: string; + effort?: EffortLevel; + facts: RuntimeProviderLaunchFacts; + }): void { + const explicitModel = getExplicitLaunchModelSelection(params.model); + + if (params.providerId !== 'codex') { + if (params.effort && !isLegacySafeEffort(params.effort)) { + throw new Error( + `${params.actorLabel} uses effort "${params.effort}", but ${getTeamProviderLabel( + params.providerId + )} currently supports only low, medium, or high effort in Agent Teams.` + ); + } + return; + } + + if ( + params.effort && + !isCodexEffortRuntimeSupported(params.effort, params.facts.runtimeCapabilities) + ) { + throw new Error( + `${params.actorLabel} uses Codex effort "${params.effort}", but this Agent Teams runtime does not expose Codex reasoning config passthrough yet. Use low, medium, or high for now.` + ); + } + + if (!explicitModel || params.facts.modelIds.has(explicitModel)) { + return; + } + + if (params.facts.runtimeCapabilities?.modelCatalog?.dynamic === true) { + return; + } + + throw new Error( + `${params.actorLabel} uses Codex model "${explicitModel}", but this Agent Teams runtime does not declare dynamic Codex model launch support yet. Upgrade the runtime or pick a listed Codex model.` + ); + } + + private async resolveAndValidateLaunchIdentity(params: { + claudePath: string; + cwd: string; + env: NodeJS.ProcessEnv; + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + >; + effectiveMembers: TeamCreateRequest['members']; + }): Promise { + const leadProviderId = resolveTeamProviderId(params.request.providerId); + const factsByProvider = new Map(); + const getFacts = async (providerId: TeamProviderId): Promise => { + const cached = factsByProvider.get(providerId); + if (cached) { + return cached; + } + const facts = await this.readRuntimeProviderLaunchFacts({ + claudePath: params.claudePath, + cwd: params.cwd, + providerId, + env: params.env, + limitContext: params.request.limitContext, + }); + factsByProvider.set(providerId, facts); + return facts; + }; + + const leadFacts = await getFacts(leadProviderId); + this.validateRuntimeLaunchSelection({ + actorLabel: 'Team lead', + providerId: leadProviderId, + model: params.request.model, + effort: params.request.effort, + facts: leadFacts, + }); + + for (const member of params.effectiveMembers) { + const memberProviderId = resolveTeamProviderId(member.providerId); + const memberFacts = await getFacts(memberProviderId); + this.validateRuntimeLaunchSelection({ + actorLabel: `Member ${member.name}`, + providerId: memberProviderId, + model: member.model, + effort: member.effort, + facts: memberFacts, + }); + } + + return this.buildProviderModelLaunchIdentity({ + request: params.request, + facts: leadFacts, + }); + } + async getClaudeLogs( teamName: string, query?: { offset?: number; limit?: number } @@ -6227,6 +6519,13 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, limitContext: request.limitContext, }); + const launchIdentity = await this.resolveAndValidateLaunchIdentity({ + claudePath, + cwd: request.cwd, + env: shellEnv, + request, + effectiveMembers: effectiveMemberSpecs, + }); const runId = randomUUID(); const startedAt = nowIso(); const run: ProvisioningRun = { @@ -6363,6 +6662,11 @@ export class TeamProvisioningService { run.bootstrapUserPromptPath = null; throw error; } + const launchModelArg = getLaunchModelArg( + resolveTeamProviderId(request.providerId), + request.model, + launchIdentity + ); const spawnArgs = [ '--input-format', 'stream-json', @@ -6385,7 +6689,7 @@ export class TeamProvisioningService { ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), - ...(request.model ? ['--model', request.model] : []), + ...(launchModelArg ? ['--model', launchModelArg] : []), ...(request.effort ? ['--effort', request.effort] : []), ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), @@ -6400,6 +6704,7 @@ export class TeamProvisioningService { geminiRuntimeAuth, promptSize, expectedMembersCount: effectiveMemberSpecs.length, + launchIdentity, }); try { // Pre-save our meta files before spawn — CLI doesn't touch these. @@ -6422,6 +6727,7 @@ export class TeamProvisioningService { worktree: request.worktree, extraCliArgs: request.extraCliArgs, limitContext: request.limitContext, + launchIdentity, createdAt: Date.now(), }); const membersToWrite = applyDistinctProvisioningMemberColors( @@ -6431,10 +6737,7 @@ export class TeamProvisioningService { workflow: m.workflow?.trim() || undefined, providerId: normalizeOptionalTeamProviderId(m.providerId), model: m.model?.trim() || undefined, - effort: - m.effort === 'low' || m.effort === 'medium' || m.effort === 'high' - ? m.effort - : undefined, + effort: isTeamEffortLevel(m.effort) ? m.effort : undefined, agentType: 'general-purpose' as const, joinedAt: Date.now(), })) @@ -6804,6 +7107,13 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, limitContext: request.limitContext, }); + const launchIdentity = await this.resolveAndValidateLaunchIdentity({ + claudePath, + cwd: request.cwd, + env: shellEnv, + request, + effectiveMembers: effectiveMemberSpecs, + }); // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { @@ -7013,8 +7323,13 @@ export class TeamProvisioningService { `[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity` ); } - if (request.model) { - launchArgs.push('--model', request.model); + const launchModelArg = getLaunchModelArg( + resolveTeamProviderId(request.providerId), + request.model, + launchIdentity + ); + if (launchModelArg) { + launchArgs.push('--model', launchModelArg); } if (request.effort) { launchArgs.push('--effort', request.effort); @@ -7033,6 +7348,7 @@ export class TeamProvisioningService { geminiRuntimeAuth, promptSize, expectedMembersCount: effectiveMemberSpecs.length, + launchIdentity, }); // --resume is added above when a valid previous session JSONL exists. // Without it, CLI creates a fresh session ID automatically. @@ -7050,6 +7366,7 @@ export class TeamProvisioningService { worktree: request.worktree, extraCliArgs: request.extraCliArgs, limitContext: request.limitContext, + launchIdentity, createdAt: Date.now(), }); await this.membersMetaStore.writeMembers( @@ -7060,10 +7377,7 @@ export class TeamProvisioningService { workflow: member.workflow?.trim() || undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt: Date.now(), @@ -8514,16 +8828,11 @@ export class TeamProvisioningService { normalizeTeamMemberProviderId(metaMember?.providerId) ?? normalizeTeamMemberProviderId(configuredMember?.providerId); const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined; - const effort = - metaMember?.effort === 'low' || - metaMember?.effort === 'medium' || - metaMember?.effort === 'high' - ? metaMember.effort - : configuredMember?.effort === 'low' || - configuredMember?.effort === 'medium' || - configuredMember?.effort === 'high' - ? configuredMember.effort - : undefined; + const effort = isTeamEffortLevel(metaMember?.effort) + ? metaMember.effort + : isTeamEffortLevel(configuredMember?.effort) + ? configuredMember.effort + : undefined; const agentType = metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; @@ -13035,12 +13344,9 @@ export class TeamProvisioningService { const effectiveLeadProviderId = normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic'; const effectiveLeadModel = launchState.model?.trim() || undefined; - const effectiveLeadEffort = - launchState.effort === 'low' || - launchState.effort === 'medium' || - launchState.effort === 'high' - ? launchState.effort - : undefined; + const effectiveLeadEffort = isTeamEffortLevel(launchState.effort) + ? launchState.effort + : undefined; const membersByName = new Map( (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const) @@ -13075,10 +13381,7 @@ export class TeamProvisioningService { delete nextMember.model; } - const effort = - state.effort === 'low' || state.effort === 'medium' || state.effort === 'high' - ? state.effort - : undefined; + const effort = isTeamEffortLevel(state.effort) ? state.effort : undefined; if (effort) { nextMember.effort = effort; } else { @@ -13712,10 +14015,7 @@ export class TeamProvisioningService { workflow: member.workflow?.trim() || undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, agentType: 'general-purpose' as const, joinedAt, })) @@ -13758,10 +14058,7 @@ export class TeamProvisioningService { const providerId = normalizeOptionalTeamProviderId(member.providerId); const model = typeof member.model === 'string' ? member.model.trim() || undefined : undefined; - const effort = - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined; + const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined; const prev = byName.get(name); if (!prev) { byName.set(name, { name, role, workflow, providerId, model, effort }); @@ -13923,10 +14220,7 @@ export class TeamProvisioningService { typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, }); } // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index 76de3985..b3395d46 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -2,54 +2,136 @@ import React from 'react'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { Brain } from 'lucide-react'; -const EFFORT_OPTIONS = [ +import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types'; + +const BASE_EFFORT_OPTIONS = [ { value: '', label: 'Default' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, ] as const; +const EFFORT_LABELS: Record = { + none: 'None', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh', +}; + +const BASE_CODEX_SAFE_EFFORTS = new Set(['low', 'medium', 'high']); + export interface EffortLevelSelectorProps { value: string; onValueChange: (value: string) => void; id?: string; + providerId?: TeamProviderId; + model?: string; +} + +function getCatalogModel( + providerStatus: CliProviderStatus | null | undefined, + model: string | undefined +): NonNullable['models'][number] | null { + const catalog = providerStatus?.modelCatalog; + if (!catalog || catalog.providerId !== 'codex') { + return null; + } + + const explicitModel = model?.trim(); + if (explicitModel) { + return ( + catalog.models.find( + (item) => item.launchModel === explicitModel || item.id === explicitModel + ) ?? null + ); + } + + return ( + catalog.models.find((item) => item.id === catalog.defaultModelId) ?? + catalog.models.find((item) => item.isDefault) ?? + null + ); +} + +function getEffortOptions(params: { + providerId?: TeamProviderId; + model?: string; + providerStatus?: CliProviderStatus | null; +}): readonly { value: string; label: string }[] { + if (params.providerId !== 'codex') { + return BASE_EFFORT_OPTIONS; + } + + const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort; + const catalogModel = getCatalogModel(params.providerStatus, params.model); + const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? []; + const candidateEfforts = + catalogEfforts.length > 0 ? catalogEfforts : (runtimeCapability?.values ?? []); + const safeEfforts = + runtimeCapability?.configPassthrough === true + ? candidateEfforts + : candidateEfforts.filter((effort) => BASE_CODEX_SAFE_EFFORTS.has(effort)); + const efforts = safeEfforts.length > 0 ? safeEfforts : (['low', 'medium', 'high'] as const); + const defaultLabel = catalogModel?.defaultReasoningEffort + ? `Default (${EFFORT_LABELS[catalogModel.defaultReasoningEffort]})` + : 'Default'; + + return [ + { value: '', label: defaultLabel }, + ...efforts.map((effort) => ({ + value: effort, + label: EFFORT_LABELS[effort], + })), + ]; } export const EffortLevelSelector: React.FC = ({ value, onValueChange, id, -}) => ( -
- -
- -
- {EFFORT_OPTIONS.map((opt) => ( - - ))} + providerId, + model, +}) => { + const providerStatus = useStore( + (s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null + ); + const effortOptions = getEffortOptions({ providerId, model, providerStatus }); + + return ( +
+ +
+ +
+ {effortOptions.map((opt) => ( + + ))} +
+

+ Controls how much reasoning the selected provider invests before responding. Default uses + the provider's standard behavior for the selected model. +

-

- Controls how much reasoning the selected provider invests before responding. Default uses the - provider's standard behavior for the selected model. -

-
-); + ); +}; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 49203ee3..bb917507 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2011,6 +2011,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen value={selectedEffort} onValueChange={setSelectedEffort} id="dialog-effort" + providerId={selectedProviderId} + model={selectedModel} /> {providerId === 'anthropic' ? ( {lockProviderModel && (

diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index dac0dee7..e7fb826e 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -5,6 +5,7 @@ import { Label } from '@renderer/components/ui/label'; import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { Plus } from 'lucide-react'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; @@ -50,10 +51,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] { const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : ''; const providerId = normalizeOptionalTeamProviderId(item.providerId); const model = typeof item.model === 'string' ? item.model.trim() : ''; - const effort: EffortLevel | undefined = - item.effort === 'low' || item.effort === 'medium' || item.effort === 'high' - ? item.effort - : undefined; + const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort) + ? item.effort + : undefined; const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = presetRoles.includes(role); return createMemberDraft({ @@ -227,8 +227,7 @@ export const MembersEditorSection = ({ c.id === memberId ? { ...c, - effort: - effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined, + effort: isTeamEffortLevel(effort) ? effort : undefined, } : c ) diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 78bda46f..336916a1 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -2,6 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { isLeadMember } from '@shared/utils/leadDetection'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; @@ -120,10 +121,7 @@ export function normalizeMemberDraftForProviderMode( } function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined { - if (value === 'low' || value === 'medium' || value === 'high') { - return value; - } - return undefined; + return isTeamEffortLevel(value) ? value : undefined; } interface ExistingMemberColorInput { diff --git a/src/renderer/services/createTeamDraftStorage.ts b/src/renderer/services/createTeamDraftStorage.ts index eb616f60..2a7cab07 100644 --- a/src/renderer/services/createTeamDraftStorage.ts +++ b/src/renderer/services/createTeamDraftStorage.ts @@ -11,6 +11,10 @@ import { del, get, set } from 'idb-keyval'; +import { isTeamEffortLevel } from '@shared/utils/effortLevels'; + +import type { EffortLevel } from '@shared/types'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -27,7 +31,7 @@ export interface SerializedMemberDraft { workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - effort?: 'low' | 'medium' | 'high'; + effort?: EffortLevel; } export interface CreateTeamDraftSnapshot { @@ -67,10 +71,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft { obj.providerId === 'codex' || obj.providerId === 'gemini') && (obj.model === undefined || typeof obj.model === 'string') && - (obj.effort === undefined || - obj.effort === 'low' || - obj.effort === 'medium' || - obj.effort === 'high') + (obj.effort === undefined || isTeamEffortLevel(obj.effort)) ); } diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts new file mode 100644 index 00000000..af312422 --- /dev/null +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { + CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, + getAvailableTeamProviderModelOptions, + getAvailableTeamProviderModels, + getTeamModelSelectionError, +} from '../teamModelAvailability'; + +import type { CliProviderStatus } from '@shared/types'; + +function createCodexProviderStatus( + models: NonNullable['models'], + options: { dynamicLaunch?: boolean } = {} +): CliProviderStatus { + return { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: models.map((model) => model.launchModel), + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: models[0]?.id ?? null, + defaultLaunchModel: models[0]?.launchModel ?? null, + models, + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + modelAvailability: [], + runtimeCapabilities: { + modelCatalog: { + dynamic: options.dynamicLaunch === true, + source: 'app-server', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }; +} + +describe('team model availability Codex catalog integration', () => { + it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => { + const providerStatus = createCodexProviderStatus( + [ + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: '5.5', + }, + ], + { dynamicLaunch: true } + ); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']); + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { + value: 'gpt-5.5', + label: '5.5', + badgeLabel: '5.5', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('shows app-server future models but blocks launch until runtime declares dynamic support', () => { + const providerStatus = createCodexProviderStatus([ + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ]); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]); + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({ + value: 'gpt-5.5', + label: '5.5', + badgeLabel: 'New', + availabilityStatus: null, + }); + expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain( + CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON + ); + }); + + it('keeps existing disabled model policy on top of the dynamic catalog', () => { + const providerStatus = createCodexProviderStatus([ + { + id: 'gpt-5.3-codex-spark', + launchModel: 'gpt-5.3-codex-spark', + displayName: 'GPT-5.3 Codex Spark', + hidden: false, + supportedReasoningEfforts: ['high'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ]); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + }); +}); diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 585ec2d5..5521f3c0 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -4,6 +4,7 @@ import { getTeamProviderLabel, getTeamProviderModelOptions, getVisibleTeamProviderModels, + CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, @@ -28,6 +29,7 @@ import type { } from '@shared/types'; export { + CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, @@ -44,8 +46,10 @@ export type TeamModelRuntimeProviderStatus = Pick< CliProviderStatus, | 'providerId' | 'models' + | 'modelCatalog' | 'modelAvailability' | 'modelVerificationState' + | 'runtimeCapabilities' | 'authMethod' | 'backend' | 'authenticated' @@ -100,6 +104,56 @@ function getFallbackTeamProviderModelOptions( })); } +function getRuntimeCatalogModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] | null { + if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') { + return null; + } + + const models = providerStatus.modelCatalog.models + .filter((model) => !model.hidden) + .map((model) => model.launchModel.trim()) + .filter(Boolean); + return models.length > 0 ? models : null; +} + +function getRuntimeCatalogModelOption( + providerId: SupportedProviderId, + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamRuntimeModelOption | null { + if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') { + return null; + } + + const catalogModel = providerStatus.modelCatalog.models.find( + (item) => item.launchModel === model || item.id === model + ); + if (!catalogModel) { + return null; + } + + return { + value: catalogModel.launchModel, + label: + getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ?? + catalogModel.displayName, + badgeLabel: + catalogModel.badgeLabel ?? + (getTeamProviderModelOptions(providerId).some((option) => option.value === model) + ? undefined + : 'New'), + availabilityStatus: getRuntimeModelAvailability( + providerId, + catalogModel.launchModel, + providerStatus + ), + availabilityReason: getRuntimeModelAvailabilityReason(catalogModel.launchModel, providerStatus), + }; +} + function getRuntimeSelectorModels( providerId: SupportedProviderId, providerStatus?: TeamModelRuntimeProviderStatus | null @@ -108,6 +162,11 @@ function getRuntimeSelectorModels( return []; } + const catalogModels = getRuntimeCatalogModels(providerId, providerStatus); + if (catalogModels) { + return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus); + } + return sortTeamProviderModels(providerId, providerStatus.models); } @@ -208,12 +267,18 @@ export function getAvailableTeamProviderModelOptions( const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); return [ { value: '', label: 'Default', badgeLabel: 'Default' }, - ...visibleModels.map((model) => ({ - value: model, - label: getProviderScopedTeamModelLabel(providerId, model) ?? model, - availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), - availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), - })), + ...visibleModels.map((model) => { + const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus); + if (catalogOption) { + return catalogOption; + } + return { + value: model, + label: getProviderScopedTeamModelLabel(providerId, model) ?? model, + availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), + availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), + }; + }), ]; } diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 94f72c49..0e0d1e2f 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -15,7 +15,10 @@ export { } from '@shared/utils/providerModelVisibility'; type SupportedProviderId = CliProviderId | TeamProviderId; -type RuntimeAwareProviderStatus = Pick; +type RuntimeAwareProviderStatus = Pick< + CliProviderStatus, + 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities' +>; export interface TeamProviderModelOption { value: string; @@ -33,6 +36,8 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; +export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON = + 'Available in Codex, waiting for Agent Teams runtime support.'; const TEAM_PROVIDER_LABELS: Record = { anthropic: 'Anthropic', @@ -152,6 +157,13 @@ function getKnownTeamProviderModelOption( return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed); } +function isKnownTeamProviderModel( + providerId: SupportedProviderId | undefined, + model: string | undefined +): boolean { + return Boolean(getKnownTeamProviderModelOption(providerId, model)); +} + export function getTeamProviderModelOptions( providerId: SupportedProviderId ): readonly TeamProviderModelOption[] { @@ -389,6 +401,18 @@ export function getRuntimeAwareTeamModelUiDisabledReason( return null; } + if ( + providerId === 'codex' && + providerStatus?.modelCatalog?.providerId === 'codex' && + providerStatus.modelCatalog.models.some( + (item) => item.launchModel === trimmed || item.id === trimmed + ) && + !isKnownTeamProviderModel(providerId, trimmed) && + providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true + ) { + return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON; + } + return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON : null; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 4f5a4fb5..027ccfc7 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -117,6 +117,57 @@ export interface CliProviderModelAvailability { checkedAt?: string | null; } +export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + +export type CliProviderModelCatalogSource = 'app-server' | 'static-fallback'; +export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable'; + +export interface CliProviderModelCatalogItem { + id: string; + launchModel: string; + displayName: string; + hidden: boolean; + supportedReasoningEfforts: CliProviderReasoningEffort[]; + defaultReasoningEffort: CliProviderReasoningEffort | null; + inputModalities: string[]; + supportsPersonality: boolean; + isDefault: boolean; + upgrade: boolean; + source: CliProviderModelCatalogSource; + badgeLabel?: string | null; + statusMessage?: string | null; +} + +export interface CliProviderModelCatalog { + schemaVersion: 1; + providerId: CliProviderId; + source: CliProviderModelCatalogSource; + status: CliProviderModelCatalogStatus; + fetchedAt: string; + staleAt: string; + defaultModelId: string | null; + defaultLaunchModel: string | null; + models: CliProviderModelCatalogItem[]; + diagnostics: { + configReadState: 'ready' | 'unsupported' | 'failed' | 'skipped'; + appServerState: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible'; + message?: string | null; + code?: string | null; + }; +} + +export interface CliProviderRuntimeCapabilities { + modelCatalog?: { + dynamic: boolean; + source?: CliProviderModelCatalogSource | 'runtime'; + }; + reasoningEffort?: { + supported: boolean; + values: CliProviderReasoningEffort[]; + configPassthrough?: boolean; + }; +} + export interface CliProviderStatus { providerId: CliProviderId; displayName: string; @@ -127,7 +178,9 @@ export interface CliProviderStatus { modelVerificationState?: 'idle' | 'verifying' | 'verified'; statusMessage?: string | null; models: string[]; + modelCatalog?: CliProviderModelCatalog | null; modelAvailability?: CliProviderModelAvailability[]; + runtimeCapabilities?: CliProviderRuntimeCapabilities | null; canLoginFromUi: boolean; capabilities: { teamLaunch: boolean; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c5f9499b..9eb2ee92 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -782,10 +782,23 @@ export interface TeamViewSnapshot { isAlive?: boolean; } -export type EffortLevel = 'low' | 'medium' | 'high'; +export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; +export interface ProviderModelLaunchIdentity { + providerId: TeamProviderId; + providerBackendId: TeamProviderBackendId | null; + selectedModel: string | null; + selectedModelKind: 'default' | 'explicit'; + resolvedLaunchModel: string | null; + catalogId: string | null; + catalogSource: 'app-server' | 'static-fallback' | 'runtime' | 'unavailable'; + catalogFetchedAt: string | null; + selectedEffort: EffortLevel | null; + resolvedEffort: EffortLevel | null; +} + export interface TeamLaunchRequest { teamName: string; cwd: string; diff --git a/src/shared/utils/effortLevels.ts b/src/shared/utils/effortLevels.ts new file mode 100644 index 00000000..958d9c03 --- /dev/null +++ b/src/shared/utils/effortLevels.ts @@ -0,0 +1,57 @@ +import type { EffortLevel, TeamProviderId } from '@shared/types/team'; + +export const TEAM_EFFORT_LEVELS = [ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const satisfies readonly EffortLevel[]; + +export const LEGACY_TEAM_EFFORT_LEVELS = [ + 'low', + 'medium', + 'high', +] as const satisfies readonly EffortLevel[]; + +export const CODEX_TEAM_EFFORT_LEVELS = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const satisfies readonly EffortLevel[]; + +const LEGACY_TEAM_EFFORT_LEVEL_SET = new Set(LEGACY_TEAM_EFFORT_LEVELS); +const CODEX_TEAM_EFFORT_LEVEL_SET = new Set(CODEX_TEAM_EFFORT_LEVELS); + +export function isTeamEffortLevel(value: unknown): value is EffortLevel { + return typeof value === 'string' && TEAM_EFFORT_LEVELS.includes(value as EffortLevel); +} + +export function formatEffortLevelList(): string { + return TEAM_EFFORT_LEVELS.join(', '); +} + +export function isTeamEffortLevelForProvider( + value: unknown, + providerId?: TeamProviderId | null +): value is EffortLevel { + if (!isTeamEffortLevel(value)) { + return false; + } + + if (providerId === 'codex') { + return CODEX_TEAM_EFFORT_LEVEL_SET.has(value); + } + + return LEGACY_TEAM_EFFORT_LEVEL_SET.has(value); +} + +export function formatEffortLevelListForProvider(providerId?: TeamProviderId | null): string { + if (providerId === 'codex') { + return CODEX_TEAM_EFFORT_LEVELS.join(', '); + } + return LEGACY_TEAM_EFFORT_LEVELS.join(', '); +} diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index d18c5f8d..948b6995 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -46,6 +46,46 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({ })); vi.mock('@main/utils/childProcess', () => ({ + execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model') { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], + }, + gemini: { + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }], + }, + }, + }), + stderr: '', + }; + } + if (args[0] === 'runtime') { + return { + stdout: JSON.stringify({ + providers: { + codex: { + runtimeCapabilities: { + modelCatalog: { dynamic: false, source: 'runtime' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + }, + }, + }), + stderr: '', + }; + } + return { stdout: '', stderr: '' }; + }), spawnCli: vi.fn(), killProcessTree: vi.fn(), })); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index d0e3e0e7..8968fdd3 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -24,6 +24,46 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ })); vi.mock('@main/utils/childProcess', () => ({ + execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model') { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], + }, + gemini: { + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }], + }, + }, + }), + stderr: '', + }; + } + if (args[0] === 'runtime') { + return { + stdout: JSON.stringify({ + providers: { + codex: { + runtimeCapabilities: { + modelCatalog: { dynamic: false, source: 'runtime' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + }, + }, + }), + stderr: '', + }; + } + return { stdout: '', stderr: '' }; + }), spawnCli: vi.fn(), killProcessTree: vi.fn(), })); @@ -45,7 +85,7 @@ import { TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { spawnCli } from '@main/utils/childProcess'; +import { execCli, spawnCli } from '@main/utils/childProcess'; import { setAppDataBasePath } from '@main/utils/pathDecoder'; function createFakeChild() { @@ -314,6 +354,72 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('blocks Codex xhigh launch effort until runtime exposes reasoning config passthrough', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); + vi.mocked(spawnCli).mockReset(); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: {}, + authSource: 'codex_runtime', + providerArgs: [], + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'codex-xhigh-blocked', + cwd: process.cwd(), + members: [], + providerId: 'codex', + effort: 'xhigh', + }, + () => {} + ) + ).rejects.toThrow('does not expose Codex reasoning config passthrough yet'); + + expect(spawnCli).not.toHaveBeenCalled(); + }); + + it('blocks future Codex catalog models until runtime declares dynamic launch support', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); + vi.mocked(spawnCli).mockReset(); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: {}, + authSource: 'codex_runtime', + providerArgs: [], + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'codex-future-model-blocked', + cwd: process.cwd(), + members: [], + providerId: 'codex', + model: 'gpt-5.5', + effort: 'medium', + }, + () => {} + ) + ).rejects.toThrow('does not declare dynamic Codex model launch support yet'); + + expect(execCli).toHaveBeenCalledWith( + '/fake/codex', + ['runtime', 'status', '--json', '--provider', 'codex'], + expect.objectContaining({ cwd: process.cwd() }) + ); + expect(spawnCli).not.toHaveBeenCalled(); + }); + it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { name: 'alice', From b449974807192c280e80a564bf0da7fe272aca69 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 15:25:47 +0300 Subject: [PATCH 03/19] fix: remove root npm workspaces for cloud installs --- package.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/package.json b/package.json index f1a8bb6c..52d6acf6 100644 --- a/package.json +++ b/package.json @@ -344,11 +344,5 @@ "ignoreBinaries": [ "pkg" ] - }, - "workspaces": [ - "agent-teams-controller", - "mcp-server", - "landing", - "packages/agent-graph" - ] + } } From 331166216efb9f4ccf00332c10a4291f30496dfd Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 15:29:23 +0300 Subject: [PATCH 04/19] feat(team-runtime): snapshot catalog-backed picker baseline --- .../runtime/ClaudeMultimodelBridgeService.ts | 160 ++++++++++++++++-- src/main/services/team/TeamMetaStore.ts | 1 + .../services/team/TeamProvisioningService.ts | 138 +++++++++++---- .../team/dialogs/CreateTeamDialog.tsx | 14 +- .../team/dialogs/EffortLevelSelector.tsx | 86 +--------- .../team/dialogs/LaunchTeamDialog.tsx | 24 ++- .../team/dialogs/TeamModelSelector.tsx | 74 ++++---- .../hooks/useEffectiveCliProviderStatus.ts | 59 +++++++ .../utils/__tests__/teamEffortOptions.test.ts | 140 +++++++++++++++ src/renderer/utils/teamEffortOptions.ts | 113 +++++++++++++ src/renderer/utils/teamModelAvailability.ts | 77 +++++++-- src/renderer/utils/teamModelCatalog.ts | 51 +++++- src/shared/types/cliInstaller.ts | 5 +- src/shared/types/team.ts | 7 +- src/shared/utils/anthropicLaunchModel.ts | 88 ++++++++++ .../ClaudeMultimodelBridgeService.test.ts | 137 +++++++++++++++ .../team/TeamProvisioningService.test.ts | 11 ++ ...eamProvisioningServiceLiveMessages.test.ts | 66 ++++++++ .../TeamProvisioningServicePrepare.test.ts | 133 +++++++++++++++ .../TeamProvisioningServicePrompts.test.ts | 11 ++ .../components/team/TeamModelSelector.test.ts | 42 +++++ .../TeamModelSelectorDisabledState.test.ts | 142 ++++++++++++++++ .../shared/utils/anthropicLaunchModel.test.ts | 71 ++++++++ 23 files changed, 1457 insertions(+), 193 deletions(-) create mode 100644 src/renderer/hooks/useEffectiveCliProviderStatus.ts create mode 100644 src/renderer/utils/__tests__/teamEffortOptions.test.ts create mode 100644 src/renderer/utils/teamEffortOptions.ts create mode 100644 src/shared/utils/anthropicLaunchModel.ts create mode 100644 test/shared/utils/anthropicLaunchModel.test.ts diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 87489e8b..0cf3f7ea 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -10,7 +10,7 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import type { CliProviderId, CliProviderStatus } from '@shared/types'; +import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types'; const logger = createLogger('ClaudeMultimodelBridgeService'); @@ -33,7 +33,7 @@ interface RuntimeExtensionCapabilitiesResponse { interface RuntimeProviderCapabilitiesResponse { modelCatalog?: { dynamic?: boolean; - source?: 'app-server' | 'static-fallback' | 'runtime'; + source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime'; }; reasoningEffort?: { supported?: boolean; @@ -42,6 +42,40 @@ interface RuntimeProviderCapabilitiesResponse { }; } +interface RuntimeProviderModelCatalogItemResponse { + id?: string; + launchModel?: string; + displayName?: string; + hidden?: boolean; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string | null; + inputModalities?: string[]; + supportsPersonality?: boolean; + isDefault?: boolean; + upgrade?: boolean; + source?: 'anthropic-models-api' | 'app-server' | 'static-fallback'; + badgeLabel?: string | null; + statusMessage?: string | null; +} + +interface RuntimeProviderModelCatalogResponse { + schemaVersion?: number; + providerId?: CliProviderId; + source?: 'anthropic-models-api' | 'app-server' | 'static-fallback'; + status?: 'ready' | 'stale' | 'degraded' | 'unavailable'; + fetchedAt?: string; + staleAt?: string; + defaultModelId?: string | null; + defaultLaunchModel?: string | null; + models?: RuntimeProviderModelCatalogItemResponse[]; + diagnostics?: { + configReadState?: 'ready' | 'unsupported' | 'failed' | 'skipped'; + appServerState?: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible'; + message?: string | null; + code?: string | null; + }; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; providers?: Record< @@ -120,6 +154,7 @@ interface UnifiedRuntimeStatusResponse { detailMessage?: string | null; }[]; models?: (string | { id?: string; label?: string; description?: string })[]; + modelCatalog?: RuntimeProviderModelCatalogResponse | null; capabilities?: { teamLaunch?: boolean; oneShot?: boolean; @@ -236,6 +271,112 @@ function extractModelIds( }); } +function normalizeRuntimeReasoningEffort( + value: string | null | undefined +): CliProviderReasoningEffort | null { + return value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' + ? value + : null; +} + +function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoningEffort[] { + return ( + values?.flatMap((value) => { + const normalized = normalizeRuntimeReasoningEffort(value); + return normalized ? [normalized] : []; + }) ?? [] + ); +} + +function mapRuntimeProviderModelCatalog( + providerId: CliProviderId, + modelCatalog?: RuntimeProviderModelCatalogResponse | null +): CliProviderStatus['modelCatalog'] { + if (modelCatalog?.providerId !== providerId) { + return null; + } + + const fetchedAt = modelCatalog.fetchedAt?.trim(); + const staleAt = modelCatalog.staleAt?.trim(); + const source = modelCatalog.source; + const status = modelCatalog.status; + if ( + modelCatalog.schemaVersion !== 1 || + !fetchedAt || + !staleAt || + (source !== 'anthropic-models-api' && + source !== 'app-server' && + source !== 'static-fallback') || + (status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable') + ) { + return null; + } + + const models: NonNullable['models'] = + modelCatalog.models?.flatMap((model) => { + const id = model.id?.trim(); + const launchModel = model.launchModel?.trim(); + const displayName = model.displayName?.trim(); + if (!id || !launchModel || !displayName) { + return []; + } + + const supportedReasoningEfforts = collectRuntimeReasoningEfforts( + model.supportedReasoningEfforts + ); + const defaultReasoningEffort = normalizeRuntimeReasoningEffort( + model.defaultReasoningEffort ?? null + ); + const itemSource = + model.source === 'anthropic-models-api' || + model.source === 'app-server' || + model.source === 'static-fallback' + ? model.source + : source; + + return [ + { + id, + launchModel, + displayName, + hidden: model.hidden === true, + supportedReasoningEfforts, + defaultReasoningEffort, + inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [], + supportsPersonality: model.supportsPersonality === true, + isDefault: model.isDefault === true, + upgrade: model.upgrade === true, + source: itemSource, + badgeLabel: model.badgeLabel ?? null, + statusMessage: model.statusMessage ?? null, + }, + ]; + }) ?? []; + + return { + schemaVersion: 1, + providerId, + source, + status, + fetchedAt, + staleAt, + defaultModelId: modelCatalog.defaultModelId ?? null, + defaultLaunchModel: modelCatalog.defaultLaunchModel ?? null, + models, + diagnostics: { + configReadState: modelCatalog.diagnostics?.configReadState ?? 'skipped', + appServerState: modelCatalog.diagnostics?.appServerState ?? 'degraded', + message: modelCatalog.diagnostics?.message ?? null, + code: modelCatalog.diagnostics?.code ?? null, + }, + }; +} + export class ClaudeMultimodelBridgeService { private async buildCliEnv( binaryPath: string @@ -308,6 +449,7 @@ export class ClaudeMultimodelBridgeService { detailMessage: diagnostic.detailMessage ?? null, })) ?? [], models: extractModelIds(runtimeStatus.models), + modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog), backend: runtimeStatus.backend?.kind ? { kind: runtimeStatus.backend.kind, @@ -328,17 +470,9 @@ export class ClaudeMultimodelBridgeService { reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort ? { supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true, - values: - runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) => - value === 'none' || - value === 'minimal' || - value === 'low' || - value === 'medium' || - value === 'high' || - value === 'xhigh' - ? [value] - : [] - ) ?? [], + values: collectRuntimeReasoningEfforts( + runtimeStatus.runtimeCapabilities.reasoningEffort.values + ), configPassthrough: runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true, } diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 71170eff..a4b41eb8 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -67,6 +67,7 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | } const catalogSource = + raw.catalogSource === 'anthropic-models-api' || raw.catalogSource === 'app-server' || raw.catalogSource === 'static-fallback' || raw.catalogSource === 'runtime' || diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 312f039a..5fd6504a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; +import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; @@ -365,6 +366,10 @@ function getLaunchModelArg( model: string | undefined, launchIdentity?: ProviderModelLaunchIdentity | null ): string | undefined { + if (providerId === 'anthropic' && launchIdentity?.resolvedLaunchModel) { + return launchIdentity.resolvedLaunchModel; + } + const explicitModel = getExplicitLaunchModelSelection(model); if (explicitModel) { return explicitModel; @@ -436,6 +441,25 @@ function isTransientModelProbeMessage(message: string): boolean { ); } +function resolveRequestedLaunchModel(params: { + providerId: TeamProviderId; + selectedModel?: string; + limitContext?: boolean; + facts: Pick; +}): string | null { + if (params.providerId === 'anthropic') { + return resolveAnthropicLaunchModel({ + selectedModel: params.selectedModel, + limitContext: params.limitContext === true, + availableLaunchModels: params.facts.modelIds, + defaultLaunchModel: params.facts.defaultModel, + }); + } + + const explicitModel = getExplicitLaunchModelSelection(params.selectedModel); + return explicitModel ?? params.facts.defaultModel; +} + function getTeamProviderLabel(providerId: TeamProviderId): string { switch (providerId) { case 'codex': @@ -2950,14 +2974,6 @@ export class TeamProvisioningService { env: NodeJS.ProcessEnv; limitContext?: boolean; }): Promise { - if (params.providerId === 'anthropic') { - return { - defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true), - modelIds: new Set(), - runtimeCapabilities: null, - }; - } - const modelListPromise = execCli( params.claudePath, ['model', 'list', '--json', '--provider', params.providerId], @@ -3024,19 +3040,34 @@ export class TeamProvisioningService { } return { - defaultModel, + defaultModel: + params.providerId === 'anthropic' + ? resolveAnthropicLaunchModel({ + limitContext: params.limitContext === true, + availableLaunchModels: modelIds, + defaultLaunchModel: defaultModel, + }) + : defaultModel, modelIds, runtimeCapabilities, }; } private buildProviderModelLaunchIdentity(params: { - request: Pick; + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + >; facts: RuntimeProviderLaunchFacts; }): ProviderModelLaunchIdentity { const providerId = resolveTeamProviderId(params.request.providerId); const explicitModel = getExplicitLaunchModelSelection(params.request.model); - const resolvedLaunchModel = explicitModel ?? params.facts.defaultModel; + const resolvedLaunchModel = resolveRequestedLaunchModel({ + providerId, + selectedModel: params.request.model, + limitContext: params.request.limitContext, + facts: params.facts, + }); const resolvedEffort = params.request.effort ?? null; return { @@ -5417,6 +5448,13 @@ export class TeamProvisioningService { } const { env } = await this.buildProvisioningEnv(providerId); + const runtimeFacts = await this.readRuntimeProviderLaunchFacts({ + claudePath, + cwd, + providerId, + env, + limitContext, + }); const probeOutcomeByResolvedModelId = new Map< string, { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } @@ -5451,16 +5489,19 @@ export class TeamProvisioningService { let targetModelId = label; if (isDefaultProviderModelSelection(label)) { if (resolvedDefaultModelId === undefined) { - try { - resolvedDefaultModelId = await this.resolveProviderDefaultModel( - claudePath, - cwd, - providerId, - env, - limitContext - ); - } catch { - resolvedDefaultModelId = null; + resolvedDefaultModelId = runtimeFacts.defaultModel; + if (!resolvedDefaultModelId) { + try { + resolvedDefaultModelId = await this.resolveProviderDefaultModel( + claudePath, + cwd, + providerId, + env, + limitContext + ); + } catch { + resolvedDefaultModelId = null; + } } } if (!resolvedDefaultModelId) { @@ -5471,6 +5512,16 @@ export class TeamProvisioningService { continue; } targetModelId = resolvedDefaultModelId; + } else if (providerId === 'anthropic') { + const resolvedAnthropicModel = resolveAnthropicLaunchModel({ + selectedModel: label, + limitContext, + availableLaunchModels: runtimeFacts.modelIds, + defaultLaunchModel: runtimeFacts.defaultModel, + }); + if (resolvedAnthropicModel) { + targetModelId = resolvedAnthropicModel; + } } const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId); @@ -5538,10 +5589,6 @@ export class TeamProvisioningService { env: NodeJS.ProcessEnv, limitContext: boolean ): Promise { - if (providerId === 'anthropic') { - return getAnthropicDefaultTeamModel(limitContext); - } - const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], { cwd, env, @@ -5549,9 +5596,21 @@ export class TeamProvisioningService { }); const parsed = extractJsonObjectFromCli(stdout); const defaultModel = parsed.providers?.[providerId]?.defaultModel; - return typeof defaultModel === 'string' && defaultModel.trim().length > 0 - ? defaultModel.trim() - : null; + const normalizedDefaultModel = + typeof defaultModel === 'string' && defaultModel.trim().length > 0 + ? defaultModel.trim() + : null; + const modelIds = normalizeProviderModelListModels(parsed.providers?.[providerId]); + + if (providerId === 'anthropic') { + return resolveAnthropicLaunchModel({ + limitContext, + availableLaunchModels: modelIds, + defaultLaunchModel: normalizedDefaultModel, + }); + } + + return normalizedDefaultModel; } private async materializeEffectiveTeamMemberSpecs(params: { @@ -5830,6 +5889,8 @@ export class TeamProvisioningService { lower.includes('quota will reset after') || lower.includes('exhausted your capacity on this model') || lower.includes('resource exhausted') || + lower.includes('model cooldown') || + lower.includes('cooling down') || lower.includes('rate limit') || lower.includes('rate_limit') ); @@ -10774,13 +10835,30 @@ export class TeamProvisioningService { const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined; const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined; const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined; - const errorMessage = + const rawErrorMessage = typeof msg.error_message === 'string' && msg.error_message.trim().length > 0 - ? this.normalizeApiRetryErrorMessage(msg.error_message.trim()) + ? msg.error_message.trim() : undefined; + const errorMessage = rawErrorMessage + ? this.normalizeApiRetryErrorMessage(rawErrorMessage) + : undefined; const looksLikeQuotaRetry = errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage); + if (looksLikeQuotaRetry && rawErrorMessage) { + const observedAt = new Date(); + const messageTimestamp = + typeof msg.timestamp === 'string' && Number.isFinite(Date.parse(msg.timestamp)) + ? new Date(msg.timestamp) + : observedAt; + peekAutoResumeService()?.handleRateLimitMessage( + run.teamName, + rawErrorMessage, + observedAt, + messageTimestamp + ); + } + // Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket. const statusLabel = looksLikeQuotaRetry ? 'rate limited' diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 72c5c43c..5ebd5c8b 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -984,10 +984,6 @@ export const CreateTeamDialog = ({ [memberColorMap, members, soloTeam] ); - const effectiveModel = useMemo( - () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), - [selectedModel, limitContext, selectedProviderId] - ); const runtimeProviderStatusById = useMemo( () => new Map( @@ -997,6 +993,16 @@ export const CreateTeamDialog = ({ ), [effectiveCliStatus?.providers] ); + const effectiveModel = useMemo( + () => + computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ), + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const teamNameInlineError = validateTeamNameInline(teamName); diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index b3395d46..53a07636 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -1,29 +1,12 @@ import React from 'react'; import { Label } from '@renderer/components/ui/label'; +import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus'; import { cn } from '@renderer/lib/utils'; -import { useStore } from '@renderer/store'; +import { getTeamEffortOptions } from '@renderer/utils/teamEffortOptions'; import { Brain } from 'lucide-react'; -import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types'; - -const BASE_EFFORT_OPTIONS = [ - { value: '', label: 'Default' }, - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' }, -] as const; - -const EFFORT_LABELS: Record = { - none: 'None', - minimal: 'Minimal', - low: 'Low', - medium: 'Medium', - high: 'High', - xhigh: 'XHigh', -}; - -const BASE_CODEX_SAFE_EFFORTS = new Set(['low', 'medium', 'high']); +import type { TeamProviderId } from '@shared/types'; export interface EffortLevelSelectorProps { value: string; @@ -33,63 +16,6 @@ export interface EffortLevelSelectorProps { model?: string; } -function getCatalogModel( - providerStatus: CliProviderStatus | null | undefined, - model: string | undefined -): NonNullable['models'][number] | null { - const catalog = providerStatus?.modelCatalog; - if (!catalog || catalog.providerId !== 'codex') { - return null; - } - - const explicitModel = model?.trim(); - if (explicitModel) { - return ( - catalog.models.find( - (item) => item.launchModel === explicitModel || item.id === explicitModel - ) ?? null - ); - } - - return ( - catalog.models.find((item) => item.id === catalog.defaultModelId) ?? - catalog.models.find((item) => item.isDefault) ?? - null - ); -} - -function getEffortOptions(params: { - providerId?: TeamProviderId; - model?: string; - providerStatus?: CliProviderStatus | null; -}): readonly { value: string; label: string }[] { - if (params.providerId !== 'codex') { - return BASE_EFFORT_OPTIONS; - } - - const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort; - const catalogModel = getCatalogModel(params.providerStatus, params.model); - const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? []; - const candidateEfforts = - catalogEfforts.length > 0 ? catalogEfforts : (runtimeCapability?.values ?? []); - const safeEfforts = - runtimeCapability?.configPassthrough === true - ? candidateEfforts - : candidateEfforts.filter((effort) => BASE_CODEX_SAFE_EFFORTS.has(effort)); - const efforts = safeEfforts.length > 0 ? safeEfforts : (['low', 'medium', 'high'] as const); - const defaultLabel = catalogModel?.defaultReasoningEffort - ? `Default (${EFFORT_LABELS[catalogModel.defaultReasoningEffort]})` - : 'Default'; - - return [ - { value: '', label: defaultLabel }, - ...efforts.map((effort) => ({ - value: effort, - label: EFFORT_LABELS[effort], - })), - ]; -} - export const EffortLevelSelector: React.FC = ({ value, onValueChange, @@ -97,10 +23,8 @@ export const EffortLevelSelector: React.FC = ({ providerId, model, }) => { - const providerStatus = useStore( - (s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null - ); - const effortOptions = getEffortOptions({ providerId, model, providerStatus }); + const { providerStatus } = useEffectiveCliProviderStatus(providerId); + const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus }); return (

diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index bb917507..4dd79acc 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -744,8 +744,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [isLaunchMode, previousProviderId, selectedProviderId]); const effectiveLeadRuntimeModel = useMemo( - () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', - [selectedModel, limitContext, selectedProviderId] + () => + computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ) ?? '', + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] ); const selectedModelChecksByProvider = useMemo(() => { const modelsByProvider = new Map(); @@ -1224,7 +1230,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--mcp-config', '', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); - const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId); + const model = computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); @@ -1460,7 +1471,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId ) ?? undefined, - model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), + model: computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ), effort: (selectedEffort as EffortLevel) || undefined, limitContext, clearContext: clearContext || undefined, diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 5cf4fb69..e5b0fc67 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,9 +1,5 @@ import React, { useEffect, useMemo } from 'react'; -import { - mergeCodexCliStatusWithSnapshot, - useCodexAccountSnapshot, -} from '@features/codex-account/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Label } from '@renderer/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -13,8 +9,8 @@ import { TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; +import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus'; import { cn } from '@renderer/lib/utils'; -import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { useStore } from '@renderer/store'; import { GEMINI_UI_DISABLED_BADGE_LABEL, @@ -30,14 +26,18 @@ import { import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, + getRuntimeAwareProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, 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 { AlertTriangle, Info } from 'lucide-react'; +import type { CliProviderStatus } from '@shared/types'; + export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; // --- Provider definitions --- @@ -108,16 +108,24 @@ export function formatTeamModelSummary( export function computeEffectiveTeamModel( selectedModel: string, limitContext: boolean, - providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' + providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic', + providerStatus?: Pick | null ): string | undefined { if (providerId !== 'anthropic') { return selectedModel.trim() || undefined; } - const base = extractProviderScopedBaseModel(selectedModel, providerId); - if (limitContext) return base || getAnthropicDefaultTeamModel(true); - if (isAnthropicHaikuTeamModel(base)) return base; - return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext); + const catalog = + providerStatus?.providerId === 'anthropic' ? (providerStatus.modelCatalog ?? null) : null; + + return ( + resolveAnthropicLaunchModel({ + selectedModel, + limitContext, + availableLaunchModels: catalog?.models.map((model) => model.launchModel), + defaultLaunchModel: catalog?.defaultLaunchModel ?? null, + }) ?? getAnthropicDefaultTeamModel(limitContext) + ); } export interface TeamModelSelectorProps { @@ -139,35 +147,36 @@ export const TeamModelSelector: React.FC = ({ disableGeminiOption = false, modelIssueReasonByValue, }) => { - const cliStatus = useStore((s) => s.cliStatus); - const cliStatusLoading = useStore((s) => s.cliStatusLoading); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); - const loadingCliStatus = useMemo( - () => - !cliStatus && cliStatusLoading && multimodelEnabled - ? createLoadingMultimodelCliStatus() - : cliStatus, - [cliStatus, cliStatusLoading, multimodelEnabled] - ); const effectiveProviderId = disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; - const codexAccount = useCodexAccountSnapshot({ - enabled: multimodelEnabled && effectiveProviderId === 'codex', - }); - const effectiveCliStatus = useMemo( - () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), - [codexAccount.snapshot, loadingCliStatus] - ); - const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null; + const { + cliStatus: effectiveCliStatus, + providerStatus: runtimeProviderStatus, + loading: effectiveCliStatusLoading, + } = useEffectiveCliProviderStatus(effectiveProviderId); const multimodelAvailable = multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { - return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.'; + const defaultLongContextModel = + getRuntimeAwareProviderScopedTeamModelLabel( + 'anthropic', + getAnthropicDefaultTeamModel(false), + runtimeProviderStatus + ) ?? 'Opus 4.7 (1M)'; + const defaultLimitedContextModel = + getRuntimeAwareProviderScopedTeamModelLabel( + 'anthropic', + getAnthropicDefaultTeamModel(true), + runtimeProviderStatus + ) ?? 'Opus 4.7'; + + return `Uses the Claude team default model.\nResolves to ${defaultLongContextModel} with 1M context, or ${defaultLimitedContextModel} with 200K context when Limit context is enabled.`; } return 'Uses the runtime default for the selected provider.'; - }, [effectiveProviderId]); + }, [effectiveProviderId, runtimeProviderStatus]); const getProviderDisabledReason = (candidateProviderId: string): string | null => { if (candidateProviderId === 'opencode') { return OPENCODE_UI_DISABLED_REASON; @@ -210,13 +219,6 @@ export const TeamModelSelector: React.FC = ({ return statusBadge; }; - const runtimeProviderStatus = useMemo( - () => - effectiveCliStatus?.providers.find( - (provider) => provider.providerId === effectiveProviderId - ) ?? null, - [effectiveCliStatus?.providers, effectiveProviderId] - ); const shouldAwaitRuntimeModelList = effectiveProviderId !== 'anthropic' && (effectiveCliStatus == null || effectiveCliStatusLoading) && diff --git a/src/renderer/hooks/useEffectiveCliProviderStatus.ts b/src/renderer/hooks/useEffectiveCliProviderStatus.ts new file mode 100644 index 00000000..de6dff01 --- /dev/null +++ b/src/renderer/hooks/useEffectiveCliProviderStatus.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; + +import { + mergeCodexCliStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; +import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; + +import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; + +export interface EffectiveCliProviderStatusSnapshot { + cliStatus: CliInstallationStatus | null; + providerStatus: CliProviderStatus | null; + loading: boolean; +} + +export function useEffectiveCliProviderStatus( + providerId: CliProviderId | undefined +): EffectiveCliProviderStatusSnapshot { + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + + const codexAccount = useCodexAccountSnapshot({ + enabled: + providerId === 'codex' && + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + }); + + const effectiveCliStatus = useMemo( + () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), + [codexAccount.snapshot, loadingCliStatus] + ); + const providerStatus = useMemo( + () => + providerId + ? (effectiveCliStatus?.providers.find((provider) => provider.providerId === providerId) ?? + null) + : null, + [effectiveCliStatus?.providers, providerId] + ); + + return { + cliStatus: effectiveCliStatus, + providerStatus, + loading: cliStatusLoading && effectiveCliStatus === null, + }; +} diff --git a/src/renderer/utils/__tests__/teamEffortOptions.test.ts b/src/renderer/utils/__tests__/teamEffortOptions.test.ts new file mode 100644 index 00000000..4ed51753 --- /dev/null +++ b/src/renderer/utils/__tests__/teamEffortOptions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { getTeamEffortOptions } from '../teamEffortOptions'; + +import type { CliProviderStatus } from '@shared/types'; + +function createProviderStatus( + providerId: CliProviderStatus['providerId'], + model: NonNullable['models'][number], + options: { + source?: 'anthropic-models-api' | 'app-server' | 'static-fallback'; + configPassthrough?: boolean; + runtimeValues?: CliProviderStatus['runtimeCapabilities']; + } = {} +): CliProviderStatus { + const source = + options.source ?? (providerId === 'anthropic' ? 'anthropic-models-api' : 'app-server'); + + return { + providerId, + displayName: providerId === 'anthropic' ? 'Anthropic' : 'Codex', + supported: true, + authenticated: true, + authMethod: providerId === 'anthropic' ? 'claude.ai' : 'chatgpt', + verificationState: 'verified', + models: [model.launchModel], + modelCatalog: { + schemaVersion: 1, + providerId, + source, + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: model.id, + defaultLaunchModel: model.launchModel, + models: [model], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + modelAvailability: [], + runtimeCapabilities: options.runtimeValues ?? { + modelCatalog: { dynamic: true, source }, + reasoningEffort: { + supported: true, + values: model.supportedReasoningEfforts, + configPassthrough: options.configPassthrough === true, + }, + }, + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }; +} + +describe('team effort options', () => { + it('keeps Codex xhigh when runtime catalog and passthrough say it is valid', () => { + const providerStatus = createProviderStatus( + 'codex', + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + { configPassthrough: true } + ); + + expect(getTeamEffortOptions({ providerId: 'codex', model: 'gpt-5.4', providerStatus })).toEqual( + [ + { value: '', label: 'Default (Medium)' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + ] + ); + }); + + it('shows only supported low/medium/high efforts for Anthropic and never leaks max', () => { + const providerStatus = createProviderStatus('anthropic', { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.7', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }); + + expect( + getTeamEffortOptions({ providerId: 'anthropic', model: 'opus', providerStatus }) + ).toEqual([ + { value: '', label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ]); + }); + + it('shows only Default when the selected Anthropic model does not support effort', () => { + const providerStatus = createProviderStatus('anthropic', { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.5', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }); + + expect( + getTeamEffortOptions({ providerId: 'anthropic', model: 'haiku', providerStatus }) + ).toEqual([{ value: '', label: 'Default' }]); + }); +}); diff --git a/src/renderer/utils/teamEffortOptions.ts b/src/renderer/utils/teamEffortOptions.ts new file mode 100644 index 00000000..5c6767c0 --- /dev/null +++ b/src/renderer/utils/teamEffortOptions.ts @@ -0,0 +1,113 @@ +import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types'; + +const BASE_EFFORT_OPTIONS = [{ value: '', label: 'Default' }] as const; +const SAFE_SHARED_EFFORTS = new Set(['low', 'medium', 'high']); + +export const TEAM_EFFORT_LABELS: Record = { + none: 'None', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh', +}; + +interface TeamEffortOption { + value: string; + label: string; +} + +function getCatalogModel( + providerId: TeamProviderId | undefined, + providerStatus: CliProviderStatus | null | undefined, + model: string | undefined +): NonNullable['models'][number] | null { + const catalog = providerStatus?.modelCatalog; + if (!providerId || catalog?.providerId !== providerId) { + return null; + } + + const explicitModel = model?.trim(); + if (explicitModel) { + return ( + catalog.models.find( + (item) => item.launchModel === explicitModel || item.id === explicitModel + ) ?? null + ); + } + + return ( + catalog.models.find((item) => item.id === catalog.defaultModelId) ?? + catalog.models.find((item) => item.launchModel === catalog.defaultLaunchModel) ?? + catalog.models.find((item) => item.isDefault) ?? + null + ); +} + +function normalizeEfforts( + providerId: TeamProviderId, + candidateEfforts: readonly EffortLevel[], + configPassthrough: boolean +): EffortLevel[] { + if (providerId === 'codex' && configPassthrough) { + return [...candidateEfforts]; + } + + return candidateEfforts.filter((effort) => SAFE_SHARED_EFFORTS.has(effort)); +} + +export function getTeamEffortOptions(params: { + providerId?: TeamProviderId; + model?: string; + providerStatus?: CliProviderStatus | null; +}): readonly TeamEffortOption[] { + const providerId = params.providerId; + if (!providerId) { + return BASE_EFFORT_OPTIONS; + } + + const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort; + const catalogModel = getCatalogModel(providerId, params.providerStatus, params.model); + const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? []; + const candidateEfforts = + catalogEfforts.length > 0 + ? catalogEfforts + : ((runtimeCapability?.values ?? []) as EffortLevel[]); + const efforts = normalizeEfforts( + providerId, + candidateEfforts, + runtimeCapability?.configPassthrough === true + ); + const defaultLabel = catalogModel?.defaultReasoningEffort + ? `Default (${TEAM_EFFORT_LABELS[catalogModel.defaultReasoningEffort]})` + : 'Default'; + + if (providerId === 'anthropic') { + return [ + { value: '', label: defaultLabel }, + ...efforts.map((effort) => ({ + value: effort, + label: TEAM_EFFORT_LABELS[effort], + })), + ]; + } + + if (providerId === 'codex') { + const fallbackEfforts = + efforts.length > 0 ? efforts : (['low', 'medium', 'high'] as EffortLevel[]); + return [ + { value: '', label: defaultLabel }, + ...fallbackEfforts.map((effort) => ({ + value: effort, + label: TEAM_EFFORT_LABELS[effort], + })), + ]; + } + + return [ + { value: '', label: defaultLabel }, + { value: 'low', label: TEAM_EFFORT_LABELS.low }, + { value: 'medium', label: TEAM_EFFORT_LABELS.medium }, + { value: 'high', label: TEAM_EFFORT_LABELS.high }, + ]; +} diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 5521f3c0..2356db15 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,21 +1,14 @@ import { getProviderScopedTeamModelLabel, + getRuntimeAwareProviderScopedTeamModelLabel, + getRuntimeAwareTeamModelBadgeLabel, getRuntimeAwareTeamModelUiDisabledReason, getTeamProviderLabel, getTeamProviderModelOptions, getVisibleTeamProviderModels, - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, - GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, - GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, - GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_2_CODEX_UI_DISABLED_MODEL, - GPT_5_2_CODEX_UI_DISABLED_REASON, - GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, - GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, isSupportedAnthropicTeamModel, normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, sortTeamProviderModels, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, type TeamProviderModelOption, } from './teamModelCatalog'; import { extractProviderScopedBaseModel } from './teamModelContext'; @@ -93,21 +86,50 @@ function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[ } function getFallbackTeamProviderModelOptions( - providerId: SupportedProviderId + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null ): TeamRuntimeModelOption[] { return getTeamProviderModelOptions(providerId).map((option) => ({ ...option, label: option.value === '' ? option.label - : (getProviderScopedTeamModelLabel(providerId, option.value) ?? option.value), + : (getRuntimeAwareProviderScopedTeamModelLabel(providerId, option.value, providerStatus) ?? + option.value), + badgeLabel: + option.value === '' + ? option.badgeLabel + : (getRuntimeAwareTeamModelBadgeLabel(providerId, option.value, providerStatus) ?? + option.badgeLabel), })); } +function hasAnthropicRuntimeCatalog( + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + return providerStatus?.modelCatalog?.providerId === 'anthropic'; +} + +function getAnthropicCatalogModel( + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): NonNullable['models'][number] | null { + const catalog = hasAnthropicRuntimeCatalog(providerStatus) ? providerStatus?.modelCatalog : null; + if (!catalog) { + return null; + } + + return catalog.models.find((item) => item.launchModel === model || item.id === model) ?? null; +} + function getRuntimeCatalogModels( providerId: SupportedProviderId, providerStatus?: TeamModelRuntimeProviderStatus | null ): string[] | null { + if (providerId === 'anthropic') { + return null; + } + if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') { return null; } @@ -193,7 +215,11 @@ function getRuntimeModelAvailability( providerStatus?: TeamModelRuntimeProviderStatus | null ): CliProviderModelAvailabilityStatus | null { if (providerId === 'anthropic') { - return 'available'; + if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) { + return isSupportedAnthropicTeamModel(model) ? 'available' : null; + } + + return getAnthropicCatalogModel(model, providerStatus) ? 'available' : null; } if (!providerStatus) { @@ -219,9 +245,10 @@ export function getTeamProviderModelVerificationCounts( providerStatus?: TeamModelRuntimeProviderStatus | null ): TeamProviderModelVerificationCounts { if (providerId === 'anthropic') { + const visibleAnthropicModels = getFallbackTeamProviderModels(providerId); return { - checkedCount: getFallbackTeamProviderModels(providerId).length, - totalCount: getFallbackTeamProviderModels(providerId).length, + checkedCount: visibleAnthropicModels.length, + totalCount: visibleAnthropicModels.length, verifying: false, }; } @@ -240,7 +267,9 @@ export function getAvailableTeamProviderModels( providerStatus?: TeamModelRuntimeProviderStatus | null ): string[] { if (providerId === 'anthropic') { - return getFallbackTeamProviderModels(providerId); + return getFallbackTeamProviderModels(providerId).filter( + (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + ); } if (!providerStatus) { @@ -257,7 +286,17 @@ export function getAvailableTeamProviderModelOptions( providerStatus?: TeamModelRuntimeProviderStatus | null ): TeamRuntimeModelOption[] { if (providerId === 'anthropic') { - return getFallbackTeamProviderModelOptions(providerId); + return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({ + ...option, + availabilityStatus: + option.value.trim().length > 0 + ? getRuntimeModelAvailability(providerId, option.value, providerStatus) + : undefined, + availabilityReason: + option.value.trim().length > 0 + ? getRuntimeModelAvailabilityReason(option.value, providerStatus) + : undefined, + })); } if (!providerStatus) { @@ -297,7 +336,11 @@ export function isTeamModelAvailableForUi( } if (providerId === 'anthropic') { - return isSupportedAnthropicTeamModel(trimmed); + if (!isSupportedAnthropicTeamModel(trimmed)) { + return false; + } + + return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; } return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 0e0d1e2f..59cc4200 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,10 +1,5 @@ import { parseModelString } from '@shared/utils/modelParser'; -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 { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; @@ -260,6 +255,23 @@ export function getTeamModelLabel(model: string | undefined): string | undefined return formatParsedClaudeModelLabel(trimmed) ?? trimmed; } +function getRuntimeCatalogModel( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): NonNullable['models'][number] | null { + const trimmed = model?.trim(); + if (!providerId || !trimmed || providerStatus?.modelCatalog?.providerId !== providerId) { + return null; + } + + return ( + providerStatus.modelCatalog.models.find( + (item) => item.launchModel === trimmed || item.id === trimmed + ) ?? null + ); +} + export function getTeamModelBadgeLabel( providerId: SupportedProviderId, model: string | undefined @@ -307,6 +319,33 @@ export function getProviderScopedTeamModelLabel( return baseLabel.replace(/^GPT-/i, ''); } +export function getRuntimeAwareProviderScopedTeamModelLabel( + providerId: SupportedProviderId, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): string | undefined { + const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus); + const runtimeLabel = runtimeModel?.displayName?.trim(); + if (runtimeLabel) { + return getProviderScopedTeamModelLabel(providerId, runtimeLabel) ?? runtimeLabel; + } + + return getProviderScopedTeamModelLabel(providerId, model); +} + +export function getRuntimeAwareTeamModelBadgeLabel( + providerId: SupportedProviderId, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): string | undefined { + const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus); + if (runtimeModel?.badgeLabel?.trim()) { + return runtimeModel.badgeLabel.trim(); + } + + return getTeamModelBadgeLabel(providerId, model); +} + export function sortTeamProviderModels( providerId: SupportedProviderId, models: readonly string[] diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 027ccfc7..12d556cf 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -119,7 +119,10 @@ export interface CliProviderModelAvailability { export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; -export type CliProviderModelCatalogSource = 'app-server' | 'static-fallback'; +export type CliProviderModelCatalogSource = + | 'anthropic-models-api' + | 'app-server' + | 'static-fallback'; export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable'; export interface CliProviderModelCatalogItem { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9eb2ee92..449838ec 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -793,7 +793,12 @@ export interface ProviderModelLaunchIdentity { selectedModelKind: 'default' | 'explicit'; resolvedLaunchModel: string | null; catalogId: string | null; - catalogSource: 'app-server' | 'static-fallback' | 'runtime' | 'unavailable'; + catalogSource: + | 'anthropic-models-api' + | 'app-server' + | 'static-fallback' + | 'runtime' + | 'unavailable'; catalogFetchedAt: string | null; selectedEffort: EffortLevel | null; resolvedEffort: EffortLevel | null; diff --git a/src/shared/utils/anthropicLaunchModel.ts b/src/shared/utils/anthropicLaunchModel.ts new file mode 100644 index 00000000..0659f9fd --- /dev/null +++ b/src/shared/utils/anthropicLaunchModel.ts @@ -0,0 +1,88 @@ +import { getAnthropicDefaultTeamModel } from './anthropicModelDefaults'; +import { isDefaultProviderModelSelection } from './providerModelSelection'; + +function stripOneMillionSuffix(model: string): string { + return model.replace(/(?:\[1m\])+$/i, ''); +} + +function isAnthropicHaikuModel(model: string): boolean { + const baseModel = stripOneMillionSuffix(model); + return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-'); +} + +function normalizeAvailableLaunchModels( + availableLaunchModels: Iterable | undefined +): Set { + const normalized = new Set(); + for (const model of availableLaunchModels ?? []) { + const trimmed = model.trim(); + if (trimmed) { + normalized.add(trimmed); + } + } + return normalized; +} + +function chooseAvailableModel( + availableModels: Set, + candidates: readonly string[] +): string | null { + if (availableModels.size === 0) { + return null; + } + + for (const candidate of candidates) { + if (availableModels.has(candidate)) { + return candidate; + } + } + + return null; +} + +export function resolveAnthropicLaunchModel(params: { + selectedModel?: string | null; + limitContext: boolean; + availableLaunchModels?: Iterable; + defaultLaunchModel?: string | null; +}): string | null { + const selectedModel = params.selectedModel?.trim() ?? ''; + const availableModels = normalizeAvailableLaunchModels(params.availableLaunchModels); + + if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) { + const staticDefault = getAnthropicDefaultTeamModel(params.limitContext); + const runtimeDefault = params.defaultLaunchModel?.trim() || null; + const preferredDefault = params.limitContext + ? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault + : runtimeDefault || staticDefault; + if (availableModels.size === 0) { + return preferredDefault; + } + + return ( + chooseAvailableModel(availableModels, [ + preferredDefault, + stripOneMillionSuffix(runtimeDefault || preferredDefault), + staticDefault, + stripOneMillionSuffix(staticDefault), + ]) ?? preferredDefault + ); + } + + const baseModel = stripOneMillionSuffix(selectedModel); + if (!baseModel) { + return null; + } + + if (params.limitContext || isAnthropicHaikuModel(baseModel)) { + return baseModel; + } + + const preferredLongContextModel = `${baseModel}[1m]`; + + if (availableModels.size === 0) { + return preferredLongContextModel; + } + + return chooseAvailableModel(availableModels, [preferredLongContextModel, baseModel]) ?? baseModel; +} diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index b38e5445..29a7497d 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -299,6 +299,143 @@ describe('ClaudeMultimodelBridgeService', () => { }); }); + it('maps anthropic runtime model catalog metadata through the bridge', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 2, + providers: { + anthropic: { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.8', + }, + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.8 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + message: null, + code: null, + }, + }, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + backend: { + kind: 'anthropic', + label: 'Anthropic', + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'anthropic'); + + expect(provider).toMatchObject({ + providerId: 'anthropic', + authenticated: true, + models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + modelCatalog: { + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + }); + expect(provider.modelCatalog?.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.8', + }), + expect.objectContaining({ + launchModel: 'opus[1m]', + displayName: 'Opus 4.8 (1M)', + hidden: true, + source: 'anthropic-models-api', + }), + ]) + ); + }); + it('keeps codex-native lane truth honest from unified runtime status through renderer summaries', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 948b6995..b73b144f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -52,6 +52,17 @@ vi.mock('@main/utils/childProcess', () => ({ stdout: JSON.stringify({ schemaVersion: 1, providers: { + anthropic: { + defaultModel: 'opus[1m]', + models: [ + { id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' }, + { + id: 'opus[1m]', + label: 'Opus 4.7 (1M)', + description: 'Anthropic long-context default', + }, + ], + }, codex: { defaultModel: 'gpt-5.4', models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 2404327a..50342e05 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -1344,4 +1344,70 @@ describe('TeamProvisioningService auto-resume cleanup', () => { getConfigSpy.mockRestore(); } }); + + it('schedules auto-resume from api_retry model_cooldown payloads during provisioning', async () => { + vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z')); + + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { + provisioningComplete: false, + detectedSessionId: 'sess-live', + }); + (run as unknown as { progress: Record }).progress = { + state: 'starting', + updatedAt: '2026-04-17T12:00:00.000Z', + }; + const onProgress = vi.fn(); + (run as unknown as { onProgress: (progress: unknown) => void }).onProgress = onProgress; + + const autoResumeProvisioning = { + getCurrentRunId: vi.fn(() => 'run-1' as string | null), + isTeamAlive: vi.fn(() => true), + sendMessageToTeam: vi.fn(async () => undefined), + }; + initializeAutoResumeService(autoResumeProvisioning); + + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + callHandleStreamJsonMessage(service, run, { + type: 'system', + subtype: 'api_retry', + timestamp: '2026-04-17T12:00:00.000Z', + attempt: 1, + max_retries: 10, + error_status: 429, + error: 'model_cooldown', + error_message: + '429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}', + retry_delay_ms: 41_000, + }); + + expect(onProgress).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000); + expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + expect.stringContaining('rate limit has reset') + ); + } finally { + getConfigSpy.mockRestore(); + } + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 2b425cd9..b09aa0f5 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -28,8 +29,70 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({ }, })); +const execCliMock = vi.fn(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model') { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + anthropic: { + defaultModel: 'opus[1m]', + models: [ + { id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' }, + { + id: 'opus[1m]', + label: 'Opus 4.7 (1M)', + description: 'Anthropic long-context default', + }, + ], + }, + codex: { + defaultModel: 'gpt-5.4-mini', + models: [{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Codex default' }], + }, + gemini: { + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + + if (args[0] === 'runtime') { + return { + stdout: JSON.stringify({ + providers: { + codex: { + runtimeCapabilities: { + modelCatalog: { dynamic: false, source: 'runtime' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + + return { stdout: '', stderr: '', exitCode: 0 }; +}); +vi.mock('@main/utils/childProcess', () => ({ + execCli: (...args: Parameters) => execCliMock(...args), + spawnCli: vi.fn(), + killProcessTree: vi.fn(), +})); + import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { spawnCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } { @@ -144,6 +207,14 @@ process.stdin.on('data', (chunk) => { return scriptPath; } +function spawnRealCli( + command: string, + args: readonly string[], + options?: Parameters[2] +) { + return options ? spawn(command, [...args], options) : spawn(command, [...args]); +} + async function removeTempRoot(dirPath: string): Promise { if (!dirPath) { return; @@ -169,6 +240,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { beforeEach(() => { vi.clearAllMocks(); + execCliMock.mockClear(); addTeamNotificationMock.mockResolvedValue(null); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-')); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); @@ -376,6 +448,64 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('falls back from an unavailable Anthropic 1M launch id to the base model during prepare', async () => { + execCliMock.mockImplementationOnce(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model') { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + anthropic: { + defaultModel: 'opus', + models: [{ id: 'opus', label: 'Opus 4.8', description: 'Only base launch value' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'oauth_token', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'oauth_token', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelIds: ['opus[1m]'], + limitContext: false, + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain('Selected model opus[1m] verified for launch.'); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'opus']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + it('fails prepare when the selected Codex model is unavailable', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ @@ -786,6 +916,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { const configPath = writeMcpConfig(tempRoot, { 'agent-teams': getRealAgentTeamsMcpLaunchSpec(), }); + vi.mocked(spawnCli).mockImplementation(spawnRealCli); await expect( (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) @@ -814,6 +945,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { args: [mockServerPath], }, }); + vi.mocked(spawnCli).mockImplementation(spawnRealCli); await expect( (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) @@ -829,6 +961,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { args: [mockServerPath], }, }); + vi.mocked(spawnCli).mockImplementation(spawnRealCli); await expect( (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 8968fdd3..40bb2ae7 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -30,6 +30,17 @@ vi.mock('@main/utils/childProcess', () => ({ stdout: JSON.stringify({ schemaVersion: 1, providers: { + anthropic: { + defaultModel: 'opus[1m]', + models: [ + { id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' }, + { + id: 'opus[1m]', + label: 'Opus 4.7 (1M)', + description: 'Anthropic long-context default', + }, + ], + }, codex: { defaultModel: 'gpt-5.4', models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }], diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 1e4bd1ea..cac95bad 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -124,6 +124,48 @@ describe('computeEffectiveTeamModel', () => { expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]'); }); + it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => { + expect( + computeEffectiveTeamModel( + 'opus', + false, + 'anthropic', + { + providerId: 'anthropic', + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus', + defaultLaunchModel: 'opus', + models: [ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + } + ) + ).toBe('opus'); + }); + it('does not double-append [1m] when input already has it', () => { expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]'); expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]'); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index b28e638a..d125f773 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -552,6 +552,148 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('keeps the curated Anthropic picker surface while showing runtime-backed labels', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'anthropic', + models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.8', + }, + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.8 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.6', + }, + { + id: 'sonnet', + launchModel: 'sonnet', + displayName: 'Sonnet 4.7', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Sonnet 4.7', + }, + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.6', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Haiku 4.6', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + message: null, + code: null, + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'anthropic', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const modelButtons = Array.from(host.querySelectorAll('button')).map((button) => + button.textContent?.trim() ?? '' + ); + + expect(modelButtons.some((text) => text.startsWith('Default'))).toBe(true); + expect(modelButtons).toContain('Opus 4.8'); + expect(modelButtons).toContain('Opus 4.6'); + expect(modelButtons).toContain('Sonnet 4.7'); + expect(modelButtons).toContain('Haiku 4.6'); + expect(modelButtons).not.toContain('Opus 4.8 (1M)'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/shared/utils/anthropicLaunchModel.test.ts b/test/shared/utils/anthropicLaunchModel.test.ts new file mode 100644 index 00000000..330cb77b --- /dev/null +++ b/test/shared/utils/anthropicLaunchModel.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; + +describe('resolveAnthropicLaunchModel', () => { + it('keeps legacy long-context fallback behavior when no runtime catalog is available', () => { + expect(resolveAnthropicLaunchModel({ selectedModel: 'opus', limitContext: false })).toBe( + 'opus[1m]' + ); + expect(resolveAnthropicLaunchModel({ selectedModel: '', limitContext: false })).toBe( + 'opus[1m]' + ); + }); + + it('falls back from long-context synthetic launch ids to base ids when runtime catalog lacks the 1M variant', () => { + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'opus', + limitContext: false, + availableLaunchModels: ['opus'], + }) + ).toBe('opus'); + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'claude-opus-4-6', + limitContext: false, + availableLaunchModels: ['claude-opus-4-6'], + }) + ).toBe('claude-opus-4-6'); + }); + + it('uses runtime default launch truth when the provider default is requested', () => { + expect( + resolveAnthropicLaunchModel({ + selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION, + limitContext: false, + defaultLaunchModel: 'opus', + availableLaunchModels: ['opus'], + }) + ).toBe('opus'); + expect( + resolveAnthropicLaunchModel({ + selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION, + limitContext: true, + defaultLaunchModel: 'opus[1m]', + availableLaunchModels: ['opus', 'opus[1m]'], + }) + ).toBe('opus'); + }); + + it('preserves limitContext requests and never manufactures 1M Haiku variants', () => { + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'sonnet', + limitContext: true, + availableLaunchModels: ['sonnet', 'sonnet[1m]'], + }) + ).toBe('sonnet'); + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'haiku', + limitContext: false, + availableLaunchModels: ['haiku'], + }) + ).toBe('haiku'); + expect(resolveAnthropicLaunchModel({ selectedModel: 'opus[1m][1m]', limitContext: false })).toBe( + 'opus[1m]' + ); + }); +}); From 1db7e501a07b4a62010d151bc98e2058f921b034 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 16:44:18 +0300 Subject: [PATCH 05/19] feat(teams): introduce fast mode configuration for Anthropic provider and enhance related UI components --- CLAUDE.md | 1 + docs/team-management/README.md | 1 + .../task-queue-derived-agenda-plan.md | 3517 +++++++++++++++++ .../domain/resolveAnthropicRuntimeProfile.ts | 212 + .../anthropic-runtime-profile/main/index.ts | 12 + .../renderer/index.ts | 12 + src/main/http/teams.ts | 18 +- src/main/ipc/configValidation.ts | 28 +- src/main/ipc/teams.ts | 83 +- .../services/infrastructure/ConfigManager.ts | 2 + .../runtime/ClaudeMultimodelBridgeService.ts | 19 +- src/main/services/team/TeamDataService.ts | 1 + .../team/TeamMemberRuntimeAdvisoryService.ts | 8 +- src/main/services/team/TeamMetaStore.ts | 18 +- .../services/team/TeamProvisioningService.ts | 205 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 80 + .../settings/hooks/useSettingsHandlers.ts | 1 + .../dialogs/AnthropicFastModeSelector.tsx | 103 + .../team/dialogs/CreateTeamDialog.tsx | 144 +- .../team/dialogs/EffortLevelSelector.tsx | 12 +- .../team/dialogs/LaunchTeamDialog.tsx | 134 +- .../team/dialogs/launchDialogPrefill.ts | 7 + .../components/team/members/LeadModelRow.tsx | 28 +- .../team/members/MemberDraftRow.tsx | 3 + .../components/team/members/MemberList.tsx | 4 +- .../team/members/MembersEditorSection.tsx | 4 + .../team/members/TeamRosterEditorSection.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 3 + .../utils/__tests__/teamEffortOptions.test.ts | 87 +- ...teamModelAvailability.codexCatalog.test.ts | 213 + src/renderer/utils/teamEffortOptions.ts | 35 +- src/shared/types/cliInstaller.ts | 16 +- src/shared/types/notifications.ts | 1 + src/shared/types/team.ts | 10 +- src/shared/utils/cliArgsParser.ts | 1 + src/shared/utils/effortLevels.ts | 16 + src/shared/utils/rateLimitDetector.ts | 95 +- .../resolveAnthropicRuntimeProfile.test.ts | 307 ++ .../services/team/AutoResumeService.test.ts | 17 + .../TeamMemberRuntimeAdvisoryService.test.ts | 1 + .../team/dialogs/launchDialogPrefill.test.ts | 20 + test/renderer/store/extensionsSlice.test.ts | 1 + test/shared/utils/rateLimitDetector.test.ts | 20 + 43 files changed, 5450 insertions(+), 65 deletions(-) create mode 100644 docs/team-management/task-queue-derived-agenda-plan.md create mode 100644 src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts create mode 100644 src/features/anthropic-runtime-profile/main/index.ts create mode 100644 src/features/anthropic-runtime-profile/renderer/index.ts create mode 100644 src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx create mode 100644 test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2d7f0988..19a14f8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x ## Commands Always use pnpm (not npm/yarn) for this project. +Workspace membership is canonical in `pnpm-workspace.yaml`; do not re-add root `package.json.workspaces`, because npm subproject installs in Codex Cloud must treat nested packages as standalone projects. Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel. When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`). diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 514fd0ae..f6b29dc7 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -20,6 +20,7 @@ | [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json | | [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification | | [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) | +| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync | ## Ключевые решения diff --git a/docs/team-management/task-queue-derived-agenda-plan.md b/docs/team-management/task-queue-derived-agenda-plan.md new file mode 100644 index 00000000..feaa3031 --- /dev/null +++ b/docs/team-management/task-queue-derived-agenda-plan.md @@ -0,0 +1,3517 @@ +# Task Queue / Agenda Rollout Plan + +**Date:** 2026-04-21 +**Status:** Proposed +**Scope:** Team Management, MCP task surfaces, agent operational queue + +--- + +## TL;DR + +✅ Базовое решение: + +- перед полноценным agenda rollout сначала делаем **Phase 0 hardening** для слабых сигналов +- source of truth остаётся в сырых task/review/kanban файлах +- в Phase 1 не заводим второй persisted projection-файл +- вместо этого строим **derived queue projection on read** через **controller-owned board snapshot** под **team-level lock** +- вводим явные derived-поля: `actionOwner`, `nextAction`, `queueCategory`, `watchers` +- разделяем 4 разных surface: + - `member_briefing` = только bootstrap + правила + роль + - `task_briefing` = каноническая очередь конкретного участника + - `lead_briefing` = каноническая очередь лида + - `task_list` = inventory/search surface, который постепенно уходит в filtered role +- Phase 2 добавляет `revision` и только потом, если реально нужно по профайлингу, `delta` + +Главная мысль: проблема не в том, что `task_list` "слишком длинный". Проблема в том, что он сейчас смешивает **inventory**, **workflow state**, **операционный приоритет** и **шум**, заставляя LLM самому угадывать "что мне делать сейчас". + +⚠️ После дополнительного code review важны 4 уточнения: + +- сейчас в коде нет общего board-level lock поверх task + kanban + review mutations, но Phase 0 надо ограничить controller snapshot/mutation scope, а не пытаться сразу перевести под него все main-side readers +- reviewer для review-задачи в Phase 0/1 надо выводить только из текущего review cycle в `historyEvents`, потому что оба write-path сегодня заводят kanban review entry с `reviewer: null` +- `needsClarification: "lead"` в коде сейчас auto-clear'ится шире, чем обещают prompt-инструкции, поэтому safest Phase 0 - explicit clear only +- `task_list` нельзя резко ломать сменой default semantics, потому что lead prompts уже используют его как full inventory entrypoint + +--- + +## 1. Почему вообще понадобился этот redesign + +Сейчас у системы есть 2 разных read-path, но ни один из них не задаёт правильную operational semantics: + +### `task_list` + +- технически возвращает почти весь список задач команды +- это не "очередь работы", а полу-сырой inventory dump +- он слишком тяжёлый по payload +- главное, он **не говорит явно**, кто сейчас должен действовать по задаче +- агенту приходится самому вычислять: + - моя ли это задача + - жду ли я ревью + - я ли ревьюер + - надо ли пинговать лида + - задача реально actionable или просто informational + +### `task_briefing` + +- даёт более компактное представление +- но сейчас завязан в основном на `owner === memberName` +- поэтому не покрывает важные сценарии: + - задача формально owned одним участником, но action сейчас у ревьюера + - задача зависла из-за отсутствующего reviewer + - задача ждёт решения лида + - участнику нужна awareness по своим задачам, даже если actionOwner временно не он + +Итог: + +- лид не получает нормальную operational queue +- тиммейт может не видеть "что от него реально требуется" +- LLM тратит токены и качество на вывод политики из сырых полей +- при росте команды и количества задач путаница становится системной + +--- + +## 2. Что именно не так в текущей модели + +### 2.1 `task_list` сегодня слишком близок к raw dump + +По факту текущий `task_list` делает почти "отдать все задачи, слегка урезав крупные поля". Это blocklist-подход: + +- убираются тяжёлые поля вроде комментариев и истории +- почти всё остальное сохраняется + +Такой подход плох не только по размеру, но и по смыслу: + +- агенту всё ещё видны почти все задачи команды +- инструмента нет opinionated workflow surface +- список не говорит, какие карточки именно должны попасть в текущую очередь участника + +### 2.2 `task_briefing` сегодня не отделяет action от awareness + +Сейчас логика ближе к "покажи мои assigned tasks". Но operational queue и assigned list не одно и то же. + +Примеры: + +- owner = `alice`, reviewer = `bob`, задача в review + `alice` должна понимать, что задача не пропала, но actionOwner уже `bob` +- задача completed, reviewer ещё не назначен + action нужен не owner, а лиду +- задача ждёт ответа от user + actionOwner = `user`, но лид должен видеть это как oversight item + +### 2.3 Raw task list не должен быть основным surface для LLM + +LLM лучше работает, когда система даёт: + +- кто сейчас должен действовать +- почему именно он +- какое следующее ожидаемое действие +- что informational, а что actionable + +LLM хуже работает, когда ему дают 50 карточек и ожидают, что он сам выведет workflow policy. + +### 2.4 Важные факты из текущего кода + +Ниже то, на что уже можно опираться в rollout, без изобретения новой подсистемы с нуля: + +- `task_list` сегодня реально отдаёт почти весь team inventory в урезанном виде, а не opinionated queue +- `task_briefing` сегодня в основном строится через `owner === memberName` +- `review_request` технически может отправить задачу в review даже без явно резолвленного reviewer +- active reviewer для agenda-safe routing в Phase 0/1 надо выводить только из текущего review cycle в `historyEvents`; per-task kanban reviewer сейчас не является надёжным signal +- runtime identity для участника уже умеет частично резолвиться из существующего runtime context +- UI и MCP уже в основном идут через `agent-teams-controller`, то есть rollout можно делать эволюционно вокруг текущего controller layer +- task и kanban сегодня пишутся atomically по файлам, но не transactionally как единый board update +- controller-side task/kanban write paths сейчас вообще не используют существующий `withFileLockSync(...)`; текущий lock primitive живёт отдельно, синхронный, busy-wait, с acquire timeout `5s` и stale timeout `30s` +- `review_request` сейчас делает multi-step sequence `kanban -> task history -> message`, значит queue read действительно может увидеть промежуточное состояние +- `review_request` при ошибке отправки уведомления сейчас делает только `kanban.clear`, но уже записанный `review_requested` history event не откатывает +- `needsClarification: "lead"` в task store сейчас auto-clear'ится при комментарии любого не-owner автора, а не только лида +- `task_list` использует blocklist-подход, поэтому payload может незаметно разрастаться при появлении новых task-полей +- текущий `task_list` в MCP напрямую вызывает `controller.tasks.listTasks()` без фильтров и затем прогоняет результат через `slimTaskForList(...)`, то есть filter-capable inventory contract пока даже не выбран +- `task_list` входит в teammate operational tool catalog, значит prompt-only migration недостаточна - нужен ещё catalog/access rollout +- в репо есть как минимум две локальные `.d.ts` декларации для `agent-teams-controller`, и они уже расходятся по полноте surface +- `mcp-server` тесты сейчас явно проверяют текущую blocklist-semantics `task_list`, значит migration должна обновлять и test contract, а не только runtime +- `owner` и `needsClarification` хранятся как текущие поля task payload, но не имеют такого же history-backed контракта, как review transitions +- task logs умеют видеть `task_set_owner` / `task_set_clarification`, но это отдельный observability слой, а не базовый queue source of truth +- в schema у `kanban-state.tasks[taskId]` есть `reviewer`, но официальный mutation patch сейчас не несёт reviewer как поддерживаемый per-task signal +- prompt знает правило вида "если есть reviewer/qa/tech-lead, отправляй на review", но это не равняется канонической machine-readable review policy в runtime state +- roster resolution уже существует минимум в двух вариантах: controller-side `resolveTeamMembers(...)` и main/UI-side `TeamMemberResolver`, и они нормализуют состав команды не полностью одинаково +- main-side `TeamDataService` compatibility layer сейчас резолвит `reviewer` как `kanbanTaskState.reviewer ?? history(review_approved|review_started|review_requested)`, а `reviewState` берёт из persisted field, что уже шире и слабее, чем queue-safe current-cycle contract +- `setTaskOwner` и `task_set_clarification` меняют task payload и `updatedAt`, но не добавляют workflow events в `historyEvents` +- stall-monitor уже использует более строгую review-window semantics, где открытое review окно начинается с `review_started`, а не просто с `review_requested` + +Это важно, потому что план не требует: + +- переписывать storage +- менять формат task-файлов +- вводить новый отдельный board runtime + +--- + +## 3. Цели redesign + +Нужно добиться одновременно 5 свойств: + +1. **Однозначность** + По каждой активной задаче должен существовать один primary `actionOwner` или явно `none/user`. + +2. **Компактность** + Обычный teammate не должен получать весь board dump только ради того, чтобы понять свои 2-4 задачи. + +3. **Надёжность** + Read model не должен становиться вторым нестабильным source of truth. + +4. **Понятная иерархия surfaces** + Bootstrap, operational queue, full context и inventory/search должны быть разными инструментами. + +5. **Эволюционность** + Phase 1 должен сильно улучшить поведение без большого риска stale projection bugs. + +--- + +## 3.1 Confidence Map After Code Review + +Ниже зоны, где после просмотра кода у нас разная степень уверенности. + +### A. Derived agenda как базовый read model + +`🎯 10 🛡️ 10 🧠 6` + +Почему уверенность высокая: + +- это не требует второго durable state +- current storage уже содержит достаточно сигналов для derived routing +- основной риск здесь не идея, а discipline around rollout + +### B. Controller-owned board snapshot + lock + +`🎯 9 🛡️ 9 🧠 5` + +Почему уверенность выросла: + +- после дополнительного просмотра видно, что main-side raw readers (`TeamTaskReader`, `TeamKanbanManager`) живут отдельно и не требуют немедленного включения в agenda path +- для agent-facing queue достаточно сначала сделать один канонический controller snapshot contract +- это сильно уже и безопаснее, чем пытаться в Phase 0 синхронно унифицировать весь repo вокруг одного lock API + +### C. Reviewer resolution + +`🎯 8 🛡️ 9 🧠 4` + +Почему уверенность стала выше: + +- и controller-side `kanbanStore.setKanbanColumn(...)`, и main-side `TeamKanbanManager.updateTask(...)` при переводе в `review` записывают `reviewer: null` +- значит ambiguity здесь на самом деле меньше, а не больше: Phase 0/1 просто не должны опираться на per-task kanban reviewer +- остаётся чётко определить только current-cycle precedence внутри `historyEvents` + +### D. Clarification semantics + +`🎯 8 🛡️ 10 🧠 3` + +Это всё ещё слабая зона текущего runtime, но у неё теперь есть понятный safe fallback. + +Причина: + +- prompts говорят одно +- код auto-clear'ит флаг шире +- но safest fix очень прямой: в Phase 0 убрать implicit auto-clear из routing contract и жить через explicit `task_set_clarification clear` + +### E. `task_list` migration без silent breakage + +`🎯 7 🛡️ 7 🧠 5` + +Почему: + +- целевая роль `task_list` понятна +- но менять его default резко опасно, потому что старые лид-промпты уже ожидают текущую семантику +- значит migration должна идти через новый canonical surface, а не через скрытую подмену смысла + +### F. Queue-side roster normalization + +`🎯 7 🛡️ 8 🧠 5` + +Почему это остаётся важной зоной: + +- controller-side `resolveTeamMembers(...)` и UI-side `TeamMemberResolver` до сих пор фильтруют roster не полностью одинаково +- для queue это критично, потому что invalid owner/reviewer routing зависит от того, кого мы вообще считаем валидным member +- но здесь достаточно зафиксировать минимальный queue contract и покрыть его тестами, без большого package refactor + +### G. Lead identity normalization for `lead_briefing` + +`🎯 6 🛡️ 8 🧠 4` + +Почему это остаётся слабее других зон: + +- controller-side `inferLeadName(...)` использует ad hoc heuristics: `agentType === "team-lead"`, `role` содержит `lead`, имя `team-lead`, иначе первый config member +- shared `leadDetection` utilities уже живут по чуть более другому контракту +- для `lead_briefing` и future ergonomic clarification clear нельзя опираться на "может быть это и есть lead" без явного phase rule + +### H. Tool-catalog scoping for `lead_briefing` + +`🎯 8 🛡️ 9 🧠 4` + +Почему это важно: + +- текущий `mcpToolCatalog` режет доступность по group-level `teammateOperational`, а не по per-tool policy +- если просто положить `lead_briefing` в existing task-group, он автоматически окажется в teammate operational tool set +- это не runtime bug, но это прямой путь к лишнему шуму и путанице у teammate agents + +--- + +## 3.2 Top 3 Options In The Least Certain Areas + +Ниже не "все возможные идеи", а именно те развилки, где реально можно ошибиться архитектурно. + +### A. Где должен жить shared board lock + +**1. Controller-owned board snapshot API + controller-local team lock** + +`🎯 10 🛡️ 9 🧠 5` + +Примерный объём: `80-160` строк изменений + +Суть: + +- внутри `agent-teams-controller` ввести один канонический `withTeamBoardLock(...)` или `getBoardSnapshot(...)` +- под него завести multi-file mutations review/task/kanban и agenda reads +- main-process не заставлять в той же фазе массово переходить на этот primitive, если ему не нужен именно agenda-grade snapshot + +Почему это лучший вариант: + +- один semantic owner для agent-facing queue +- не раздувает Phase 0 до cross-layer refactor всей файловой модели +- оставляет возможность позже экспортировать тот же snapshot наружу, если он реально понадобится UI + +**2. Exported generic shared primitive across controller + main** + +`🎯 7 🛡️ 8 🧠 6` + +Примерный объём: `100-180` строк изменений + +Суть: + +- сделать универсальный lock/snapshot contract, который одинаково используют controller и main + +Риск: + +- полезно в долгую, но для ближайшего rollout это уже больше работа, чем нужно +- есть риск начать чинить "архитектурную красоту" вместо реально агентского queue path + +**3. Пер-file lock orchestration без отдельного team lock** + +`🎯 3 🛡️ 3 🧠 6` + +Примерный объём: `60-120` строк изменений + +Суть: + +- пытаться добиться консистентности через набор локов на task/kanban файлы + +Риск: + +- сложнее reasoning +- выше риск partial interleaving +- хуже читается и хуже тестируется + +Выбор: + +- брать **вариант 1** +- вариант 2 рассматривать только если после rollout тот же snapshot contract реально понадобится non-controller consumers + +### B. Как резолвить reviewer для active review cycle + +**1. Pure history-first current-cycle resolver** + +`🎯 10 🛡️ 10 🧠 4` + +Примерный объём: `70-140` строк изменений + +Суть: + +- сканировать history backwards только в пределах текущего review cycle +- использовать `review_started.actor` и `review_requested.reviewer` как основные сигналы +- в Phase 0/1 вообще **не использовать** `kanban-state.tasks[taskId].reviewer` как routing signal + +Почему это лучший вариант: + +- максимально опирается на уже существующие durable events +- согласуется с уже существующей derivation логикой review state через history +- это полностью соответствует реальному коду: и controller, и main сейчас создают review-entry с `reviewer: null` + +**2. History-first now, kanban fallback only after writer hardening** + +`🎯 6 🛡️ 8 🧠 6` + +Примерный объём: `110-210` строк изменений + +Суть: + +- сначала расширить write-path так, чтобы per-task `reviewer` реально persist'ился +- только после этого разрешить kanban fallback в resolver + +Риск: + +- это уже не Phase 0 hardening, а изменение durable semantics +- если делать одновременно с agenda rollout, возрастает blast radius + +**3. Новый persisted reviewer slot в task schema** + +`🎯 7 🛡️ 8 🧠 7` + +Примерный объём: `120-240` строк изменений + +Суть: + +- добавить explicit reviewer прямо в task payload и синхронизировать его в review flow + +Плюсы: + +- проще future reads + +Минусы: + +- новая durable schema responsibility +- выше цена ошибок и миграции + +Выбор: + +- для Phase 0/1 брать **вариант 1** +- вариант 3 оставлять как later optimization, только если history-based resolver окажется недостаточным + +### C. Как harden'ить clarification clearing + +**1. Phase 0 explicit-clear-only semantics** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `25-60` строк изменений + +Суть: + +- не делаем никакой implicit auto-clear частью routing contract +- clarification снимается только через явный `task_set_clarification clear` +- prompts и briefing обновляются синхронно + +Почему это лучший вариант: + +- максимально надёжно +- убирает silent false negatives в очереди +- делает поведение легко тестируемым и объяснимым + +**2. Controller-layer lead-aware auto-clear after alias hardening** + +`🎯 8 🛡️ 9 🧠 6` + +Примерный объём: `70-140` строк изменений + +Суть: + +- после стабилизации lead identity вернуть удобство: + - `lead` clarification clear'ится на комментарий лида + - `user` clarification clear'ится на комментарий пользователя + +Риск: + +- нужно сначала жёстко определить: + - canonical lead identity + - alias policy (`team-lead`, фактическое имя лида, возможный `lead`) + - valid actor normalization + +**3. Протянуть explicit `canClearClarification` / `leadName` в taskStore API** + +`🎯 5 🛡️ 6 🧠 6` + +Примерный объём: `50-110` строк изменений + +Суть: + +- taskStore всё ещё делает auto-clear, но получает workflow policy сверху + +Риск: + +- API store начинает знать слишком много про workflow policy + +Выбор: + +- для Phase 0 брать **вариант 1** +- вариант 2 оставлять как optional ergonomics pass только после стабилизации lead alias semantics + +Отдельное уточнение: + +- это не двухстрочная правка в storage layer +- `taskStore.addTaskComment()` сам по себе не знает, кто является lead для команды + +Поэтому "clear only when lead answers" нельзя надёжно реализовать внутри store без: + +- переноса policy выше, в controller/context layer +- или явной передачи lead-aware policy signal в storage API + +### D. Как делать queue revision в Phase 2 + +**1. Stable hash of compact agenda DTO** + +`🎯 9 🛡️ 9 🧠 4` + +Примерный объём: `40-90` строк изменений + +Суть: + +- как в `feedRevision`, считать hash от уже нормализованного compact agenda payload + +Почему это лучший вариант: + +- не требует extra durable file +- легко проверять +- удобно для `unchanged` short-circuit + +**2. Team directory mtime / file timestamps** + +`🎯 4 🛡️ 4 🧠 3` + +Примерный объём: `20-40` строк изменений + +Риск: + +- платформенная хрупкость +- плохая детерминированность + +**3. Dedicated revision counter file** + +`🎯 7 🛡️ 8 🧠 6` + +Примерный объём: `70-140` строк изменений + +Плюсы: + +- дешёвые reads + +Минусы: + +- ещё одна durable write responsibility + +Выбор: + +- сначала **вариант 1** +- вариант 3 рассматривать только если hash on read реально станет bottleneck + +### E. Где должна жить canonical agenda derivation logic + +**1. Controller-owned single implementation** + +`🎯 9 🛡️ 9 🧠 5` + +Примерный объём: `90-170` строк изменений + +Суть: + +- agenda derivation живёт в `agent-teams-controller` +- MCP tools только оборачивают её +- main-process, если ему нужен тот же semantic surface, либо вызывает controller API, либо использует тот же exported helper + +Почему это лучший вариант: + +- один semantic owner +- меньше риска drift между MCP/runtime/UI +- уже сейчас controller является главным write-path для board operations + +**2. Отдельные реализации в controller и main-process** + +`🎯 4 🛡️ 4 🧠 4` + +Примерный объём: `120-220` строк изменений + +Риск: + +- review/clarification semantics почти гарантированно разъедутся со временем +- сложнее тестировать и объяснять различия + +**3. Новый общий workspace package / shared helper** + +`🎯 7 🛡️ 8 🧠 7` + +Примерный объём: `140-260` строк изменений + +Плюсы: + +- архитектурно чище в долгую + +Минусы: + +- это уже mini-refactor package boundaries +- для Phase 0/1 цена выше, чем польза + +Выбор: + +- для ближайшего rollout брать **вариант 1** +- вариант 3 оставлять как later cleanup, если agenda surface реально понадобится нескольким runtime слоям в одном виде + +### F. Как уводить `task_list` от teammate default workflow + +**1. Prompt + tool-catalog phased migration** + +`🎯 9 🛡️ 9 🧠 5` + +Примерный объём: `60-120` строк изменений + +Суть: + +- сначала вводим `lead_briefing` +- затем меняем prompts +- затем убираем `task_list` из teammate operational catalog или переводим его в less-prominent surface + +Почему это лучший вариант: + +- migration управляемая +- меньше surprising behavior для уже работающих flows + +**2. Prompt-only migration** + +`🎯 5 🛡️ 5 🧠 2` + +Примерный объём: `15-35` строк изменений + +Риск: + +- `task_list` всё равно остаётся у тиммейта "под рукой" +- модель будет продолжать иногда использовать его как shortcut + +**3. Instant hard removal from teammate catalog** + +`🎯 6 🛡️ 7 🧠 4` + +Примерный объём: `20-60` строк изменений + +Плюсы: + +- быстро убирает temptation + +Минусы: + +- выше шанс сломать существующие prompt assumptions и recovery workflows + +Выбор: + +- брать **вариант 1** + +### G. Как трактовать "review required" без явной policy-модели + +**1. Explicit-review-only semantics in Phase 0/1** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- queue считает review обязательным только когда есть явный review state / review cycle signal +- completed task без active review не получает auto-routing в `assign_reviewer` только потому, что "в команде есть reviewer" + +Почему это лучший вариант: + +- не выдумывает policy, которой в runtime state сейчас нет +- минимизирует ложные lead action items + +**2. Inference from free-form member roles** + +`🎯 3 🛡️ 3 🧠 3` + +Примерный объём: `20-40` строк изменений + +Суть: + +- пытаться читать `role` вроде `reviewer`, `qa`, `tech-lead` и решать, что completed task должна уйти в review + +Риск: + +- free-form role text не является надёжным policy contract +- легко получить false positives и странные queue jumps + +**3. Introduce explicit review policy field later** + +`🎯 8 🛡️ 9 🧠 7` + +Примерный объём: `90-180` строк изменений + +Суть: + +- в будущем добавить machine-readable team/task review policy +- только тогда можно safely строить routing вроде `completed -> assign_reviewer` без explicit review event + +Выбор: + +- для Phase 0/1 брать **вариант 1** +- вариант 3 держать как possible future enhancement + +### H. Какая roster resolution считается канонической для queue + +**1. Controller-owned roster normalization for queue** + +`🎯 8 🛡️ 9 🧠 5` + +Примерный объём: `50-110` строк изменений + +Суть: + +- queue в controller layer использует controller-side roster resolver +- но этот resolver сначала доводится до **минимально достаточного** predictable contract: + - removed members + - lead alias normalization + - suppression of phantom inbox-derived aliases where necessary + - ignore qualified external recipients + - ignore generated/internal pseudo-agent names, если они не пришли из explicit config/meta + +Плюсы: + +- queue остаётся рядом со своим runtime context +- не появляется cross-layer dependency на UI resolver + +**2. Reuse UI `TeamMemberResolver` semantics indirectly** + +`🎯 5 🛡️ 6 🧠 7` + +Примерный объём: `90-170` строк изменений + +Суть: + +- пытаться тащить roster rules из main/UI слоя обратно в controller path + +Риск: + +- package boundary усложняется +- для ближайшего rollout это слишком тяжело + +**3. Shared future roster package/helper** + +`🎯 7 🛡️ 9 🧠 8` + +Примерный объём: `120-220` строк изменений + +Суть: + +- вынести roster normalization в реально общий helper/package + +Плюсы: + +- меньше semantic drift в долгую + +Минусы: + +- для Phase 0/1 это уже дополнительный refactor + +Выбор: + +- в ближайшем rollout брать **вариант 1** +- но явно зафиксировать expected queue roster semantics тестами, чтобы drift от UI не был скрытым + +### I. Где должен жить `lead_briefing` в MCP tool catalog + +**1. Отдельная `lead` group с `teammateOperational: false`** + +`🎯 10 🛡️ 10 🧠 4` + +Примерный объём: `30-70` строк изменений + +Суть: + +- добавить отдельную tool group, например `lead` +- положить туда `lead_briefing` +- не смешивать этот tool с existing `task` group +- синхронно расширить `AgentTeamsMcpToolGroupId` в обеих локальных `.d.ts` + +Почему это лучший вариант: + +- текущий catalog already group-scoped, а не per-tool scoped +- это самый дешёвый способ гарантировать, что lead surface не попадёт в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- semantics остаётся прозрачной: teammate task tools отдельно, lead-only tool отдельно + +**2. Оставить `lead_briefing` в `task` group, но добавить per-tool denylist override** + +`🎯 5 🛡️ 7 🧠 6` + +Примерный объём: `40-90` строк изменений + +Суть: + +- сохраняем существующую `task` group +- поверх неё добавляем дополнительную per-tool политику, чтобы скрыть только `lead_briefing` + +Риск: + +- чинит symptom, а не модель +- делает catalog rules менее очевидными +- повышает шанс future drift между group semantics и effective availability + +**3. Оставить `lead_briefing` в `task` group и полагаться только на prompts** + +`🎯 2 🛡️ 2 🧠 1` + +Примерный объём: `5-20` строк изменений + +Суть: + +- tool физически доступен тиммейтам +- просто стараемся не упоминать его в teammate prompts + +Риск: + +- это не access policy, а надежда на prompt discipline +- модель всё равно сможет брать лишний tool как shortcut +- именно так и рождается путаница "почему у меня есть lead queue" + +Выбор: + +- для rollout брать **вариант 1** +- отдельную per-tool policy layer обсуждать только если позже реально появятся mixed-access tools внутри одной semantic group + +### J. Где должна жить semantics для filtered `task_list` + +**1. Dedicated controller-owned inventory method с явным allowlisted output contract** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `50-110` строк изменений + +Суть: + +- raw `tasks.listTasks()` остаётся raw/read helper +- для `task_list` вводится отдельный semantic owner: + - либо public `tasks.listTaskInventory(filters?)` + - либо internal controller helper с тем же смыслом, если public width хотят держать узкой +- output contract фиксируется как explicit inventory row allowlist, а не `slimTaskForList(...)` blocklist passthrough + +Почему это лучший вариант: + +- фильтры и payload-shaping живут рядом с controller-owned queue/inventory semantics +- не приходится тихо переопределять смысл старого raw `listTasks()` +- migration к компактному output становится явной и тестируемой + +**2. Перегрузить существующий `tasks.listTasks(filters?)` и постепенно менять его смысл** + +`🎯 5 🛡️ 6 🧠 4` + +Примерный объём: `35-90` строк изменений + +Суть: + +- тот же метод начинает иногда означать raw list, а иногда filtered inventory + +Риск: + +- имя метода начинает врать о своей семантике +- выше шанс type/runtime drift и неожиданных побочных эффектов в existing callers + +**3. Оставить controller raw, а фильтрацию и shape собирать прямо в MCP tool** + +`🎯 4 🛡️ 5 🧠 3` + +Примерный объём: `25-70` строк изменений + +Суть: + +- `task_list` MCP tool сам вызывает raw `tasks.listTasks()` + kanban/review helpers и строит filtered inventory локально + +Риск: + +- semantics inventory начинает жить вне controller +- MCP и другие consumers почти гарантированно начнут drift'ить + +Выбор: + +- брать **вариант 1** +- raw `tasks.listTasks()` не надо тихо превращать в semantic inventory API + +### K. Как безопасно seed'ить lead-first tool access после появления `lead_briefing` + +**1. Отдельный explicit lead bootstrap seed list** + +`🎯 9 🛡️ 10 🧠 4` + +Примерный объём: `25-70` строк изменений + +Суть: + +- не переиспользовать `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` как proxy для lead-first surfaces +- завести отдельный explicit список initial lead bootstrap tools +- включить туда `lead_briefing` и другие реально нужные first-turn lead surfaces + +Почему это лучший вариант: + +- lead bootstrap и teammate operational workflow - разные вещи +- появление lead-only tool больше не ломает initial permission seed path +- semantics разрешений становится явной, а не побочной + +**2. Seed'ить лидеру все registered agent-teams tools** + +`🎯 4 🛡️ 6 🧠 2` + +Примерный объём: `10-30` строк изменений + +Суть: + +- чтобы не думать о разделении, лид получает сразу весь namespaced surface + +Риск: + +- слишком широкий blast radius по permissions +- теряется смысл role-scoped tool surfaces + +**3. Ничего не seed'ить отдельно, положиться на runtime permission prompt** + +`🎯 3 🛡️ 5 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- `lead_briefing` может быть доступен, но первый вызов пройдёт через ad hoc permission flow + +Риск: + +- первый ход лида становится менее детерминированным +- prompt говорит "сначала вызови lead_briefing", а runtime может отвечать permission friction + +Выбор: + +- брать **вариант 1** +- bootstrap-critical lead surfaces не должны зависеть от teammate permission list случайно + +### L. Как удержать agenda/inventory helpers internal при текущем `bindModule(...)` + +**1. Вынести их в отдельный internal module, который controller не bind'ит** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `40-100` строк изменений + +Суть: + +- agenda/inventory derivation живёт в отдельном internal helper module +- `tasks.js` импортирует его, но не ре-экспортирует целиком +- `createController()` не видит этот helper module как public API + +Почему это лучший вариант: + +- accidental public export risk исчезает по самой структуре кода +- проще держать public controller contract узким + +**2. Оставить helpers в `tasks.js`, но не экспортировать их** + +`🎯 8 🛡️ 8 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- helpers остаются локальными функциями файла + +Риск: + +- работает, но `tasks.js` и так уже перегружен разными обязанностями +- выше шанс, что позже кто-то всё же экспортнет helper "для удобства" + +**3. Экспортнуть helper из `tasks.js`, но считать его internal по договорённости** + +`🎯 1 🛡️ 2 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- helper появляется в `module.exports`, но мы просто стараемся не использовать его как public API + +Риск: + +- при текущем `bindModule(...)` это уже не internal helper, а реальный public controller method +- это прямой путь к silent surface creep и type drift + +Выбор: + +- брать **вариант 1** +- вариант 2 допустим только если хотят минимальный diff и готовы жёстко держать export discipline + +### M. Как harden'ить lead bootstrap readiness для `lead_briefing` + +**1. Role-aware required-tool preflight после wiring stabilization** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `40-100` строк изменений + +Суть: + +- после того как `lead` group, registration path и permission seed уже стабилизированы +- добавляем role-aware preflight invariant: + - teammate path требует `member_briefing` + - lead path требует `lead_briefing` +- validation делается через тот же `tools/list` / `tools/call` style readiness check + +Почему это лучший вариант: + +- если prompt делает `lead_briefing` canonical first action, runtime тоже должен это уметь проверять +- исчезает класс багов "prompt уже требует tool, а launch path его ещё не гарантирует" + +**2. Prompt-level canonical first call + explicit fallback until readiness proven** + +`🎯 7 🛡️ 7 🧠 2` + +Примерный объём: `15-40` строк изменений + +Суть: + +- lead prompt рекомендует `lead_briefing` +- но пока нет role-aware preflight, допускается fallback на inventory path при tool/permission failure + +Риск: + +- migration мягче, но менее детерминированна +- остаётся окно, где canonical surface ещё не является hard runtime invariant + +**3. Ничего не проверять дополнительно и надеяться на registration/permissions** + +`🎯 2 🛡️ 4 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- считаем, что если tool зарегистрирован где-то в системе, этого достаточно + +Риск: + +- код уже показывает, что explicit MCP readiness gate сейчас существует только для `member_briefing` +- значит для lead path это просто wishful thinking, а не контракт + +Выбор: + +- брать **вариант 1** +- до его внедрения prompt не должен делать `lead_briefing` hard bootstrap blocker без fallback + +### N. Где должен жить source of truth для lead bootstrap tool list + +**1. Exported controller/package constant рядом с existing teammate constants** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- если нужен lead bootstrap tool list, он живёт не локально в `TeamProvisioningService` +- а экспортируется из `mcpToolCatalog` рядом с: + - `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` + - `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- например как: + - `AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES` + - `AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES` + +Почему это лучший вариант: + +- bootstrap runtime не получает второй ручной список tool names +- catalog/permissions/prompts/tests смотрят в один и тот же source of truth +- migration для `lead_briefing` не размазывает naming policy по нескольким слоям + +**2. Локальный список прямо в `TeamProvisioningService`** + +`🎯 4 🛡️ 5 🧠 2` + +Примерный объём: `10-30` строк изменений + +Суть: + +- просто прописать lead bootstrap tools строками в runtime service + +Риск: + +- это создаёт второй source of truth рядом с catalog +- при следующем tool rename или regrouping drift почти неизбежен + +**3. Вычислять список динамически из `AGENT_TEAMS_MCP_TOOL_GROUPS` по эвристике** + +`🎯 5 🛡️ 6 🧠 5` + +Примерный объём: `20-60` строк изменений + +Суть: + +- runtime сам выводит lead bootstrap tools из group metadata или naming conventions + +Риск: + +- слишком неявно для bootstrap-critical path +- эвристика потом будет спорить с реальным prompt/runtime contract + +Выбор: + +- брать **вариант 1** +- bootstrap-critical tool lists должны экспортироваться явно, а не вычисляться по косвенным признакам + +--- + +## 3.3 Decision Freeze For Phase 0/1 + +Чтобы rollout не расползся и агенты не получили смесь полу-готовых правил, для Phase 0/1 фиксируем жёсткие ограничения: + +- per-task `kanban reviewer` не участвует в routing, пока write-path не начнёт реально его поддерживать +- `kanban.reviewers[]` трактуется только как reviewer pool / availability list, а не assignment на конкретную задачу +- clarification в routing contract живёт через explicit clear, а не через implicit "кто-то ответил" +- board lock в Phase 0 обязан покрыть controller agenda snapshot и multi-file board mutations, но не обязан сразу заменять все main-side raw readers +- queue roster использует controller-owned normalization c явно зафиксированными тестами, а не ad hoc смесь controller/UI эвристик +- `lead_briefing` в Phase 1 остаётся role-scoped surface без обязательного `leadName` параметра +- `lead_briefing` в catalog живёт в отдельной non-teammate-operational group, а не прячется внутри `task` group +- delivery уведомлений не входит в rollback boundary board-state mutation; board commit и message send надо разводить +- новая tool group не считается введённой, пока у неё нет явного registration path без duplicate registrations +- lead-first bootstrap не зависит только от teammate operational permission seed list +- source of truth для lead bootstrap tools экспортируется явно, а не живёт локальным списком в runtime service +- availability `lead_briefing` и permission seed для него трактуются как разные rollout concerns +- canonical first-turn `lead_briefing` не становится hard bootstrap invariant раньше, чем у lead path появляется явная readiness validation или безопасный fallback +- agenda/inventory helpers не утекают в public controller surface через случайный `module.exports` +- filtered `task_list` не должен вечно оставаться MCP-local blocklist wrapper над raw `tasks.listTasks()` +- generic board snapshot helper не становится public API, пока у него нет второго доказанного consumer + +--- + +## 3.4 Concrete Migration Blockers Found In Code + +Это не теоретические риски, а уже существующие места, которые диктуют rollout order. + +### A. Lead prompt still teaches `task_list` + +`🎯 10 🛡️ 10 🧠 2` + +Факт из кода: + +- `TeamProvisioningService` прямо содержит строку `List all tasks: task_list { teamName: "..." }` + +Следствие: + +- нельзя считать migration завершённым, пока lead prompt не будет переписан на `lead_briefing` как canonical first call +- одного нового MCP tool недостаточно +- member path сегодня жёстко валидирует именно `member_briefing`; lead path такого bootstrap gate пока не имеет +- поэтому у плана только 2 честных варианта: + - либо `lead_briefing` сначала canonical recommendation with fallback + - либо после stabilization wiring добавить явный lead-side preflight invariant +- prompt-only canonicalization без одного из этих двух путей здесь недостаточна + +### B. Member path already anchors on `member_briefing` -> `task_briefing` + +`🎯 10 🛡️ 10 🧠 2` + +Факт из кода: + +- member bootstrap сейчас жёстко валидирует наличие `member_briefing` +- member prompts уже учат использовать `task_briefing` как compact queue + +Следствие: + +- teammate migration почти не требует semantic renaming +- основной prompt migration blocker сейчас именно на lead path, а не на teammate path + +### C. Current `task_briefing` renderer lives in `taskStore` + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `tasks.taskBriefing(...)` просто проксирует в `taskStore.formatTaskBriefing(...)` + +Следствие: + +- нельзя делать `lead_briefing` как второй независимый renderer рядом в store +- сначала нужен общий structured agenda DTO в controller-owned layer, и только потом два text renderer поверх него + +### D. Local `.d.ts` shims already drift in concrete ways + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `src/types/agent-teams-controller.d.ts` и `mcp-server/src/agent-teams-controller.d.ts` уже расходятся не только "вообще", а по конкретным методам и формам +- примеры: + - main shim не знает `tasks.memberBriefing(...)` + - main shim не знает `tasks.getTaskComment(...)` + - main shim не знает `review.startReview(...)` + - main shim не знает `runtime` + - `lookupMessage(...)` типизирован по-разному между shim файлами + +Следствие: + +- любой rollout нового agenda/helper surface обязан идти вместе с type-sync gate +- иначе документ будет обещать безопасную миграцию, а монорепа получит тихий type/runtime drift + +### E. `lead_briefing` лучше, чем `lead_queue`, именно в Phase 1 + +`🎯 9 🛡️ 9 🧠 3` + +Причина выбора: + +- текущие briefing surfaces уже текстовые и хорошо ложатся в prompt workflow +- `lead_briefing` семантически ближе к уже существующим `member_briefing` и `task_briefing` +- JSON/queue-style surface можно добавить позже отдельным mode или отдельным tool, не мешая Phase 1 rollout + +Итог: + +- в Phase 1 canonical lead surface называем **`lead_briefing`** +- название `lead_queue` не используем как primary tool name в первой фазе + +### F. Lead name inference still drifts from shared lead detection + +`🎯 9 🛡️ 8 🧠 3` + +Факт из кода: + +- controller-side `inferLeadName(...)` ищет lead по ad hoc правилам и даже может упасть в `config.members[0]` +- shared `leadDetection` utilities живут по другому контракту и не должны тихо расходиться с queue semantics + +Следствие: + +- нельзя делать `lead_briefing` tool, который зависит от обязательного `leadName` input или от жёсткого "угадай одного lead actor" до того, как это выровнено +- role-scoped `lead_briefing` безопаснее, чем name-scoped surface + +### G. Как `lead_briefing` должен резолвить lead identity + +**1. Role-based lead surface without required `leadName` input** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-40` строк изменений + +Суть: + +- `lead_briefing` - это role-scoped surface +- tool не требует `leadName` +- внутри response можно вернуть display header с каноническим lead name, если он надёжно резолвится, но routing не зависит от этого имени + +Почему это лучший вариант: + +- lead agenda одна на команду, а не на произвольного actor name +- не заставляет новый tool зависеть от слабого `inferLeadName(...)` как от hard requirement +- снижает шанс ложных ошибок из-за alias drift + +Edge cases, которые этот выбор должен переживать без падения: + +- в конфиге нет ни одного явно резолвимого lead candidate +- в конфиге несколько lead-like actors и single canonical name не выводится надёжно +- исторические task payload всё ещё содержат owner/reviewer alias `team-lead` + +Во всех этих случаях: + +- `lead_briefing` всё равно должен возвращать valid oversight queue +- допустим generic header уровня `Lead queue` +- ошибка из-за ambiguous lead identity здесь считается неправильным поведением Phase 1 + +**2. Require explicit `leadName` parameter** + +`🎯 4 🛡️ 6 🧠 4` + +Примерный объём: `20-50` строк изменений + +Суть: + +- сделать `lead_briefing(memberNameLikeLead)` по аналогии с `task_briefing(memberName)` + +Риск: + +- лишняя зависимость от prompt/runtime name correctness +- появится ещё один alias-sensitive contract там, где role-based surface и так достаточен + +**3. Infer lead name hard and fail if ambiguous** + +`🎯 5 🛡️ 7 🧠 5` + +Примерный объём: `40-80` строк изменений + +Суть: + +- заставить tool сначала выбрать один canonical lead name и падать при малейшей ambiguity + +Риск: + +- создаёт лишний bootstrap blocker для surface, которому имя лида не нужно для core routing + +Выбор: + +- для Phase 1 брать **вариант 1** +- отдельную canonical lead-name cleanup делать независимо от самого `lead_briefing` + +### H. Public API width in Phase 0/1 + +**1. Keep new snapshot/agenda helpers internal, expose only user-facing surfaces** + +`🎯 9 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- structured agenda DTO и board snapshot helper живут как internal controller implementation detail +- наружу в public controller/tool surface добавляются только: + - `tasks.taskBriefing(...)` + - `tasks.leadBriefing(...)` + - расширенный `task_list(...)` + +Почему это лучший вариант: + +- минимальный public blast radius +- меньше type-shim drift +- проще Phase 0 acceptance и rollback + +**2. Export generic `getBoardSnapshot(...)` immediately** + +`🎯 5 🛡️ 7 🧠 5` + +Примерный объём: `40-90` строк изменений + +Риск: + +- второй consumer для этого API ещё не доказан +- придётся сразу синхронно расширять runtime export surface и оба локальных `.d.ts` shims + +**3. Export both generic snapshot helper and structured agenda DTO** + +`🎯 3 🛡️ 5 🧠 6` + +Примерный объём: `70-140` строк изменений + +Риск: + +- слишком раннее раскрытие внутреннего abstraction surface +- потом намного сложнее будет менять внутренний shape без downstream breakage + +Выбор: + +- для Phase 0/1 брать **вариант 1** + +### I. `mcpToolCatalog` сейчас group-scoped и не умеет безопасно скрывать `lead_briefing` внутри `task` + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `AGENT_TEAMS_MCP_TOOL_GROUPS` определяет teammate visibility на уровне whole group через `teammateOperational` +- `task` group сейчас teammate-operational +- `lead_briefing`, если просто добавить его в `AGENT_TEAMS_TASK_TOOL_NAMES`, автоматически попадёт в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- обе локальные `.d.ts` декларации сейчас знают только group ids: + - `task` + - `kanban` + - `review` + - `message` + - `process` + - `runtime` + - `crossTeam` + +Следствие: + +- rollout `lead_briefing` должен включать не только новый tool, но и отдельное catalog решение +- safest Phase 1 choice: + - новая `lead` group + - `teammateOperational: false` + - синхронное обновление обеих `AgentTeamsMcpToolGroupId` деклараций +- prompt migration без catalog migration здесь недостаточна + +### J. Board lock в controller path ещё не существует как реальный board primitive + +`🎯 10 🛡️ 9 🧠 4` + +Факт из кода: + +- task/kanban write paths сегодня не обёрнуты в общий board lock +- существующий `withFileLockSync(...)` живёт отдельно и используется не board path'ом, а cross-team inbox/outbox flow +- сам primitive синхронный, busy-wait и имеет фиксированные таймауты `5s` acquire / `30s` stale + +Следствие: + +- нельзя писать в плане просто "reuse existing lock" и считать, что board consistency уже почти есть +- нужен новый явный board contract: + - один lock на team board scope + - без silent unlocked fallback + - без ложного обещания, что low-level writes уже сериализованы сами по себе + +### K. `review_request` сейчас может оставить history/kanban drift при ошибке уведомления + +`🎯 10 🛡️ 10 🧠 4` + +Факт из кода: + +- `review_request(...)` делает `kanban.set -> task.history/reviewState update -> message send` +- если `message send` падает, catch делает только `kanban.clear` +- уже записанный `review_requested` history event не откатывается + +Следствие: + +- Phase 0 не должен строиться на предположении, что текущий rollback у review flow transactionally чистый +- safest fix: + - board mutation commit считать отдельной фазой + - уведомления отправлять post-commit как best-effort side effect + - queue semantics не должна зависеть от успеха inbox delivery + +### L. Main-side compatibility helpers уже расходятся с queue-safe reviewer contract + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `TeamDataService.attachKanbanCompatibility(...)` берёт `reviewState` из persisted field +- `reviewer` там резолвится как `kanbanTaskState.reviewer ?? history(review_approved|review_started|review_requested)` + +Следствие: + +- agenda rollout нельзя строить через reuse этих compatibility semantics +- current-cycle queue resolver должен жить отдельно и явно +- если потом main/UI захочет тот же semantic surface, он должен звать официальный queue/inventory contract, а не копировать старую compatibility derivation + +### M. У `task_list` ещё нет настоящего backing contract для filters + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- MCP `task_list` сейчас делает только: + - `controller.tasks.listTasks()` + - `.map(slimTaskForList)` +- `ControllerTaskApi.listTasks()` типизирован без filters + +Следствие: + +- filters/limit для `task_list` требуют не только новую zod-схему в MCP tool +- нужно отдельно выбрать semantic owner: + - dedicated controller inventory method + - или internal controller inventory helper +- иначе filtered `task_list` получится полуручным MCP-side overlay без нормального contract owner + +### N. Новая MCP group требует отдельного registration wiring, а не только catalog entry + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `mcp-server/src/tools/index.ts` строит registration через `REGISTRATION_BY_GROUP[group.id]` +- если добавить новый group id без этого map entry, `registerTools()` получит `undefined register` +- если наивно направить и `task`, и `lead` group на один и тот же `registerTaskTools`, легко получить duplicate registration того же tool set + +Следствие: + +- rollout новой `lead` group обязан синхронно решить registration strategy +- safest path: + - либо отдельный `registerLeadTools` + - либо другой явный one-time registration design без дублирования task tools +- одного изменения `mcpToolCatalog.js` здесь недостаточно + +### O. Lead permission seed path сейчас завязан на teammate operational tool set + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `TeamProvisioningService` в lead bootstrap spec кладёт `permissionSeedTools` из `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- runtime suggestion expansion и teammate settings seeding тоже завязаны на этот же список +- этот permission seed path вообще активируется только когда `skipPermissions === false` +- при `skipPermissions !== false` launch идёт через `bypassPermissions`, и availability tool'а определяется registration/runtime wiring, а не seed list + +Следствие: + +- если `lead_briefing` уходит в новую non-teammate group, он не попадёт в lead bootstrap seed автоматически +- prompt может требовать `lead_briefing` как first call раньше, чем runtime permission path к нему стабилизирован +- rollout должен добавить отдельный lead-first seed contract, а не надеяться, что teammate list "и так почти подходит" +- при этом в плане надо различать 2 разных вопроса: + - tool **available** at runtime + - tool **pre-allowed/seeded** in permission-enabled mode +- смешивать availability и permission seeding в один пункт нельзя, иначе rollout обещает слишком много + +### Q. MCP preflight readiness сейчас зафиксирован только для `member_briefing` + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `TeamProvisioningService` делает explicit `tools/list` + `tools/call` validation для `member_briefing` +- при отсутствии этого tool launch path падает как real bootstrap error +- аналогичного explicit readiness gate для `lead_briefing` сейчас нет + +Следствие: + +- если `lead_briefing` становится canonical first call, это пока ещё не равно hard-validated launch invariant +- Phase 1/1.5 должен выбрать один из честных путей: + - добавить role-aware required-tool preflight + - или сохранить explicit fallback до его появления +- нельзя писать в плане так, будто у lead path уже есть та же степень bootstrap гарантии, что и у teammate path + +### R. Для lead bootstrap list сейчас нет exported constant, аналогичного teammate constant + +`🎯 10 🛡️ 9 🧠 2` + +Факт из кода: + +- `mcpToolCatalog.js` экспортирует `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- `TeamProvisioningService` импортирует именно этот exported constant +- аналогичного exported constant для lead bootstrap surfaces сейчас нет + +Следствие: + +- если lead bootstrap tools выбрать правильно, их всё равно легко положить "временным локальным массивом" в runtime service +- это создаст новый source of truth рядом с catalog и tests +- safest path: + - экспортировать dedicated lead bootstrap constants из controller package root + - и уже их использовать в runtime/bootstrap wiring + +### S. Unreadable task rows сейчас silently выпадают из raw reader + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `taskStore.listRawTasks(...)` обходит все task JSON files +- при `normalizeTask(...)` error такая строка просто пропускается +- это значит, что malformed task row может исчезнуть из list-based views без явного сигнала + +Следствие: + +- derived queue не должна inherit'ить semantics "если задача не читается, притворимся, что её нет" +- особенно опасно это для lead surface, потому что board corruption превращается в silent omission +- safest Phase 0 path: + - queue-grade snapshot builder собирает `anomalies[]` + - `lead_briefing` поднимает такие случаи как repair bucket / warning summary + - `task_briefing` не делает вид, что board чист, если snapshot уже увидел unreadable rows + +### P. `createController()` автоматически публикует весь `module.exports` bound module'а + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `controller.js` делает `bindModule(context, tasks)` поверх всего `module.exports` из `internal/tasks.js` +- значит любой новый export из `tasks.js` автоматически становится method на `controller.tasks` + +Следствие: + +- internal agenda/inventory helpers нельзя просто "временно экспортнуть" из `tasks.js` +- иначе public controller surface начнёт расползаться почти случайно +- safest path: + - отдельный internal helper module + - или local non-exported functions в `tasks.js` + +--- + +## 4. Что не является целью + +- не строим в Phase 1 полноценный event-sourced board engine +- не переносим source of truth в отдельный projection-файл +- не делаем сложный real-time delta protocol с первой итерации +- не пытаемся заменить `task_get` подробной очередью +- не делаем `task_list` основным рабочим API для тиммейта + +--- + +## 5. Рассмотренные варианты + +### Вариант 1. Derived agenda on read + team lock + role queues + +`🎯 10 🛡️ 10 🧠 6` + +Примерный объём: `250-420` строк изменений + +Суть: + +- не создаём persisted projection как новый durable слой +- строим derived queue прямо во время чтения +- читаем raw task/review/kanban состояние под общим team-level lock +- делаем канонические queue surfaces для лида и участника +- `task_list` превращаем в filtered inventory + +Плюсы: + +- минимальный риск двойного source of truth +- проще доказать корректность +- быстрее rollout +- меньше шансов получить stale queue + +Минусы: + +- нет built-in delta sync с первого дня +- каждое queue чтение требует пересчёта + +### Вариант 2. Persisted projection sidecar с первого дня + +`🎯 8 🛡️ 7 🧠 6` + +Примерный объём: `320-520` строк изменений + +Суть: + +- при каждом mutation пересчитывать и сохранять projection-файл рядом с board state + +Плюсы: + +- быстрые чтения +- удобная база для future delta + +Минусы: + +- второй durable слой +- выше риск расхождения raw state и projection +- нужно аккуратно закрывать partial write / multi-file atomicity + +### Вариант 3. Сразу agenda + delta + persisted projection + +`🎯 6 🛡️ 5 🧠 8` + +Примерный объём: `450-750` строк изменений + +Суть: + +- сразу строить полноценный snapshot/revision/delta протокол + +Плюсы: + +- самая "умная" архитектура на бумаге + +Минусы: + +- это лучший способ слишком рано усложнить систему +- высокий риск словить subtle bugs в очередях +- большая цена ошибок, потому что именно queue tool влияет на поведение агентов + +### Выбор + +Выбираем **Вариант 1 как Phase 1**, а delta/revision переносим в **Phase 2**. + +Это даёт правильную последовательность: + +1. сначала стабилизируем семантику и ownership +2. потом оптимизируем транспорт и payload + +--- + +## 6. Архитектурный принцип + +Правильный порядок слоёв такой: + +1. **Raw source of truth** + - task files + - kanban-state + - review history / reviewState + +2. **Derived decision layer** + - `actionOwner` + - `nextAction` + - `queueCategory` + - `watchers` + - `reasonCode` + +3. **Role-based queue surfaces** + - teammate agenda + - lead agenda via `lead_briefing` + +4. **Inventory/search surface** + - filtered `task_list` + +5. **Full-detail surface** + - `task_get` + +⚠️ Важно: политика работы должна жить в layer 2, а не в голове LLM. + +--- + +## 7. Канонические tool surfaces + +### 7.1 `member_briefing` + +Назначение: + +- identity +- роль +- рабочие правила +- как пользоваться board tooling + +Не должно включать: + +- полный живой список задач +- большие live queue payload + +Причина: + +- bootstrap-инструмент не должен разрастаться вместе с board state +- иначе он станет одновременно и prompt bootstrap, и live queue, и будет быстро протухать + +### 7.2 `task_briefing` + +Назначение: + +- каноническая compact operational queue для конкретного участника + +Новый смысл: + +- не просто "мои assigned tasks" +- а "что мне сейчас делать и что мне важно знать" + +Структура ответа: + +- identity / actor +- `actionable` +- `awareness` +- компактные counters +- `revision` в future phase + +⚠️ Важное уточнение после code review: + +- в текущем runtime `task_briefing` возвращает текст, а не JSON +- поэтому safest Phase 0/1 path такой: + - внутри строим **structured agenda DTO** + - наружу по-прежнему рендерим компактный текст для совместимости + - JSON-variant можно добавлять позже как отдельный surface или новый output mode + +Так мы не блокируем future delta/revision, но и не делаем лишний breaking change в самом начале. + +Exact Phase 1 contract: + +- input: + - `teamName` + - `memberName` - обязателен +- output: + - text briefing +- semantics: + - actor-specific + - primary teammate operational surface + - built from internal structured agenda DTO, а не напрямую из legacy formatter logic + +Дополнительное архитектурное правило: + +- `taskStore.formatTaskBriefing(...)` в нынешнем виде не должен становиться permanent semantic owner новой agenda logic +- правильный direction такой: + - controller-owned structured agenda DTO + - текстовый renderer для `task_briefing` + - текстовый renderer для `lead_briefing` + +### 7.3 `lead_briefing` + +Нужен отдельный lead surface. + +Выбор имени для Phase 1: + +- canonical tool name: `lead_briefing` + +Почему не `lead_queue`: + +- текущие briefings уже текстовые +- prompt ecosystem уже заточен на human-readable briefing surfaces +- это уменьшает объём breaking changes в MCP descriptions и lead prompts +- future structured/JSON variant можно добавить позже без конфликта имён + +Почему не надо использовать raw `task_list` как lead queue: + +- лид тоже не должен разгребать весь inventory каждый ход +- lead queue должна показывать именно: + - unassigned + - reviewer missing + - clarification needed + - dependency repair + - review routing anomalies + - waiting on user + - aggregate board pressure + +Рекомендация по rollout: + +- вводим новый lead-specific surface отдельно +- в первой фазе это именно `lead_briefing`, а не новый JSON queue contract +- только после этого перестаём учить lead использовать `task_list` как first stop +- не делаем silent redefinition `task_list` в тот же самый момент + +Exact Phase 1 contract: + +- input: + - `teamName` + - без обязательного `leadName` +- controller contract: + - `tasks.leadBriefing(): Promise` +- MCP contract: + - `lead_briefing { teamName, claudeDir? }` +- output: + - text briefing +- semantics: + - role-scoped lead operational surface + - не зависит от точного caller alias + - может отображать resolved lead name в header, но routing не должен зависеть от него +- ambiguity handling: + - unique lead name найден -> можно показать его в header + - unique lead name не найден -> вернуть generic `Lead queue` header без ошибки +- catalog placement: + - отдельная `lead` group + - `teammateOperational: false` + - `lead_briefing` не должен попадать в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` + +### 7.3.1 Bootstrap sequencing for `lead_briefing` + +Здесь нельзя делать rollout "в любом порядке". + +Безопасная последовательность такая: + +1. сначала зарегистрировать `lead_briefing` как отдельный tool surface: + - новая `lead` group + - явный registration path в `mcp-server/src/tools/index.ts` + - синхронные `.d.ts` updates +2. потом экспортировать отдельный lead bootstrap tool list рядом с teammate constants +3. потом wired'ить lead permission seed path для режима, где `skipPermissions === false` +4. потом выбрать один честный runtime contract для first-turn availability: + - либо role-aware preflight hard-check + - либо явный documented fallback +5. только после этого переписывать lead prompt так, чтобы `lead_briefing` был canonical first action + +No-go rule: + +- нельзя сначала написать в lead prompt "сначала вызови `lead_briefing`", а потом уже разбираться, видит ли его runtime вообще +- нельзя считать `bypassPermissions` mode доказательством того, что отдельный lead bootstrap path не нужен +- availability, registration и permission seeding - это три разных слоя rollout + +### 7.4 `task_list` + +Новая роль: + +- filtered inventory/search +- fallback browse tool +- не основной operational queue + +Желаемое поведение: + +- additive filters только по стабильным dimension'ам: + - `owner` + - `status` + - `reviewState` + - `kanbanColumn` + - `relatedTo` + - `blockedBy` + - `limit` +- для routine workflow агентам в prompt рекомендуем не `task_list`, а `task_briefing` + +Что сознательно **не** добавляем в первой фазе: + +- `member` filter +- `reviewer` filter + +Причина: + +- `member` слишком двусмысленный: owner, actionOwner, watcher или просто "как-то связан" +- `reviewer` в текущем runtime не является стабильным raw inventory field +- actor-centric queries должны идти через `task_briefing` и `lead_briefing`, а не через перегруженный `task_list` + +Уточнение по фильтрам: + +- `status` фильтрует raw `task.status` +- `reviewState` фильтрует effective review state +- `kanbanColumn` в первой фазе допустим только как alias для review overlay columns: + - `review` + - `approved` +- не надо вводить generic `column=todo|in_progress|done`, будто inventory layer уже знает full kanban model для всех задач +- `relatedTo` и `blockedBy` должны принимать обычный task ref в том же формате, что и `task_get` / `resolveTaskRef` + +Правила фильтрации: + +- фильтры conjunctive, а не "любой из" +- `limit` применяется после фильтрации +- отсутствие фильтров сохраняет совместимую unfiltered semantics +- filtered `task_list` не сортирует по agenda-priority и не подменяет `actionOwner` +- output остаётся slim inventory JSON, а не agenda DTO +- если filter требует kanban knowledge (`reviewState`, `kanbanColumn`), он реализуется в controller-owned inventory layer, а не через raw `taskStore.listTasks()` напрямую + +Exact Phase 1 MCP contract: + +- `task_list { teamName, claudeDir?, owner?, status?, reviewState?, kanbanColumn?, relatedTo?, blockedBy?, limit? }` + +Exact backing rule: + +- raw `tasks.listTasks()` не надо тихо переопределять как filtered inventory API +- safest path: + - dedicated controller inventory contract + - или internal controller-owned inventory helper, который является semantic owner для MCP tool +- MCP layer не должен сам становиться permanent owner review/kanban-aware filtering logic + +Output-shape migration rule: + +- текущий `slimTaskForList(...)` blocklist-подход приемлем только как legacy compatibility starting point +- целевой compact contract для `task_list` должен быть explicit allowlist inventory row +- migration на allowlist делается явно и с contract tests, а не тихо "когда-нибудь потом" + +Public-surface rule for Phase 0/1: + +- `task_briefing` +- `lead_briefing` +- `task_list` + +это public MCP/controller surfaces. + +А вот: + +- structured agenda DTO +- generic board snapshot helper + +остаются internal implementation details, пока у них не появится второй доказанный consumer. + +### 7.4.1 Target inventory row for `task_list` + +Чтобы migration away from blocklist была однозначной, у `task_list` нужен целевой public row contract уже в плане. + +Target V1 row: + +```ts +type TaskInventoryRow = { + id: string; + displayId: string; + subject: string; + status: 'pending' | 'in_progress' | 'completed' | 'deleted'; + owner?: string; + reviewState: 'none' | 'review' | 'needsFix' | 'approved'; + needsClarification?: 'lead' | 'user'; + blockedBy?: string[]; + blocks?: string[]; + related?: string[]; + commentCount: number; + createdAt?: string; + updatedAt?: string; +}; +``` + +Важно: + +- здесь `reviewState` - это effective inventory review state, а не слепой passthrough сырого `task.reviewState` +- inventory row deliberately не включает: + - `comments` + - `historyEvents` + - `workIntervals` + - `attachments` + - `prompt` + - `sourceMessage` + - произвольные future fields по blocklist-инерции +- allowlist должен быть закрытым и тестируемым +- если позже реально понадобится ещё одно поле, его добавляют осознанно через contract change, а не "оно само просочилось" + +Уточнение по migration safety: + +- в раннем rollout лучше сначала добавить фильтры и новый `lead_briefing` +- а default unfiltered semantics `task_list` оставить совместимой, пока prompts и callers не переедут +- teammate catalog можно урезать раньше, чем глобально менять meaning самого инструмента + +### 7.5 `task_get` + +Роль остаётся прежней: + +- полный контекст одной конкретной задачи +- history/comments/dependencies/files/clarifications + +Новая практика: + +- queue surface выдаёт компактные карточки и ссылки на task ids +- детали читаются через `task_get` + +--- + +## 8. Derived read model + +### 8.1 Raw входы + +Derived layer должен смотреть на: + +- `task.status` +- `task.owner` +- `task.dependencies` +- `task.reviewState` +- history events по review / status / clarification +- kanban column +- reviewer pool availability +- runtime identity actor +- queue roster snapshot + +### 8.1.1 History-derived vs field-derived signals + +Это важное уточнение после code review. + +Не все workflow-сигналы в текущей системе одинаково "историчны": + +- **history-derived** + - review state + - review cycle transitions + - work status transitions +- **field-derived** + - current owner + - current `needsClarification` + - current dependency lists + - current related links + +Практическое следствие: + +- review resolver можно и нужно строить на `historyEvents` +- owner/clarification resolver нельзя делать так, будто у него такой же append-only history contract внутри task JSON +- task logs и transcript activity могут быть полезны для диагностики, но не должны становиться required input для queue derivation в Phase 0/1 +- actor identity normalization обязательна: + - реальное имя лида + - `team-lead` + должны считаться одним logical actor там, где речь идёт о valid ownership/reply semantics +- roster validity должна проверяться по одной канонической queue-side нормализации, а не по смешению controller и UI resolver heuristics + +Ещё одно важное следствие: + +- queue semantics и stall-monitor semantics не обязаны совпадать 1-в-1 +- для queue review может быть actionable уже с `review_requested` +- для stall-monitor "started review" может начинаться только с `review_started` + +Это не конфликт, а разные вопросы: + +- queue отвечает "кто должен сделать следующий шаг" +- stall-monitor отвечает "есть ли доказательство, что review реально начали" + +### 8.1.2 Hard exclusions for Phase 0/1 + +Чтобы derived queue не строилась на сигналах, которые код сегодня не поддерживает как надёжные: + +- не используем `kanban-state.tasks[taskId].reviewer` для routing +- не используем `kanban.reviewers[]` как assignment конкретного reviewer +- не используем task logs / transcript activity как required input +- не выводим mandatory review из free-form role текста вроде `reviewer`, `qa`, `tech-lead` + +### 8.1.3 Signal trust matrix + +Важное правило: derived queue не "усредняет" все сигналы. У каждого сигнала должен быть свой trust level. + +| Signal | Role in queue | Trust level in Phase 0/1 | +| --- | --- | --- | +| `historyEvents.review_*` текущего цикла | active review routing | authoritative | +| `task.reviewState` | fallback review signal | advisory fallback | +| `kanban-state.tasks[taskId].column` | overlay hint for review/approved | advisory fallback | +| `task.owner` | current accountable implementer | authoritative after roster normalization | +| `task.needsClarification` | current wait reason | authoritative after explicit-clear hardening | +| `task.blockedBy` / `blocks` / `related` | current dependency graph | authoritative but target refs must be revalidated | +| `kanban.reviewers[]` | reviewer pool / candidate set | advisory only, never per-task routing | +| `kanban-state.tasks[taskId].reviewer` | per-task reviewer | forbidden for routing in Phase 0/1 | +| task logs / transcript activity | diagnostics | debug-only | + +Практическое правило: + +- если два сигнала противоречат друг другу, выигрывает более trusted source +- weaker signal можно использовать только как fallback, но не как "доказательство против" stronger signal + +### 8.1.4 Conflict resolution when sources disagree + +Ниже не просто примеры, а конкретные implementation guardrails: + +| Conflict | Winner | Why | +| --- | --- | --- | +| `historyEvents` говорит, что review активен, а `task.reviewState === none` | history | review cycle уже durable в истории | +| `review_approved` или `review_changes_requested` уже записан, но kanban всё ещё выглядит как `review` | history | stale kanban overlay не должен держать review открытым | +| `task.reviewState === review`, но current-cycle reviewer не резолвится | lead `assign_reviewer` | open review без валидного reviewer хуже, чем false confident routing | +| `needsClarification` выставлен и одновременно есть owner/reviewer routing | clarification | вопрос/блокер сильнее обычного execution flow | +| owner невалиден, но review reviewer валиден | lead `assign_owner` | потерян accountable owner, Phase 0/1 чинит ownership раньше обычного routing | +| `requestReview` durable commit прошёл, а notification send упал | committed board state | queue truth не должна зависеть от доставки сообщения | + +Дополнительный guardrail: + +- derived queue не пытается "склеивать" conflicting truth из `task.reviewState` и `kanban` в новый synthetic state +- если resolver не может безопасно доказать ownership/reviewer path, он обязан деградировать в lead repair bucket + +### 8.2 Derived поля + +Минимальный набор: + +```ts +type DerivedActionOwner = + | { kind: 'member'; memberName: string } + | { kind: 'lead' } + | { kind: 'user' } + | { kind: 'none' }; + +type DerivedNextAction = + | 'execute' + | 'review' + | 'apply_changes' + | 'assign_owner' + | 'assign_reviewer' + | 'clarify_with_user' + | 'clarify_with_lead' + | 'repair_dependencies' + | 'wait_dependency' + | 'wait_review' + | 'none'; + +type DerivedQueueCategory = 'actionable' | 'waiting' | 'oversight' | 'done'; +``` + +### 8.2.1 Minimal internal agenda DTO for Phase 0/1 + +Чтобы text renderers не собирали семантику каждый по-своему, у внутреннего DTO должен быть один минимальный контракт: + +```ts +type AgendaItem = { + taskId: string; + displayId: string; + subject: string; + status: 'pending' | 'in_progress' | 'completed' | 'deleted'; + reviewState: 'none' | 'review' | 'needsFix' | 'approved'; + actionOwner: DerivedActionOwner; + nextAction: DerivedNextAction; + queueCategory: DerivedQueueCategory; + reasonCode: string; + owner?: string; + reviewer?: string | null; + blockedBy?: string[]; + watchers?: string[]; + needsClarification?: 'lead' | 'user'; + lastMeaningfulEventAt?: string; + derivedFrom?: string[]; +}; + +type AgendaAnomaly = { + code: + | 'unreadable_task' + | 'invalid_dependency_ref' + | 'invalid_owner_ref' + | 'invalid_reviewer_ref'; + detail: string; + taskId?: string; +}; + +type AgendaSnapshot = { + actor: + | { kind: 'member'; memberName: string } + | { kind: 'lead' }; + actionable: AgendaItem[]; + awareness: AgendaItem[]; + anomalies: AgendaAnomaly[]; + counters: { + actionable: number; + awareness: number; + blocked: number; + waitingOnUser: number; + waitingOnLead: number; + reviewNeeded: number; + anomalies: number; + }; +}; +``` + +Важно: + +- это internal contract для derivation + rendering +- `task_briefing` и `lead_briefing` рендерятся из него +- `task_list` из него не рендерится и не обязан совпадать с ним по shape + +Дополнительно полезно: + +- `reasonCode` +- `watchers` +- `relatedMemberNames` +- `blockedBy` +- `reviewer` +- `lastMeaningfulEventAt` +- `derivedFrom` + +Где `derivedFrom` - это внутренний debug/verification след: + +- `history_review_requested` +- `history_review_started` +- `kanban_state` +- `clarification_flag` +- `dependency_graph` +- `owner_status` + +Он не обязателен в финальном публичном payload, но сильно помогает тестировать resolver и объяснять спорные queue decisions. + +Ограничение по `lastMeaningfulEventAt`: + +- это поле нельзя делать опорой для routing +- его можно использовать для sorting / summaries / tie-breaks + +Причина: + +- часть важных изменений живёт только в current task fields и `updatedAt` +- а не в полноценном append-only workflow event stream + +### 8.2.2 `reasonCode` should use a closed v1 taxonomy + +Если `reasonCode` оставить "любой строкой", resolver и renderer очень быстро начнут drift'ить. + +Минимальный закрытый v1 набор лучше зафиксировать сразу: + +- `waiting_user_clarification` +- `waiting_lead_clarification` +- `owner_missing` +- `owner_invalid` +- `review_reviewer_missing` +- `review_requested_waiting_pickup` +- `review_in_progress` +- `dependency_broken` +- `dependency_waiting` +- `needs_fix` +- `owner_executing` +- `owner_ready` +- `completed_no_followup` +- `terminal_approved` +- `terminal_deleted` +- `anomaly_unreadable_task` + +Это не значит, что список никогда не расширится. + +Это значит: + +- расширение должно быть явным +- snapshot tests и text renderers должны работать от одной allowlist reason codes +- новая строка reasonCode считается contract change, а не случайной внутренней деталью + +### 8.3 Что такое `actionOwner` + +`actionOwner` - это **один primary actor**, который должен сделать следующий meaningful workflow шаг. + +Это не: + +- просто owner +- просто reviewer +- просто последний писавший комментарий + +Это именно ответ на вопрос: + +> кто сейчас должен сдвинуть задачу вперёд + +### 8.4 Что такое `watchers` + +`watchers` - это **не primary routing**, а вспомогательная visibility-модель. + +Их роль: + +- awareness +- summaries +- future notifications +- optional context in lead queue + +Их нельзя использовать как главный способ строить operational queue, иначе лид или участник снова начнут видеть почти всё подряд. + +--- + +## 9. Базовый precedence для `actionOwner` + +Ниже канонический порядок принятия решения. Более раннее правило сильнее позднего. + +### 9.1 Terminal + +Если задача: + +- deleted +- approved +- окончательно завершена без ожидаемого follow-up + +Тогда: + +- `actionOwner = none` +- `nextAction = none` +- в operational queue не попадает +- остаётся доступной через inventory/search + +### 9.2 Clarification from user + +Если задача явно ждёт ответа от пользователя: + +- `actionOwner = user` +- `nextAction = clarify_with_user` +- лид получает oversight item +- owner получает awareness item, если задача его + +### 9.3 Clarification from lead + +Если задача ждёт решения/уточнения от лида: + +- `actionOwner = lead` +- `nextAction = clarify_with_lead` + +Phase 0 guardrail: + +- пока clarification semantics стабилизированы через explicit clear, комментарий лида сам по себе не считается достаточным для снятия wait-state +- это сознательная консервативность: лучше лишний lead oversight item, чем скрытый неочищенный blocker + +### 9.4 Invalid owner + +Если owner отсутствует, пустой, удалён или не резолвится в roster: + +- `actionOwner = lead` +- `nextAction = assign_owner` + +Уточнение: + +- `owner === "team-lead"` нельзя автоматически считать invalid +- alias `team-lead` и canonical lead name должны проходить через одну lead-normalization логику + +### 9.5 Review requested, reviewer unresolved + +Если задача ушла в review, но reviewer не удалось надёжно определить: + +- `actionOwner = lead` +- `nextAction = assign_reviewer` + +### 9.6 Review requested, reviewer resolved + +Если review активен и reviewer валиден: + +- `actionOwner = member(reviewer)` +- `nextAction = review` +- owner получает awareness item `wait_review` + +Дополнительная проверка валидности reviewer обязательна: + +- если reviewer помечен как removed member +- или reviewer больше не резолвится в текущий roster + +то задача должна деградировать в: + +- `actionOwner = lead` +- `nextAction = assign_reviewer` + +Уточнение: + +- reviewer нельзя выводить только из `kanban-state.reviewers[0]` как будто это стабильное назначение на конкретную задачу +- для queue нужен отдельный resolver активного reviewer именно для **текущего review cycle** +- safest precedence: + - latest `review_started.actor` внутри текущего review cycle + - latest `review_request.reviewer` внутри текущего review cycle + - иначе unresolved -> `assign_reviewer` + +Здесь есть важное ужесточение после повторного code review: + +- в Phase 0/1 **не надо** делать fallback в `kanban-state.tasks[taskId].reviewer` +- текущие write-path на входе в review всё равно пишут туда `null` +- значит такой fallback только создаст ложное ощущение надёжности, но не даст реальной пользы + +Практическая реализация должна быть совместима с уже существующей history-based derivation review state: + +```ts +function resolveCurrentCycleReviewer(task, validMembers) { + const events = [...(task.historyEvents ?? [])]; + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + + if (event.type === 'review_started' && isValidQueueMember(event.actor, validMembers)) { + return { reviewer: event.actor, source: 'history_review_started_actor' }; + } + + if ( + event.type === 'review_requested' && + isValidQueueMember(event.reviewer, validMembers) + ) { + return { reviewer: event.reviewer, source: 'history_review_requested_reviewer' }; + } + + if (event.type === 'review_approved' || event.type === 'review_changes_requested') { + break; + } + + if (event.type === 'status_changed' && event.to === 'in_progress') { + break; + } + + if (event.type === 'task_created') { + break; + } + } + + return { reviewer: null, source: 'none' }; +} +``` + +Консервативное правило: + +- если resolver сомневается, задача идёт в lead queue как `assign_reviewer` +- лучше false positive в lead queue, чем false confident routing на не того reviewer + +И ещё один важный guardrail: + +- даже если в `kanban-state` schema есть поле `reviewer`, Phase 0/1 не должны считать его canonical signal +- пока официальный mutation surface не поддерживает reviewer как стабильно обновляемое per-task поле, history-first resolver остаётся единственно правильной базой + +Дополнительные edge rules: + +- если `review_started.actor` и `review_requested.reviewer` расходятся, выигрывает `review_started.actor` как более сильное доказательство того, кто реально взял review +- если reviewer совпадает с owner, queue не пытается "исправить" это сама - self-review сегодня не запрещён runtime-контрактом, значит это отдельная policy-задача, а не routing-эвристика +- если последний `review_requested.reviewer` или `review_started.actor` больше не проходит queue roster normalization, routing деградирует в `assign_reviewer`, а не в попытку взять "первого доступного reviewer из пула" + +### 9.7 Broken dependencies + +Если есть dependency на несуществующую, deleted или испорченную задачу: + +- не делаем auto-unblock +- `actionOwner = lead` +- `nextAction = repair_dependencies` + +### 9.8 Healthy blocking dependency + +Если задача блокируется живой dependency: + +- `actionOwner = none` +- `nextAction = wait_dependency` +- owner получает awareness +- лид может видеть это только в aggregate summary или при stall/escalation + +### 9.9 Needs fixes after review + +Если был `review_request_changes` или equivalent state: + +- `actionOwner = member(owner)` +- `nextAction = apply_changes` + +### 9.10 In progress + +Если задача реально исполняется и ничего выше не сработало: + +- `actionOwner = member(owner)` +- `nextAction = execute` + +### 9.11 Pending with owner + +Если задача pending и готова к работе: + +- `actionOwner = member(owner)` +- `nextAction = execute` + +### 9.12 Completed without active review + +Если задача completed, reviewer не нужен или ещё не инициирован: + +- в Phase 0/1 по умолчанию `actionOwner = none` +- completed task не должна автоматически превращаться в `assign_reviewer` только из-за reviewer pool или free-form reviewer role в команде + +Важно: + +- пока в системе нет explicit machine-readable review policy, queue не должна придумывать обязательность review из эвристик +- review routing начинается только с явного review signal: + - `review_requested` + - `review_started` + - `reviewState === review` + - future explicit review policy field, если он когда-то появится + +--- + +## 10. Критические edge cases + +### 10.1 Self-review + +Если `reviewer === owner`, это плохой routing. + +Правильное поведение: + +- не давать такой задаче стать нормальной review-card для owner +- отправлять её в lead queue как `assign_reviewer` +- reasonCode: `self_review_invalid` + +### 10.2 Несколько reviewers + +Если когда-то появится multi-review: + +- Phase 1 не должен пытаться распределить actionOwner между несколькими людьми +- если активных reviewers > 1, это lead attention item, пока не появится формальная multi-review модель + +Иначе будет неочевидный primary owner. + +### 10.3 Missing reviewer после `review_request` + +Даже если текущий runtime допускает `review_request` без reviewer, queue layer не должен делать вид, что всё нормально. + +Правильное поведение: + +- lead action item +- не "висит у owner" +- не "висит ни у кого без объяснения" + +### 10.3.1 Previous review cycle bleed-through + +Отдельный риск: + +- если reviewer выводится из history слишком грубо, можно случайно подтянуть reviewer из предыдущего review cycle + +Поэтому queue resolver должен быть cycle-aware: + +- смотреть на последние review events +- учитывать reset после возврата задачи в `in_progress` / нового рабочего цикла +- не использовать старого approver как текущего reviewer только потому, что это последнее известное review-событие + +### 10.3.2 Review requested but not started yet + +Это отдельный важный sub-state. + +Если: + +- review уже request'нут +- reviewer валиден +- но `review_started` ещё не было + +то для queue это всё равно reviewer-owned actionable item: + +- `actionOwner = member(reviewer)` +- `nextAction = review` +- `reasonCode` стоит различать, например `review_requested_waiting_pickup` + +Но при этом нельзя механически переносить сюда stall-monitor semantics: + +- для stall policy review окно может считаться ещё не "started" +- для queue reviewer уже является тем, кто должен сделать следующий шаг + +### 10.4 Missing/deleted dependency + +Это не wait-state. Это broken workflow. + +Правильное поведение: + +- лид получает repair item +- owner не должен автоматически считать задачу разблокированной + +### 10.5 Комментарий на completed/review/approved задаче + +Новый комментарий сам по себе не меняет `actionOwner`. + +Иначе будут ложные reopening-сигналы. + +Комментарий может: + +- обновить `lastMeaningfulEventAt` +- появиться в awareness + +Но не должен автоматически переводить задачу в actionable queue другого актёра. + +### 10.6 Owner changed mid-review + +Если owner поменяли, пока review активен: + +- actionOwner остаётся reviewer, пока review открыт +- новый owner видит awareness +- после `review_request_changes` или `review_approve` уже действует обычная пост-review логика + +### 10.6.1 Removed member as owner/reviewer + +Если owner или reviewer указывает на участника, который уже в `removedNames`: + +- queue не должна считать такого actor valid action owner +- это не waiting state, а routing repair case + +Правильное поведение: + +- removed owner -> lead queue `assign_owner` +- removed reviewer -> lead queue `assign_reviewer` + +Причина: + +- `resolveTeamMembers(...)` уже умеет исключать removed members из активного roster +- значит derived agenda должна использовать ту же реальность, а не слепо верить старому имени в task field/history + +### 10.7 Runtime identity не определился + +После code review это не такой большой риск для `task_briefing`, как казалось сначала, потому что текущий MCP tool уже требует явный `memberName`. + +Поэтому правильная формулировка такая: + +- для `task_briefing` ambiguity runtime identity не должна быть primary problem +- для `member_briefing` fallback по runtime identity остаётся полезным bootstrap-path +- если появится future tool без явного `memberName`, он уже не должен молча опираться на неуверенную runtime identity + +Если system не смогла надёжно вывести `memberName` там, где identity всё же нужна: + +- queue tool не должен молча вернуть `No tasks` +- нужно: + - либо требовать `memberName` + - либо возвращать явную ошибку identity resolution + +Тихой пустой очереди быть не должно. + +### 10.8 Внешние записи мимо controller + +Если кто-то пишет в raw файлы вне контроллера: + +- derived read path всё равно должен уметь читать tolerant snapshot +- но team-level lock гарантирует consistency только для controller-driven mutations + +Это важно явно зафиксировать, чтобы не переоценить силу lock. + +### 10.9 Race при multi-file update + +Самый опасный баг в этой зоне: + +- reader увидел уже обновлённый task +- но ещё старый kanban/review state + +или наоборот. + +Именно поэтому Phase 1 лучше строить вокруг: + +- общего mutation coordination +- общего snapshot чтения под одним team-level lock + +### 10.10 Unreadable / corrupt task row + +Это отдельный failure mode, который нельзя трактовать как "задачи просто нет". + +Если queue-grade reader не может нормализовать task row: + +- snapshot builder должен записать anomaly +- `lead_briefing` должен показать repair/warning signal +- `task_briefing` не должен молча превращать board problem в "No tasks" + +Важное уточнение: + +- tolerant snapshot допустим +- silent omission - нет + +Самая опасная версия бага здесь такая: + +- damaged task исчезает из inventory +- никто не видит, что board уже частично испорчен +- derived queue выглядит "чище", чем реальное состояние команды + +--- + +## 11. Как должны выглядеть очереди + +### 11.1 Очередь участника + +Участник должен получать не "все свои задачи", а такой shape: + +```json +{ + "actor": { "kind": "member", "memberName": "alice" }, + "actionable": [ + { + "taskId": "task-12", + "displayId": "12", + "subject": "Implement sync retries", + "actionOwner": { "kind": "member", "memberName": "alice" }, + "nextAction": "execute", + "queueCategory": "actionable", + "reasonCode": "owner_ready", + "reviewer": null, + "blockedBy": [] + } + ], + "awareness": [ + { + "taskId": "task-9", + "displayId": "9", + "subject": "API auth refactor", + "actionOwner": { "kind": "member", "memberName": "bob" }, + "nextAction": "review", + "queueCategory": "waiting", + "reasonCode": "waiting_review" + } + ], + "anomalies": [], + "counters": { + "actionable": 1, + "awareness": 1, + "blocked": 0, + "waitingOnUser": 0, + "waitingOnLead": 0, + "reviewNeeded": 0, + "anomalies": 0 + } +} +``` + +Ключевая идея: + +- **actionable** должно быть коротким и операционным +- **awareness** должно быть ещё компактнее и не засорять reasoning + +### 11.2 Очередь лида + +Лид должен получать не full board, а priority bucket: + +- needs owner assignment +- needs reviewer assignment +- needs clarification from lead +- broken dependency graph +- waiting on user + +Важно: + +- `stalled review / stalled work` не должны становиться primary lead bucket самого agenda resolver в Phase 0/1 +- stall-сигналы можно потом добавить как отдельное summary/enrichment, если они уже приходят из существующего stall-monitor surface + +Плюс summary: + +- сколько задач у кого actionable +- сколько pending review +- сколько blocked +- сколько orphaned / anomalous + +Лид потом уже по id прицельно открывает `task_get` или filtered `task_list`. + +### 11.3 Inventory + +`task_list` остаётся полезным, но только как: + +- browse/search +- audit/debug +- filtered discovery + +Не как "starting point for every turn". + +--- + +## 12. Почему `watchers` нужны, но не должны править миром + +Полезные watcher-кейсы: + +- owner должен знать, что его задача ушла на review +- лид должен знать, что задача ждёт user input +- reviewer может быть watcher до момента formal review assignment + +Но если строить queue по watchers, получится: + +- лидер снова видит почти весь board +- участник получает слишком много secondary state +- LLM начинает путать "надо делать" и "полезно знать" + +Поэтому правило такое: + +- **routing делается по `actionOwner`** +- **watchers идут только во вторичный слой visibility** + +--- + +## 13. Locking и consistency model + +### 13.1 Что вводим + +В Phase 0 вводим controller-owned team-level board lock для операций, которые меняют: + +- task files +- review routing +- kanban state +- derived queue snapshot reads + +Но lock должен жить на правильном уровне: + +- не внутри каждой мелкой low-level функции +- а вокруг **композитных board operations** +- и вокруг queue snapshot read, который хочет увидеть консистентный cross-file state + +Рекомендуемая реализация: + +- новый controller-owned primitive вида `withTeamBoardLock(paths, fn)` +- lock file рядом с team state, например в `teamDir` +- reuse можно брать только как low-level идею file-based exclusivity, но не как слепое обещание, что текущий primitive уже подходит без contract hardening + +После дополнительного code review это важно уточнить жёстче: + +- существующий `withFileLockSync(...)` sync-only и busy-wait +- его текущие таймауты `5s` acquire / `30s` stale сами по себе ещё не являются доказанным board contract +- Phase 0 не должен молча падать обратно на unlocked read/write, если board lock не взят + +Минимальный board-lock contract для плана: + +- один lock на board scope команды, а не набор несвязанных task-file lock'ов +- lock timeout должен отдавать явную ошибку mutation/read caller'у, а не скрытый unlocked fallback +- lock держим только вокруг canonical board files и derived snapshot build +- уведомления и прочие non-board side effects не должны бесконечно удерживать board lock + +### 13.1.1 Mutation classes and exact commit boundary + +Чтобы rollout не был расплывчатым, полезно разделить board operations на 3 класса: + +**Class A - routing-affecting task mutations** + +- `task_set_owner` +- `task_set_status` +- `task_start` +- `task_complete` +- `task_set_clarification` +- `task_add_comment`, если комментарий меняет queue-visible state или `lastMeaningfulEventAt` +- dependency link/unlink, если оно меняет blocking graph + +Правило: + +- даже если durable write происходит в один task file, mutation всё равно должна проходить под board lock +- иначе queue snapshot под тем же contract не имеет единого serialization point + +**Class B - multi-file board mutations** + +- `review_request` +- `review_start` +- `review_approve` +- `review_request_changes` +- любые операции, которые меняют и task state, и kanban overlay, и возможно несколько task rows + +Правило: + +- весь board commit происходит под одним board lock +- lock release только после того, как durable board files уже записаны + +**Class C - post-commit side effects** + +- inbox/system notifications +- observability warnings +- expensive attachment copy/link work, если оно не влияет на queue routing + +Правило: + +- эти шаги идут после board commit +- они не имеют права отменять уже committed board mutation + +### 13.2 Зачем + +Чтобы queue read видел: + +- либо старый консистентный state +- либо новый консистентный state + +Но не невозможную смесь. + +### 13.3 Почему этого достаточно для Phase 1 + +Потому что Phase 1 не добавляет второй durable projection. + +Следовательно: + +- нечему отдельно "протухать" +- нет projection write, который надо атомарно коммитить рядом с raw state + +### 13.4 Ограничение + +Если кто-то пишет в raw файлы вообще в обход controller, lock этого не предотвратит. + +Но это допустимое ограничение, если: + +- UI +- MCP tools +- planned task workflow + +маршрутизируются через controller. + +После дополнительного просмотра кода это допущение выглядит разумным: + +- основные UI mutation paths уже идут через `getController(...).tasks/review/kanban` +- но это не значит, что в Phase 0 надо сразу переписать все main-side read paths под тот же lock + +Отдельно важно: + +- board читают не только MCP tools +- main-process сервисы вроде `TeamTaskReader`, `TeamKanbanManager` и stall-monitor snapshots тоже строят picture of truth из тех же файлов + +Более правильный scope для первой фазы: + +- queue projector читает state под controller-owned lock +- review/task multi-file mutations используют тот же controller-owned lock +- если позже какой-то non-controller consumer реально потребует agenda-grade snapshot, он должен идти через официальный snapshot API, а не копировать сырой read path + +Иначе можно случайно раздуть rollout до общего I/O refactor и потерять фокус на агентском operational surface. + +### 13.4.1 Notification boundary must be post-commit + +Это отдельное ужесточение после просмотра `review_request(...)`. + +Для queue correctness опасно, когда board mutation и inbox notification живут в одном pseudo-transaction, но rollback умеет откатить только часть шагов. + +Phase 0 rule: + +- canonical board mutation commit завершается до отправки inbox/system notifications +- notification send считается best-effort side effect +- неуспешная доставка уведомления не должна оставлять board в "откаченном наполовину" состоянии + +Практический смысл: + +- queue semantics не зависит от того, дошло ли служебное сообщение +- если уведомление упало, нужен warning/observability signal, а не partial rollback board state + +### 13.4.2 Failure contract must be explicit + +У board path должен быть не только lock, но и понятная failure semantics. + +Минимальный contract: + +- lock acquire timeout -> explicit retryable error, никакого unlocked fallback +- board commit failed до durable write -> mutation error, side effects не запускаются +- board commit succeeded, side effect failed -> mutation считается committed, side effect failure попадает в warning/diagnostics +- snapshot reader встретил unreadable row -> snapshot помечает anomaly, а не делает вид, что board полностью здоров + +Это особенно важно для `review_request(...)`-подобных flows: + +- если task/history уже committed +- а notification не ушла + +то queue обязана видеть committed review state, а не fictional rollback state + +--- + +## 13.5 Почему clarification надо стабилизировать до agenda rollout + +Это отдельный guardrail, потому что здесь уже есть расхождение между "как система себя описывает" и "как она реально работает". + +Сейчас: + +- prompts говорят, что `needsClarification: "lead"` auto-clear'ится, когда отвечает lead +- код в task store снимает этот флаг, когда комментирует **любой не-owner автор** + +Риск: + +- derived queue может решить, что лидер ответил и задача больше не ждёт clarification +- хотя фактически мог прокомментировать другой teammate или reviewer + +Рекомендуемая нормализация перед полноценным agenda rollout: + +- **Phase 0 safe mode** + - любой clarification clear'ится только через явный `task_set_clarification clear` + - queue не верит implicit auto-clear вообще +- **Phase 1 optional ergonomics** + - после стабилизации lead identity можно вернуть: + - `lead` clarification clear на комментарий лида + - `user` clarification clear на комментарий `user` + +Это важное изменение по сравнению с предыдущей версией плана: + +- раньше мы пытались сразу сохранить удобство auto-clear +- теперь приоритет сдвинут в пользу надёжности и объяснимости + +Практический вывод для rollout: + +- в Phase 0 `needsClarification` становится надёжным routing-сигналом именно потому, что clear происходит только явно +- ambiguous clarification больше не должен зависеть от того, кто случайно оставил комментарий + +Минимальный implementation note: + +- Phase 0: убрать implicit clear из routing expectation и синхронно обновить prompts/briefings +- если потом возвращать ergonomic auto-clear, делать это только на controller layer, а не внутри storage + +No-go rule: + +- нельзя оставлять situation, где prompt обещает "lead comment closes clarification", а queue layer всё ещё зависит от текущего store behavior "любой non-owner comment clears it" + +### 13.6 Почему task logs не должны становиться queue dependency в Phase 0/1 + +После дополнительного code review видно, что task log / transcript слой уже умеет видеть: + +- `task_set_owner` +- `task_set_clarification` +- reviewer details в board activity + +Но это **не** означает, что queue надо строить поверх логов. + +Почему это плохая идея для первой фазы: + +- логи тяжелее и дороже для read path +- это observability layer, а не canonical board state +- появится второй semantic source рядом с task/kanban state +- при расхождении будет очень сложно объяснить, почему board показывает одно, а queue решила другое + +Правило для Phase 0/1: + +- queue derivation строится только из canonical board state: + - task files + - kanban state + - roster resolution +- task logs допускаются только как: + - debug aid + - diagnostics + - future validation tooling + +--- + +## 14. Phase rollout + +### Phase 0 - Hardening the weak signals first + +Это новая обязательная фаза после дополнительного code review. + +Её цель: + +- не начинать agenda rollout поверх уже известных semantic cracks + +Что делаем: + +1. вводим controller-owned `withTeamBoardLock(...)` / `getBoardSnapshot(...)` для agenda reads и multi-file board mutations +2. выделяем отдельный queue-grade reviewer resolver, который работает только по текущему review cycle history +3. выносим inbox/system notifications за board-state commit boundary, чтобы message delivery не ломала queue semantics частичным rollback'ом +4. в Phase 0 переводим clarification semantics в explicit-clear-only mode и синхронно обновляем prompts/briefings +5. фиксируем canonical queue roster normalization в controller layer, а не в нескольких runtime местах сразу +6. создаём внутренний structured agenda DTO + text renderers для backward-compatible `task_briefing` и нового `lead_briefing` +7. выбираем настоящий semantic owner для filtered `task_list` и не оставляем это MCP-local ad hoc логикой +8. фиксируем registration strategy для новой `lead` group, чтобы `registerTools()` не получил missing или duplicate registration path +9. экспортируем явный source of truth для lead bootstrap tool list рядом с existing teammate constants +10. добавляем отдельный lead bootstrap permission seed path для `lead_briefing` и других first-turn lead surfaces +11. выбираем lead bootstrap strategy честно: + - role-aware preflight + - или explicit fallback, пока preflight ещё не внедрён +12. держим agenda/inventory helpers вне accidental public `controller.tasks` surface +13. добавляем filters/limit в `task_list`, не ломая пока его default meaning +14. синхронизируем local `.d.ts` contracts для `agent-teams-controller`, чтобы новые exports/tools не жили только в runtime без type surface +15. явно фиксируем phase rule: no inferred mandatory review without explicit policy signal +16. переписываем lead prompt snippets в `TeamProvisioningService`, чтобы canonical first call стал `lead_briefing`, а не raw `task_list` +17. вводим queue-grade anomaly reporting, чтобы unreadable task rows не исчезали silently из board views + +Критерий завершения Phase 0: + +- у нас есть набор слабых сигналов, которые либо стабилизированы, либо явно помечены как unreliable +- после этого derived agenda уже можно строить на нормальной базе, а не на wishful thinking + +Phase 0 exit gates: + +- agenda resolver не использует per-task kanban reviewer +- review/task multi-file operations и agenda snapshot используют один controller lock contract +- board-state mutations не откатываются частично из-за ошибки inbox/system notification +- clarification больше не может тихо исчезнуть из queue из-за комментария произвольного teammate +- queue roster normalization зафиксирован тестами на `team-lead`/`lead`, removed members, external recipients и generated ids +- новая `lead` group имеет реальный MCP registration path без duplicate tool registration +- exported lead bootstrap constant существует и используется как source of truth +- lead bootstrap permission seed включает `lead_briefing`, если prompt делает его first action +- lead path либо валидирует `lead_briefing` как required tool, либо сохраняет явный fallback до такой валидации +- agenda/inventory helper не утёк в public `controller.tasks` surface случайным export'ом +- filtered `task_list` больше не висит на неопределённой MCP-only semantics +- `task_list` default unfiltered semantics остаётся совместимой, несмотря на добавление filters/limit +- lead prompt больше не рекламирует `task_list` как primary board entrypoint +- `lead_briefing` contract зафиксирован как role-scoped tool без обязательного `leadName` +- generic board snapshot helper остаётся internal implementation detail +- queue snapshot больше не может silently терять unreadable task rows без warning/anomaly signal + +### Phase 1 - Semantic cleanup without persisted projection + +Цель: + +- правильно определить action routing +- сократить payload +- убрать путаницу между inventory и queue + +Что делаем: + +1. добавляем derived resolver для `actionOwner` / `nextAction` / `reasonCode` +2. строим новый queue projector поверх raw state +3. используем controller-owned team-level lock для queue snapshot и board mutations +4. перерабатываем `task_briefing` под operational agenda +5. добавляем отдельный `lead_briefing` +6. ужимаем и фильтруем `task_list`, но без слишком раннего silent semantic break +7. обновляем prompts, чтобы: + - teammates стартовали с `task_briefing` + - lead стартовал с `lead_briefing` + - `task_list` использовался только при необходимости browse/search +8. поэтапно меняем teammate operational tool catalog, чтобы `task_list` перестал быть default teammate shortcut +9. переводим `task_list` output с legacy blocklist semantics на explicit allowlisted inventory contract, когда callers уже готовы к явному переходу +10. только если когда-то появится explicit review policy, рассматриваем более сильный post-complete review routing + +Ожидаемый результат: + +- агент сразу видит свой реальный action set +- лид видит routing issues и pressure points, а не весь board dump +- исчезает большая часть лишней token-нагрузки + +### Phase 1.5 - Compatibility and prompt hardening + +Что делаем: + +- сохраняем backward compatibility имени `task_briefing` +- если нужно, старый формат прячем за флагом или мягким переходом +- обновляем tool descriptions +- обновляем team provisioning instructions +- меняем lead prompts так, чтобы `lead_briefing` стал canonical first call, а `task_list` остался inventory/audit tool +- teammate prompts дополнительно поджимаем, чтобы они не тянули `task_list` без явной причины +- если role-aware lead preflight выбрали не сразу, сохраняем temporary explicit fallback path до завершения readiness hardening +- обновляем tests, которые сегодня закрепляют blocklist-semantics `task_list`, чтобы transition был явным и осознанным + +### Phase 2 - Revision first, delta second + +Важно: Phase 2 не должен начинаться, пока Phase 1 не покажет стабильную queue semantics. + +### Phase 2A - Revision / no-change short-circuit + +Добавляем: + +- stable queue `revision` +- возможность ответа `unchanged` + +Это уже даст экономию токенов и ускорение без сложного diff protocol. + +Лучший practical path: + +- повторить pattern, уже используемый в `feedRevision` +- строить `revision` как stable hash от уже нормализованного compact agenda DTO +- обязательно сортировать items детерминированно перед hashing + +Что именно должно входить в revision payload: + +- actor +- actionable task refs + minimal derived fields +- awareness task refs + minimal derived fields +- counters +- critical summary buckets for lead queue + +Что не должно влиять на revision в первой версии: + +- декоративный текст renderer +- локальные wording changes +- поля, которые не меняют queue semantics + +### Phase 2B - Optional delta sync + +Делать только если реально нужно по профайлингу. + +Возможный контракт: + +- клиент передаёт `sinceRevision` +- если сервер может корректно отдать delta, отдаёт delta +- если нет, отдает full compact queue + +Правило: + +- delta должен быть **оптимизацией транспорта** +- а не единственным способом понять board state + +--- + +## 15. Миграция и backward compatibility + +### Что не ломаем + +- `task_get` остаётся source of full details +- `member_briefing` остаётся bootstrap tool +- старые raw task files остаются валидными + +### Что меняется в поведении + +- `task_briefing` перестаёт быть простым owner list +- lead перестаёт использовать full `task_list` как первую точку входа +- teammate больше не зависит от общего списка задач команды +- reviewer routing становится более явным и меньше зависит от "угадай reviewer из косвенных полей" +- clarification queue semantics в первой фазе становятся deliberately explicit, а не "магически auto-cleared" + +### Совместимость + +Если где-то старый runtime ещё вызывает `task_briefing` с ожиданием owner-only semantics, это обычно безопасно: + +- новая agenda всё ещё покажет owned actionable items +- просто дополнительно добавит правильный awareness + +`task_list` compatibility надо трактовать отдельно и жёстче: + +- Phase 0/1: + - можно сохранить совместимую unfiltered semantics + - но filters/limit уже должны идти через выбранный semantic owner, а не через случайный MCP overlay +- Phase 1/1.5: + - переход к explicit allowlisted inventory row делается как осознанный contract change + - tests и prompt/docs migration должны идти в той же фазе, а не постфактум + +Отдельный compatibility note: + +- новые controller exports / tool names / helper contracts нельзя добавлять только в runtime код +- нужно синхронно обновлять: + - `src/types/agent-teams-controller.d.ts` + - `mcp-server/src/agent-teams-controller.d.ts` + - runtime export surface + +После повторного code review здесь уже есть конкретные известные расхождения, а не абстрактный риск: + +- main shim не знает `tasks.memberBriefing(...)` +- main shim не знает `tasks.getTaskComment(...)` +- main shim не знает `review.startReview(...)` +- main shim не знает `runtime` +- `lookupMessage(...)` типизирован по-разному в двух shim файлах + +Именно поэтому новый public controller contract в этой задаче должен быть узким: + +- добавить `tasks.leadBriefing(): Promise` +- сохранить `tasks.taskBriefing(memberName): Promise` +- не добавлять generic public `getBoardSnapshot(...)` в ту же фазу + +Иначе получится неприятный класс ошибок: + +- код работает локально в одном слое +- но типы и другой слой monorepo продолжают жить со старым контрактом + +--- + +## 16. Что конкретно важно протестировать + +### 16.1 Unit tests на resolver + +Нужны table-driven тесты для сценариев: + +- pending + owner +- in_progress + owner +- completed + reviewer missing +- review + reviewer resolved +- review + reviewer missing +- review + stale reviewer from previous cycle +- review requested but not started yet +- review column entry exists but per-task kanban reviewer is null +- self-review +- removed owner +- removed reviewer +- canonical lead name vs `team-lead` alias +- zero explicit lead candidates still yields valid lead queue +- multiple lead-like members do not make `lead_briefing` fail +- external inbox recipient does not become valid queue member +- generated/internal pseudo-agent id does not become valid queue member +- completed task + reviewer pool configured + no explicit review event +- waiting on user clarification +- waiting on lead clarification +- clarification cleared by wrong actor +- healthy dependency block +- broken dependency +- needsFix after review +- approved/deleted terminal +- unreadable task row produces anomaly instead of silent omission + +### 16.2 Snapshot tests на queue surfaces + +Проверить отдельно: + +- teammate actionable/awareness +- lead action buckets +- filtered inventory outputs +- anomaly summary surfaces in lead-facing outputs when board rows are unreadable +- `lead_briefing` text output не дублирует весь board и не превращается в disguised `task_list` +- `lead_briefing` output не зависит от обязательного `leadName` input + +### 16.3 Concurrency tests + +Проверить: + +- multi-file mutation не даёт невозможного mixed snapshot +- read под lock не ломает корректность +- stale lock cleanup не создаёт ложных успешных чтений +- board lock timeout не приводит к silent unlocked fallback +- `review_request` и `review_request_changes` не оставляют queue в промежуточном ambiguity state +- ошибка inbox/system notification после board commit не оставляет history/kanban drift +- unreadable task row не теряется silently и поднимается как anomaly +- новая `lead` group не ломает `registerTools()` и не вызывает duplicate registration существующих task tools +- exported lead bootstrap constant совпадает с реальным runtime usage path, а не дублируется локальным списком +- если lead path делает `lead_briefing` hard first action, readiness preflight действительно валидирует его наличие +- текстовый renderer `task_briefing` не расходится со structured agenda DTO + +### 16.4 Prompt-path tests + +Проверить, что team prompts реально подталкивают: + +- member -> `task_briefing` +- lead -> `lead_briefing` +- details -> `task_get` + +И отдельно зафиксировать: + +- `TeamProvisioningService` больше не содержит lead hint `List all tasks: task_list ...` как primary recommendation +- новый lead hint рекомендует `lead_briefing` раньше, чем `task_list` +- если lead prompt делает `lead_briefing` hard-first, launch/preflight path тоже проверяет этот tool, а не только prompt text + +### 16.5 Contract tests and type sync + +Проверить: + +- local `.d.ts` shim declarations синхронны с реальным runtime surface +- `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` отражает новый teammate access policy +- `lead_briefing` явно отсутствует в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- если вводится новая `lead` group, обе локальные `AgentTeamsMcpToolGroupId` декларации знают `lead` +- если выбран public controller inventory method, обе локальные `.d.ts` декларации знают и его точный contract +- у каждого `AgentTeamsMcpToolGroupId` есть валидный registration path в MCP layer +- exported lead bootstrap tool constants синхронны между runtime package surface, `.d.ts` и фактическим bootstrap usage +- lead bootstrap permission seed contract отдельно зафиксирован: + - `lead_briefing` доступен для first-turn lead flow + - seed path не зависит только от teammate operational list + - seed path трактуется отдельно от general runtime availability при `bypassPermissions` +- lead bootstrap readiness contract отдельно зафиксирован: + - teammate path hard-checks `member_briefing` + - lead path не объявляется equally strict, пока не добавлен explicit preflight или documented fallback +- accidental helper exports не появляются в `createController(...).tasks`, если они не объявлены частью public contract +- `task_list` tests меняются осознанно и явно фиксируют transition semantics, а не ломаются побочным эффектом +- queue-side roster normalization behaviour зафиксирован явно и не drift'ит незаметно относительно UI expectations +- `task_list` filter contract зафиксирован отдельно: + - only stable filters + - conjunctive semantics + - `limit` after filtering + - no hidden agenda ordering +- `task_list` output contract зафиксирован отдельно: + - explicit allowlisted inventory row as target contract + - migration away from legacy blocklist is explicit and tested +- queue anomaly contract зафиксирован отдельно: + - unreadable task rows surface as anomaly + - lead-facing outputs do not silently hide board corruption +- `TeamProvisioningService` lead prompt contract зафиксирован отдельно: + - `lead_briefing` рекомендуют раньше `task_list` + - строка `List all tasks: task_list ...` больше не используется как primary recommendation +- `lead_briefing` public contract зафиксирован отдельно: + - no required `leadName` + - text surface in Phase 1 + - role-scoped semantics, not name-scoped semantics + +### 16.6 Non-goals enforcement tests + +Проверить: + +- queue derivation не зависит от task logs/transcript activity в Phase 0/1 +- owner/clarification semantics не подменяются history heuristics там, где canonical signal - это текущее task field +- queue не копирует бездумно stall-monitor правило "open review window only after review_started" + +--- + +## 17. Признаки того, что rollout удался + +### Хорошие сигналы + +- lead перестал регулярно вызывать полный `task_list` для рутинной навигации +- teammate получает короткий actionable набор +- меньше "забытых" задач в review без reviewer +- меньше orphaned tasks +- меньше случаев, где LLM делает неверный workflow шаг из-за неоднозначного контекста + +### Плохие сигналы + +- queue стала слишком "умной" и начала скрывать важные задачи +- разные инструменты дают разное понимание одного и того же task state +- lead queue снова разрастается до pseudo-full-board +- delta/revision добавлены слишком рано и ломают простые сценарии +- clarification queue decisions всё ещё расходятся с реальным поведением board +- reviewer остаётся "магическим" и не объясняется понятным resolver precedence + +--- + +## 17.1 Conservative Bias Rules + +Если сигнал неоднозначен, queue system должна ошибаться в сторону безопасности: + +1. ambiguous reviewer -> lead queue, а не speculative reviewer routing +2. ambiguous clarification clear -> lead oversight, а не скрытие wait-state +3. ambiguous dependency integrity -> repair bucket у лида, а не auto-unblock +4. ambiguous ownership -> `assign_owner`, а не молчаливая подстановка watcher/last actor +5. unreadable task row -> anomaly / lead repair signal, а не тихое исчезновение из queue + +Это очень важное правило для LLM-facing surface: + +- false positive в lead queue обычно терпим +- false negative в actionable queue часто приводит к реально потерянной работе + +--- + +## 18. Чего делать не надо + +### Не надо 1 + +Не надо сразу вводить persisted `board-projection.json` как обязательную истину. + +Это красивее на схеме, но опаснее в реальности. + +### Не надо 2 + +Не надо строить primary queue по `watchers`. + +Это почти гарантированный путь обратно к информационному шуму. + +### Не надо 3 + +Не надо считать, что фильтрованный `task_list` уже равен agenda. + +Даже хороший фильтрованный inventory не заменяет явный `actionOwner`. + +### Не надо 4 + +Не надо молча возвращать пустую очередь, если runtime identity не определился. + +Такие silent failures очень дорогие. + +### Не надо 5 + +Не надо строить Phase 1 на предположении, что текущие clarification и reviewer signals уже идеально надёжны. + +Сначала их нужно harden или честно ограничить область применения. + +### Не надо 6 + +Не надо добавлять новый queue surface только в runtime код, забыв про tool catalog, local `.d.ts` и тестовый контракт. + +Для этой зоны "почти работает" особенно опасно, потому что баг всплывает не сразу и не в одном месте. + +### Не надо 7 + +Не надо соблазняться task logs как "богаче сигналами" и тихо тащить их в основной queue resolver первой фазы. + +Это почти гарантированный способ получить вторую, более дорогую и менее объяснимую source-of-truth плоскость. + +### Не надо 8 + +Не надо делать `lead_briefing` как второй независимый renderer рядом с `taskStore.formatTaskBriefing(...)`. + +Если teammate и lead surfaces будут собираться разными ad hoc formatter'ами, drift почти неизбежен. + +### Не надо 9 + +Не надо преждевременно экспортировать generic `getBoardSnapshot(...)` как public controller API. + +Пока у него нет второго доказанного consumer, это только увеличит surface area и type-sync burden. + +### Не надо 10 + +Не надо вводить в `task_list` misleading filter вроде `column=todo|in_progress|done`, будто inventory layer уже стал полным kanban projection. + +Для actor-centric и agenda-centric views есть `task_briefing` и `lead_briefing`. + +### Не надо 11 + +Не надо класть `lead_briefing` в existing `task` group и надеяться, что prompts сами спрячут его от тиммейтов. + +Если доступность инструмента определяется catalog'ом, то и ограничение должно быть в catalog, а не только в тексте подсказок. + +### Не надо 12 + +Не надо оставлять `task_list` навсегда на blocklist-подходе только потому, что так проще пережить первую миграцию. + +Иначе payload снова будет незаметно пухнуть при каждом новом task field, а inventory surface опять начнёт вести себя как полу-сырой dump. + +### Не надо 13 + +Не надо завязывать rollback board-state mutation на успех inbox/system notification. + +Если notification упала, надо сигнализировать о delivery problem, а не оставлять history и kanban в полурасходящемся состоянии. + +### Не надо 14 + +Не надо добавлять новую `lead` group только в catalog и `.d.ts`, забыв про `mcp-server/src/tools/index.ts`. + +Иначе rollout сломается не на semantics, а на missing registration path. + +### Не надо 15 + +Не надо считать teammate operational permission seed list автоматически подходящей базой для lead-first surfaces. + +Как только появляется lead-only tool вроде `lead_briefing`, это предположение перестаёт быть надёжным. + +### Не надо 16 + +Не надо экспортить agenda/inventory helper из `internal/tasks.js` "временно", если он не должен быть public API. + +При текущем `bindModule(...)` это уже не временный helper, а новый `controller.tasks.*` method. + +### Не надо 17 + +Не надо писать в prompt, что `lead_briefing` является обязательным first step для лида, если runtime path ещё не умеет это валидировать или честно fallback'ить. + +Иначе получится фальшивая bootstrap-гарантия: текст обещает одно, а launch contract её ещё не держит. + +### Не надо 18 + +Не надо заводить lead bootstrap tool list как локальный массив в `TeamProvisioningService`, если catalog уже является source of truth для MCP surface. + +Иначе первый же rename/regrouping даст тихий drift между catalog, permissions и bootstrap runtime. + +### Не надо 19 + +Не надо inherit'ить из текущего raw reader правило "unreadable task row можно просто пропустить". + +Для queue это не tolerant behavior, а скрытая потеря board truth. + +--- + +## 19. Рекомендуемый implementation order + +1. ввести controller-owned board lock primitive / snapshot API +2. вынести board notifications в post-commit best-effort path +3. перевести clarification routing в explicit-clear-only semantics и обновить prompts +4. выделить queue-grade reviewer resolver для текущего review cycle +5. формализовать queue roster normalization +6. выделить общий derived resolver `resolveTaskActionState(...)` +7. покрыть resolver table-driven тестами +8. собрать structured agenda DTO + text renderers +9. обновить `task_briefing` +10. добавить `lead_briefing` +11. завести отдельную `lead` tool group и синхронно обновить local `.d.ts` group unions +12. завести для `lead` group отдельный MCP registration path без duplicate task registrations +13. экспортировать dedicated lead bootstrap tool constants из controller package surface +14. добавить explicit lead bootstrap permission seed contract +15. выбрать lead bootstrap readiness path: + - explicit preflight + - или temporary documented fallback +16. удержать agenda/inventory helpers вне accidental public controller surface +17. выбрать backing contract для filtered `task_list` +18. добавить filters/limit в `task_list`, не ломая его default слишком рано +19. после migration readiness перевести `task_list` на allowlisted inventory row contract +20. обновить tool descriptions и provisioning prompts +21. после стабилизации добавить `revision` +22. только потом решать, нужен ли `delta` + +## 19.1 Likely Change Surface + +Наиболее вероятные точки изменения, если реализовывать этот план без лишнего расползания: + +- `agent-teams-controller/src/internal/tasks.js` + - новый `lead_briefing` surface + - controller-level agenda snapshot orchestration + - agenda renderer wiring +- `agent-teams-controller/src/internal/agenda.js` или аналогичный internal helper module + - safest home для derived agenda/inventory helpers, которые не должны стать public controller methods автоматически +- `agent-teams-controller/src/internal/taskStore.js` + - storage-only cleanup + - legacy formatting only + - убрать из store implicit clarification policy как routing assumption +- `agent-teams-controller/src/internal/review.js` + - queue-grade reviewer resolution hooks + - board commit vs notification side-effect boundary + - при необходимости явнее фиксировать signals текущего review cycle +- `agent-teams-controller/src/internal/fileLock.js` + - база для controller-owned board lock primitive +- `agent-teams-controller/src/internal/runtimeHelpers.js` + - queue roster normalization + - lead alias normalization helpers + - `inferLeadName(...)` scope reduction so role-scoped `lead_briefing` does not depend on weak name inference +- `src/shared/utils/leadDetection.ts` + - only if controller-side lead normalization is aligned with shared rules instead of ad hoc heuristics +- `src/shared/types/team.ts` + - only if future explicit review policy is introduced + - until then, no invented review-required field in Phase 0/1 +- `mcp-server/src/tools/taskTools.ts` + - новый lead tool + - optional filters/limit для `task_list` + - совместимая эволюция `task_briefing` + - явный transition away from `slimTaskForList(...)` blocklist semantics +- `mcp-server/src/tools/index.ts` + - registration wiring for new `lead` group + - защита от missing/duplicate group registration +- `agent-teams-controller/src/mcpToolCatalog.js` + - phased teammate access policy for `task_list` + - отдельная `lead` group + - registration of `lead_briefing` + - exported lead bootstrap tool constants +- `src/main/services/team/TeamProvisioningService.ts` + - prompt migration для lead/member flows + - lead bootstrap permission seed split from teammate operational seed + - role-aware MCP readiness validation or explicit fallback for `lead_briefing` +- `agent-teams-controller/src/controller.js` + - only if public bind surface needs extra guardrails around what becomes `controller.tasks.*` +- `src/types/agent-teams-controller.d.ts` + - local type shim sync for main app +- `mcp-server/src/agent-teams-controller.d.ts` + - local type shim sync for MCP package +- `mcp-server/test/tools.test.ts` + - explicit transition of `task_list` expectations +- `agent-teams-controller/test/controller.test.js` + - reviewer resolver and clarification semantics hardening +- `src/shared/utils/taskHistory.ts` + - при необходимости helper для current review cycle traversal + +Принцип: + +- сначала добавлять новые explicit surfaces +- только потом снижать роль старых ambiguous surfaces + +--- + +## 20. Финальная формулировка решения + +Итоговое решение, к которому пришли: + +- **Phase 0:** hardening слабых сигналов (`lock`, `reviewer resolver`, `clarification semantics`, compatible renderer) +- **Phase 1:** derived projection on read как база +- **Primary routing:** через `actionOwner` +- **Secondary visibility:** через `watchers` +- **Main teammate surface:** `task_briefing` +- **Main lead surface:** отдельный `lead_briefing` +- **Search/inventory:** `task_list` с filters/limit и постепенным уходом из роли default queue +- **Inventory contract:** `task_list` идёт к explicit allowlisted `TaskInventoryRow`, а не остаётся вечным blocklist dump +- **Consistency:** controller-owned team-level lock around board mutations and queue snapshot reads +- **Reviewer source:** only current-cycle history in Phase 0/1 +- **Clarification source:** explicit clear semantics first, convenience auto-clear only later if really needed +- **Signal trust:** history/task/kanban signals имеют явный priority order, queue не усредняет conflicting inputs +- **Lead contract:** `lead_briefing` role-scoped, without required `leadName` +- **Lead plumbing:** отдельная `lead` group, отдельный MCP registration path и отдельный lead bootstrap permission seed +- **Bootstrap sequencing:** lead prompt становится hard-first only after registration + seed + readiness path are real +- **Public API discipline:** snapshot/DTO helpers stay internal until a second real consumer exists +- **Export discipline:** agenda/inventory helpers не должны случайно становиться `controller.tasks.*` methods через `bindModule(...)` +- **Board anomalies:** unreadable task rows surface as anomalies, not as silent omissions +- **Phase 2:** сначала `revision`, потом только при реальной необходимости `delta` + +Это самый оптимальный баланс между: + +- надёжностью +- предсказуемостью для агентов +- понятностью инструментария +- умеренной сложностью rollout + +Если сформулировать совсем коротко: + +> Не надо учить агента самому вычислять board policy из сырых задач. Надо один раз правильно вывести `actionOwner` и отдать каждому актёру его компактную очередь. diff --git a/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts new file mode 100644 index 00000000..1d4b5f52 --- /dev/null +++ b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts @@ -0,0 +1,212 @@ +import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; + +import type { + CliProviderModelCatalog, + CliProviderModelCatalogItem, + CliProviderRuntimeCapabilities, + EffortLevel, + TeamFastMode, +} from '@shared/types'; + +export interface AnthropicRuntimeProfileSource { + modelCatalog?: CliProviderModelCatalog | null; + runtimeCapabilities?: CliProviderRuntimeCapabilities | null; +} + +export interface AnthropicRuntimeSelection { + resolvedLaunchModel: string | null; + catalogModel: CliProviderModelCatalogItem | null; + displayName: string | null; + catalogSource: CliProviderModelCatalog['source'] | 'unavailable'; + catalogStatus: CliProviderModelCatalog['status'] | 'unavailable'; + catalogFetchedAt: string | null; + supportedEfforts: EffortLevel[]; + defaultEffort: EffortLevel | null; + supportsFastMode: boolean; + providerFastModeSupported: boolean; + providerFastModeAvailable: boolean; + providerFastModeReason: string | null; +} + +export interface AnthropicFastModeResolution { + selectedFastMode: TeamFastMode; + requestedFastMode: boolean; + resolvedFastMode: boolean; + showFastModeControl: boolean; + selectable: boolean; + disabledReason: string | null; +} + +export interface AnthropicRuntimeReconciliation { + nextEffort: EffortLevel | ''; + effortResetReason: string | null; + nextFastMode: TeamFastMode; + fastModeResetReason: string | null; +} + +function getAnthropicCatalog( + source: AnthropicRuntimeProfileSource +): CliProviderModelCatalog | null { + return source.modelCatalog?.providerId === 'anthropic' ? source.modelCatalog : null; +} + +function normalizeEffortLevel(value: string | null | undefined): EffortLevel | null { + return value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' || + value === 'max' + ? value + : null; +} + +function normalizeEffortLevels(values: readonly string[] | undefined): EffortLevel[] { + const normalized = new Set(); + for (const value of values ?? []) { + const effort = normalizeEffortLevel(value); + if (effort) { + normalized.add(effort); + } + } + return Array.from(normalized); +} + +function hasCatalogTruth(selection: AnthropicRuntimeSelection): boolean { + return selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable'; +} + +export function resolveAnthropicRuntimeSelection(params: { + source: AnthropicRuntimeProfileSource; + selectedModel?: string | null; + limitContext: boolean; +}): AnthropicRuntimeSelection { + const catalog = getAnthropicCatalog(params.source); + const resolvedLaunchModel = + resolveAnthropicLaunchModel({ + selectedModel: params.selectedModel, + limitContext: params.limitContext, + availableLaunchModels: catalog?.models.map((model) => model.launchModel), + defaultLaunchModel: catalog?.defaultLaunchModel ?? null, + }) ?? null; + + const catalogModel = + resolvedLaunchModel && catalog + ? (catalog.models.find( + (model) => + model.launchModel.trim() === resolvedLaunchModel || + model.id.trim() === resolvedLaunchModel + ) ?? null) + : null; + + return { + resolvedLaunchModel, + catalogModel, + displayName: catalogModel?.displayName?.trim() ?? null, + catalogSource: catalog?.source ?? 'unavailable', + catalogStatus: catalog?.status ?? 'unavailable', + catalogFetchedAt: catalog?.fetchedAt ?? null, + supportedEfforts: normalizeEffortLevels(catalogModel?.supportedReasoningEfforts), + defaultEffort: normalizeEffortLevel(catalogModel?.defaultReasoningEffort ?? null), + supportsFastMode: catalogModel?.supportsFastMode === true, + providerFastModeSupported: params.source.runtimeCapabilities?.fastMode?.supported === true, + providerFastModeAvailable: params.source.runtimeCapabilities?.fastMode?.available === true, + providerFastModeReason: params.source.runtimeCapabilities?.fastMode?.reason ?? null, + }; +} + +export function resolveAnthropicFastMode(params: { + selection: AnthropicRuntimeSelection; + selectedFastMode?: TeamFastMode | null; + providerFastModeDefault?: boolean; +}): AnthropicFastModeResolution { + const selectedFastMode = params.selectedFastMode ?? 'inherit'; + const requestedFastMode = + selectedFastMode === 'on' + ? true + : selectedFastMode === 'off' + ? false + : params.providerFastModeDefault === true; + + const selectable = + params.selection.providerFastModeSupported && + params.selection.providerFastModeAvailable && + params.selection.supportsFastMode; + + let disabledReason: string | null = null; + if (!hasCatalogTruth(params.selection) && !params.selection.providerFastModeSupported) { + disabledReason = 'Anthropic runtime capability data is still loading.'; + } else if (!params.selection.providerFastModeSupported) { + disabledReason = + params.selection.providerFastModeReason ?? + 'Fast mode is not supported by this Anthropic runtime.'; + } else if (!params.selection.supportsFastMode) { + disabledReason = params.selection.displayName + ? `Fast mode is available only for Opus 4.6. Selected model resolves to ${params.selection.displayName}.` + : 'Fast mode is available only for Opus 4.6.'; + } else if (!params.selection.providerFastModeAvailable) { + disabledReason = + params.selection.providerFastModeReason ?? 'Fast mode is currently unavailable.'; + } + + return { + selectedFastMode, + requestedFastMode, + resolvedFastMode: requestedFastMode && selectable, + showFastModeControl: + params.selection.providerFastModeSupported || + selectedFastMode !== 'inherit' || + params.providerFastModeDefault === true, + selectable, + disabledReason, + }; +} + +export function reconcileAnthropicRuntimeSelections(params: { + selection: AnthropicRuntimeSelection; + selectedEffort?: string | null; + selectedFastMode?: TeamFastMode | null; + providerFastModeDefault?: boolean; +}): AnthropicRuntimeReconciliation { + const selectedEffort = normalizeEffortLevel(params.selectedEffort ?? null); + if (!hasCatalogTruth(params.selection)) { + return { + nextEffort: selectedEffort ?? '', + effortResetReason: null, + nextFastMode: params.selectedFastMode ?? 'inherit', + fastModeResetReason: null, + }; + } + + const nextEffort = + selectedEffort && !params.selection.supportedEfforts.includes(selectedEffort) + ? '' + : (selectedEffort ?? ''); + const effortResetReason = + selectedEffort && nextEffort === '' + ? `${selectedEffort} effort is not available for the currently selected Anthropic model. Reset to Default.` + : null; + + const fastResolution = resolveAnthropicFastMode({ + selection: params.selection, + selectedFastMode: params.selectedFastMode, + providerFastModeDefault: params.providerFastModeDefault, + }); + const nextFastMode = + fastResolution.selectedFastMode === 'on' && !fastResolution.selectable + ? 'inherit' + : fastResolution.selectedFastMode; + const fastModeResetReason = + fastResolution.selectedFastMode === 'on' && nextFastMode !== 'on' + ? (fastResolution.disabledReason ?? + 'Fast mode is not available for the currently selected Anthropic model. Reset to Default.') + : null; + + return { + nextEffort, + effortResetReason, + nextFastMode, + fastModeResetReason, + }; +} diff --git a/src/features/anthropic-runtime-profile/main/index.ts b/src/features/anthropic-runtime-profile/main/index.ts new file mode 100644 index 00000000..2ef91e4d --- /dev/null +++ b/src/features/anthropic-runtime-profile/main/index.ts @@ -0,0 +1,12 @@ +export { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; + +export type { + AnthropicFastModeResolution, + AnthropicRuntimeProfileSource, + AnthropicRuntimeReconciliation, + AnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; diff --git a/src/features/anthropic-runtime-profile/renderer/index.ts b/src/features/anthropic-runtime-profile/renderer/index.ts new file mode 100644 index 00000000..2ef91e4d --- /dev/null +++ b/src/features/anthropic-runtime-profile/renderer/index.ts @@ -0,0 +1,12 @@ +export { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; + +export type { + AnthropicFastModeResolution, + AnthropicRuntimeProfileSource, + AnthropicRuntimeReconciliation, + AnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index afebd5d1..c02e5ccc 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -9,7 +9,7 @@ import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isAbsolute } from 'path'; import type { HttpServices } from './index'; -import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team'; +import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:teams'); @@ -95,6 +95,18 @@ function assertOptionalEffort( return value; } +function assertOptionalFastMode(value: unknown): TeamFastMode | undefined { + if (value == null) { + return undefined; + } + + if (value !== 'inherit' && value !== 'on' && value !== 'off') { + throw new HttpBadRequestError('fastMode must be one of: inherit, on, off'); + } + + return value; +} + function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; const providerId = @@ -117,6 +129,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest } const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort, providerId); + const fastMode = assertOptionalFastMode(payload.fastMode); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); const worktree = assertOptionalString(payload.worktree, 'worktree'); @@ -138,6 +151,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest ...(effort && { effort, }), + ...(fastMode && { + fastMode, + }), ...(clearContext !== undefined && { clearContext, }), diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index e10b5653..95a93fb5 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -497,25 +497,37 @@ function validateProviderConnectionsSection( const anthropicUpdate: Partial = {}; for (const [connectionKey, connectionValue] of Object.entries(value)) { - if (connectionKey !== 'authMode') { + if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') { return { valid: false, error: `providerConnections.anthropic.${connectionKey} is not a valid setting`, }; } - if ( - connectionValue !== 'auto' && - connectionValue !== 'oauth' && - connectionValue !== 'api_key' - ) { + if (connectionKey === 'authMode') { + if ( + connectionValue !== 'auto' && + connectionValue !== 'oauth' && + connectionValue !== 'api_key' + ) { + return { + valid: false, + error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key', + }; + } + + anthropicUpdate.authMode = connectionValue; + continue; + } + + if (typeof connectionValue !== 'boolean') { return { valid: false, - error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key', + error: 'providerConnections.anthropic.fastModeDefault must be a boolean', }; } - anthropicUpdate.authMode = connectionValue; + anthropicUpdate.fastModeDefault = connectionValue; } result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic']; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 11f34910..1a272c0f 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -190,6 +190,7 @@ import type { TeamLaunchResponse, TeamMemberActivityMeta, TeamMessageNotificationData, + TeamFastMode, TeamProviderBackendId, TeamProviderId, TeamProvisioningPrepareResult, @@ -1198,6 +1199,21 @@ function parseOptionalTeamEffort( }; } +function parseOptionalTeamFastMode( + value: unknown +): { valid: true; value: TeamFastMode | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (value === 'inherit' || value === 'on' || value === 'off') { + return { valid: true, value }; + } + return { + valid: false, + error: 'fastMode must be one of inherit, on, or off', + }; +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -1224,12 +1240,15 @@ async function validateProvisioningRequest( if (!Array.isArray(payload.members)) { return { valid: false, error: 'members must be an array' }; } - const providerId = + const explicitProviderId = payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' - : 'anthropic'; + : payload.providerId === 'anthropic' + ? 'anthropic' + : undefined; + const providerId = explicitProviderId ?? 'anthropic'; const seenNames = new Set(); const members: TeamCreateRequest['members'] = []; @@ -1304,6 +1323,10 @@ async function validateProvisioningRequest( if (!effortValidation.valid) { return { valid: false, error: effortValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { valid: false, error: fastModeValidation.error }; + } try { await fs.promises.mkdir(cwd, { recursive: true }); @@ -1359,6 +1382,7 @@ async function validateProvisioningRequest( providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: effortValidation.value, + fastMode: fastModeValidation.value, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, worktree: @@ -1512,6 +1536,10 @@ async function handleLaunchTeam( if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } const createRequest: TeamCreateRequest = { teamName: tn, @@ -1527,6 +1555,7 @@ async function handleLaunchTeam( ), model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: effortValidation.value, + fastMode: fastModeValidation.value ?? meta?.fastMode, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -1558,10 +1587,44 @@ async function handleLaunchTeam( ); } - const effortValidation = parseOptionalTeamEffort(payload.effort, providerId); + const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null); + const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId; + const rawLaunchProviderBackendId = + payload.providerBackendId ?? + persistedMeta?.providerBackendId ?? + persistedMeta?.launchIdentity?.providerBackendId ?? + undefined; + const launchProviderBackendValidation = parseOptionalProviderBackendId( + rawLaunchProviderBackendId, + launchProviderId + ); + if (!launchProviderBackendValidation.valid) { + return { success: false, error: launchProviderBackendValidation.error }; + } + const rawLaunchEffort = + payload.effort ?? + persistedMeta?.effort ?? + persistedMeta?.launchIdentity?.selectedEffort ?? + undefined; + const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } + const rawLaunchFastMode = + payload.fastMode ?? + persistedMeta?.fastMode ?? + persistedMeta?.launchIdentity?.selectedFastMode ?? + undefined; + const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } + const rawLaunchModel = + typeof payload.model === 'string' && payload.model.trim().length > 0 + ? payload.model.trim() + : (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined); + const launchLimitContext = + typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext; return wrapTeamHandler('launch', () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); @@ -1570,10 +1633,12 @@ async function handleLaunchTeam( teamName: validatedTeamName.value!, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, - providerId, - providerBackendId: providerBackendValidation.value, - model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + providerId: launchProviderId, + providerBackendId: launchProviderBackendValidation.value, + model: rawLaunchModel, effort: effortValidation.value, + fastMode: fastModeValidation.value, + limitContext: launchLimitContext, clearContext: payload.clearContext === true ? true : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -2660,6 +2725,10 @@ async function handleCreateConfig( if (!providerBackendValidation.valid) { return { success: false, error: providerBackendValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } const seenNames = new Set(); const members: TeamCreateConfigRequest['members'] = []; @@ -2721,6 +2790,7 @@ async function handleCreateConfig( members, cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, providerBackendId: providerBackendValidation.value, + fastMode: fastModeValidation.value, }) ); } @@ -4023,6 +4093,7 @@ async function handleGetSavedRequest( ), model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], + fastMode: meta.fastMode as TeamCreateRequest['fastMode'], skipPermissions: meta.skipPermissions, worktree: meta.worktree, extraCliArgs: meta.extraCliArgs, diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index e4f324fe..3cf0e470 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -237,6 +237,7 @@ export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key'; export interface ProviderConnectionsConfig { anthropic: { authMode: ProviderConnectionAuthMode; + fastModeDefault: boolean; }; codex: { preferredAuthMode: CodexAccountAuthMode; @@ -333,6 +334,7 @@ const DEFAULT_CONFIG: AppConfig = { providerConnections: { anthropic: { authMode: 'auto', + fastModeDefault: false, }, codex: { preferredAuthMode: 'auto', diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 0cf3f7ea..c4bd3ab1 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -40,6 +40,12 @@ interface RuntimeProviderCapabilitiesResponse { values?: string[]; configPassthrough?: boolean; }; + fastMode?: { + supported?: boolean; + available?: boolean; + reason?: string | null; + source?: 'runtime'; + }; } interface RuntimeProviderModelCatalogItemResponse { @@ -49,6 +55,7 @@ interface RuntimeProviderModelCatalogItemResponse { hidden?: boolean; supportedReasoningEfforts?: string[]; defaultReasoningEffort?: string | null; + supportsFastMode?: boolean; inputModalities?: string[]; supportsPersonality?: boolean; isDefault?: boolean; @@ -279,7 +286,8 @@ function normalizeRuntimeReasoningEffort( value === 'low' || value === 'medium' || value === 'high' || - value === 'xhigh' + value === 'xhigh' || + value === 'max' ? value : null; } @@ -347,6 +355,7 @@ function mapRuntimeProviderModelCatalog( hidden: model.hidden === true, supportedReasoningEfforts, defaultReasoningEffort, + supportsFastMode: model.supportsFastMode === true, inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [], supportsPersonality: model.supportsPersonality === true, isDefault: model.isDefault === true, @@ -477,6 +486,14 @@ export class ClaudeMultimodelBridgeService { runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true, } : undefined, + fastMode: runtimeStatus.runtimeCapabilities.fastMode + ? { + supported: runtimeStatus.runtimeCapabilities.fastMode.supported === true, + available: runtimeStatus.runtimeCapabilities.fastMode.available === true, + reason: runtimeStatus.runtimeCapabilities.fastMode.reason ?? null, + source: 'runtime', + } + : undefined, } : null, }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9bdf17b9..a0413574 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2384,6 +2384,7 @@ export class TeamDataService { color: request.color, cwd: request.cwd?.trim() || '', providerBackendId: request.providerBackendId, + fastMode: request.fastMode, createdAt: joinedAt, }); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4e221bc2..86ad0465 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -15,7 +15,13 @@ const QUOTA_EXHAUSTED_TOKENS = [ 'quota exceeded', 'quota exhausted', ]; -const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429']; +const RATE_LIMITED_TOKENS = [ + 'rate limit', + 'too many requests', + '429', + 'model cooldown', + 'cooling down', +]; const AUTH_ERROR_TOKENS = [ 'unauthorized', 'forbidden', diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index a4b41eb8..57f727a5 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; -import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types'; +import type { ProviderModelLaunchIdentity, TeamFastMode, TeamProviderId } from '@shared/types'; /** * Persisted team-level metadata saved by the UI before CLI provisioning. @@ -25,6 +25,7 @@ export interface TeamMetaFile { providerBackendId?: string; model?: string; effort?: string; + fastMode?: TeamFastMode; skipPermissions?: boolean; worktree?: string; extraCliArgs?: string; @@ -51,6 +52,10 @@ function normalizeOptionalString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } +function normalizeFastMode(value: unknown): TeamFastMode | null { + return value === 'inherit' || value === 'on' || value === 'off' ? value : null; +} + function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -80,7 +85,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | raw.selectedEffort === 'low' || raw.selectedEffort === 'medium' || raw.selectedEffort === 'high' || - raw.selectedEffort === 'xhigh' + raw.selectedEffort === 'xhigh' || + raw.selectedEffort === 'max' ? raw.selectedEffort : null; const resolvedEffort = @@ -89,7 +95,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | raw.resolvedEffort === 'low' || raw.resolvedEffort === 'medium' || raw.resolvedEffort === 'high' || - raw.resolvedEffort === 'xhigh' + raw.resolvedEffort === 'xhigh' || + raw.resolvedEffort === 'max' ? raw.resolvedEffort : null; @@ -105,6 +112,9 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt), selectedEffort, resolvedEffort, + selectedFastMode: normalizeFastMode(raw.selectedFastMode), + resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null, + fastResolutionReason: normalizeOptionalString(raw.fastResolutionReason), }; } @@ -173,6 +183,7 @@ export class TeamMetaStore { ), model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, + fastMode: normalizeFastMode(file.fastMode) ?? undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined, extraCliArgs: @@ -198,6 +209,7 @@ export class TeamMetaStore { ), model: data.model?.trim() || undefined, effort: data.effort?.trim() || undefined, + fastMode: normalizeFastMode(data.fastMode) ?? undefined, skipPermissions: data.skipPermissions, worktree: data.worktree?.trim() || undefined, extraCliArgs: data.extraCliArgs?.trim() || undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5fd6504a..1f1e069b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,6 +2,10 @@ import { killTmuxPaneForCurrentPlatformSync, listTmuxPanePidsForCurrentPlatform, } from '@features/tmux-installer/main'; +import { + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -170,6 +174,7 @@ interface RelayInboxMessageView { } import type { + CliProviderModelCatalog, ActiveToolCall, CliProviderRuntimeCapabilities, CrossTeamSendResult, @@ -190,6 +195,7 @@ import type { TeamConfig, TeamCreateRequest, TeamCreateResponse, + TeamFastMode, TeamLaunchAggregateState, TeamLaunchRequest, TeamLaunchResponse, @@ -328,6 +334,7 @@ interface RuntimeStatusCommandResponse { providers?: Record< string, { + modelCatalog?: CliProviderModelCatalog | null; runtimeCapabilities?: CliProviderRuntimeCapabilities | null; } >; @@ -336,6 +343,7 @@ interface RuntimeStatusCommandResponse { interface RuntimeProviderLaunchFacts { defaultModel: string | null; modelIds: Set; + modelCatalog: CliProviderModelCatalog | null; runtimeCapabilities: CliProviderRuntimeCapabilities | null; } @@ -416,6 +424,47 @@ function isCodexEffortRuntimeSupported( return reasoning?.configPassthrough === true && reasoning.values.includes(effort); } +function getAnthropicFastModeDefault(): boolean { + return ( + ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true + ); +} + +function resolveAnthropicSelectionFromFacts(params: { + selectedModel?: string; + limitContext?: boolean; + facts: Pick; +}) { + return resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: params.facts.modelCatalog, + runtimeCapabilities: params.facts.runtimeCapabilities, + }, + selectedModel: params.selectedModel, + limitContext: params.limitContext === true, + }); +} + +function buildAnthropicSettingsArgs( + providerId: TeamProviderId, + launchIdentity?: ProviderModelLaunchIdentity | null +): string[] { + if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') { + return []; + } + + const settings = launchIdentity.resolvedFastMode + ? { + fastMode: true, + fastModePerSessionOptIn: false, + } + : { + fastMode: false, + }; + + return ['--settings', JSON.stringify(settings)]; +} + function isProbeTimeoutMessage(message: string): boolean { const lower = message.toLowerCase(); return ( @@ -522,7 +571,10 @@ function mergeProvisioningWarnings( } function buildRuntimeLaunchWarning( - request: Pick, + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -534,6 +586,10 @@ function buildRuntimeLaunchWarning( const providerLabel = getTeamProviderLabel(providerId); const modelLabel = request.model?.trim() || 'default'; const effortLabel = request.effort ?? 'default'; + const fastLabel = + providerId === 'anthropic' + ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}` + : ''; const backend = migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || getConfiguredRuntimeBackend(providerId); @@ -564,14 +620,17 @@ function buildRuntimeLaunchWarning( typeof options?.expectedMembersCount === 'number' ? `, members ${options.expectedMembersCount}` : ''; - return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; + return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; } function logRuntimeLaunchSnapshot( teamName: string, claudePath: string, args: string[], - request: Pick, + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -586,6 +645,7 @@ function logRuntimeLaunchSnapshot( providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null, model: request.model ?? null, effort: request.effort ?? null, + fastMode: request.fastMode ?? null, configuredBackend: migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || getConfiguredRuntimeBackend(providerId), @@ -2984,12 +3044,16 @@ export class TeamProvisioningService { } ); const runtimeStatusPromise = - params.providerId === 'codex' - ? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], { - cwd: params.cwd, - env: params.env, - timeout: 8_000, - }) + params.providerId === 'codex' || params.providerId === 'anthropic' + ? execCli( + params.claudePath, + ['runtime', 'status', '--json', '--provider', params.providerId], + { + cwd: params.cwd, + env: params.env, + timeout: 8_000, + } + ) : null; const [modelListResult, runtimeStatusResult] = await Promise.allSettled([ @@ -3020,6 +3084,7 @@ export class TeamProvisioningService { } let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null; + let modelCatalog: CliProviderModelCatalog | null = null; if ( runtimeStatusResult.status === 'fulfilled' && runtimeStatusResult.value && @@ -3029,7 +3094,12 @@ export class TeamProvisioningService { const parsed = extractJsonObjectFromCli( runtimeStatusResult.value.stdout ); - runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null; + const providerStatus = parsed.providers?.[params.providerId]; + runtimeCapabilities = providerStatus?.runtimeCapabilities ?? null; + modelCatalog = + providerStatus?.modelCatalog?.providerId === params.providerId + ? providerStatus.modelCatalog + : null; } catch (error) { logger.warn( `[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${ @@ -3039,16 +3109,32 @@ export class TeamProvisioningService { } } + if (modelCatalog) { + for (const model of modelCatalog.models ?? []) { + const launchModel = model.launchModel?.trim(); + if (launchModel) { + modelIds.add(launchModel); + } + const catalogId = model.id?.trim(); + if (catalogId) { + modelIds.add(catalogId); + } + } + defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel; + } + return { defaultModel: params.providerId === 'anthropic' ? resolveAnthropicLaunchModel({ limitContext: params.limitContext === true, - availableLaunchModels: modelIds, + availableLaunchModels: + modelCatalog?.models.map((model) => model.launchModel) ?? modelIds, defaultLaunchModel: defaultModel, }) : defaultModel, modelIds, + modelCatalog, runtimeCapabilities, }; } @@ -3056,7 +3142,7 @@ export class TeamProvisioningService { private buildProviderModelLaunchIdentity(params: { request: Pick< TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' >; facts: RuntimeProviderLaunchFacts; }): ProviderModelLaunchIdentity { @@ -3068,6 +3154,39 @@ export class TeamProvisioningService { limitContext: params.request.limitContext, facts: params.facts, }); + if (providerId === 'anthropic') { + const selection = resolveAnthropicSelectionFromFacts({ + selectedModel: params.request.model, + limitContext: params.request.limitContext, + facts: params.facts, + }); + const fastResolution = resolveAnthropicFastMode({ + selection, + selectedFastMode: params.request.fastMode, + providerFastModeDefault: getAnthropicFastModeDefault(), + }); + + return { + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null, + selectedModel: explicitModel ?? null, + selectedModelKind: explicitModel ? 'explicit' : 'default', + resolvedLaunchModel: selection.resolvedLaunchModel ?? resolvedLaunchModel, + catalogId: + selection.catalogModel?.id?.trim() || + selection.resolvedLaunchModel || + resolvedLaunchModel, + catalogSource: selection.catalogSource, + catalogFetchedAt: selection.catalogFetchedAt, + selectedEffort: params.request.effort ?? null, + resolvedEffort: params.request.effort ?? selection.defaultEffort ?? null, + selectedFastMode: params.request.fastMode ?? 'inherit', + resolvedFastMode: fastResolution.resolvedFastMode, + fastResolutionReason: fastResolution.disabledReason, + }; + } + const resolvedEffort = params.request.effort ?? null; return { @@ -3090,10 +3209,51 @@ export class TeamProvisioningService { providerId: TeamProviderId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + limitContext?: boolean; facts: RuntimeProviderLaunchFacts; }): void { const explicitModel = getExplicitLaunchModelSelection(params.model); + if (params.providerId === 'anthropic') { + const selection = resolveAnthropicSelectionFromFacts({ + selectedModel: params.model, + limitContext: params.limitContext, + facts: params.facts, + }); + const resolvedLaunchModel = selection.resolvedLaunchModel?.trim() || null; + if (!resolvedLaunchModel) { + throw new Error( + `${params.actorLabel} could not resolve the selected Anthropic model against the current runtime catalog.` + ); + } + if (params.facts.modelIds.size > 0 && !params.facts.modelIds.has(resolvedLaunchModel)) { + throw new Error( + `${params.actorLabel} resolves to Anthropic model "${resolvedLaunchModel}", but the current runtime does not list it as launchable.` + ); + } + if (params.effort && !selection.supportedEfforts.includes(params.effort)) { + const modelLabel = selection.displayName ?? resolvedLaunchModel; + throw new Error( + `${params.actorLabel} uses Anthropic effort "${params.effort}", but ${modelLabel} does not support it in the current runtime.` + ); + } + + const fastResolution = resolveAnthropicFastMode({ + selection, + selectedFastMode: params.fastMode, + providerFastModeDefault: getAnthropicFastModeDefault(), + }); + if ((params.fastMode ?? 'inherit') === 'on' && !fastResolution.selectable) { + throw new Error( + `${params.actorLabel} enables Anthropic Fast mode, but ${ + fastResolution.disabledReason ?? 'it is unavailable for the selected runtime or model.' + }` + ); + } + return; + } + if (params.providerId !== 'codex') { if (params.effort && !isLegacySafeEffort(params.effort)) { throw new Error( @@ -3133,7 +3293,7 @@ export class TeamProvisioningService { env: NodeJS.ProcessEnv; request: Pick< TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' >; effectiveMembers: TeamCreateRequest['members']; }): Promise { @@ -3161,6 +3321,8 @@ export class TeamProvisioningService { providerId: leadProviderId, model: params.request.model, effort: params.request.effort, + fastMode: params.request.fastMode, + limitContext: params.request.limitContext, facts: leadFacts, }); @@ -3172,6 +3334,7 @@ export class TeamProvisioningService { providerId: memberProviderId, model: member.model, effort: member.effort, + limitContext: params.request.limitContext, facts: memberFacts, }); } @@ -4867,6 +5030,7 @@ export class TeamProvisioningService { run?.request.providerId ?? persistedTeamMeta?.providerId, run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId ), + fastMode: run?.request.fastMode ?? persistedTeamMeta?.fastMode, members: snapshotMembers, }; @@ -6728,6 +6892,8 @@ export class TeamProvisioningService { request.model, launchIdentity ); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); const spawnArgs = [ '--input-format', 'stream-json', @@ -6751,7 +6917,8 @@ export class TeamProvisioningService { ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(launchModelArg ? ['--model', launchModelArg] : []), - ...(request.effort ? ['--effort', request.effort] : []), + ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), + ...anthropicSettingsArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), ...providerArgs, @@ -6784,6 +6951,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, worktree: request.worktree, extraCliArgs: request.extraCliArgs, @@ -7185,6 +7353,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, }; @@ -7389,12 +7558,15 @@ export class TeamProvisioningService { request.model, launchIdentity ); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); if (launchModelArg) { launchArgs.push('--model', launchModelArg); } - if (request.effort) { - launchArgs.push('--effort', request.effort); + if (launchIdentity.resolvedEffort) { + launchArgs.push('--effort', launchIdentity.resolvedEffort); } + launchArgs.push(...anthropicSettingsArgs); if (request.worktree) { launchArgs.push('--worktree', request.worktree); } @@ -7423,6 +7595,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, worktree: request.worktree, extraCliArgs: request.extraCliArgs, diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index c7e210b5..970a0b4c 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -722,6 +722,19 @@ export const ProviderRuntimeSettingsDialog = ({ const codexActionBusy = disabled || selectedProviderLoading || connectionSaving || codexAccount.loading; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; + const anthropicFastModeCapability = + selectedProvider?.providerId === 'anthropic' + ? (selectedProvider.runtimeCapabilities?.fastMode ?? null) + : null; + const anthropicFastModeEnabled = + appConfig?.providerConnections?.anthropic.fastModeDefault === true; + const anthropicFastModeSupported = anthropicFastModeCapability?.supported === true; + const anthropicFastModeAvailable = anthropicFastModeCapability?.available === true; + const anthropicFastModeDisabledReason = + anthropicFastModeCapability?.reason ?? + (anthropicFastModeSupported + ? 'Fast mode is currently unavailable for this Anthropic runtime.' + : 'This Anthropic runtime does not expose Fast mode.'); const connectionMethodCardsHint = selectedProvider ? getConnectionMethodCardsHint(selectedProvider) : null; @@ -969,6 +982,29 @@ export const ProviderRuntimeSettingsDialog = ({ } }; + const handleAnthropicFastModeDefaultChange = async (enabled: boolean): Promise => { + if (selectedProvider?.providerId !== 'anthropic' || anthropicFastModeEnabled === enabled) { + return; + } + + setConnectionSaving(true); + setConnectionError(null); + try { + await updateConfig('providerConnections', { + anthropic: { + fastModeDefault: enabled, + }, + }); + await onRefreshProvider?.('anthropic'); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Failed to update Anthropic Fast mode' + ); + } finally { + setConnectionSaving(false); + } + }; + return ( @@ -1188,6 +1224,50 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
+ {selectedProvider.providerId === 'anthropic' ? ( +
+
+ Fast mode default +
+
+ Apply Claude Code Fast mode by default for new Anthropic team launches when the + resolved model and runtime allow it. +
+ {anthropicFastModeSupported ? ( +
+ {[ + { enabled: false, label: 'Default Off' }, + { enabled: true, label: 'Prefer Fast' }, + ].map((option) => ( + + ))} +
+ ) : null} +
+ {anthropicFastModeSupported && anthropicFastModeAvailable + ? anthropicFastModeEnabled + ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.' + : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.' + : anthropicFastModeDisabledReason} +
+
+ ) : null} + {selectedProvider.providerId === 'codex' ? (
void; + providerFastModeDefault: boolean; + model?: string; + limitContext: boolean; + id?: string; +} + +export const AnthropicFastModeSelector: React.FC = ({ + value, + onValueChange, + providerFastModeDefault, + model, + limitContext, + id, +}) => { + const { providerStatus } = useEffectiveCliProviderStatus('anthropic'); + + const selection = useMemo( + () => + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: providerStatus?.modelCatalog, + runtimeCapabilities: providerStatus?.runtimeCapabilities, + }, + selectedModel: model, + limitContext, + }), + [limitContext, model, providerStatus?.modelCatalog, providerStatus?.runtimeCapabilities] + ); + + const resolution = useMemo( + () => + resolveAnthropicFastMode({ + selection, + selectedFastMode: value, + providerFastModeDefault, + }), + [providerFastModeDefault, selection, value] + ); + + if (!resolution.showFastModeControl) { + return null; + } + + const defaultLabel = providerFastModeDefault ? 'Default (Fast)' : 'Default (Off)'; + const helperText = + value === 'inherit' + ? `Default currently resolves to ${resolution.resolvedFastMode ? 'Fast' : 'Off'}.` + : (resolution.disabledReason ?? + 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.'); + + return ( +
+ +
+ +
+ {[ + { value: 'inherit' as const, label: defaultLabel, disabled: false }, + { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable }, + { value: 'off' as const, label: 'Off', disabled: false }, + ].map((option) => ( + + ))} +
+
+

{helperText}

+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5ebd5c8b..34285df3 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, @@ -100,6 +105,7 @@ import type { EffortLevel, Project, TeamCreateRequest, + TeamFastMode, TeamProviderId, TeamProvisioningMemberInput, } from '@shared/types'; @@ -121,6 +127,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string { return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } +function getStoredTeamFastMode(): TeamFastMode { + const stored = localStorage.getItem('team:lastSelectedFastMode'); + return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit'; +} + function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { const normalized = normalizePath(projectPath ?? '').toLowerCase(); return ( @@ -313,6 +324,9 @@ export const CreateTeamDialog = ({ }: CreateTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const anthropicProviderFastModeDefault = useStore( + (s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false + ); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); @@ -396,6 +410,8 @@ export const CreateTeamDialog = ({ const stored = localStorage.getItem('team:lastSelectedEffort'); return stored === null ? 'medium' : stored; }); + const [selectedFastMode, setSelectedFastModeRaw] = useState(getStoredTeamFastMode); + const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState(null); // Advanced CLI section state (use teamName-derived key for localStorage) const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_'; @@ -456,6 +472,11 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastSelectedEffort', value); }; + const setSelectedFastMode = (value: TeamFastMode): void => { + setSelectedFastModeRaw(value); + localStorage.setItem('team:lastSelectedFastMode', value); + }; + const setWorktreeEnabled = (value: boolean): void => { setWorktreeEnabledRaw(value); localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value)); @@ -1003,6 +1024,84 @@ export const CreateTeamDialog = ({ ), [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] ); + const anthropicRuntimeSelection = useMemo( + () => + selectedProviderId === 'anthropic' + ? resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog, + runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, + }, + selectedModel, + limitContext, + }) + : null, + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + ); + const anthropicFastModeResolution = useMemo( + () => + selectedProviderId === 'anthropic' && anthropicRuntimeSelection + ? resolveAnthropicFastMode({ + selection: anthropicRuntimeSelection, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : null, + [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + selectedFastMode, + selectedProviderId, + ] + ); + + useEffect(() => { + if (selectedProviderId !== 'anthropic') { + setAnthropicRuntimeNotice(null); + return; + } + + const reconciliation = reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }); + + const notices: string[] = []; + if (reconciliation.nextEffort !== selectedEffort) { + setSelectedEffortRaw(reconciliation.nextEffort); + localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort); + if (reconciliation.effortResetReason) { + notices.push(reconciliation.effortResetReason); + } + } + if (reconciliation.nextFastMode !== selectedFastMode) { + setSelectedFastModeRaw(reconciliation.nextFastMode); + localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode); + if (reconciliation.fastModeResetReason) { + notices.push(reconciliation.fastModeResetReason); + } + } + setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null); + }, [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + limitContext, + selectedEffort, + selectedFastMode, + selectedModel, + selectedProviderId, + ]); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const teamNameInlineError = validateTeamNameInline(teamName); @@ -1026,6 +1125,7 @@ export const CreateTeamDialog = ({ ) ?? undefined, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, + fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, limitContext, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, @@ -1043,6 +1143,7 @@ export const CreateTeamDialog = ({ runtimeProviderStatusById, effectiveModel, selectedEffort, + selectedFastMode, limitContext, skipPermissions, worktreeEnabled, @@ -1132,18 +1233,49 @@ export const CreateTeamDialog = ({ args.push('--mcp-config', '', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); if (effectiveModel) args.push('--model', effectiveModel); - if (selectedEffort) args.push('--effort', selectedEffort); + const effectiveEffort = + selectedProviderId === 'anthropic' + ? selectedEffort || anthropicRuntimeSelection?.defaultEffort || '' + : selectedEffort; + if (effectiveEffort) args.push('--effort', effectiveEffort); + if (selectedProviderId === 'anthropic') { + const fastSettings = anthropicFastModeResolution?.resolvedFastMode + ? { fastMode: true, fastModePerSessionOptIn: false } + : { fastMode: false }; + args.push('--settings', JSON.stringify(fastSettings)); + } return args; - }, [skipPermissions, effectiveModel, selectedEffort]); + }, [ + anthropicFastModeResolution?.resolvedFastMode, + anthropicRuntimeSelection?.defaultEffort, + effectiveModel, + selectedEffort, + selectedProviderId, + skipPermissions, + ]); const launchOptionalSummary = useMemo(() => { const summary: string[] = []; if (prompt.trim()) summary.push('Lead prompt'); if (skipPermissions) summary.push('Auto-approve tools'); + if (selectedProviderId === 'anthropic') { + if (selectedFastMode === 'on') summary.push('Fast mode'); + else if (selectedFastMode === 'off') summary.push('Fast disabled'); + else if (anthropicProviderFastModeDefault) summary.push('Fast default'); + } if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; - }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]); + }, [ + anthropicProviderFastModeDefault, + customArgs, + prompt, + selectedFastMode, + selectedProviderId, + skipPermissions, + worktreeEnabled, + worktreeName, + ]); const teamDetailsSummary = useMemo(() => { const summary: string[] = []; @@ -1212,6 +1344,8 @@ export const CreateTeamDialog = ({ color: request.color, members: request.members, cwd: effectiveCwd || undefined, + providerBackendId: request.providerBackendId, + fastMode: request.fastMode, }); onOpenTeam(request.teamName, effectiveCwd || undefined); resetFormState(); @@ -1389,11 +1523,15 @@ export const CreateTeamDialog = ({ onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} + fastMode={selectedFastMode} + providerFastModeDefault={anthropicProviderFastModeDefault} + onFastModeChange={setSelectedFastMode} onLimitContextChange={setLimitContext} syncModelsWithTeammates={syncModelsWithLead} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} disableGeminiOption={isGeminiUiFrozen()} leadModelIssueText={leadModelIssueText} + leadFastModeNotice={anthropicRuntimeNotice} memberModelIssueById={memberModelIssueById} headerTop={
diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index 53a07636..c63a5f5c 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -14,6 +14,7 @@ export interface EffortLevelSelectorProps { id?: string; providerId?: TeamProviderId; model?: string; + limitContext?: boolean; } export const EffortLevelSelector: React.FC = ({ @@ -22,9 +23,12 @@ export const EffortLevelSelector: React.FC = ({ id, providerId, model, + limitContext, }) => { const { providerStatus } = useEffectiveCliProviderStatus(providerId); - const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus }); + const effortOptions = getTeamEffortOptions({ providerId, model, limitContext, providerStatus }); + const showsAnthropicMax = + providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max'); return (
@@ -56,6 +60,12 @@ export const EffortLevelSelector: React.FC = ({ Controls how much reasoning the selected provider invests before responding. Default uses the provider's standard behavior for the selected model.

+ {showsAnthropicMax ? ( +

+ Max is Anthropic's heavier reasoning mode and only appears when the resolved launch + model supports it. +

+ ) : null}
); }; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 4dd79acc..413080f1 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,5 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, @@ -115,6 +120,7 @@ import type { Schedule, ScheduleLaunchConfig, TeamCreateRequest, + TeamFastMode, TeamLaunchRequest, TeamProviderId, UpdateSchedulePatch, @@ -216,6 +222,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string { return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } +function getStoredTeamFastMode(): TeamFastMode { + const stored = localStorage.getItem('team:lastSelectedFastMode'); + return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit'; +} + function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } @@ -254,6 +265,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const { open, onClose } = props; const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const anthropicProviderFastModeDefault = useStore( + (s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false + ); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); @@ -341,6 +355,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const stored = localStorage.getItem('team:lastSelectedEffort'); return stored === null ? 'medium' : stored; }); + const [selectedFastMode, setSelectedFastModeRaw] = useState(getStoredTeamFastMode); + const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState(null); // --------------------------------------------------------------------------- // Launch-only state @@ -535,6 +551,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen localStorage.setItem('team:lastSelectedEffort', value); }; + const setSelectedFastMode = (value: TeamFastMode): void => { + setSelectedFastModeRaw(value); + localStorage.setItem('team:lastSelectedFastMode', value); + }; + // --------------------------------------------------------------------------- // localStorage migration: schedule → team namespace (one-time) // --------------------------------------------------------------------------- @@ -625,6 +646,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); + setSelectedFastModeRaw(getStoredTeamFastMode()); } else { // Create mode — reset to defaults setSchedLabel(''); @@ -641,6 +663,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedProviderIdRaw(storedProviderId); setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); + setSelectedFastModeRaw(getStoredTeamFastMode()); } setLocalError(null); @@ -690,6 +713,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen multimodelEnabled, storedProviderId, storedEffort: storedEffort === null ? 'medium' : storedEffort, + storedFastMode: getStoredTeamFastMode(), storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true', getStoredModel: getStoredTeamModel, }); @@ -709,6 +733,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedProviderIdRaw(launchPrefill.providerId); setSelectedModelRaw(launchPrefill.model); setSelectedEffortRaw(launchPrefill.effort); + setSelectedFastModeRaw(launchPrefill.fastMode); setLimitContextRaw(launchPrefill.limitContext); setSkipPermissionsRaw( savedRequest?.skipPermissions ?? @@ -753,6 +778,85 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) ?? '', [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] ); + const anthropicRuntimeSelection = useMemo( + () => + selectedProviderId === 'anthropic' + ? resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog, + runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, + }, + selectedModel, + limitContext, + }) + : null, + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + ); + const anthropicFastModeResolution = useMemo( + () => + selectedProviderId === 'anthropic' && anthropicRuntimeSelection + ? resolveAnthropicFastMode({ + selection: anthropicRuntimeSelection, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : null, + [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + selectedFastMode, + selectedProviderId, + ] + ); + + useEffect(() => { + if (selectedProviderId !== 'anthropic') { + setAnthropicRuntimeNotice(null); + return; + } + + const reconciliation = reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }); + + const notices: string[] = []; + if (reconciliation.nextEffort !== selectedEffort) { + setSelectedEffortRaw(reconciliation.nextEffort); + localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort); + if (reconciliation.effortResetReason) { + notices.push(reconciliation.effortResetReason); + } + } + if (reconciliation.nextFastMode !== selectedFastMode) { + setSelectedFastModeRaw(reconciliation.nextFastMode); + localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode); + if (reconciliation.fastModeResetReason) { + notices.push(reconciliation.fastModeResetReason); + } + } + setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null); + }, [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + limitContext, + selectedEffort, + selectedFastMode, + selectedModel, + selectedProviderId, + ]); + const selectedModelChecksByProvider = useMemo(() => { const modelsByProvider = new Map(); const defaultSelectionByProvider = new Map(); @@ -1237,17 +1341,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeProviderStatusById.get(selectedProviderId) ); if (model) args.push('--model', model); - if (selectedEffort) args.push('--effort', selectedEffort); + const effectiveEffort = + selectedProviderId === 'anthropic' + ? selectedEffort || anthropicRuntimeSelection?.defaultEffort || '' + : selectedEffort; + if (effectiveEffort) args.push('--effort', effectiveEffort); + if (selectedProviderId === 'anthropic') { + const fastSettings = anthropicFastModeResolution?.resolvedFastMode + ? { fastMode: true, fastModePerSessionOptIn: false } + : { fastMode: false }; + args.push('--settings', JSON.stringify(fastSettings)); + } if (!clearContext) args.push('--resume', ''); return args; }, [ + anthropicFastModeResolution?.resolvedFastMode, + anthropicRuntimeSelection?.defaultEffort, isLaunchMode, skipPermissions, selectedModel, limitContext, selectedEffort, - clearContext, selectedProviderId, + clearContext, + runtimeProviderStatusById, ]); const launchOptionalSummary = useMemo(() => { @@ -1258,6 +1375,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (selectedProviderId === 'anthropic') { + if (selectedFastMode === 'on') summary.push('Fast mode'); + else if (selectedFastMode === 'off') summary.push('Fast disabled'); + else if (anthropicProviderFastModeDefault) summary.push('Fast default'); + } if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); @@ -1270,6 +1392,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedModel, selectedProviderId, selectedEffort, + selectedFastMode, + anthropicProviderFastModeDefault, limitContext, skipPermissions, clearContext, @@ -1478,6 +1602,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeProviderStatusById.get(selectedProviderId) ), effort: (selectedEffort as EffortLevel) || undefined, + fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, limitContext, clearContext: clearContext || undefined, skipPermissions, @@ -1869,14 +1994,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen providerId={selectedProviderId} model={selectedModel} effort={(selectedEffort as EffortLevel) || undefined} + fastMode={selectedFastMode} + providerFastModeDefault={anthropicProviderFastModeDefault} limitContext={limitContext} onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} + onFastModeChange={setSelectedFastMode} onLimitContextChange={setLimitContext} syncModelsWithTeammates={syncModelsWithLead} onSyncModelsWithTeammatesChange={setSyncModelsWithLead} leadWarningText={leadRuntimeWarningText} + leadFastModeNotice={anthropicRuntimeNotice} memberWarningById={memberRuntimeWarningById} leadModelIssueText={leadModelIssueText} memberModelIssueById={memberModelIssueById} @@ -2029,6 +2158,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen id="dialog-effort" providerId={selectedProviderId} model={selectedModel} + limitContext={false} /> string; } @@ -31,6 +33,7 @@ interface LaunchDialogPrefillResult { providerBackendId?: string; model: string; effort: string; + fastMode: 'inherit' | 'on' | 'off'; limitContext: boolean; } @@ -62,6 +65,7 @@ export function resolveLaunchDialogPrefill({ multimodelEnabled, storedProviderId, storedEffort, + storedFastMode, storedLimitContext, getStoredModel, }: LaunchDialogPrefillInput): LaunchDialogPrefillResult { @@ -99,6 +103,8 @@ export function resolveLaunchDialogPrefill({ const effort = currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort; + const fastMode = + savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit'; const limitContext = previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext; @@ -113,6 +119,7 @@ export function resolveLaunchDialogPrefill({ ? normalizeExplicitTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, + fastMode, limitContext, }; } diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index c9c84a94..8f91c89e 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; @@ -20,20 +21,24 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'; import { Button } from '../../ui/button'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types'; interface LeadModelRowProps { providerId: TeamProviderId; model: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + providerFastModeDefault?: boolean; limitContext: boolean; onProviderChange: (providerId: TeamProviderId) => void; onModelChange: (model: string) => void; onEffortChange: (effort: string) => void; + onFastModeChange?: (fastMode: TeamFastMode) => void; onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; warningText?: string | null; + fastModeNotice?: string | null; disableGeminiOption?: boolean; modelIssueText?: string | null; } @@ -42,14 +47,18 @@ export const LeadModelRow = ({ providerId, model, effort, + fastMode = 'inherit', + providerFastModeDefault = false, limitContext, onProviderChange, onModelChange, onEffortChange, + onFastModeChange, onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, warningText, + fastModeNotice, disableGeminiOption = false, modelIssueText, }: LeadModelRowProps): React.JSX.Element => { @@ -157,7 +166,18 @@ export const LeadModelRow = ({ id="lead-effort" providerId={providerId} model={model} + limitContext={limitContext} /> + {providerId === 'anthropic' && onFastModeChange ? ( + + ) : null} {providerId === 'anthropic' ? ( ) : null} + {fastModeNotice ? ( +
+ +

{fastModeNotice}

+
+ ) : null}

diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 498a483e..51b67cfe 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -46,6 +46,7 @@ interface MemberDraftRowProps { inheritedProviderId?: TeamProviderId; inheritedModel?: string; inheritedEffort?: EffortLevel; + limitContext?: boolean; draftKeyPrefix?: string; projectPath?: string | null; mentionSuggestions?: MentionSuggestion[]; @@ -91,6 +92,7 @@ export const MemberDraftRow = ({ inheritedProviderId = 'anthropic', inheritedModel = '', inheritedEffort, + limitContext = false, draftKeyPrefix, projectPath, mentionSuggestions = [], @@ -448,6 +450,7 @@ export const MemberDraftRow = ({ id={`member-${member.id}-effort`} providerId={effectiveProviderId} model={effectiveModel} + limitContext={limitContext} /> {lockProviderModel && (

diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index a9785ecd..d1d4ead5 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -168,7 +168,9 @@ function areLaunchParamsEquivalent( left.providerId === right.providerId && left.providerBackendId === right.providerBackendId && left.model === right.model && - left.effort === right.effort + left.effort === right.effort && + left.fastMode === right.fastMode && + left.limitContext === right.limitContext ); } diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index e7fb826e..06c34474 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -101,6 +101,7 @@ export interface MembersEditorSectionProps { inheritedProviderId?: TeamProviderId; inheritedModel?: string; inheritedEffort?: EffortLevel; + limitContext?: boolean; inheritModelSettingsByDefault?: boolean; forceInheritedModelSettings?: boolean; modelLockReason?: string; @@ -134,6 +135,7 @@ export const MembersEditorSection = ({ inheritedProviderId, inheritedModel, inheritedEffort, + limitContext = false, inheritModelSettingsByDefault = false, forceInheritedModelSettings = false, modelLockReason, @@ -331,6 +333,7 @@ export const MembersEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} @@ -374,6 +377,7 @@ export const MembersEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 5b6480ba..e3d423d6 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types'; interface TeamRosterEditorSectionProps { members: MemberDraft[]; @@ -31,10 +31,13 @@ interface TeamRosterEditorSectionProps { providerId: TeamProviderId; model: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + providerFastModeDefault?: boolean; limitContext: boolean; onProviderChange: (providerId: TeamProviderId) => void; onModelChange: (model: string) => void; onEffortChange: (effort: string) => void; + onFastModeChange?: (fastMode: TeamFastMode) => void; onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; @@ -42,6 +45,7 @@ interface TeamRosterEditorSectionProps { headerBottom?: React.ReactNode; softDeleteMembers?: boolean; leadWarningText?: string | null; + leadFastModeNotice?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; leadModelIssueText?: string | null; @@ -72,10 +76,13 @@ export const TeamRosterEditorSection = ({ providerId, model, effort, + fastMode, + providerFastModeDefault, limitContext, onProviderChange, onModelChange, onEffortChange, + onFastModeChange, onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, @@ -83,6 +90,7 @@ export const TeamRosterEditorSection = ({ headerBottom, softDeleteMembers = false, leadWarningText, + leadFastModeNotice, memberWarningById, disableGeminiOption = false, leadModelIssueText, @@ -106,6 +114,7 @@ export const TeamRosterEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} inheritModelSettingsByDefault={inheritModelSettingsByDefault} lockProviderModel={lockProviderModel} forceInheritedModelSettings={forceInheritedModelSettings} @@ -120,14 +129,18 @@ export const TeamRosterEditorSection = ({ providerId={providerId} model={model} effort={effort} + fastMode={fastMode} + providerFastModeDefault={providerFastModeDefault} limitContext={limitContext} onProviderChange={onProviderChange} onModelChange={onModelChange} onEffortChange={onEffortChange} + onFastModeChange={onFastModeChange} onLimitContextChange={onLimitContextChange} syncModelsWithTeammates={syncModelsWithTeammates} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} + fastModeNotice={leadFastModeNotice} disableGeminiOption={disableGeminiOption} modelIssueText={leadModelIssueText} /> diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 1a86a712..8bb11d0d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1526,6 +1526,7 @@ export interface TeamLaunchParams { providerBackendId?: string; model?: string; // 'opus' | 'sonnet' | 'haiku' effort?: EffortLevel; + fastMode?: 'inherit' | 'on' | 'off'; limitContext?: boolean; } @@ -4426,6 +4427,7 @@ export const createTeamSlice: StateCreator = (set, providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, + fastMode: request.fastMode, limitContext: request.limitContext ?? false, }; saveLaunchParams(request.teamName, params); @@ -4598,6 +4600,7 @@ export const createTeamSlice: StateCreator = (set, providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, + fastMode: request.fastMode, limitContext: request.limitContext ?? false, }; saveLaunchParams(request.teamName, params); diff --git a/src/renderer/utils/__tests__/teamEffortOptions.test.ts b/src/renderer/utils/__tests__/teamEffortOptions.test.ts index 4ed51753..91c268b0 100644 --- a/src/renderer/utils/__tests__/teamEffortOptions.test.ts +++ b/src/renderer/utils/__tests__/teamEffortOptions.test.ts @@ -93,15 +93,16 @@ describe('team effort options', () => { ); }); - it('shows only supported low/medium/high efforts for Anthropic and never leaks max', () => { + it('keeps Anthropic aliases conservative when the resolved runtime model does not support effort', () => { const providerStatus = createProviderStatus('anthropic', { - id: 'opus', - launchModel: 'opus', - displayName: 'Opus 4.7', - hidden: false, - supportedReasoningEfforts: ['low', 'medium', 'high'], + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], defaultReasoningEffort: null, inputModalities: ['text', 'image'], + supportsFastMode: false, supportsPersonality: false, isDefault: true, upgrade: false, @@ -110,11 +111,83 @@ describe('team effort options', () => { expect( getTeamEffortOptions({ providerId: 'anthropic', model: 'opus', providerStatus }) + ).toEqual([{ value: '', label: 'Default' }]); + }); + + it('shows Anthropic max only for the exact resolved model that supports it', () => { + const providerStatus = { + ...createProviderStatus('anthropic', { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }), + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic' as const, + source: 'anthropic-models-api' as const, + status: 'ready' as const, + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api' as const, + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api' as const, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + } satisfies CliProviderStatus; + + expect( + getTeamEffortOptions({ + providerId: 'anthropic', + model: 'claude-opus-4-6', + providerStatus, + }) ).toEqual([ - { value: '', label: 'Default' }, + { value: '', label: 'Default (Medium)' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, ]); }); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index af312422..ac4f6def 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -5,6 +5,8 @@ import { getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, getTeamModelSelectionError, + isTeamModelAvailableForUi, + normalizeTeamModelForUi, } from '../teamModelAvailability'; import type { CliProviderStatus } from '@shared/types'; @@ -62,6 +64,58 @@ function createCodexProviderStatus( }; } +function createAnthropicProviderStatus( + models: NonNullable['models'] +): CliProviderStatus { + return { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models, + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + modelAvailability: [], + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }; +} + describe('team model availability Codex catalog integration', () => { it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => { const providerStatus = createCodexProviderStatus( @@ -158,4 +212,163 @@ describe('team model availability Codex catalog integration', () => { expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); }); + + it('keeps the curated Anthropic picker surface while using runtime-backed labels', () => { + const providerStatus = createAnthropicProviderStatus([ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.8', + }, + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.8 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.6', + }, + { + id: 'sonnet', + launchModel: 'sonnet', + displayName: 'Sonnet 4.7', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Sonnet 4.7', + }, + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.6', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Haiku 4.6', + }, + { + id: 'claude-sonnet-4-6[1m]', + launchModel: 'claude-sonnet-4-6[1m]', + displayName: 'Sonnet 4.6 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'static-fallback', + }, + ]); + + expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([ + 'haiku', + 'opus', + 'claude-opus-4-6', + 'sonnet', + ]); + expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([ + { + value: '', + label: 'Default', + badgeLabel: 'Default', + availabilityStatus: undefined, + availabilityReason: undefined, + }, + { + value: 'opus', + label: 'Opus 4.8', + badgeLabel: 'Opus 4.8', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'claude-opus-4-6', + label: 'Opus 4.6', + badgeLabel: 'Opus 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'sonnet', + label: 'Sonnet 4.7', + badgeLabel: 'Sonnet 4.7', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'haiku', + label: 'Haiku 4.6', + badgeLabel: 'Haiku 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('keeps persisted hidden Anthropic compatibility values valid when runtime catalog supplies them', () => { + const providerStatus = createAnthropicProviderStatus([ + { + id: 'claude-sonnet-4-6[1m]', + launchModel: 'claude-sonnet-4-6[1m]', + displayName: 'Sonnet 4.6 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'static-fallback', + }, + ]); + + expect(isTeamModelAvailableForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + true + ); + expect(normalizeTeamModelForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + 'claude-sonnet-4-6[1m]' + ); + expect(getTeamModelSelectionError('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + null + ); + }); }); diff --git a/src/renderer/utils/teamEffortOptions.ts b/src/renderer/utils/teamEffortOptions.ts index 5c6767c0..c3465d70 100644 --- a/src/renderer/utils/teamEffortOptions.ts +++ b/src/renderer/utils/teamEffortOptions.ts @@ -1,3 +1,5 @@ +import { resolveAnthropicRuntimeSelection } from '@features/anthropic-runtime-profile/renderer'; + import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types'; const BASE_EFFORT_OPTIONS = [{ value: '', label: 'Default' }] as const; @@ -9,6 +11,7 @@ export const TEAM_EFFORT_LABELS: Record = { low: 'Low', medium: 'Medium', high: 'High', + max: 'Max', xhigh: 'XHigh', }; @@ -59,6 +62,7 @@ function normalizeEfforts( export function getTeamEffortOptions(params: { providerId?: TeamProviderId; model?: string; + limitContext?: boolean; providerStatus?: CliProviderStatus | null; }): readonly TeamEffortOption[] { const providerId = params.providerId; @@ -66,6 +70,27 @@ export function getTeamEffortOptions(params: { return BASE_EFFORT_OPTIONS; } + if (providerId === 'anthropic') { + const selection = resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: params.providerStatus?.modelCatalog, + runtimeCapabilities: params.providerStatus?.runtimeCapabilities, + }, + selectedModel: params.model, + limitContext: params.limitContext === true, + }); + const defaultLabel = selection.defaultEffort + ? `Default (${TEAM_EFFORT_LABELS[selection.defaultEffort]})` + : 'Default'; + return [ + { value: '', label: defaultLabel }, + ...selection.supportedEfforts.map((effort) => ({ + value: effort, + label: TEAM_EFFORT_LABELS[effort], + })), + ]; + } + const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort; const catalogModel = getCatalogModel(providerId, params.providerStatus, params.model); const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? []; @@ -82,16 +107,6 @@ export function getTeamEffortOptions(params: { ? `Default (${TEAM_EFFORT_LABELS[catalogModel.defaultReasoningEffort]})` : 'Default'; - if (providerId === 'anthropic') { - return [ - { value: '', label: defaultLabel }, - ...efforts.map((effort) => ({ - value: effort, - label: TEAM_EFFORT_LABELS[effort], - })), - ]; - } - if (providerId === 'codex') { const fallbackEfforts = efforts.length > 0 ? efforts : (['low', 'medium', 'high'] as EffortLevel[]); diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 12d556cf..d5309f8f 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -117,7 +117,14 @@ export interface CliProviderModelAvailability { checkedAt?: string | null; } -export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +export type CliProviderReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; export type CliProviderModelCatalogSource = | 'anthropic-models-api' @@ -132,6 +139,7 @@ export interface CliProviderModelCatalogItem { hidden: boolean; supportedReasoningEfforts: CliProviderReasoningEffort[]; defaultReasoningEffort: CliProviderReasoningEffort | null; + supportsFastMode?: boolean; inputModalities: string[]; supportsPersonality: boolean; isDefault: boolean; @@ -169,6 +177,12 @@ export interface CliProviderRuntimeCapabilities { values: CliProviderReasoningEffort[]; configPassthrough?: boolean; }; + fastMode?: { + supported: boolean; + available: boolean; + reason?: string | null; + source: 'runtime'; + }; } export interface CliProviderStatus { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 3adcda4f..3c39c08e 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -327,6 +327,7 @@ export interface AppConfig { providerConnections: { anthropic: { authMode: 'auto' | 'oauth' | 'api_key'; + fastModeDefault: boolean; }; codex: { preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 449838ec..7bd7820f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -782,9 +782,10 @@ export interface TeamViewSnapshot { isAlive?: boolean; } -export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; +export type TeamFastMode = 'inherit' | 'on' | 'off'; export interface ProviderModelLaunchIdentity { providerId: TeamProviderId; @@ -802,6 +803,9 @@ export interface ProviderModelLaunchIdentity { catalogFetchedAt: string | null; selectedEffort: EffortLevel | null; resolvedEffort: EffortLevel | null; + selectedFastMode?: TeamFastMode | null; + resolvedFastMode?: boolean | null; + fastResolutionReason?: string | null; } export interface TeamLaunchRequest { @@ -812,6 +816,7 @@ export interface TeamLaunchRequest { providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; /** When true, context window is limited to 200K tokens instead of the default. */ limitContext?: boolean; /** When true, skip --resume and start a fresh session (clears context memory). */ @@ -949,6 +954,7 @@ export interface TeamAgentRuntimeSnapshot { updatedAt: string; runId: string | null; providerBackendId?: TeamProviderBackendId; + fastMode?: TeamFastMode; members: Record; } @@ -1061,6 +1067,7 @@ export interface TeamCreateRequest { providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; /** When true, context window is limited to 200K tokens instead of the default. */ limitContext?: boolean; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ @@ -1079,6 +1086,7 @@ export interface TeamCreateConfigRequest { members: TeamProvisioningMemberInput[]; cwd?: string; providerBackendId?: TeamProviderBackendId; + fastMode?: TeamFastMode; } export interface TeamCreateResponse { diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts index bd7ddaa9..66ebd0ec 100644 --- a/src/shared/utils/cliArgsParser.ts +++ b/src/shared/utils/cliArgsParser.ts @@ -28,6 +28,7 @@ export const PROTECTED_CLI_FLAGS = new Set([ '--effort', '--teammate-mode', '--resume', + '--settings', '--permission-mode', '--permission-prompt-tool', '--dangerously-skip-permissions', diff --git a/src/shared/utils/effortLevels.ts b/src/shared/utils/effortLevels.ts index 958d9c03..63ee24f1 100644 --- a/src/shared/utils/effortLevels.ts +++ b/src/shared/utils/effortLevels.ts @@ -7,6 +7,7 @@ export const TEAM_EFFORT_LEVELS = [ 'medium', 'high', 'xhigh', + 'max', ] as const satisfies readonly EffortLevel[]; export const LEGACY_TEAM_EFFORT_LEVELS = [ @@ -23,8 +24,16 @@ export const CODEX_TEAM_EFFORT_LEVELS = [ 'xhigh', ] as const satisfies readonly EffortLevel[]; +export const ANTHROPIC_TEAM_EFFORT_LEVELS = [ + 'low', + 'medium', + 'high', + 'max', +] as const satisfies readonly EffortLevel[]; + const LEGACY_TEAM_EFFORT_LEVEL_SET = new Set(LEGACY_TEAM_EFFORT_LEVELS); const CODEX_TEAM_EFFORT_LEVEL_SET = new Set(CODEX_TEAM_EFFORT_LEVELS); +const ANTHROPIC_TEAM_EFFORT_LEVEL_SET = new Set(ANTHROPIC_TEAM_EFFORT_LEVELS); export function isTeamEffortLevel(value: unknown): value is EffortLevel { return typeof value === 'string' && TEAM_EFFORT_LEVELS.includes(value as EffortLevel); @@ -46,6 +55,10 @@ export function isTeamEffortLevelForProvider( return CODEX_TEAM_EFFORT_LEVEL_SET.has(value); } + if (providerId === 'anthropic') { + return ANTHROPIC_TEAM_EFFORT_LEVEL_SET.has(value); + } + return LEGACY_TEAM_EFFORT_LEVEL_SET.has(value); } @@ -53,5 +66,8 @@ export function formatEffortLevelListForProvider(providerId?: TeamProviderId | n if (providerId === 'codex') { return CODEX_TEAM_EFFORT_LEVELS.join(', '); } + if (providerId === 'anthropic') { + return ANTHROPIC_TEAM_EFFORT_LEVELS.join(', '); + } return LEGACY_TEAM_EFFORT_LEVELS.join(', '); } diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts index 2d1252a1..7fa87922 100644 --- a/src/shared/utils/rateLimitDetector.ts +++ b/src/shared/utils/rateLimitDetector.ts @@ -3,12 +3,24 @@ */ const RATE_LIMIT_SUBSTRING = "You've hit your limit"; +const MODEL_COOLDOWN_CODE = 'model_cooldown'; + +interface StructuredRateLimitPayload { + code: string | null; + message: string | null; + resetSeconds: number | null; + resetTime: string | null; +} /** * Returns true if the message text contains the rate limit indicator. */ export function isRateLimitMessage(text: string): boolean { - return text.includes(RATE_LIMIT_SUBSTRING); + if (!text) return false; + if (text.includes(RATE_LIMIT_SUBSTRING)) return true; + + const structured = extractStructuredRateLimitPayload(text); + return structured ? isStructuredRateLimitPayload(structured) : false; } // --------------------------------------------------------------------------- @@ -63,6 +75,14 @@ export function parseRateLimitResetTime(text: string, now: Date = new Date()): D // words like "reset" (e.g. "reset the 5pm meeting"). if (!isRateLimitMessage(text)) return null; + const structured = extractStructuredRateLimitPayload(text); + if (structured && isStructuredRateLimitPayload(structured)) { + const structuredReset = parseStructuredResetTime(structured, now); + if (structuredReset) { + return structuredReset; + } + } + const relative = parseRelativeResetDuration(text); if (relative !== null) { return new Date(now.getTime() + relative); @@ -120,6 +140,79 @@ function parseRelativeResetDuration(text: string): number | null { return null; } +function extractStructuredRateLimitPayload(text: string): StructuredRateLimitPayload | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + const prefixedMatch = /^(?:API Error:\s*\d+\s+|\d+\s+)?(\{[\s\S]*\})$/i.exec(trimmed); + const jsonCandidate = prefixedMatch?.[1] ?? (trimmed.startsWith('{') ? trimmed : null); + if (!jsonCandidate) return null; + + try { + const parsed = JSON.parse(jsonCandidate) as { + error?: { + code?: unknown; + message?: unknown; + reset_seconds?: unknown; + reset_time?: unknown; + }; + code?: unknown; + message?: unknown; + reset_seconds?: unknown; + reset_time?: unknown; + }; + const errorPayload = parsed.error; + + return { + code: readStringField(errorPayload?.code) ?? readStringField(parsed.code), + message: readStringField(errorPayload?.message) ?? readStringField(parsed.message), + resetSeconds: + readNumberField(errorPayload?.reset_seconds) ?? readNumberField(parsed.reset_seconds), + resetTime: readStringField(errorPayload?.reset_time) ?? readStringField(parsed.reset_time), + }; + } catch { + return null; + } +} + +function isStructuredRateLimitPayload(payload: StructuredRateLimitPayload): boolean { + const code = payload.code?.trim().toLowerCase(); + if (code === MODEL_COOLDOWN_CODE) { + return true; + } + + const message = payload.message?.trim().toLowerCase() ?? ''; + return ( + (message.includes('cooling down') || message.includes('model cooldown')) && + (payload.resetSeconds !== null || payload.resetTime !== null) + ); +} + +function parseStructuredResetTime(payload: StructuredRateLimitPayload, now: Date): Date | null { + if (payload.resetSeconds !== null) { + return new Date(now.getTime() + Math.max(0, payload.resetSeconds) * 1000); + } + + const resetTime = payload.resetTime?.trim(); + if (!resetTime) return null; + + const relative = parseRelativeResetDuration(`Resets in ${resetTime}`); + if (relative !== null) { + return new Date(now.getTime() + relative); + } + + const absolute = Date.parse(resetTime); + return Number.isFinite(absolute) ? new Date(absolute) : null; +} + +function readStringField(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function readNumberField(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + // --------------------------------------------------------------------------- // Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC" // --------------------------------------------------------------------------- diff --git a/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts b/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts new file mode 100644 index 00000000..c806d70b --- /dev/null +++ b/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; + +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; +import type { CliProviderModelCatalog, CliProviderRuntimeCapabilities } from '@shared/types'; + +import type { AnthropicRuntimeProfileSource } from '@features/anthropic-runtime-profile/renderer'; + +function createAnthropicSource(options: { + models: CliProviderModelCatalog['models']; + defaultLaunchModel?: string; + fastMode?: CliProviderRuntimeCapabilities['fastMode']; +}): AnthropicRuntimeProfileSource { + return { + modelCatalog: { + schemaVersion: 1 as const, + providerId: 'anthropic' as const, + source: 'anthropic-models-api' as const, + status: 'ready' as const, + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: options.defaultLaunchModel ?? options.models[0]?.id ?? 'opus[1m]', + defaultLaunchModel: options.defaultLaunchModel ?? options.models[0]?.launchModel ?? 'opus[1m]', + models: options.models, + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api' as const, + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high', 'max'], + configPassthrough: true, + }, + fastMode: options.fastMode ?? { + supported: true, + available: true, + reason: null, + source: 'runtime' as const, + }, + }, + }; +} + +describe('resolveAnthropicRuntimeProfile', () => { + it('uses the resolved launch model, not the alias family, for effort and fast capability truth', () => { + const source = createAnthropicSource({ + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }); + + const aliasSelection = resolveAnthropicRuntimeSelection({ + source, + selectedModel: 'opus', + limitContext: false, + }); + const explicit46Selection = resolveAnthropicRuntimeSelection({ + source, + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect(aliasSelection.resolvedLaunchModel).toBe('opus[1m]'); + expect(aliasSelection.supportedEfforts).toEqual([]); + expect(aliasSelection.supportsFastMode).toBe(false); + + expect(explicit46Selection.resolvedLaunchModel).toBe('claude-opus-4-6'); + expect(explicit46Selection.supportedEfforts).toEqual(['low', 'medium', 'high', 'max']); + expect(explicit46Selection.defaultEffort).toBe('medium'); + expect(explicit46Selection.supportsFastMode).toBe(true); + }); + + it('resolves inherited fast mode from the provider default only when the exact model supports it', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: undefined, + providerFastModeDefault: true, + }) + ).toMatchObject({ + selectedFastMode: 'inherit', + requestedFastMode: true, + resolvedFastMode: true, + selectable: true, + disabledReason: null, + }); + }); + + it('resets only the invalid fast selection when an alias resolves to a non-fast model', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'opus', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: '', + selectedFastMode: 'on', + providerFastModeDefault: false, + }) + ).toEqual({ + nextEffort: '', + effortResetReason: null, + nextFastMode: 'inherit', + fastModeResetReason: + 'Fast mode is available only for Opus 4.6. Selected model resolves to Opus 4.7 (1M).', + }); + }); + + it('resets invalid max effort without mutating unrelated fast intent', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.5', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'haiku', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: 'max', + selectedFastMode: 'off', + providerFastModeDefault: true, + }) + ).toEqual({ + nextEffort: '', + effortResetReason: + 'max effort is not available for the currently selected Anthropic model. Reset to Default.', + nextFastMode: 'off', + fastModeResetReason: null, + }); + }); + + it('does not reset explicit max or fast while runtime catalog truth is still unavailable', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: 'max', + selectedFastMode: 'on', + providerFastModeDefault: false, + }) + ).toEqual({ + nextEffort: 'max', + effortResetReason: null, + nextFastMode: 'on', + fastModeResetReason: null, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: 'on', + providerFastModeDefault: false, + }).disabledReason + ).toBe('Anthropic runtime capability data is still loading.'); + }); + + it('keeps the fast control visible in degraded states and surfaces the provider reason', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + fastMode: { + supported: true, + available: false, + reason: 'Fast mode status is degraded right now.', + source: 'runtime', + }, + }), + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: 'inherit', + providerFastModeDefault: true, + }) + ).toMatchObject({ + showFastModeControl: true, + selectable: false, + requestedFastMode: true, + resolvedFastMode: false, + disabledReason: 'Fast mode status is degraded right now.', + }); + }); +}); diff --git a/test/main/services/team/AutoResumeService.test.ts b/test/main/services/team/AutoResumeService.test.ts index c7ca7d6d..76992148 100644 --- a/test/main/services/team/AutoResumeService.test.ts +++ b/test/main/services/team/AutoResumeService.test.ts @@ -6,6 +6,8 @@ import type { ConfigManager } from '../../../../src/main/services/infrastructure const TEAM = 'test-team'; const RATE_LIMIT_MSG = "You've hit your limit. Resets in 5 minutes."; +const MODEL_COOLDOWN_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}'; describe('AutoResumeService', () => { const mockConfig = { autoResumeOnRateLimit: false }; @@ -60,6 +62,21 @@ describe('AutoResumeService', () => { expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); }); + it('schedules auto-resume from model_cooldown API errors', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, MODEL_COOLDOWN_API_ERROR, now); + + await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + it('reschedules when a later rate-limit message changes the reset time', async () => { mockConfig.autoResumeOnRateLimit = true; provisioningService.isTeamAlive.mockReturnValue(true); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 2e6e5098..d5ae6870 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -152,6 +152,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { it.each([ ['rate_limited', 'Provider returned 429 rate limit for this request.'], + ['rate_limited', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.'], ['auth_error', 'Authentication failed due to invalid API key.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index a0b197fe..59664344 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -50,6 +51,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -81,6 +83,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -93,6 +96,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -110,6 +114,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -122,6 +127,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.3-codex', effort: 'high', + fastMode: 'inherit', limitContext: false, }); }); @@ -142,6 +148,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -154,6 +161,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -174,6 +182,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'codex', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ codex: 'gpt-5.4', @@ -185,6 +194,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -207,6 +217,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -216,8 +227,10 @@ describe('resolveLaunchDialogPrefill', () => { expect(result).toEqual({ providerId: 'anthropic', + providerBackendId: undefined, model: 'haiku', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -235,6 +248,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -243,8 +257,10 @@ describe('resolveLaunchDialogPrefill', () => { expect(result).toEqual({ providerId: 'anthropic', + providerBackendId: undefined, model: 'opus', effort: 'high', + fastMode: 'inherit', limitContext: true, }); }); @@ -261,6 +277,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -273,6 +290,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'custom-model[1m]', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -289,6 +307,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -301,6 +320,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'custom-model[1m]', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index f3c3f474..aa48f8f5 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -246,6 +246,7 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig { providerConnections: { anthropic: { authMode: 'auto', + fastModeDefault: false, }, codex: { preferredAuthMode: 'auto', diff --git a/test/shared/utils/rateLimitDetector.test.ts b/test/shared/utils/rateLimitDetector.test.ts index ecdcfb9d..81eb3ae4 100644 --- a/test/shared/utils/rateLimitDetector.test.ts +++ b/test/shared/utils/rateLimitDetector.test.ts @@ -8,6 +8,10 @@ import { // Helper: every production rate-limit message starts with this substring. // Prefix test inputs so they clear the parser's rate-limit-context gate. const RL = "You've hit your limit. "; +const MODEL_COOLDOWN_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}'; +const MODEL_COOLDOWN_NO_SECONDS_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_time":"40s"}}'; describe('isRateLimitMessage', () => { it('detects the canonical substring', () => { @@ -22,6 +26,10 @@ describe('isRateLimitMessage', () => { expect(isRateLimitMessage('hit the limit')).toBe(false); // missing "You've" expect(isRateLimitMessage('')).toBe(false); }); + + it('detects structured model_cooldown API errors as rate limits', () => { + expect(isRateLimitMessage(MODEL_COOLDOWN_API_ERROR)).toBe(true); + }); }); describe('parseRateLimitResetTime', () => { @@ -41,6 +49,18 @@ describe('parseRateLimitResetTime', () => { expect(parseRateLimitResetTime('Resets in 2 hours.', now)).toBeNull(); }); + it('parses model_cooldown reset_seconds from structured API errors', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(MODEL_COOLDOWN_API_ERROR, now); + expect(result?.toISOString()).toBe('2026-04-17T12:00:41.000Z'); + }); + + it('falls back to structured reset_time when reset_seconds is missing', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(MODEL_COOLDOWN_NO_SECONDS_API_ERROR, now); + expect(result?.toISOString()).toBe('2026-04-17T12:00:40.000Z'); + }); + // --------------------------------------------------------------------- // Relative durations // --------------------------------------------------------------------- From 7374108a719160a8f5c1a0ea5ad34dc365925427 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 17:16:49 +0300 Subject: [PATCH 06/19] fix(teams): preserve explicit provider during relaunch --- src/main/ipc/teams.ts | 7 +- .../TeamProvisioningServicePrepare.test.ts | 147 ++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1a272c0f..e4ff39ef 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1490,12 +1490,15 @@ async function handleLaunchTeam( if (payload.model !== undefined && typeof payload.model !== 'string') { return { success: false, error: 'model must be a string' }; } - const providerId = + const explicitProviderId = payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' - : 'anthropic'; + : payload.providerId === 'anthropic' + ? 'anthropic' + : undefined; + const providerId = explicitProviderId ?? 'anthropic'; const providerBackendValidation = parseOptionalProviderBackendId( payload.providerBackendId, providerId diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index b09aa0f5..74ce7c7d 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -849,6 +849,153 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); }); + it('builds Anthropic launch identity with exact max effort and resolved fast mode', () => { + const svc = new TeamProvisioningService(); + const launchIdentity = (svc as any).buildProviderModelLaunchIdentity({ + request: { + providerId: 'anthropic', + model: 'claude-opus-4-6', + effort: 'max', + fastMode: 'on', + limitContext: true, + }, + facts: { + defaultModel: 'opus[1m]', + modelIds: new Set(['claude-opus-4-6']), + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'opus', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'high', + supportsFastMode: true, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'anthropic-models-api' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high', 'max'], + configPassthrough: true, + }, + fastMode: { + supported: true, + available: true, + reason: null, + source: 'runtime', + }, + }, + }, + }); + + expect(launchIdentity).toMatchObject({ + providerId: 'anthropic', + selectedModel: 'claude-opus-4-6', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'claude-opus-4-6', + selectedEffort: 'max', + resolvedEffort: 'max', + selectedFastMode: 'on', + resolvedFastMode: true, + fastResolutionReason: null, + }); + }); + + it('rejects Anthropic max and fast when the exact resolved launch model does not support them', () => { + const svc = new TeamProvisioningService(); + const facts = { + defaultModel: 'opus[1m]', + modelIds: new Set(['opus[1m]']), + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'opus', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + supportsFastMode: false, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'anthropic-models-api' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high', 'max'], + configPassthrough: true, + }, + fastMode: { + supported: true, + available: true, + reason: null, + source: 'runtime', + }, + }, + }; + + expect(() => + (svc as any).validateRuntimeLaunchSelection({ + actorLabel: 'Team lead', + providerId: 'anthropic', + model: 'opus', + effort: 'max', + limitContext: false, + facts, + }) + ).toThrow('does not support it in the current runtime'); + + expect(() => + (svc as any).validateRuntimeLaunchSelection({ + actorLabel: 'Team lead', + providerId: 'anthropic', + model: 'opus', + fastMode: 'on', + limitContext: false, + facts, + }) + ).toThrow('enables Anthropic Fast mode'); + }); + it('emits a lead-message refresh after provisioning reaches ready', async () => { const svc = new TeamProvisioningService(); const emitter = vi.fn(); From c69b7e421206e0b4f76f97c33b04673007e7e56f Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 17:21:29 +0300 Subject: [PATCH 07/19] feat(task-change-ledger): harden review flow --- .../renderer/adapters/TeamGraphAdapter.ts | 3 +- src/main/ipc/review.ts | 16 +- src/main/ipc/teams.ts | 3 +- .../services/team/ChangeExtractorService.ts | 10 +- src/main/services/team/FileContentResolver.ts | 26 +- .../services/team/ReviewApplierService.ts | 21 +- src/main/services/team/ReviewDecisionStore.ts | 207 ++- .../services/team/TaskChangeLedgerReader.ts | 1281 ++++++++++++++--- .../services/team/TeamLogSourceTracker.ts | 71 +- .../cache/JsonTaskChangePresenceRepository.ts | 7 +- .../cache/taskChangePresenceCacheSchema.ts | 10 +- .../cache/taskChangePresenceCacheTypes.ts | 3 +- .../TeamTaskLogFreshnessReader.ts | 66 +- src/preload/index.ts | 15 +- src/renderer/api/httpClient.ts | 1 + .../team/dialogs/TaskDetailDialog.tsx | 25 +- .../team/kanban/KanbanTaskCard.test.tsx | 37 + .../components/team/kanban/KanbanTaskCard.tsx | 3 +- .../team/review/ChangeReviewDialog.tsx | 59 +- .../team/review/CodeMirrorDiffUtils.ts | 9 +- .../team/review/ContinuousScrollView.tsx | 15 +- .../components/team/review/ReviewFileTree.tsx | 20 +- .../store/slices/changeReviewSlice.ts | 514 ++++--- src/renderer/utils/reviewDecisionScope.ts | 72 + src/renderer/utils/reviewKey.ts | 104 ++ src/renderer/utils/taskChangePresence.ts | 19 + src/shared/types/api.ts | 6 +- src/shared/types/review.ts | 71 + src/shared/types/team.ts | 6 +- src/shared/utils/taskChangePresence.ts | 15 + .../task-change-ledger/binary/manifest.json | 17 + .../fixture-binary.json | 1 + ...9294ff43def81c6cdcad6cbb1820cff48d3aa4355d | 1 + .../bundles/fixture-binary.json | 1 + .../events/fixture-binary.jsonl | 1 + .../binary/project/fixtures/blob.bin | 1 + .../task-change-ledger/copy/manifest.json | 17 + .../fixture-copy.json | 1 + ...9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 | 1 + .../bundles/fixture-copy.json | 1 + .../events/fixture-copy.jsonl | 1 + .../copy/project/src/base.ts | 1 + .../copy/project/src/copy.ts | 1 + .../generation-mismatch/manifest.json | 15 + .../fixture-generation-mismatch.json | 18 + ...2507ead5461b8dc5a491189747c5f87ff3c278e797 | 1 + .../bundles/fixture-generation-mismatch.json | 1 + .../events/fixture-generation-mismatch.jsonl | 1 + .../missing-blob/manifest.json | 15 + .../fixture-missing-blob.json | 1 + ...37dcfe10ef98f288997f79669e5374a60615277f74 | 1 + .../bundles/fixture-missing-blob.json | 1 + .../events/fixture-missing-blob.jsonl | 1 + .../missing-blob/project/src/missing.ts | 1 + .../notices-only/manifest.json | 15 + .../fixture-notices-only-other.json | 1 + .../fixture-notices-only.json | 1 + .../bundles/fixture-notices-only-other.json | 1 + .../bundles/fixture-notices-only.json | 1 + .../notices/fixture-notices-only-other.jsonl | 1 + .../notices/fixture-notices-only.jsonl | 1 + .../recovered-journal/manifest.json | 17 + .../fixture-recovered-journal.json | 1 + ...a13fbe07068662681d75f2c253244ec898a773c120 | 1 + ...a9d91424d938780b7475ff8025cd131a37997bc5c5 | 1 + .../bundles/fixture-recovered-journal.json | 1 + .../events/fixture-recovered-journal.jsonl | 3 + .../task-change-ledger/rename/manifest.json | 17 + .../fixture-rename.json | 1 + ...8044763f64919804d92341f50935df2d46eed748b9 | 1 + .../bundles/fixture-rename.json | 1 + .../events/fixture-rename.jsonl | 2 + .../rename/project/src/new.ts | 1 + .../v2-summary/manifest.json | 15 + .../fixture-v2-summary.json | 1 + ...2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf | 1 + .../bundles/fixture-v2-summary.json | 1 + .../events/fixture-v2-summary.jsonl | 1 + .../team/ChangeExtractorService.test.ts | 46 + .../services/team/FileContentResolver.test.ts | 39 + .../team/ReviewApplierService.test.ts | 71 + .../services/team/ReviewDecisionStore.test.ts | 135 ++ .../team/TaskChangeLedgerReader.test.ts | 166 +++ .../services/team/TeamDataService.test.ts | 76 + .../team/TeamLogSourceTracker.test.ts | 74 +- .../TeamTaskLogFreshnessReader.test.ts | 29 + .../team/taskChangeLedgerFixtureUtils.ts | 88 ++ ...skChangeLedgerFixtures.integration.test.ts | 421 ++++++ .../taskChangePresenceCacheSchema.test.ts | 91 ++ test/renderer/store/changeReviewSlice.test.ts | 250 +++- .../utils/reviewDecisionScope.test.ts | 49 + test/renderer/utils/reviewKey.test.ts | 29 + 92 files changed, 3890 insertions(+), 577 deletions(-) create mode 100644 src/renderer/utils/reviewDecisionScope.ts create mode 100644 src/renderer/utils/reviewKey.ts create mode 100644 src/renderer/utils/taskChangePresence.ts create mode 100644 src/shared/utils/taskChangePresence.ts create mode 100644 test/fixtures/team/task-change-ledger/binary/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl create mode 100644 test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin create mode 100644 test/fixtures/team/task-change-ledger/copy/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl create mode 100644 test/fixtures/team/task-change-ledger/copy/project/src/base.ts create mode 100644 test/fixtures/team/task-change-ledger/copy/project/src/copy.ts create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts create mode 100644 test/fixtures/team/task-change-ledger/notices-only/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl create mode 100644 test/fixtures/team/task-change-ledger/rename/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl create mode 100644 test/fixtures/team/task-change-ledger/rename/project/src/new.ts create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl create mode 100644 test/main/services/team/ReviewDecisionStore.test.ts create mode 100644 test/main/services/team/taskChangeLedgerFixtureUtils.ts create mode 100644 test/main/services/team/taskChangeLedgerFixtures.integration.test.ts create mode 100644 test/main/services/team/taskChangePresenceCacheSchema.test.ts create mode 100644 test/renderer/utils/reviewDecisionScope.test.ts create mode 100644 test/renderer/utils/reviewKey.test.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 179d4e1d..b1c8a051 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -642,7 +642,8 @@ export class TeamGraphAdapter { reviewerName: isReviewCycle ? reviewerName : null, reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined, reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined, - changePresence: task.changePresence, + changePresence: + task.changePresence === 'needs_attention' ? 'has_changes' : task.changePresence, displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index a2acf0f4..1f789fa1 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -454,7 +454,8 @@ async function handleGetGitFileLog( async function handleLoadDecisions( _event: IpcMainInvokeEvent, teamName: string, - scopeKey: string + scopeKey: string, + scopeToken: string | null = null ): Promise< IpcResult<{ hunkDecisions: Record; @@ -462,19 +463,23 @@ async function handleLoadDecisions( hunkContextHashesByFile?: Record>; } | null> > { - return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey)); + return wrapReviewHandler('loadDecisions', () => + reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined) + ); } async function handleSaveDecisions( _event: IpcMainInvokeEvent, teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile: Record> | null = null ): Promise> { return wrapReviewHandler('saveDecisions', () => reviewDecisionStore.save(teamName, scopeKey, { + scopeToken, hunkDecisions, fileDecisions, hunkContextHashesByFile: hunkContextHashesByFile ?? undefined, @@ -485,7 +490,10 @@ async function handleSaveDecisions( async function handleClearDecisions( _event: IpcMainInvokeEvent, teamName: string, - scopeKey: string + scopeKey: string, + scopeToken: string | null = null ): Promise> { - return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey)); + return wrapReviewHandler('clearDecisions', () => + reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined) + ); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 11f34910..fa91f4ab 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -177,6 +177,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TaskRef, TeamAgentRuntimeSnapshot, @@ -916,7 +917,7 @@ async function handleGetData( async function handleGetTaskChangePresence( _event: IpcMainInvokeEvent, teamName: unknown -): Promise>> { +): Promise>> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 58f783b3..9486b74f 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -31,6 +31,7 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; +import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; const logger = createLogger('Service:ChangeExtractorService'); @@ -759,11 +760,8 @@ export class ChangeExtractorService { return; } - if ( - result.files.length === 0 && - result.confidence !== 'high' && - result.confidence !== 'medium' - ) { + const resolvedPresence = resolveTaskChangePresenceFromResult(result); + if (!resolvedPresence) { return; } @@ -789,7 +787,7 @@ export class ChangeExtractorService { { taskId, taskSignature: descriptor.taskSignature, - presence: result.files.length > 0 ? 'has_changes' : 'no_changes', + presence: resolvedPresence, writtenAt: now, logSourceGeneration: snapshot.logSourceGeneration, } diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index bb80c25f..32732b7b 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -269,16 +269,7 @@ export class FileContentResolver { modified: string | null; source: FileChangeWithContent['contentSource']; } | null { - const ledgerSnippets = snippets - .filter((snippet) => snippet.ledger && !snippet.isError) - .sort((a, b) => { - const aTime = Date.parse(a.timestamp); - const bTime = Date.parse(b.timestamp); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) { - return aTime - bTime; - } - return a.toolUseId.localeCompare(b.toolUseId); - }); + const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); if (ledgerSnippets.length === 0) { return null; @@ -289,8 +280,19 @@ export class FileContentResolver { if (!first || !last) { return null; } - const original = first.originalFullContent ?? (first.operation === 'create' ? '' : null); - const modified = last.modifiedFullContent ?? (last.operation === 'delete' ? '' : null); + const canUseSyntheticOriginal = + first.originalFullContent === null && + first.operation === 'create' && + last.modifiedFullContent !== null && + !first.beforeState?.unavailableReason; + const canUseSyntheticModified = + last.modifiedFullContent === null && + last.operation === 'delete' && + first.originalFullContent !== null && + !last.afterState?.unavailableReason; + + const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null); + const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null); if (original === null && modified === null) { return null; } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 41af1ca3..95fe585c 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -454,9 +454,6 @@ export class ReviewApplierService { } const fullReject = fileRejected || allHunksRejected; - const hasSnapshot = ledgerSnippets.some( - (snippet) => snippet.type === 'shell-snapshot' || snippet.type === 'hook-snapshot' - ); const hasUnavailableState = ledgerSnippets.some( (snippet) => snippet.ledger?.beforeState?.unavailableReason || @@ -465,26 +462,26 @@ export class ReviewApplierService { const relation = this.resolveLedgerRelation(ledgerSnippets); if (!fullReject) { - if (relation?.kind === 'rename') { + if (relation?.kind === 'rename' || relation?.kind === 'copy') { return { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger rename partial reject requires manual review.', + error: `Ledger ${relation.kind} partial reject requires manual review.`, }; } - if (!hasSnapshot) { - return { handled: false }; - } if (original === null || modified === null) { return { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger snapshot content is unavailable; partial reject requires manual review.', + error: 'Ledger full text is unavailable; partial reject requires manual review.', }; } - const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + const guard = await this.checkLedgerCurrentHash( + filePath, + lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined + ); if (!guard.ok) { return guard.outcome; } @@ -494,7 +491,7 @@ export class ReviewApplierService { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger snapshot partial reject could not be applied safely.', + error: 'Ledger partial reject could not be applied safely.', }; } try { @@ -598,7 +595,7 @@ export class ReviewApplierService { return { handled: true, status: 'error', - code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + code: 'manual-review-required', error: 'Ledger before content is unavailable; rejecting this change requires manual review.', }; diff --git a/src/main/services/team/ReviewDecisionStore.ts b/src/main/services/team/ReviewDecisionStore.ts index 83bb97c7..21f7998f 100644 --- a/src/main/services/team/ReviewDecisionStore.ts +++ b/src/main/services/team/ReviewDecisionStore.ts @@ -1,5 +1,6 @@ import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -10,6 +11,7 @@ import type { HunkDecision } from '@shared/types'; const logger = createLogger('ReviewDecisionStore'); export interface ReviewDecisionsData { + scopeToken?: string; hunkDecisions: Record; fileDecisions: Record; /** filePath -> (hunkIndex -> contextHash) */ @@ -17,50 +19,60 @@ export interface ReviewDecisionsData { updatedAt: string; } +interface ReviewDecisionsDataV2 extends ReviewDecisionsData { + version: 2; + scopeKey: string; + scopeToken: string; +} + export class ReviewDecisionStore { - private getDirPath(teamName: string): string { + private getLegacyDirPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'review-decisions'); } - private getFilePath(teamName: string, scopeKey: string): string { - return path.join(this.getDirPath(teamName), `${scopeKey}.json`); + private getLegacyFilePath(teamName: string, scopeKey: string): string { + return path.join(this.getLegacyDirPath(teamName), `${scopeKey}.json`); } - async load( - teamName: string, - scopeKey: string - ): Promise<{ - hunkDecisions: Record; - fileDecisions: Record; - hunkContextHashesByFile?: Record>; - } | null> { - const filePath = this.getFilePath(teamName, scopeKey); + private getV2DirPath(teamName: string, scopeKey: string): string { + return path.join( + this.getLegacyDirPath(teamName), + 'v2', + encodeURIComponent(scopeKey) + ); + } - let raw: string; - try { - raw = await fs.promises.readFile(filePath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - logger.error(`Failed to read review decisions for ${teamName}/${scopeKey}: ${String(error)}`); - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw) as unknown; - } catch { - logger.error(`Corrupted review decisions file for ${teamName}/${scopeKey}`); - return null; - } + private getV2FilePath(teamName: string, scopeKey: string, scopeToken: string): string { + const scopeHash = createHash('sha256').update(scopeToken).digest('hex'); + return path.join(this.getV2DirPath(teamName, scopeKey), `${scopeHash}.json`); + } + private parseStoredData(parsed: unknown): ReviewDecisionsData | ReviewDecisionsDataV2 | null { if (!parsed || typeof parsed !== 'object') { return null; } - const data = parsed as Partial; + const data = parsed as Partial; + const isV2 = + data.version === 2 && + typeof data.scopeKey === 'string' && + typeof data.scopeToken === 'string'; + if (data.version !== undefined && !isV2) { + return null; + } + + return data as ReviewDecisionsData | ReviewDecisionsDataV2; + } + + private extractDecisions( + data: ReviewDecisionsData | ReviewDecisionsDataV2, + scopeToken?: string + ): { + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null { const hunkDecisions: Record = data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {}; const fileDecisions: Record = @@ -70,37 +82,162 @@ export class ReviewDecisionStore { ? data.hunkContextHashesByFile : undefined; + if (scopeToken) { + if (typeof data.scopeToken !== 'string' || data.scopeToken !== scopeToken) { + return null; + } + } + return { hunkDecisions, fileDecisions, hunkContextHashesByFile }; } + private async loadFromPath( + filePath: string, + scopeToken?: string + ): Promise<{ + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null> { + let raw: string; + try { + raw = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to read review decisions at ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + logger.error(`Corrupted review decisions file at ${filePath}`); + return null; + } + + const data = this.parseStoredData(parsed); + return data ? this.extractDecisions(data, scopeToken) : null; + } + + private async pruneScopeDir(teamName: string, scopeKey: string): Promise { + const dirPath = this.getV2DirPath(teamName, scopeKey); + let entries: string[]; + try { + entries = await fs.promises.readdir(dirPath); + } catch { + return; + } + + if (entries.length <= 16) { + return; + } + + const files = await Promise.all( + entries + .filter((entry) => entry.endsWith('.json')) + .map(async (entry) => { + const filePath = path.join(dirPath, entry); + try { + const stats = await fs.promises.stat(filePath); + return { filePath, mtimeMs: stats.mtimeMs }; + } catch { + return null; + } + }) + ); + + const staleFiles = files + .filter((entry): entry is { filePath: string; mtimeMs: number } => !!entry) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(16); + + await Promise.all( + staleFiles.map((entry) => + fs.promises.unlink(entry.filePath).catch(() => undefined) + ) + ); + } + + async load( + teamName: string, + scopeKey: string, + scopeToken?: string + ): Promise<{ + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null> { + if (scopeToken) { + const exact = await this.loadFromPath( + this.getV2FilePath(teamName, scopeKey, scopeToken), + scopeToken + ); + if (exact) { + return exact; + } + } + + return this.loadFromPath(this.getLegacyFilePath(teamName, scopeKey), scopeToken); + } + async save( teamName: string, scopeKey: string, data: { + scopeToken: string; hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; } ): Promise { try { - const payload: ReviewDecisionsData = { + const payload: ReviewDecisionsDataV2 = { + version: 2, + scopeKey, + scopeToken: data.scopeToken, hunkDecisions: data.hunkDecisions, fileDecisions: data.fileDecisions, hunkContextHashesByFile: data.hunkContextHashesByFile, updatedAt: new Date().toISOString(), }; + const filePath = this.getV2FilePath(teamName, scopeKey, data.scopeToken); await atomicWriteAsync( - this.getFilePath(teamName, scopeKey), + filePath, JSON.stringify(payload, null, 2) ); + await this.pruneScopeDir(teamName, scopeKey); } catch (error) { logger.error(`Failed to save review decisions for ${teamName}/${scopeKey}: ${String(error)}`); } } - async clear(teamName: string, scopeKey: string): Promise { + async clear(teamName: string, scopeKey: string, scopeToken?: string): Promise { try { - await fs.promises.unlink(this.getFilePath(teamName, scopeKey)); + if (scopeToken) { + await fs.promises + .unlink(this.getV2FilePath(teamName, scopeKey, scopeToken)) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + const legacyPath = this.getLegacyFilePath(teamName, scopeKey); + const legacy = await this.loadFromPath(legacyPath, scopeToken); + if (legacy) { + await fs.promises.unlink(legacyPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + } + return; + } + await fs.promises.unlink(this.getLegacyFilePath(teamName, scopeKey)).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + }); + await fs.promises.rm(this.getV2DirPath(teamName, scopeKey), { + recursive: true, + force: true, + }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error( diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 01cd38b6..d3dde5ee 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -1,21 +1,68 @@ -import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import { diffLines } from 'diff'; -import { readFile } from 'fs/promises'; +import { open, readFile } from 'fs/promises'; import * as path from 'path'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; +import { createLogger } from '@shared/utils/logger'; + import type { FileChangeSummary, FileEditEvent, FileEditTimeline, + LedgerChangeRelation, + LedgerContentState, SnippetDiff, + TaskChangeJournalStamp, + TaskChangeProvenance, TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; const logger = createLogger('Service:TaskChangeLedgerReader'); -const TASK_CHANGE_LEDGER_SCHEMA_VERSION = 1; +const TASK_CHANGE_JOURNAL_SCHEMA_VERSION = 1; +const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2; +const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; +const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; + +function isWindowsReservedArtifactSegment(segment: string): boolean { + const stem = segment.split('.')[0]?.toUpperCase() ?? ''; + return ( + !segment || + stem === 'CON' || + stem === 'PRN' || + stem === 'AUX' || + stem === 'NUL' || + /^COM[1-9]$/.test(stem) || + /^LPT[1-9]$/.test(stem) + ); +} + +function encodeTaskId(taskId: string): string { + const encoded = encodeURIComponent(taskId); + return isWindowsReservedArtifactSegment(encoded) + ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` + : encoded; +} + +function taskIdArtifactSegments(taskId: string): string[] { + const safe = encodeTaskId(taskId); + const legacy = encodeURIComponent(taskId); + return safe === legacy ? [safe] : [safe, legacy]; +} + +function taskArtifactPathCandidates( + projectDir: string, + taskId: string, + dirName: string, + fileSuffix: string +): string[] { + return taskIdArtifactSegments(taskId).map((segment) => + path.join(projectDir, dirName, `${segment}${fileSuffix}`) + ); +} type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; @@ -26,21 +73,8 @@ interface LedgerContentRef { unavailableReason?: string; } -interface LedgerContentState { - exists?: boolean; - sha256?: string; - sizeBytes?: number; - unavailableReason?: string; -} - -interface LedgerChangeRelation { - kind: 'rename' | 'copy'; - oldPath: string; - newPath: string; -} - interface LedgerEvent { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; eventId: string; taskId: string; taskRef: string; @@ -49,6 +83,7 @@ interface LedgerEvent { executionSeq: number; sessionId: string; agentId?: string; + memberName?: string; toolUseId: string; source: | 'file_edit' @@ -79,7 +114,7 @@ interface LedgerEvent { } interface LedgerNotice { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; noticeId: string; taskId: string; taskRef: string; @@ -88,27 +123,31 @@ interface LedgerNotice { executionSeq: number; sessionId: string; agentId?: string; + memberName?: string; toolUseId: string; timestamp: string; severity: 'warning'; message: string; + code?: 'multi-scope-skipped' | 'journal-recovered' | 'writer-lock-stolen'; } -interface LedgerBundle { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; +interface LedgerBundleFileV1 { + filePath: string; + relativePath: string; + eventIds: string[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + latestAfterHash: string | null; +} + +interface LedgerBundleV1 { + schemaVersion: 1; source: 'task-change-ledger'; taskId: string; generatedAt: string; eventCount: number; - files: { - filePath: string; - relativePath: string; - eventIds: string[]; - linesAdded: number; - linesRemoved: number; - isNewFile: boolean; - latestAfterHash: string | null; - }[]; + files: LedgerBundleFileV1[]; totalLinesAdded: number; totalLinesRemoved: number; totalFiles: number; @@ -118,6 +157,123 @@ interface LedgerBundle { notices?: LedgerNotice[]; } +interface LedgerSummaryContributorV2 { + actorKey: string; + agentId?: string; + memberName?: string; + eventCount: number; + noticeCount: number; + touchedFileCount: number; + visibleFileCount: number; + toolUseCount: number; + cumulativeLinesAdded: number; + cumulativeLinesRemoved: number; + firstTimestamp: string; + lastTimestamp: string; +} + +interface LedgerSummaryScopeV2 { + confidence: TaskChangeScope['confidence']; + primaryActorKey?: string; + primaryAgentId?: string; + primaryMemberName?: string; + memberName: string; + agentIds: string[]; + memberNames?: string[]; + startTimestamp: string; + endTimestamp: string; + toolUseIds: string[]; + toolUseCount: number; + toolUseIdsTruncated?: boolean; + phaseSet: Array<'work' | 'review'>; + executionSeqRange?: { start: number; end: number }; + confidenceBreakdown?: TaskChangeScope['confidenceBreakdown']; + visibleFileCount: number; + contributors: LedgerSummaryContributorV2[]; +} + +interface LedgerSummaryFileV2 { + changeKey: string; + filePath: string; + relativePath: string; + displayPath?: string; + linesAdded: number; + linesRemoved: number; + diffStatKnown: boolean; + eventCount: number; + firstTimestamp: string; + lastTimestamp: string; + latestOperation: 'create' | 'modify' | 'delete'; + createdInTask: boolean; + deletedInTask: boolean; + baselineExists?: boolean; + finalExists?: boolean; + latestBeforeHash: string | null; + latestAfterHash: string | null; + latestBeforeState?: LedgerContentState; + latestAfterState?: LedgerContentState; + contentAvailability: 'full-text' | 'hash-only' | 'metadata-only'; + reviewability: 'full-text' | 'partial-text' | 'metadata-only'; + relation?: LedgerChangeRelation; + primaryActorKey?: string; + agentIds: string[]; + memberNames?: string[]; + executionSeqRange?: { start: number; end: number }; + warnings?: string[]; +} + +interface LedgerSummaryBundleV2 { + schemaVersion: typeof TASK_CHANGE_SUMMARY_SCHEMA_VERSION; + source: 'task-change-ledger'; + bundleKind: 'summary'; + taskId: string; + generatedAt: string; + journalStamp: TaskChangeJournalStamp; + integrity: 'ok' | 'recovered' | 'partial'; + eventCount: number; + noticeCount: number; + scope: LedgerSummaryScopeV2; + files: LedgerSummaryFileV2[]; + totalLinesAdded: number; + totalLinesRemoved: number; + diffStatCompleteness: 'complete' | 'partial'; + totalFiles: number; + confidence: 'high' | 'medium' | 'low'; + warningCount: number; + warnings: string[]; +} + +interface LedgerFreshnessV2 { + schemaVersion: typeof TASK_CHANGE_FRESHNESS_SCHEMA_VERSION; + source: 'task-change-ledger'; + taskId: string; + updatedAt: string; + journalStamp: TaskChangeJournalStamp; + eventCount: number; + noticeCount: number; + integrity: 'ok' | 'recovered' | 'partial'; + bundleSchemaVersion: 2; + bundleKind: 'summary'; +} + +type JournalReadResult = { + entries: T[]; + recovered: boolean; +}; + +type JournalData = { + events: LedgerEvent[]; + notices: LedgerNotice[]; + recovered: boolean; +}; + +type SummaryBundleRead = { + bundle: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + mode: 'validated' | 'degraded'; + degradedWarning?: string; +}; + export class TaskChangeLedgerReader { async readTaskChanges(params: { teamName: string; @@ -126,84 +282,716 @@ export class TaskChangeLedgerReader { projectPath?: string; includeDetails: boolean; }): Promise { - const bundle = await this.readBundle(params.projectDir, params.taskId); + const bundleRead = await this.tryReadSummaryBundleV2( + params.projectDir, + params.taskId, + params.projectPath + ); + + if (params.includeDetails) { + const journal = await this.readJournalData(params.projectDir, params.taskId); + if (journal) { + return this.buildDetailedResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + journal, + bundle: bundleRead?.bundle, + provenance: + bundleRead?.provenance ?? + this.buildLedgerProvenanceFromJournal( + (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, + undefined, + journal.recovered ? 'recovered' : 'ok' + ), + }); + } + + const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); + if (legacy) { + return this.buildLegacyResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + bundle: legacy, + includeDetails: true, + }); + } + + if (bundleRead) { + const result = this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, + }); + return { + ...result, + warnings: [ + ...result.warnings, + 'Ledger journal was unavailable; detailed snippets could not be loaded.', + ], + }; + } + + return null; + } + + if (bundleRead?.mode === 'validated') { + return this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + }); + } + + const journal = await this.readJournalData(params.projectDir, params.taskId); + if (journal) { + return this.buildJournalFallbackSummary({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + journal, + }); + } + + if (bundleRead) { + return this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, + }); + } + + const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); + if (legacy) { + return this.buildLegacyResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + bundle: legacy, + includeDetails: false, + }); + } + + return null; + } + + private async tryReadSummaryBundleV2( + projectDir: string, + taskId: string, + _projectPath?: string + ): Promise { + const [bundle, freshness, journalStamp] = await Promise.all([ + this.readSummaryBundleV2(projectDir, taskId), + this.readFreshnessV2(projectDir, taskId), + this.readJournalStampFromDisk(projectDir, taskId), + ]); if (!bundle) { return null; } - const events = bundle.events - .filter((event) => event.taskId === params.taskId) - .sort((a, b) => { - const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); - return timeDiff === 0 ? a.eventId.localeCompare(b.eventId) : timeDiff; - }); - const notices = (bundle.notices ?? []) - .filter((notice) => notice.taskId === params.taskId) - .sort((a, b) => { - const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); - return timeDiff === 0 ? a.noticeId.localeCompare(b.noticeId) : timeDiff; - }); - if (events.length === 0 && notices.length === 0) { + const provenance = this.buildLedgerProvenance( + bundle.journalStamp, + bundle.integrity, + bundle.schemaVersion + ); + + if ( + freshness && + this.bundleMatchesFreshness(bundle, freshness) && + freshness.integrity !== 'partial' + ) { + return { bundle, provenance, mode: 'validated' }; + } + + if ( + !freshness && + journalStamp && + JSON.stringify(journalStamp) === JSON.stringify(bundle.journalStamp) && + bundle.integrity !== 'partial' + ) { + return { + bundle, + provenance: this.buildLedgerProvenance(journalStamp, bundle.integrity, bundle.schemaVersion), + mode: 'validated', + }; + } + + if (!freshness && !journalStamp) { + return { + bundle, + provenance, + mode: 'degraded', + degradedWarning: + 'Task change summary used bundle v2 without live validation because freshness and journal files were unavailable.', + }; + } + + return { + bundle, + provenance, + mode: 'degraded', + degradedWarning: + 'Task change summary bypassed bundle v2 fast-path because bundle freshness did not match the current ledger generation.', + }; + } + + private async readSummaryBundleV2( + projectDir: string, + taskId: string + ): Promise { + const bundlePaths = taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), + '.json' + ); + for (const bundlePath of bundlePaths) { + try { + const raw = await readFile(bundlePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== TASK_CHANGE_SUMMARY_SCHEMA_VERSION || + parsed.source !== 'task-change-ledger' || + parsed.bundleKind !== 'summary' || + parsed.taskId !== taskId || + !Array.isArray(parsed.files) + ) { + return null; + } + return parsed as LedgerSummaryBundleV2; + } catch { + continue; + } + } + logger.debug(`No v2 task-change bundle for ${taskId}.`); + return null; + } + + private async readFreshnessV2( + projectDir: string, + taskId: string + ): Promise { + const freshnessPaths = taskArtifactPathCandidates( + projectDir, + taskId, + TASK_CHANGE_FRESHNESS_DIRNAME, + '.json' + ); + for (const freshnessPath of freshnessPaths) { + try { + const raw = await readFile(freshnessPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== TASK_CHANGE_FRESHNESS_SCHEMA_VERSION || + parsed.source !== 'task-change-ledger' || + parsed.taskId !== taskId || + parsed.bundleKind !== 'summary' + ) { + return null; + } + return parsed as LedgerFreshnessV2; + } catch { + continue; + } + } + return null; + } + + private async readLegacyBundleV1( + projectDir: string, + taskId: string + ): Promise { + const bundlePaths = taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), + '.json' + ); + for (const bundlePath of bundlePaths) { + try { + const raw = await readFile(bundlePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== 1 || + parsed.source !== 'task-change-ledger' || + parsed.taskId !== taskId || + !Array.isArray(parsed.events) + ) { + return null; + } + return parsed as LedgerBundleV1; + } catch { + continue; + } + } + return null; + } + + private async readJournalData(projectDir: string, taskId: string): Promise { + const [events, notices] = await Promise.all([ + this.readJournalEntries({ + filePath: taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), + '.jsonl' + ), + taskId, + schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, + idField: 'eventId', + }), + this.readJournalEntries({ + filePath: taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), + '.jsonl' + ), + taskId, + schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, + idField: 'noticeId', + }), + ]); + + if (events.entries.length === 0 && notices.entries.length === 0) { return null; } - const snippets = params.includeDetails - ? await this.buildSnippets(params.projectDir, events) - : []; - const files = params.includeDetails - ? this.aggregateByFile(snippets, params.projectPath, true) - : this.buildSummaryFiles(bundle, params.projectPath); - const scope = this.buildScope(params.taskId, events, files, notices); - const warnings = new Set(bundle.warnings ?? []); - for (const notice of notices) warnings.add(notice.message); - for (const event of events) { - for (const warning of event.warnings ?? []) warnings.add(warning); - if (event.toolStatus === 'failed') { - warnings.add(`Tool ${event.toolUseId} failed after changing files.`); + return { + events: events.entries, + notices: notices.entries, + recovered: events.recovered || notices.recovered, + }; + } + + private async readJournalEntries(params: { + filePath: string | string[]; + taskId: string; + schemaVersion: number; + idField: 'eventId' | 'noticeId'; + }): Promise> { + let raw: string | null = null; + for (const filePath of Array.isArray(params.filePath) ? params.filePath : [params.filePath]) { + try { + raw = await readFile(filePath, 'utf8'); + break; + } catch { + continue; } - if (event.toolStatus === 'killed') { - warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + if (raw === null) { + return { entries: [], recovered: false }; + } + + const entries: T[] = []; + const seenIds = new Set(); + let recovered = false; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as T & Record; + const id = parsed?.[params.idField]; + if ( + parsed?.schemaVersion !== params.schemaVersion || + parsed.taskId !== params.taskId || + typeof id !== 'string' + ) { + recovered = true; + continue; + } + if (seenIds.has(id)) { + recovered = true; + continue; + } + seenIds.add(id); + entries.push(parsed); + } catch { + recovered = true; } } + return { entries, recovered }; + } + + private bundleMatchesFreshness(bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2): boolean { + return ( + JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && + bundle.eventCount === freshness.eventCount && + bundle.noticeCount === freshness.noticeCount && + freshness.bundleSchemaVersion === bundle.schemaVersion && + freshness.bundleKind === bundle.bundleKind + ); + } + + private buildLedgerProvenance( + journalStamp: TaskChangeJournalStamp, + integrity: 'ok' | 'recovered' | 'partial', + bundleSchemaVersion?: number + ): TaskChangeProvenance { + return { + sourceKind: 'ledger', + sourceFingerprint: this.hashFingerprintPayload({ + journalStamp, + integrity, + ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), + }), + journalStamp, + ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), + integrity, + }; + } + + private buildLedgerProvenanceFromJournal( + journalStamp: TaskChangeJournalStamp, + bundleSchemaVersion?: number, + integrity: 'ok' | 'recovered' | 'partial' = 'ok' + ): TaskChangeProvenance { + return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion); + } + + private hashFingerprintPayload(payload: unknown): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + } + + private async readJournalStampFromDisk( + projectDir: string, + taskId: string + ): Promise { + const readFileStamp = async (filePaths: string[]) => { + let handle: Awaited> | null = null; + for (const filePath of filePaths) { + try { + handle = await open(filePath, 'r'); + const fileStat = await handle.stat(); + if (!fileStat.isFile()) { + continue; + } + const tailLength = Math.min(fileStat.size, 4096); + const tail = Buffer.alloc(tailLength); + if (tailLength > 0) { + await handle.read(tail, 0, tailLength, fileStat.size - tailLength); + } + return { + bytes: fileStat.size, + mtimeMs: fileStat.mtimeMs, + tailSha256: tailLength > 0 ? createHash('sha256').update(tail).digest('hex') : null, + }; + } catch { + continue; + } finally { + await handle?.close().catch(() => undefined); + handle = null; + } + } + return undefined; + }; + + const [events, notices] = await Promise.all([ + readFileStamp( + taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), + '.jsonl' + ) + ), + readFileStamp( + taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), + '.jsonl' + ) + ), + ]); + + if (!events && !notices) { + return null; + } + + return { + ...(events ? { events } : {}), + ...(notices ? { notices } : {}), + }; + } + + private buildSummaryResultFromBundle(params: { + teamName: string; + taskId: string; + projectPath?: string; + bundle: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + extraWarnings?: string[]; + }): TaskChangeSetV2 { + return { + teamName: params.teamName, + taskId: params.taskId, + files: params.bundle.files.map((file) => this.mapV2SummaryFile(file, params.projectPath)), + totalLinesAdded: params.bundle.totalLinesAdded, + totalLinesRemoved: params.bundle.totalLinesRemoved, + totalFiles: params.bundle.totalFiles, + confidence: params.bundle.confidence, + computedAt: params.bundle.generatedAt, + scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files), + warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])], + diffStatCompleteness: params.bundle.diffStatCompleteness, + provenance: params.provenance, + }; + } + + private async buildDetailedResult(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + journal: JournalData; + bundle?: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + }): Promise { + const snippets = await this.buildSnippets(params.projectDir, params.journal.events); + const groupedSnippets = this.groupSnippets(snippets); + const warnings = this.collectWarnings(params.journal.events, params.journal.notices, { + recovered: params.journal.recovered, + }); + + let files: FileChangeSummary[]; + let totalLinesAdded: number; + let totalLinesRemoved: number; + let totalFiles: number; + let confidence: TaskChangeSetV2['confidence']; + let scope: TaskChangeScope; + let diffStatCompleteness: 'complete' | 'partial' | undefined; + + if (params.bundle) { + files = params.bundle.files.map((file) => { + const groupKey = this.groupKeyForFileSummary(file.filePath, file.relation); + const entry = groupedSnippets.get(groupKey); + return { + ...this.mapV2SummaryFile(file, params.projectPath), + snippets: entry?.snippets ?? [], + timeline: entry ? this.buildTimeline(file.filePath, entry.snippets) : undefined, + }; + }); + totalLinesAdded = params.bundle.totalLinesAdded; + totalLinesRemoved = params.bundle.totalLinesRemoved; + totalFiles = params.bundle.totalFiles; + confidence = params.bundle.confidence; + scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); + diffStatCompleteness = params.bundle.diffStatCompleteness; + } else { + const fallback = this.buildFallbackFilesFromGroupedSnippets(groupedSnippets, params.projectPath); + files = fallback.files; + totalLinesAdded = fallback.totalLinesAdded; + totalLinesRemoved = fallback.totalLinesRemoved; + totalFiles = fallback.files.length; + confidence = params.journal.events.some((event) => event.confidence === 'low') + ? 'low' + : params.journal.events.some((event) => event.confidence === 'medium') + ? 'medium' + : 'high'; + scope = this.buildFallbackScope(params.taskId, files, params.journal.events, params.journal.notices); + diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial'; + warnings.push('Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'); + } return { teamName: params.teamName, taskId: params.taskId, files, - totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), - totalFiles: files.length, - confidence: bundle.confidence, - computedAt: bundle.generatedAt, + totalLinesAdded, + totalLinesRemoved, + totalFiles, + confidence, + computedAt: params.bundle?.generatedAt ?? new Date().toISOString(), scope, - warnings: [...warnings], + warnings, + ...(diffStatCompleteness ? { diffStatCompleteness } : {}), + provenance: params.provenance, }; } - private async readBundle(projectDir: string, taskId: string): Promise { - const bundlePath = path.join( - projectDir, - TASK_CHANGE_LEDGER_DIRNAME, - 'bundles', - `${encodeURIComponent(taskId)}.json` + private async buildJournalFallbackSummary(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + journal: JournalData; + }): Promise { + const provenance = this.buildLedgerProvenanceFromJournal( + (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, + undefined, + params.journal.recovered ? 'recovered' : 'ok' ); + const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null)); + const grouped = this.groupSnippets(snippets); + const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); + return { + teamName: params.teamName, + taskId: params.taskId, + files: fallback.files.map((file) => ({ ...file, snippets: [] })), + totalLinesAdded: fallback.totalLinesAdded, + totalLinesRemoved: fallback.totalLinesRemoved, + totalFiles: fallback.files.length, + confidence: params.journal.events.some((event) => event.confidence === 'low') + ? 'low' + : params.journal.events.some((event) => event.confidence === 'medium') + ? 'medium' + : 'high', + computedAt: new Date().toISOString(), + scope: this.buildFallbackScope( + params.taskId, + fallback.files, + params.journal.events, + params.journal.notices + ), + warnings: [ + ...this.collectWarnings(params.journal.events, params.journal.notices, { + recovered: params.journal.recovered, + }), + 'Task change summary fell back to journal reconstruction.', + ], + diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial', + provenance, + }; + } - try { - const raw = await readFile(bundlePath, 'utf8'); - const parsed = JSON.parse(raw) as LedgerBundle; - if ( - parsed?.schemaVersion !== TASK_CHANGE_LEDGER_SCHEMA_VERSION || - parsed.source !== 'task-change-ledger' || - parsed.taskId !== taskId || - !Array.isArray(parsed.events) - ) { - return null; - } - return parsed; - } catch (error) { - logger.debug(`No task-change ledger bundle for ${taskId}: ${String(error)}`); - return null; + private async buildLegacyResult(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + bundle: LedgerBundleV1; + includeDetails: boolean; + }): Promise { + const snippets = params.includeDetails + ? await this.buildSnippets(params.projectDir, params.bundle.events) + : params.bundle.events.map((event) => this.eventToSnippet(event, null, null)); + const grouped = this.groupSnippets(snippets); + const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); + const warnings = new Set(params.bundle.warnings ?? []); + warnings.add( + 'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.' + ); + for (const notice of params.bundle.notices ?? []) warnings.add(notice.message); + + return { + teamName: params.teamName, + taskId: params.taskId, + files: params.includeDetails + ? fallback.files + : fallback.files.map((file) => ({ ...file, snippets: [], timeline: undefined })), + totalLinesAdded: fallback.totalLinesAdded, + totalLinesRemoved: fallback.totalLinesRemoved, + totalFiles: fallback.files.length, + confidence: params.bundle.confidence, + computedAt: params.bundle.generatedAt, + scope: this.buildFallbackScope( + params.taskId, + fallback.files, + params.bundle.events, + params.bundle.notices ?? [] + ), + warnings: [...warnings], + diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial', + provenance: { + sourceKind: 'ledger', + sourceFingerprint: this.hashFingerprintPayload({ + legacyTaskId: params.taskId, + generatedAt: params.bundle.generatedAt, + eventCount: params.bundle.eventCount, + }), + }, + }; + } + + private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { + const displayPath = file.displayPath ?? file.filePath; + return { + filePath: file.filePath, + relativePath: this.relativePath(displayPath, projectPath, file.relativePath), + snippets: [], + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + isNewFile: Boolean( + file.createdInTask && file.latestOperation !== 'delete' && file.relation?.kind !== 'rename' + ), + changeKey: this.normalizeSummaryChangeKey(file), + diffStatKnown: file.diffStatKnown, + ledgerSummary: { + latestOperation: file.latestOperation, + createdInTask: file.createdInTask, + deletedInTask: file.deletedInTask, + contentAvailability: file.contentAvailability, + reviewability: file.reviewability, + ...(file.relation ? { relation: file.relation } : {}), + ...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}), + ...(file.latestAfterState ? { afterState: file.latestAfterState } : {}), + ...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}), + ...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}), + ...(file.memberNames ? { memberNames: file.memberNames } : {}), + ...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}), + }, + }; + } + + private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string { + if (file.relation) { + return `${file.relation.kind}:${normalizePathForComparison(file.relation.oldPath)}->${normalizePathForComparison(file.relation.newPath)}`; } + const slashNormalized = file.changeKey.replace(/\\/g, '/'); + const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized); + if (pathKeyMatch) { + return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`; + } + return slashNormalized; + } + + private mapV2Scope( + taskId: string, + scope: LedgerSummaryScopeV2, + files: LedgerSummaryFileV2[] + ): TaskChangeScope { + return { + taskId, + memberName: + scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', + startLine: 0, + endLine: 0, + startTimestamp: scope.startTimestamp, + endTimestamp: scope.endTimestamp, + toolUseIds: scope.toolUseIds, + filePaths: files.map((file) => file.filePath), + confidence: scope.confidence, + ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), + ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), + ...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}), + ...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}), + ...(scope.memberNames ? { memberNames: scope.memberNames } : {}), + ...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}), + ...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}), + ...(scope.phaseSet ? { phaseSet: scope.phaseSet } : {}), + ...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}), + ...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}), + ...(scope.contributors ? { contributors: scope.contributors } : {}), + }; } private async buildSnippets(projectDir: string, events: LedgerEvent[]): Promise { @@ -216,10 +1004,7 @@ export class TaskChangeLedgerReader { ); } - private async readContentRef( - projectDir: string, - ref: LedgerContentRef | null - ): Promise { + private async readContentRef(projectDir: string, ref: LedgerContentRef | null): Promise { if (!ref?.blobRef) { return null; } @@ -238,14 +1023,11 @@ export class TaskChangeLedgerReader { beforeContent: string | null, afterContent: string | null ): SnippetDiff { - const toolName = this.mapToolName(event.source); - const type = this.mapSnippetType(event); - const source = event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot'; return { toolUseId: event.toolUseId, filePath: event.filePath, - toolName, - type, + toolName: this.mapToolName(event.source), + type: this.mapSnippetType(event), oldString: event.oldString ?? beforeContent ?? '', newString: event.newString ?? afterContent ?? '', replaceAll: event.replaceAll ?? false, @@ -253,7 +1035,7 @@ export class TaskChangeLedgerReader { isError: false, ledger: { eventId: event.eventId, - source, + source: event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot', confidence: event.confidence, originalFullContent: beforeContent, modifiedFullContent: afterContent, @@ -264,6 +1046,14 @@ export class TaskChangeLedgerReader { afterState: event.afterState, relation: event.relation, executionSeq: event.executionSeq, + linesAdded: event.linesAdded, + linesRemoved: event.linesRemoved, + textAvailability: + beforeContent !== null && afterContent !== null + ? 'full-text' + : event.oldString !== undefined || event.newString !== undefined + ? 'patch-text' + : 'unavailable', }, }; } @@ -302,151 +1092,150 @@ export class TaskChangeLedgerReader { return 'edit'; } - private aggregateByFile( - snippets: SnippetDiff[], - projectPath: string | undefined, - includeDetails: boolean - ): FileChangeSummary[] { - const fileMap = new Map< + private groupSnippets( + snippets: SnippetDiff[] + ): Map { + const grouped = new Map< string, - { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } >(); for (const snippet of snippets) { - const key = this.fileGroupKey(snippet); - const existing = fileMap.get(key); + const groupKey = this.groupKeyForSnippet(snippet); + const existing = grouped.get(groupKey); if (existing) { existing.snippets.push(snippet); - existing.isNewFile ||= - snippet.type === 'write-new' || snippet.ledger?.operation === 'create'; } else { - fileMap.set(key, { + grouped.set(groupKey, { filePath: snippet.filePath, + ...(snippet.ledger?.relation ? { relation: snippet.ledger.relation } : {}), snippets: [snippet], - isNewFile: snippet.type === 'write-new' || snippet.ledger?.operation === 'create', }); } } + return grouped; + } - return [...fileMap.values()].map((entry) => { + private buildFallbackFilesFromGroupedSnippets( + grouped: Map, + projectPath?: string + ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { + const files: FileChangeSummary[] = []; + for (const entry of grouped.values()) { + const relation = entry.relation ?? this.relationForSnippets(entry.snippets); let linesAdded = 0; let linesRemoved = 0; for (const snippet of entry.snippets) { + if ( + typeof snippet.ledger?.linesAdded === 'number' || + typeof snippet.ledger?.linesRemoved === 'number' + ) { + linesAdded += snippet.ledger?.linesAdded ?? 0; + linesRemoved += snippet.ledger?.linesRemoved ?? 0; + continue; + } const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); linesAdded += added; linesRemoved += removed; } - - const displayFilePath = this.displayFilePathForGroup(entry); - const relation = this.relationForSnippets(entry.snippets); - return { - filePath: displayFilePath, - relativePath: this.relativePath(displayFilePath, projectPath), - snippets: includeDetails ? entry.snippets : [], + const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets); + files.push({ + filePath: displayPath, + relativePath: this.relativePath(displayPath, projectPath), + snippets: entry.snippets, linesAdded, linesRemoved, - isNewFile: relation?.kind === 'rename' ? false : entry.isNewFile, - timeline: includeDetails ? this.buildTimeline(displayFilePath, entry.snippets) : undefined, - }; - }); - } - - private buildSummaryFiles( - bundle: LedgerBundle, - projectPath: string | undefined - ): FileChangeSummary[] { - const eventById = new Map(bundle.events.map((event) => [event.eventId, event])); - const fileMap = new Map< - string, - { - filePath: string; - filePaths: string[]; - linesAdded: number; - linesRemoved: number; - isNewFile: boolean; - relation?: LedgerChangeRelation; - } - >(); - - for (const file of bundle.files) { - const relation = file.eventIds - .map((eventId) => eventById.get(eventId)?.relation) - .find((value): value is LedgerChangeRelation => Boolean(value)); - const key = relation - ? `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}` - : this.normalizePathKey(file.filePath); - const displayFilePath = relation?.newPath ?? file.filePath; - const existing = fileMap.get(key); - if (existing) { - existing.filePaths.push(file.filePath); - existing.filePath = relation - ? this.displayFilePathForRelation(relation, existing.filePaths) - : existing.filePath; - existing.linesAdded += file.linesAdded; - existing.linesRemoved += file.linesRemoved; - existing.isNewFile ||= file.isNewFile; - existing.relation ??= relation; - } else { - fileMap.set(key, { - filePath: relation - ? this.displayFilePathForRelation(relation, [file.filePath]) - : displayFilePath, - filePaths: [file.filePath], - linesAdded: file.linesAdded, - linesRemoved: file.linesRemoved, - isNewFile: file.isNewFile, - relation, - }); - } + isNewFile: + relation?.kind !== 'rename' && + entry.snippets.some( + (snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create' + ), + changeKey: relation + ? `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}` + : `path:${normalizePathForComparison(displayPath)}`, + diffStatKnown: true, + ledgerSummary: { + ...(relation ? { relation } : {}), + latestOperation: + entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? + (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' + ? 'create' + : 'modify'), + }, + timeline: this.buildTimeline(displayPath, entry.snippets), + }); } - - return [...fileMap.values()].map((file) => ({ - filePath: file.filePath, - relativePath: this.relativePath(file.filePath, projectPath), - snippets: [], - linesAdded: file.linesAdded, - linesRemoved: file.linesRemoved, - isNewFile: file.relation?.kind === 'rename' ? false : file.isNewFile, - })); + const totalLinesAdded = files.reduce((sum, file) => sum + file.linesAdded, 0); + const totalLinesRemoved = files.reduce((sum, file) => sum + file.linesRemoved, 0); + return { files, totalLinesAdded, totalLinesRemoved }; } - private buildScope( + private buildFallbackScope( taskId: string, - events: LedgerEvent[], files: FileChangeSummary[], - notices: LedgerNotice[] = [] + events: LedgerEvent[], + notices: LedgerNotice[] ): TaskChangeScope { - const first = events[0]; - const last = events[events.length - 1]; - const firstNotice = notices[0]; - const lastNotice = notices[notices.length - 1]; - const worstConfidence = events.some((event) => event.confidence !== 'exact') ? 2 : 1; + const primaryMemberName = events.find((event) => event.memberName)?.memberName; + const primaryAgentId = events.find((event) => event.agentId)?.agentId; return { taskId, - memberName: first?.agentId ?? firstNotice?.agentId ?? '', + memberName: primaryMemberName ?? primaryAgentId ?? '', startLine: 0, endLine: 0, - startTimestamp: first?.timestamp ?? firstNotice?.timestamp ?? new Date().toISOString(), + startTimestamp: events[0]?.timestamp ?? notices[0]?.timestamp ?? '', endTimestamp: - last?.timestamp ?? - first?.timestamp ?? - lastNotice?.timestamp ?? - firstNotice?.timestamp ?? - new Date().toISOString(), + events[events.length - 1]?.timestamp ?? notices[notices.length - 1]?.timestamp ?? '', toolUseIds: [ - ...new Set([ - ...events.map((event) => event.toolUseId), - ...notices.map((notice) => notice.toolUseId), - ]), + ...new Set([...events.map((event) => event.toolUseId), ...notices.map((n) => n.toolUseId)]), ], filePaths: files.map((file) => file.filePath), confidence: { - tier: worstConfidence, - label: worstConfidence === 1 ? 'high' : 'medium', + tier: events.some((event) => event.confidence !== 'exact') ? 2 : 1, + label: events.some((event) => event.confidence !== 'exact') ? 'medium' : 'high', reason: 'Scoped by orchestrator task-change ledger', }, + ...(primaryMemberName ? { primaryMemberName } : {}), + ...(primaryAgentId ? { primaryAgentId } : {}), + ...(events.some((event) => !!event.memberName) + ? { + memberNames: [ + ...new Set(events.flatMap((event) => (event.memberName ? [event.memberName] : []))), + ].sort(), + } + : {}), + ...(events.length > 0 + ? { + executionSeqRange: { + start: Math.min(...events.map((event) => event.executionSeq)), + end: Math.max(...events.map((event) => event.executionSeq)), + }, + } + : {}), }; } + private collectWarnings( + events: LedgerEvent[], + notices: LedgerNotice[], + options: { recovered: boolean } + ): string[] { + const warnings = new Set(); + for (const notice of notices) warnings.add(notice.message); + for (const event of events) { + for (const warning of event.warnings ?? []) warnings.add(warning); + if (event.toolStatus === 'failed') { + warnings.add(`Tool ${event.toolUseId} failed after changing files.`); + } + if (event.toolStatus === 'killed') { + warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + } + if (options.recovered) { + warnings.add('Task change ledger recovered from malformed journal lines.'); + } + return [...warnings]; + } + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { const events: FileEditEvent[] = snippets.map((snippet, index) => { const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); @@ -491,46 +1280,88 @@ export class TaskChangeLedgerReader { return { added, removed }; } - private normalizePathKey(filePath: string): string { - return path.normalize(filePath).toLowerCase(); + private groupKeyForSnippet(snippet: SnippetDiff): string { + return this.groupKeyForFileSummary(snippet.filePath, snippet.ledger?.relation); } - private fileGroupKey(snippet: SnippetDiff): string { - const relation = snippet.ledger?.relation; + private groupKeyForFileSummary(filePath: string, relation?: LedgerChangeRelation): string { if (relation) { - return `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}`; + return `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`; } - return this.normalizePathKey(snippet.filePath); - } - - private displayFilePathForGroup(entry: { filePath: string; snippets: SnippetDiff[] }): string { - const relation = this.relationForSnippets(entry.snippets); - if (!relation) { - return entry.filePath; - } - return this.displayFilePathForRelation( - relation, - entry.snippets.map((snippet) => snippet.filePath) - ); + return `path:${normalizePathForComparison(filePath)}`; } private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; } - private displayFilePathForRelation(relation: LedgerChangeRelation, filePaths: string[]): string { - const expected = relation.newPath.replace(/\\/g, '/'); - const match = filePaths.find((filePath) => { - const normalized = filePath.replace(/\\/g, '/'); - return normalized === expected || normalized.endsWith(`/${expected}`); - }); - return match ?? relation.newPath; + private resolveGroupedDisplayPath( + fallbackPath: string, + relation: LedgerChangeRelation | undefined, + snippets: SnippetDiff[] + ): string { + if (!relation) { + return fallbackPath; + } + + const newPathSnippet = snippets.find((snippet) => + this.pathMatchesRelationPath(snippet.filePath, relation.newPath) + ); + if (newPathSnippet) { + return newPathSnippet.filePath; + } + + const createdSnippet = snippets.find( + (snippet) => snippet.ledger?.operation === 'create' || snippet.type === 'write-new' + ); + if (createdSnippet) { + return createdSnippet.filePath; + } + + return ( + this.resolveRelatedPathFromRelation(fallbackPath, relation.oldPath, relation.newPath) ?? + fallbackPath + ); } - private relativePath(filePath: string, projectPath?: string): string { + private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedRelationPath = relationPath.replace(/\\/g, '/'); + return ( + normalizedFilePath === normalizedRelationPath || + normalizedFilePath.endsWith(`/${normalizedRelationPath}`) + ); + } + + private resolveRelatedPathFromRelation( + anchorPath: string, + anchorRelationPath: string, + targetRelationPath: string + ): string | null { + const normalizedAnchor = anchorPath.replace(/\\/g, '/'); + const normalizedAnchorRelation = anchorRelationPath.replace(/\\/g, '/'); + if (!normalizedAnchor.endsWith(normalizedAnchorRelation)) { + return null; + } + + return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; + } + + private relativePath(filePath: string, projectPath?: string, explicitRelativePath?: string): string { + if (explicitRelativePath) { + return explicitRelativePath.replace(/\\/g, '/'); + } const normalizedFilePath = filePath.replace(/\\/g, '/'); const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); - if (normalizedProjectPath && normalizedFilePath.startsWith(normalizedProjectPath + '/')) { + const comparableFilePath = normalizePathForComparison(normalizedFilePath); + const comparableProjectPath = normalizedProjectPath + ? normalizePathForComparison(normalizedProjectPath) + : undefined; + if ( + normalizedProjectPath && + comparableProjectPath && + comparableFilePath.startsWith(`${comparableProjectPath}/`) + ) { return normalizedFilePath.slice(normalizedProjectPath.length + 1); } return normalizedFilePath.split('/').slice(-3).join('/'); diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index cb23ee30..85f248b0 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; +const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { @@ -42,6 +43,28 @@ interface TrackingState { lifecycleVersion: number; } +type DecodedFreshnessTaskId = + | { kind: 'task-id'; taskId: string } + | { kind: 'opaque-safe-segment' } + | { kind: 'invalid' }; + +function isOpaqueSafeTaskIdSegment(segment: string): boolean { + return /^task-id-[0-9a-f]{32}$/.test(segment); +} + +export function shouldIgnoreLogSourceWatcherPath( + projectDir: string, + watchedPath: string +): boolean { + const relativePath = path.relative(projectDir, watchedPath); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return false; + } + + const parts = relativePath.split(path.sep).filter(Boolean); + return parts[0] === BOARD_TASK_CHANGES_DIRNAME; +} + export class TeamLogSourceTracker { private readonly stateByTeam = new Map(); private emitter: ((event: TeamChangeEvent) => void) | null = null; @@ -276,6 +299,7 @@ export class TeamLogSourceTracker { ignorePermissionErrors: true, followSymlinks: false, depth: 3, + ignored: (watchedPath) => shouldIgnoreLogSourceWatcherPath(projectDir, watchedPath), awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50, @@ -343,37 +367,68 @@ export class TeamLogSourceTracker { return true; } - const taskId = this.decodeTaskLogFreshnessTaskId(relativePath); - if (!taskId) { + const decoded = this.decodeTaskLogFreshnessTaskId(relativePath); + if (decoded.kind === 'invalid') { + return true; + } + if (decoded.kind === 'opaque-safe-segment') { + void this.emitTaskFreshnessSignalFromFile(teamName, changedPath); return true; } this.emitter?.({ type: 'task-log-change', teamName, - taskId, + taskId: decoded.taskId, }); return true; } - private decodeTaskLogFreshnessTaskId(fileName: string): string | null { + private decodeTaskLogFreshnessTaskId(fileName: string): DecodedFreshnessTaskId { if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) { - return null; + return { kind: 'invalid' }; } const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length); if (!encodedTaskId) { - return null; + return { kind: 'invalid' }; + } + if (isOpaqueSafeTaskIdSegment(encodedTaskId)) { + return { kind: 'opaque-safe-segment' }; } try { const taskId = decodeURIComponent(encodedTaskId); - return taskId.trim().length > 0 ? taskId : null; + return taskId.trim().length > 0 + ? { kind: 'task-id', taskId } + : { kind: 'invalid' }; } catch { - return null; + return { kind: 'invalid' }; } } + private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const taskId = + typeof parsed.taskId === 'string' && parsed.taskId.trim().length > 0 + ? parsed.taskId.trim() + : null; + if (taskId) { + this.emitter?.({ + type: 'task-log-change', + teamName, + taskId, + }); + return; + } + } catch { + // Deletions or partially unavailable files still need a team-level refresh. + } + this.emitLogSourceChange(teamName); + } + private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); if (this.getActiveConsumerCount(state) === 0) { diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts index 0e573e5e..ef271951 100644 --- a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -10,7 +10,10 @@ import { } from './taskChangePresenceCacheSchema'; import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; -import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; +import type { + PersistedTaskChangePresence, + PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; const logger = createLogger('Service:JsonTaskChangePresenceRepository'); @@ -87,7 +90,7 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos entry: { taskId: string; taskSignature: string; - presence: 'has_changes' | 'no_changes'; + presence: PersistedTaskChangePresence; writtenAt: string; logSourceGeneration: string; } diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts index b65af3e8..1ebcfed2 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -1,4 +1,5 @@ import { + LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, type PersistedTaskChangePresence, type PersistedTaskChangePresenceEntry, type PersistedTaskChangePresenceIndex, @@ -10,7 +11,9 @@ function isIsoString(value: unknown): value is string { } function normalizePresence(value: unknown): PersistedTaskChangePresence | null { - return value === 'has_changes' || value === 'no_changes' ? value : null; + return value === 'has_changes' || value === 'needs_attention' || value === 'no_changes' + ? value + : null; } function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null { @@ -47,8 +50,11 @@ export function normalizePersistedTaskChangePresenceIndex( } const raw = value as Record; + const rawVersion = + typeof raw.version === 'number' ? raw.version : Number.NaN; if ( - raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION || + (rawVersion !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION && + rawVersion !== LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION) || typeof raw.teamName !== 'string' || typeof raw.projectFingerprint !== 'string' || raw.projectFingerprint.length === 0 || diff --git a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts index f06f853f..0116564a 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts @@ -1,6 +1,7 @@ import type { TaskChangePresenceState } from '@shared/types/team'; -export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; +export const LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; +export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 2; export type PersistedTaskChangePresence = Exclude; diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts index 326af24e..db5bc230 100644 --- a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -14,8 +15,40 @@ interface ParsedFreshnessSignal { transcriptFileBasename?: string; } +function isWindowsReservedArtifactSegment(segment: string): boolean { + const stem = segment.split('.')[0]?.toUpperCase() ?? ''; + return ( + !segment || + stem === 'CON' || + stem === 'PRN' || + stem === 'AUX' || + stem === 'NUL' || + /^COM[1-9]$/.test(stem) || + /^LPT[1-9]$/.test(stem) + ); +} + function encodeTaskId(taskId: string): string { - return encodeURIComponent(taskId); + const encoded = encodeURIComponent(taskId); + return isWindowsReservedArtifactSegment(encoded) + ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` + : encoded; +} + +function taskIdArtifactSegments(taskId: string): string[] { + const safe = encodeTaskId(taskId); + const legacy = encodeURIComponent(taskId); + return safe === legacy ? [safe] : [safe, legacy]; +} + +function taskSignalPathCandidates(projectDir: string, taskId: string): string[] { + return taskIdArtifactSegments(taskId).map((segment) => + path.join( + projectDir, + BOARD_TASK_LOG_FRESHNESS_DIRNAME, + `${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}` + ) + ); } function isValidTimestamp(value: unknown): value is string { @@ -30,28 +63,25 @@ export class TeamTaskLogFreshnessReader { taskIds: string[] ): Promise> { const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort(); - const signalFilePaths = uniqueTaskIds.map((taskId) => - path.join( - projectDir, - BOARD_TASK_LOG_FRESHNESS_DIRNAME, - `${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}` - ) + const signalFilePathCandidates = uniqueTaskIds.map((taskId) => + taskSignalPathCandidates(projectDir, taskId) ); - this.cache.retainOnly(new Set(signalFilePaths)); + this.cache.retainOnly(new Set(signalFilePathCandidates.flat())); const rows = await Promise.all( uniqueTaskIds.map(async (taskId, index) => { - const filePath = signalFilePaths[index]; - const parsed = await this.readSignal(filePath); - if (!parsed || parsed.taskId !== taskId) { + const candidates = signalFilePathCandidates[index] ?? []; + const result = await this.readFirstSignal(candidates); + if (!result || result.parsed.taskId !== taskId) { return null; } + const parsed = result.parsed; return [ taskId, { taskId, updatedAt: parsed.updatedAt, - filePath, + filePath: result.filePath, ...(parsed.transcriptFileBasename ? { transcriptFileBasename: parsed.transcriptFileBasename } : {}), @@ -63,6 +93,18 @@ export class TeamTaskLogFreshnessReader { return new Map(rows.filter((row): row is NonNullable => row !== null)); } + private async readFirstSignal( + filePaths: string[] + ): Promise<{ filePath: string; parsed: ParsedFreshnessSignal } | null> { + for (const filePath of filePaths) { + const parsed = await this.readSignal(filePath); + if (parsed) { + return { filePath, parsed }; + } + } + return null; + } + private async readSignal(filePath: string): Promise { try { const stat = await fs.stat(filePath); diff --git a/src/preload/index.ts b/src/preload/index.ts index ccf85725..fb50e9cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1398,16 +1398,17 @@ const electronAPI: ElectronAPI = { }; }, // Decision persistence - loadDecisions: async (teamName: string, scopeKey: string) => { + loadDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => { return invokeIpcWithResult<{ hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; - } | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey); + } | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey, scopeToken ?? null); }, saveDecisions: async ( teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile?: Record> @@ -1416,13 +1417,19 @@ const electronAPI: ElectronAPI = { REVIEW_SAVE_DECISIONS, teamName, scopeKey, + scopeToken, hunkDecisions, fileDecisions, hunkContextHashesByFile ?? null ); }, - clearDecisions: async (teamName: string, scopeKey: string) => { - return invokeIpcWithResult(REVIEW_CLEAR_DECISIONS, teamName, scopeKey); + clearDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => { + return invokeIpcWithResult( + REVIEW_CLEAR_DECISIONS, + teamName, + scopeKey, + scopeToken ?? null + ); }, onCmdN: (callback: () => void): (() => void) => { const handler = (): void => callback(); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 58b5af8b..84b62d4c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1133,6 +1133,7 @@ export class HttpAPIClient implements ElectronAPI { saveDecisions: async ( _teamName: string, _scopeKey: string, + _scopeToken: string, _hunkDecisions: Record, _fileDecisions: Record, _hunkContextHashesByFile?: Record> diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 6eb67a21..ba760d20 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -51,6 +51,7 @@ import { buildTaskChangeSignature, deriveTaskSince, } from '@renderer/utils/taskChangeRequest'; +import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -103,16 +104,6 @@ import type { TeamTaskWithKanban, } from '@shared/types'; -function resolveTaskChangePresenceFromResult( - data: Pick -): 'has_changes' | 'no_changes' | null { - if (data.files.length > 0) { - return 'has_changes'; - } - - return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; -} - interface TaskDetailDialogProps { open: boolean; loading?: boolean; @@ -154,7 +145,7 @@ export const TaskDetailDialog = ({ const { isLight } = useTheme(); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); - const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges); + const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); const [logsRefreshing, setLogsRefreshing] = useState(false); @@ -391,22 +382,22 @@ export const TaskDetailDialog = ({ const syncTaskChangeSummaryResult = useCallback( (data: TaskChangeSetV2 | null) => { setTaskChangesFiles(data?.files ?? null); + const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && taskChangeRequestOptions) { - recordTaskHasChanges( + recordTaskChangePresence( teamName, currentTask.id, taskChangeRequestOptions, - !!data?.files.length + nextPresence ); } - const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; - if (currentTask && nextPresence) { - setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence); + if (currentTask) { + setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence ?? 'unknown'); } }, [ currentTask, - recordTaskHasChanges, + recordTaskChangePresence, setSelectedTeamTaskChangePresence, taskChangeRequestOptions, teamName, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 908c7c35..4f4b8da9 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -159,4 +159,41 @@ describe('KanbanTaskCard change badge', () => { await Promise.resolve(); }); }); + + it('still renders the Changes action when changePresence needs attention', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'needs_attention' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7c84488d..de058bdf 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -257,7 +257,8 @@ export const KanbanTaskCard = memo( const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; const metaActions = ( <> - {canDisplay && task.changePresence === 'has_changes' ? ( + {canDisplay && + (task.changePresence === 'has_changes' || task.changePresence === 'needs_attention') ? ( } diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 25daf6c3..68a00a78 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -14,7 +14,12 @@ import { buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo'; import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; import { displayMemberName } from '@renderer/utils/memberHelpers'; -import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; +import { buildReviewDecisionScopeToken } from '@renderer/utils/reviewDecisionScope'; +import { + buildTaskChangeSignature, + type TaskChangeRequestOptions, +} from '@renderer/utils/taskChangeRequest'; import { normalizePathForComparison } from '@shared/utils/platformPath'; import { ChevronDown, Clock, X } from 'lucide-react'; @@ -122,6 +127,25 @@ export const ChangeReviewDialog = ({ const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; // Filesystem-safe: use `-` instead of `:` for decision persistence key const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`; + const decisionScopeToken = useMemo(() => { + if (!activeChangeSet) return null; + if (mode === 'task') { + if (!('taskId' in activeChangeSet) || activeChangeSet.taskId !== taskId) { + return null; + } + } else if (!('memberName' in activeChangeSet) || activeChangeSet.memberName !== memberName) { + return null; + } + + return buildReviewDecisionScopeToken({ + mode, + taskId, + memberName, + requestSignature: + mode === 'task' ? buildTaskChangeSignature(taskChangeRequestOptions ?? {}) : undefined, + changeSet: activeChangeSet, + }); + }, [activeChangeSet, memberName, mode, taskChangeRequestOptions, taskId]); // Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll) const [activeFilePath, setActiveFilePath] = useState(null); @@ -813,8 +837,7 @@ export const ChangeReviewDialog = ({ useEffect(() => { if (!open) return; - // Load persisted decisions from disk - void loadDecisionsFromDisk(teamName, decisionScopeKey); + resetAllReviewState(); // Fetch changeSet if (mode === 'agent' && memberName) { @@ -836,9 +859,14 @@ export const ChangeReviewDialog = ({ fetchAgentChanges, fetchTaskChanges, clearChangeReviewCache, - loadDecisionsFromDisk, + resetAllReviewState, ]); + useEffect(() => { + if (!open || !decisionScopeToken) return; + void loadDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken); + }, [decisionScopeKey, decisionScopeToken, loadDecisionsFromDisk, open, teamName]); + // Persist decisions to disk on change (debounced via store action). // When decisions go from non-empty to empty (e.g. undo to clean state), // clear the persisted file so stale decisions don't reload on reopen. @@ -846,21 +874,27 @@ export const ChangeReviewDialog = ({ Object.keys(hunkDecisions).length > 0 || Object.keys(fileDecisions).length > 0; const hadDecisionsRef = useRef(false); useEffect(() => { - if (!open) return; + hadDecisionsRef.current = false; + }, [decisionScopeToken]); + useEffect(() => { + if (!open || !decisionScopeToken) return; if (hasDecisions) { hadDecisionsRef.current = true; - persistDecisions(teamName, decisionScopeKey); + persistDecisions(teamName, decisionScopeKey, decisionScopeToken); } else if (hadDecisionsRef.current) { hadDecisionsRef.current = false; - void clearDecisionsFromDisk(teamName, decisionScopeKey); + void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken); } }, [ open, hasDecisions, hunkDecisions, fileDecisions, + fileContents, + fileChunkCounts, teamName, decisionScopeKey, + decisionScopeToken, persistDecisions, clearDecisionsFromDisk, ]); @@ -1125,7 +1159,8 @@ export const ChangeReviewDialog = ({ for (const file of activeChangeSet.files) { // File-level decision takes priority (set by Accept All / Reject All) - const fileDec = fileDecisions[file.filePath]; + const reviewKey = getFileReviewKey(file); + const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath]; const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); if (fileDec === 'accepted') { @@ -1138,8 +1173,9 @@ export const ChangeReviewDialog = ({ } for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; - const decision: HunkDecision = hunkDecisions[key] ?? 'pending'; + const key = buildHunkDecisionKey(reviewKey, i); + const decision: HunkDecision = + hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending'; if (decision === 'pending') pending++; else if (decision === 'accepted') accepted++; else if (decision === 'rejected') rejected++; @@ -1163,7 +1199,7 @@ export const ChangeReviewDialog = ({ // Only cleanup if apply succeeded (no error in store) const state = useStore.getState(); if (!state.applyError) { - void clearDecisionsFromDisk(teamName, decisionScopeKey); + void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken ?? undefined); resetAllReviewState(); } }, [ @@ -1173,6 +1209,7 @@ export const ChangeReviewDialog = ({ memberName, clearDecisionsFromDisk, decisionScopeKey, + decisionScopeToken, resetAllReviewState, ]); diff --git a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts index 7eda04af..3014d8cb 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts +++ b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts @@ -9,6 +9,7 @@ import { } from '@codemirror/merge'; import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state'; import { type EditorView } from '@codemirror/view'; +import { buildHunkDecisionKey } from '@renderer/utils/reviewKey'; import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { structuredPatch } from 'diff'; @@ -93,7 +94,7 @@ export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) = */ export function replayHunkDecisions( view: EditorView, - filePath: string, + reviewKey: string, hunkDecisions: Record ): void { const result = getChunks(view.state); @@ -102,7 +103,7 @@ export function replayHunkDecisions( // Collect decisions that need replaying const toReplay: { index: number; decision: 'accepted' | 'rejected' }[] = []; for (let i = 0; i < result.chunks.length; i++) { - const key = `${filePath}:${i}`; + const key = buildHunkDecisionKey(reviewKey, i); const d = hunkDecisions[key]; if (d === 'accepted' || d === 'rejected') { toReplay.push({ index: i, decision: d }); @@ -134,7 +135,7 @@ export function replayHunkDecisions( */ export function replayHunkDecisionsSmart( view: EditorView, - filePath: string, + reviewKey: string, hunkDecisions: Record, hunkContextHashes?: Record ): void { @@ -171,7 +172,7 @@ export function replayHunkDecisionsSmart( } // Collect all decided indices from the decision map (don't assume contiguous 0..N) - const prefix = `${filePath}:`; + const prefix = `${reviewKey}:`; const decided: { mappedIndex: number; decision: 'accepted' | 'rejected' }[] = []; const usedMapped = new Set(); diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 5758d80a..d6fc2ec1 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; import { useStore } from '@renderer/store'; +import { getFileReviewKey } from '@renderer/utils/reviewKey'; import { acceptAllChunks, @@ -189,6 +190,8 @@ export const ContinuousScrollView = ({ const handleEditorViewReady = useCallback( (filePath: string, view: EditorView | null) => { if (view) { + const file = files.find((candidate) => candidate.filePath === filePath); + const reviewKey = file ? getFileReviewKey(file) : filePath; // Skip if this exact view instance was already processed if (editorViewMapRef.current.get(filePath) === view && replayedViewsRef.current.has(view)) { return; @@ -202,7 +205,8 @@ export const ContinuousScrollView = ({ setFileChunkCount(filePath, chunks.chunks.length); } - const fileDecision = fileDecisionsRef.current[filePath]; + const fileDecision = + fileDecisionsRef.current[reviewKey] ?? fileDecisionsRef.current[filePath]; if (fileDecision === 'accepted' || fileDecision === 'rejected') { // Sync file-level "Accept All" / "Reject All" decisions requestAnimationFrame(() => { @@ -217,9 +221,9 @@ export const ContinuousScrollView = ({ requestAnimationFrame(() => { replayHunkDecisionsSmart( view, - filePath, + reviewKey, hunkDecisionsRef.current, - hunkHashesRef.current[filePath] + hunkHashesRef.current[reviewKey] ?? hunkHashesRef.current[filePath] ); }); } @@ -229,7 +233,7 @@ export const ContinuousScrollView = ({ // is not needed since view instances are unique and old ones get GC'd) } }, - [editorViewMapRef, setFileChunkCount] + [editorViewMapRef, files, setFileChunkCount] ); if (files.length === 0) { @@ -253,11 +257,12 @@ export const ContinuousScrollView = ({ ) : null} {files.map((file) => { const filePath = file.filePath; + const reviewKey = getFileReviewKey(file); const content = fileContents[filePath] ?? null; const hasContent = filePath in fileContents; const hasEdits = filePath in editedContents; const isViewed = viewedSet.has(filePath); - const decision = fileDecisions[filePath]; + const decision = fileDecisions[reviewKey] ?? fileDecisions[filePath]; const isCollapsed = collapsedFiles.has(filePath); diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index c80c5747..94d3ce70 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -6,6 +6,7 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice'; import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; import { Check, ChevronRight, @@ -47,7 +48,8 @@ function getFileStatus( fileChunkCounts: Record ): FileStatus { // File-level decision takes priority (set by Accept All / Reject All) - const fileDec = fileDecisions[file.filePath]; + const reviewKey = getFileReviewKey(file); + const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath]; if (fileDec === 'accepted') return 'accepted'; if (fileDec === 'rejected') return 'rejected'; @@ -56,8 +58,8 @@ function getFileStatus( const decisions: HunkDecision[] = []; for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; - decisions.push(hunkDecisions[key] ?? 'pending'); + const key = buildHunkDecisionKey(reviewKey, i); + decisions.push(hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending'); } const allAccepted = decisions.every((d) => d === 'accepted'); @@ -300,10 +302,18 @@ export const ReviewFileTree = ({ }; const hasAnyRejected = (f: FileChangeSummary): boolean => { - if (fileDecisions[f.filePath] === 'rejected') return true; + const reviewKey = getFileReviewKey(f); + if (fileDecisions[reviewKey] === 'rejected' || fileDecisions[f.filePath] === 'rejected') { + return true; + } const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts); for (let i = 0; i < count; i++) { - if (hunkDecisions[`${f.filePath}:${i}`] === 'rejected') return true; + if ( + hunkDecisions[buildHunkDecisionKey(reviewKey, i)] === 'rejected' || + hunkDecisions[`${f.filePath}:${i}`] === 'rejected' + ) { + return true; + } } return false; }; diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 98044fb2..b3b1e153 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -4,6 +4,20 @@ import { isTaskSummaryCacheableForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; +import { + getReviewChangeSetIdentityToken, + type ReviewChangeSetLike, +} from '@renderer/utils/reviewDecisionScope'; +import { + buildHunkDecisionKey, + getFileReviewKey, + getReviewKeyForFilePath, + normalizePersistedReviewState, +} from '@renderer/utils/reviewKey'; +import { + resolveTaskChangePresenceFromResult, + shouldBackgroundRevalidateTaskPresence, +} from '@renderer/utils/taskChangePresence'; import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { createLogger } from '@shared/utils/logger'; import { normalizePathForComparison } from '@shared/utils/platformPath'; @@ -18,10 +32,12 @@ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; const TASK_CHANGE_WARM_CONCURRENCY = 4; const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); +let latestAgentChangesRequestToken = 0; let latestTaskChangesRequestToken = 0; +let latestDecisionLoadRequestToken = 0; /** Debounce timer for persisting decisions to disk */ -let persistDebounceTimer: ReturnType | null = null; +const persistDebounceTimers = new Map>(); const PERSIST_DEBOUNCE_MS = 500; import type { AppState } from '../types'; @@ -37,11 +53,23 @@ import type { SnippetDiff, TaskChangeSet, TaskChangeSetV2, + TaskChangePresenceState, } from '@shared/types'; import type { StateCreator } from 'zustand'; const logger = createLogger('changeReviewSlice'); +function reviewPathsEqual(left: string, right: string): boolean { + return normalizePathForComparison(left) === normalizePathForComparison(right); +} + +function findReviewFileByPath( + files: readonly FileChangeSummary[] | null | undefined, + filePath: string +): FileChangeSummary | undefined { + return files?.find((file) => reviewPathsEqual(file.filePath, filePath)); +} + /** Snapshot of review decisions for undo support */ interface DecisionSnapshot { hunkDecisions: Record; @@ -52,8 +80,6 @@ export interface ReviewExternalChange { type: 'change' | 'add' | 'unlink'; } -type ReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; - const MAX_REVIEW_UNDO_DEPTH = 10; /** @@ -70,22 +96,53 @@ function mapReviewError(error: unknown): string { return message || 'Failed to apply review changes'; } -function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { - const computedAtMs = Date.parse(data.computedAt); - if (!Number.isFinite(computedAtMs)) { - return true; - } - return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; +function clearPersistDecisionTimer(scopeStorageKey: string): void { + const timer = persistDebounceTimers.get(scopeStorageKey); + if (!timer) return; + clearTimeout(timer); + persistDebounceTimers.delete(scopeStorageKey); } -function resolveTaskChangePresenceFromResult( - data: Pick -): 'has_changes' | 'no_changes' | null { - if (data.files.length > 0) { - return 'has_changes'; - } +function buildPersistDecisionScopeKey( + teamName: string, + scopeKey: string, + scopeToken?: string +): string { + return scopeToken ? `${teamName}:${scopeKey}:${scopeToken}` : `${teamName}:${scopeKey}`; +} - return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +function clearAllPersistDecisionTimers(): void { + for (const timer of persistDebounceTimers.values()) { + clearTimeout(timer); + } + persistDebounceTimers.clear(); +} + +function applyTaskChangePresenceCacheUpdate( + taskChangePresenceByKey: Record>, + cacheKey: string, + presence: TaskChangePresenceState | null +): Record> { + const nextTaskChangePresenceByKey = { ...taskChangePresenceByKey }; + if (presence && presence !== 'unknown') { + nextTaskChangePresenceByKey[cacheKey] = presence; + } else { + delete nextTaskChangePresenceByKey[cacheKey]; + } + return nextTaskChangePresenceByKey; +} + +function syncTaskChangeNegativeCache( + cacheKey: string, + presence: TaskChangePresenceState | null +): void { + if (presence === 'has_changes' || presence === 'needs_attention') { + taskChangesNegativeCache.delete(cacheKey); + } else if (presence === 'no_changes') { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } else { + taskChangesNegativeCache.delete(cacheKey); + } } export interface ChangeReviewSlice { @@ -118,8 +175,8 @@ export interface ChangeReviewSlice { // Editable diff state editedContents: Record; - /** Cache: "teamName:taskId:signature" → true/false (has file changes) */ - taskHasChanges: Record; + /** Cache: "teamName:taskId:signature" → resolved task change presence */ + taskChangePresenceByKey: Record>; // Phase 1 actions fetchAgentChanges: (teamName: string, memberName: string) => Promise; @@ -128,11 +185,11 @@ export interface ChangeReviewSlice { taskId: string, options: TaskChangeRequestOptions ) => Promise; - recordTaskHasChanges: ( + recordTaskChangePresence: ( teamName: string, taskId: string, options: TaskChangeRequestOptions, - hasChanges: boolean + presence: TaskChangePresenceState | null ) => void; selectReviewFile: (filePath: string | null) => void; clearChangeReview: () => void; @@ -141,9 +198,13 @@ export interface ChangeReviewSlice { fetchChangeStats: (teamName: string, memberName: string) => Promise; // Decision persistence actions - loadDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise; - persistDecisions: (teamName: string, scopeKey: string) => void; - clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise; + loadDecisionsFromDisk: (teamName: string, scopeKey: string, scopeToken: string) => Promise; + persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => void; + clearDecisionsFromDisk: ( + teamName: string, + scopeKey: string, + scopeToken?: string + ) => Promise; // Phase 2 actions /** @@ -218,14 +279,14 @@ export interface ChangeReviewSlice { * This function reverses that shift so decisions are stored with stable indices. */ function mapCurrentToOriginalIndex( - filePath: string, + reviewKey: string, currentIdx: number, hunkDecisions: Record, totalChunks: number ): number { const decided = new Set(); for (let i = 0; i < totalChunks; i++) { - if (`${filePath}:${i}` in hunkDecisions) { + if (buildHunkDecisionKey(reviewKey, i) in hunkDecisions) { decided.add(i); } } @@ -251,11 +312,11 @@ export function getFileHunkCount( } function getMaxDecisionIndexForFile( - filePath: string, + reviewKey: string, hunkDecisions: Record ): number { let max = -1; - const prefix = `${filePath}:`; + const prefix = `${reviewKey}:`; for (const key of Object.keys(hunkDecisions)) { if (!key.startsWith(prefix)) continue; const raw = key.slice(prefix.length); @@ -296,38 +357,6 @@ function buildHunkContextHashesForFile( return out; } -function encodeFingerprintField(value: string): string { - return `${value.length}:${value}`; -} - -function fingerprintSnippet(snippet: SnippetDiff): string { - return [ - encodeFingerprintField(normalizePathForComparison(snippet.filePath)), - encodeFingerprintField(snippet.toolUseId), - encodeFingerprintField(snippet.timestamp), - encodeFingerprintField(snippet.type), - encodeFingerprintField(snippet.oldString), - encodeFingerprintField(snippet.newString), - encodeFingerprintField(snippet.replaceAll ? '1' : '0'), - encodeFingerprintField(snippet.isError ? '1' : '0'), - encodeFingerprintField(snippet.contextHash ?? ''), - ].join('|'); -} - -function fingerprintChangeSet(changeSet: ReviewChangeSet): string { - return [...changeSet.files] - .sort((a, b) => - normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath)) - ) - .map((file) => - [ - encodeFingerprintField(normalizePathForComparison(file.filePath)), - ...file.snippets.map(fingerprintSnippet), - ].join('|') - ) - .join('||'); -} - export const createChangeReviewSlice: StateCreator = ( set, get @@ -353,6 +382,8 @@ export const createChangeReviewSlice: StateCreator ): void => { set((s) => ({ activeChangeSet: data, changeSetLoading: false, selectedReviewFilePath: data.files[0]?.filePath ?? null, + hunkDecisions: {}, + fileDecisions: {}, fileContents: {}, fileContentsLoading: {}, fileChunkCounts: {}, + reviewUndoStack: [], hunkContextHashesByFile: {}, applyError: null, + editedContents: {}, changeSetEpoch: s.changeSetEpoch + 1, fileContentVersionByPath: {}, reviewExternalChangesByFile: {}, @@ -388,7 +423,7 @@ export const createChangeReviewSlice: StateCreator { set((s) => ({ @@ -430,14 +465,16 @@ export const createChangeReviewSlice: StateCreator ({ - taskHasChanges: { ...state.taskHasChanges, [cacheKey]: data.files.length > 0 }, + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + state.taskChangePresenceByKey, + cacheKey, + nextPresence + ), })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + syncTaskChangeNegativeCache(cacheKey, nextPresence); + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown'); } catch { // Best-effort background revalidation; keep optimistic state on transient failure. } finally { @@ -472,35 +509,40 @@ export const createChangeReviewSlice: StateCreator { + const requestToken = ++latestAgentChangesRequestToken; set({ changeSetLoading: true, changeSetError: null }); try { const data = await api.review.getAgentChanges(teamName, memberName); + if (requestToken !== latestAgentChangesRequestToken) return; installActiveChangeSetForLoad(data, { activeTaskChangeRequestOptions: null }); } catch (error) { + if (requestToken !== latestAgentChangesRequestToken) return; const message = error instanceof Error ? error.message : 'Failed to fetch agent changes'; logger.error('fetchAgentChanges error:', message); set({ changeSetError: message, changeSetLoading: false }); } }, - recordTaskHasChanges: ( + recordTaskChangePresence: ( teamName: string, taskId: string, options: TaskChangeRequestOptions, - hasChanges: boolean + presence: TaskChangePresenceState | null ) => { const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges }, - })); - if (hasChanges) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + set((s) => { + return { + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + s.taskChangePresenceByKey, + cacheKey, + presence + ), + }; + }); + syncTaskChangeNegativeCache(cacheKey, presence); }, fetchTaskChanges: async ( @@ -517,16 +559,14 @@ export const createChangeReviewSlice: StateCreator 0 }, + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + get().taskChangePresenceByKey, + cacheKey, + nextPresence + ), }); - if (nextPresence) { - get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); - } - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown'); + syncTaskChangeNegativeCache(cacheKey, nextPresence); } catch (error) { if (requestToken !== latestTaskChangesRequestToken) return; const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; @@ -540,7 +580,10 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, @@ -564,13 +607,18 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, changeSetError: null, selectedReviewFilePath: null, activeTaskChangeRequestOptions: null, + hunkDecisions: {}, + fileDecisions: {}, fileChunkCounts: {}, reviewUndoStack: [], hunkContextHashesByFile: {}, @@ -586,7 +634,10 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, @@ -611,72 +662,93 @@ export const createChangeReviewSlice: StateCreator { + loadDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken: string) => { + const requestToken = ++latestDecisionLoadRequestToken; try { - const data = await api.review.loadDecisions(teamName, scopeKey); + const data = await api.review.loadDecisions(teamName, scopeKey, scopeToken); + if (requestToken !== latestDecisionLoadRequestToken) return; + const normalized = normalizePersistedReviewState(get().activeChangeSet?.files ?? [], { + hunkDecisions: data?.hunkDecisions, + fileDecisions: data?.fileDecisions, + hunkContextHashesByFile: data?.hunkContextHashesByFile, + }); // Always set decisions — even to empty if no saved file exists. // This prevents stale decisions from a previous scope leaking through. set({ - hunkDecisions: data?.hunkDecisions ?? {}, - fileDecisions: data?.fileDecisions ?? {}, - hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {}, + hunkDecisions: normalized.hunkDecisions, + fileDecisions: normalized.fileDecisions, + hunkContextHashesByFile: normalized.hunkContextHashesByFile, }); } catch (error) { + if (requestToken !== latestDecisionLoadRequestToken) return; logger.error('loadDecisionsFromDisk error:', error); + set({ + hunkDecisions: {}, + fileDecisions: {}, + hunkContextHashesByFile: {}, + }); } }, - persistDecisions: (teamName: string, scopeKey: string) => { - if (persistDebounceTimer) { - clearTimeout(persistDebounceTimer); + persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => { + const scopeStorageKey = buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken); + clearPersistDecisionTimer(scopeStorageKey); + + const { + hunkDecisions, + fileDecisions, + hunkContextHashesByFile, + activeChangeSet, + fileContents, + fileChunkCounts, + } = get(); + + const computed: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + const content = fileContents[fp]; + if (!content) continue; + const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); + const hashes = buildHunkContextHashesForFile( + content.originalFullContent, + content.modifiedFullContent, + expected + ); + if (hashes) computed[fp] = hashes; } - persistDebounceTimer = setTimeout(() => { - const { - hunkDecisions, - fileDecisions, - hunkContextHashesByFile, - activeChangeSet, - fileContents, - fileChunkCounts, - } = get(); - const computed: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - const content = fileContents[fp]; - if (!content) continue; - const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); - const hashes = buildHunkContextHashesForFile( - content.originalFullContent, - content.modifiedFullContent, - expected - ); - if (hashes) computed[fp] = hashes; - } + const mergedHashes: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + const reviewKey = getFileReviewKey(file); + mergedHashes[reviewKey] = + computed[fp] ?? hunkContextHashesByFile[reviewKey] ?? hunkContextHashesByFile[fp] ?? {}; + } + set({ hunkContextHashesByFile: mergedHashes }); - // Prune to only files in the current scope. This avoids persisting stale file paths - // (e.g. from older sessions) that could confuse future replays. - const mergedHashes: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {}; - } - // Keep store in sync so replay can use hashes without reload. - set({ hunkContextHashesByFile: mergedHashes }); + const persistedHunkDecisions = { ...hunkDecisions }; + const persistedFileDecisions = { ...fileDecisions }; + const persistedHashes = { ...mergedHashes }; + const timer = setTimeout(() => { + persistDebounceTimers.delete(scopeStorageKey); void api.review.saveDecisions( teamName, scopeKey, - hunkDecisions, - fileDecisions, - mergedHashes + scopeToken, + persistedHunkDecisions, + persistedFileDecisions, + persistedHashes ); }, PERSIST_DEBOUNCE_MS); + + persistDebounceTimers.set(scopeStorageKey, timer); }, - clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => { + clearDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken?: string) => { + clearPersistDecisionTimer(buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken)); try { - await api.review.clearDecisions(teamName, scopeKey); + await api.review.clearDecisions(teamName, scopeKey, scopeToken); } catch (error) { logger.error('clearDecisionsFromDisk error:', error); } @@ -699,13 +771,14 @@ export const createChangeReviewSlice: StateCreator { const state = get(); const totalChunks = state.fileChunkCounts[filePath] ?? 0; + const reviewKey = getReviewKeyForFilePath(state.activeChangeSet?.files, filePath); // Map current chunk index to original: after accept/reject, chunks shift in CM. // We need the original index to keep decisions stable across shifts. const originalIndex = totalChunks > 0 - ? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks) + ? mapCurrentToOriginalIndex(reviewKey, hunkIndex, state.hunkDecisions, totalChunks) : hunkIndex; - const key = `${filePath}:${originalIndex}`; + const key = buildHunkDecisionKey(reviewKey, originalIndex); set((s) => ({ hunkDecisions: { ...s.hunkDecisions, [key]: decision }, })); @@ -713,7 +786,10 @@ export const createChangeReviewSlice: StateCreator { - const key = `${filePath}:${originalIndex}`; + const key = buildHunkDecisionKey( + getReviewKeyForFilePath(get().activeChangeSet?.files, filePath), + originalIndex + ); set((s) => { if (!(key in s.hunkDecisions)) return s; const next = { ...s.hunkDecisions }; @@ -723,8 +799,9 @@ export const createChangeReviewSlice: StateCreator { + const reviewKey = getReviewKeyForFilePath(get().activeChangeSet?.files, filePath); set((state) => ({ - fileDecisions: { ...state.fileDecisions, [filePath]: decision }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: decision }, })); }, @@ -762,33 +839,35 @@ export const createChangeReviewSlice: StateCreator { const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + const file = findReviewFileByPath(state.activeChangeSet?.files, filePath); if (!file) return; - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; + const reviewKey = getFileReviewKey(file); for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'accepted'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted'; } set({ hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: 'accepted' }, }); }, rejectAllFile: (filePath: string) => { const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + const file = findReviewFileByPath(state.activeChangeSet?.files, filePath); if (!file) return; - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; + const reviewKey = getFileReviewKey(file); for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'rejected'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected'; } set({ hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: 'rejected' }, }); }, @@ -800,10 +879,11 @@ export const createChangeReviewSlice: StateCreator = {}; for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'accepted'; + const reviewKey = getFileReviewKey(file); + newFileDecisions[reviewKey] = 'accepted'; const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'accepted'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted'; } } set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); @@ -817,10 +897,11 @@ export const createChangeReviewSlice: StateCreator = {}; for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'rejected'; + const reviewKey = getFileReviewKey(file); + newFileDecisions[reviewKey] = 'rejected'; const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'rejected'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected'; } } set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); @@ -848,10 +929,11 @@ export const createChangeReviewSlice: StateCreator f.filePath === filePath); + const fileEntry = findReviewFileByPath(activeChangeSet?.files, filePath); + const canonicalFilePath = fileEntry?.filePath ?? filePath; const snippets = fileEntry?.snippets ?? []; - const content = await api.review.getFileContent(teamName, memberName, filePath, snippets); + const content = await api.review.getFileContent(teamName, memberName, canonicalFilePath, snippets); const latest = get(); if (changeSetEpoch !== latest.changeSetEpoch) return; if ((latest.fileContentVersionByPath[filePath] ?? 0) !== fileVersion) return; @@ -868,7 +950,7 @@ export const createChangeReviewSlice: StateCreator - f.filePath === filePath + reviewPathsEqual(f.filePath, canonicalFilePath) ? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved } : f ); @@ -902,15 +984,13 @@ export const createChangeReviewSlice: StateCreator = {}; const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions); + const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions); const count = Math.max(baseCount, maxIdx + 1); for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; + const key = buildHunkDecisionKey(reviewKey, i); hunkDecs[i] = hunkDecisions[key] ?? 'pending'; } @@ -1009,16 +1090,17 @@ export const createChangeReviewSlice: StateCreator f.filePath === filePath); + const file = findReviewFileByPath(activeChangeSet.files, filePath); if (!file) return null; - const fileDecision = fileDecisions[filePath] ?? 'pending'; + const reviewKey = getFileReviewKey(file); + const fileDecision = fileDecisions[reviewKey] ?? 'pending'; const hunkDecs: Record = {}; - const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); + const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions); const count = Math.max(baseCount, maxIdx + 1); for (let i = 0; i < count; i++) { - hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending'; + hunkDecs[i] = hunkDecisions[buildHunkDecisionKey(reviewKey, i)] ?? 'pending'; } const hasRejected = @@ -1026,9 +1108,9 @@ export const createChangeReviewSlice: StateCreator { set((s) => { if (!s.activeChangeSet) return s; - const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath); + const existing = findReviewFileByPath(s.activeChangeSet.files, filePath); if (!existing) return s; - const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath); + const nextFiles = s.activeChangeSet.files.filter( + (f) => !reviewPathsEqual(f.filePath, existing.filePath) + ); const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; + const reviewKey = getReviewKeyForFilePath(s.activeChangeSet.files, filePath); + const prefix = `${reviewKey}:`; for (const key of Object.keys(nextHunkDecisions)) { if (key.startsWith(prefix)) delete nextHunkDecisions[key]; } const nextFileDecisions = { ...s.fileDecisions }; - delete nextFileDecisions[filePath]; + delete nextFileDecisions[reviewKey]; const nextFileChunkCounts = { ...s.fileChunkCounts }; delete nextFileChunkCounts[filePath]; + delete nextFileChunkCounts[existing.filePath]; const nextFileContents = { ...s.fileContents }; delete nextFileContents[filePath]; + delete nextFileContents[existing.filePath]; const nextFileContentsLoading = { ...s.fileContentsLoading }; delete nextFileContentsLoading[filePath]; + delete nextFileContentsLoading[existing.filePath]; const nextEditedContents = { ...s.editedContents }; delete nextEditedContents[filePath]; + delete nextEditedContents[existing.filePath]; const nextHashes = { ...s.hunkContextHashesByFile }; + delete nextHashes[reviewKey]; delete nextHashes[filePath]; const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile }; delete nextReviewExternalChangesByFile[filePath]; + delete nextReviewExternalChangesByFile[existing.filePath]; const nextFileContentVersionByPath = { ...s.fileContentVersionByPath, [filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1, + [existing.filePath]: (s.fileContentVersionByPath[existing.filePath] ?? 0) + 1, }; const nextSelected = - s.selectedReviewFilePath === filePath + s.selectedReviewFilePath && reviewPathsEqual(s.selectedReviewFilePath, existing.filePath) ? (nextFiles[0]?.filePath ?? null) : s.selectedReviewFilePath; @@ -1137,7 +1229,7 @@ export const createChangeReviewSlice: StateCreator { set((s) => { if (!s.activeChangeSet) return s; - if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s; + if (findReviewFileByPath(s.activeChangeSet.files, file.filePath)) return s; const idxRaw = options?.index; const idx = @@ -1186,7 +1278,8 @@ export const createChangeReviewSlice: StateCreator { set((s) => { const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; + const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath); + const prefix = `${reviewKey}:`; for (const key of Object.keys(nextHunkDecisions)) { if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') { delete nextHunkDecisions[key]; @@ -1194,8 +1287,8 @@ export const createChangeReviewSlice: StateCreator 0) { + if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, + taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: nextPresence }, })); taskChangesNegativeCache.delete(cacheKey); - get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); - if (wasRestoredBeforeCurrentSession(data)) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); + if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) { void revalidateTaskChangePresence(teamName, taskId, options); } - } else { + } else if (nextPresence === 'no_changes') { set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false }, + taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: 'no_changes' }, })); taskChangesNegativeCache.set(cacheKey, Date.now()); - if (nextPresence === 'no_changes') { - get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes'); - } else if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') { + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes'); + } else { + set((s) => { + const nextTaskChangePresenceByKey = { ...s.taskChangePresenceByKey }; + delete nextTaskChangePresenceByKey[cacheKey]; + return { taskChangePresenceByKey: nextTaskChangePresenceByKey }; + }); + taskChangesNegativeCache.delete(cacheKey); + if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') { get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown'); } } @@ -1394,7 +1499,12 @@ export const createChangeReviewSlice: StateCreator => { - if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) { + const cachedPresence = get().taskChangePresenceByKey[cacheKey]; + if ( + cachedPresence === 'has_changes' || + cachedPresence === 'needs_attention' || + taskChangesCheckInFlight.has(cacheKey) + ) { return; } @@ -1404,16 +1514,24 @@ export const createChangeReviewSlice: StateCreator ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { + const nextPresence = resolveTaskChangePresenceFromResult(data); + if (nextPresence) { + set((s) => ({ + taskChangePresenceByKey: { + ...s.taskChangePresenceByKey, + [cacheKey]: nextPresence, + }, + })); + } + if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { taskChangesNegativeCache.delete(cacheKey); - if (wasRestoredBeforeCurrentSession(data)) { + if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) { void revalidateTaskChangePresence(request.teamName, request.taskId, request.options); } - } else { + } else if (nextPresence === 'no_changes') { taskChangesNegativeCache.set(cacheKey, Date.now()); + } else { + taskChangesNegativeCache.delete(cacheKey); } } catch { // Best-effort warm path. @@ -1435,16 +1553,16 @@ export const createChangeReviewSlice: StateCreator { - const nextTaskHasChanges = { ...state.taskHasChanges }; + const nextTaskChangePresenceByKey = { ...state.taskChangePresenceByKey }; let changed = false; for (const key of keySet) { - if (key in nextTaskHasChanges) { - delete nextTaskHasChanges[key]; + if (key in nextTaskChangePresenceByKey) { + delete nextTaskChangePresenceByKey[key]; changed = true; } taskChangesNegativeCache.delete(key); } - return changed ? { taskHasChanges: nextTaskHasChanges } : {}; + return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {}; }); }, diff --git a/src/renderer/utils/reviewDecisionScope.ts b/src/renderer/utils/reviewDecisionScope.ts new file mode 100644 index 00000000..99b251eb --- /dev/null +++ b/src/renderer/utils/reviewDecisionScope.ts @@ -0,0 +1,72 @@ +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { AgentChangeSet, SnippetDiff, TaskChangeSet, TaskChangeSetV2 } from '@shared/types'; + +export type ReviewChangeSetLike = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; + +function encodeFingerprintField(value: string): string { + return `${value.length}:${value}`; +} + +function fingerprintSnippet(snippet: SnippetDiff): string { + return [ + encodeFingerprintField(normalizePathForComparison(snippet.filePath)), + encodeFingerprintField(snippet.toolUseId), + encodeFingerprintField(snippet.timestamp), + encodeFingerprintField(snippet.type), + encodeFingerprintField(snippet.oldString), + encodeFingerprintField(snippet.newString), + encodeFingerprintField(snippet.replaceAll ? '1' : '0'), + encodeFingerprintField(snippet.isError ? '1' : '0'), + encodeFingerprintField(snippet.contextHash ?? ''), + ].join('|'); +} + +export function fingerprintReviewChangeSet(changeSet: ReviewChangeSetLike): string { + return [...changeSet.files] + .sort((a, b) => + normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath)) + ) + .map((file) => + [ + encodeFingerprintField(normalizePathForComparison(file.filePath)), + ...(file.changeKey ? [encodeFingerprintField(file.changeKey)] : []), + ...file.snippets.map(fingerprintSnippet), + ].join('|') + ) + .join('||'); +} + +export function getReviewChangeSetIdentityToken( + changeSet: ReviewChangeSetLike | null | undefined +): string | null { + if (!changeSet) { + return null; + } + + const provenance = 'provenance' in changeSet ? changeSet.provenance : undefined; + if (provenance?.sourceFingerprint) { + return `provenance:${provenance.sourceKind}:${provenance.sourceFingerprint}`; + } + + return `content:${fingerprintReviewChangeSet(changeSet)}`; +} + +export function buildReviewDecisionScopeToken(params: { + mode: 'agent' | 'task'; + taskId?: string; + memberName?: string; + requestSignature?: string | null; + changeSet: ReviewChangeSetLike | null | undefined; +}): string | null { + const identity = getReviewChangeSetIdentityToken(params.changeSet); + if (!identity) { + return null; + } + + if (params.mode === 'task') { + return `task:${params.taskId ?? ''}:${params.requestSignature ?? ''}:${identity}`; + } + + return `agent:${params.memberName ?? ''}:${identity}`; +} diff --git a/src/renderer/utils/reviewKey.ts b/src/renderer/utils/reviewKey.ts new file mode 100644 index 00000000..62363273 --- /dev/null +++ b/src/renderer/utils/reviewKey.ts @@ -0,0 +1,104 @@ +import type { FileChangeSummary, HunkDecision } from '@shared/types'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +function normalizeReviewAlias(alias: string): string { + const slashNormalized = alias.replace(/\\/g, '/'); + const relationMatch = /^(rename|copy):(.+)->(.+)$/.exec(slashNormalized); + if (relationMatch) { + return `${relationMatch[1]}:${normalizePathForComparison(relationMatch[2] ?? '')}->${normalizePathForComparison(relationMatch[3] ?? '')}`; + } + const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized); + if (pathKeyMatch) { + return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`; + } + return normalizePathForComparison(alias); +} + +export function getFileReviewKey( + file: Pick +): string { + return file.changeKey ?? file.filePath; +} + +export function getReviewKeyForFilePath( + files: readonly Pick[] | null | undefined, + filePath: string +): string { + const normalizedFilePath = normalizePathForComparison(filePath); + const file = files?.find( + (candidate) => normalizePathForComparison(candidate.filePath) === normalizedFilePath + ); + return file ? getFileReviewKey(file) : filePath; +} + +export function buildHunkDecisionKey(reviewKey: string, index: number): string { + return `${reviewKey}:${index}`; +} + +export function parseHunkDecisionKey(key: string): { reviewKey: string; index: number } | null { + const match = /^(.*):(\d+)$/.exec(key); + if (!match) { + return null; + } + return { + reviewKey: match[1] ?? '', + index: Number.parseInt(match[2] ?? '', 10), + }; +} + +export function normalizePersistedReviewState( + files: readonly Pick[], + state: { + fileDecisions?: Record; + hunkDecisions?: Record; + hunkContextHashesByFile?: Record>; + } +): { + fileDecisions: Record; + hunkDecisions: Record; + hunkContextHashesByFile: Record>; +} { + const reviewKeyByAlias = new Map(); + const addAlias = (alias: string, reviewKey: string): void => { + reviewKeyByAlias.set(alias, reviewKey); + reviewKeyByAlias.set(normalizeReviewAlias(alias), reviewKey); + }; + const resolveReviewKey = (alias: string): string | undefined => { + return reviewKeyByAlias.get(alias) ?? reviewKeyByAlias.get(normalizeReviewAlias(alias)); + }; + for (const file of files) { + const reviewKey = getFileReviewKey(file); + addAlias(reviewKey, reviewKey); + addAlias(file.filePath, reviewKey); + } + + const fileDecisions: Record = {}; + for (const [key, decision] of Object.entries(state.fileDecisions ?? {})) { + const reviewKey = resolveReviewKey(key); + if (reviewKey) { + fileDecisions[reviewKey] = decision; + } + } + + const hunkDecisions: Record = {}; + for (const [key, decision] of Object.entries(state.hunkDecisions ?? {})) { + const parsed = parseHunkDecisionKey(key); + if (!parsed) { + continue; + } + const reviewKey = resolveReviewKey(parsed.reviewKey); + if (reviewKey) { + hunkDecisions[buildHunkDecisionKey(reviewKey, parsed.index)] = decision; + } + } + + const hunkContextHashesByFile: Record> = {}; + for (const [key, hashes] of Object.entries(state.hunkContextHashesByFile ?? {})) { + const reviewKey = resolveReviewKey(key); + if (reviewKey) { + hunkContextHashesByFile[reviewKey] = hashes; + } + } + + return { fileDecisions, hunkDecisions, hunkContextHashesByFile }; +} diff --git a/src/renderer/utils/taskChangePresence.ts b/src/renderer/utils/taskChangePresence.ts new file mode 100644 index 00000000..5cecee7f --- /dev/null +++ b/src/renderer/utils/taskChangePresence.ts @@ -0,0 +1,19 @@ +export { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; + +import type { TaskChangeSetV2 } from '@shared/types'; + +export function shouldBackgroundRevalidateTaskPresence( + data: TaskChangeSetV2, + sessionStartedAtMs: number +): boolean { + if (data.provenance?.sourceKind === 'ledger' && !!data.provenance.sourceFingerprint) { + return false; + } + + const computedAtMs = Date.parse(data.computedAt); + if (!Number.isFinite(computedAtMs)) { + return true; + } + + return computedAtMs < sessionStartedAtMs; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 30ff59bc..01bf5cb4 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -700,7 +700,8 @@ export interface ReviewAPI { // Decision persistence loadDecisions: ( teamName: string, - scopeKey: string + scopeKey: string, + scopeToken?: string ) => Promise<{ hunkDecisions: Record; fileDecisions: Record; @@ -713,11 +714,12 @@ export interface ReviewAPI { saveDecisions: ( teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile?: Record> ) => Promise; - clearDecisions: (teamName: string, scopeKey: string) => Promise; + clearDecisions: (teamName: string, scopeKey: string, scopeToken?: string) => Promise; onCmdN?: (callback: () => void) => (() => void) | undefined; // Phase 4 getGitFileLog: ( diff --git a/src/shared/types/review.ts b/src/shared/types/review.ts index fe7e68e0..abbaac45 100644 --- a/src/shared/types/review.ts +++ b/src/shared/types/review.ts @@ -3,6 +3,9 @@ export interface LedgerContentState { exists?: boolean; sha256?: string; sizeBytes?: number; + contentKind?: 'text' | 'binary' | 'unknown'; + blobRef?: string; + unavailableCode?: 'binary' | 'too-large' | 'read-error' | 'not-captured' | 'blob-missing'; unavailableReason?: string; } @@ -45,9 +48,31 @@ export interface SnippetDiff { afterState?: LedgerContentState; relation?: LedgerChangeRelation; executionSeq?: number; + linesAdded?: number; + linesRemoved?: number; + textAvailability?: 'patch-text' | 'full-text' | 'unavailable'; }; } +export interface TaskChangeJournalFileStamp { + bytes: number; + mtimeMs: number; + tailSha256: string | null; +} + +export interface TaskChangeJournalStamp { + events?: TaskChangeJournalFileStamp; + notices?: TaskChangeJournalFileStamp; +} + +export interface TaskChangeProvenance { + sourceKind: 'ledger' | 'legacy'; + sourceFingerprint: string; + journalStamp?: TaskChangeJournalStamp; + bundleSchemaVersion?: number; + integrity?: 'ok' | 'recovered' | 'partial'; +} + /** Агрегированные изменения по файлу */ export interface FileChangeSummary { filePath: string; @@ -56,6 +81,22 @@ export interface FileChangeSummary { linesAdded: number; linesRemoved: number; isNewFile: boolean; + changeKey?: string; + diffStatKnown?: boolean; + ledgerSummary?: { + latestOperation?: 'create' | 'modify' | 'delete'; + createdInTask?: boolean; + deletedInTask?: boolean; + contentAvailability?: 'full-text' | 'hash-only' | 'metadata-only'; + reviewability?: 'full-text' | 'partial-text' | 'metadata-only'; + relation?: LedgerChangeRelation; + beforeState?: LedgerContentState; + afterState?: LedgerContentState; + primaryActorKey?: string; + agentIds?: string[]; + memberNames?: string[]; + executionSeqRange?: { start: number; end: number }; + }; /** Edit timeline for this file (Phase 4) */ timeline?: FileEditTimeline; } @@ -192,6 +233,34 @@ export interface TaskChangeScope { toolUseIds: string[]; filePaths: string[]; confidence: TaskScopeConfidence; + primaryActorKey?: string; + primaryAgentId?: string; + primaryMemberName?: string; + agentIds?: string[]; + memberNames?: string[]; + toolUseCount?: number; + toolUseIdsTruncated?: boolean; + phaseSet?: Array<'work' | 'review'>; + executionSeqRange?: { start: number; end: number }; + confidenceBreakdown?: { + capture: 'exact' | 'high' | 'medium' | 'low'; + attribution: 'high' | 'medium' | 'low' | 'ambiguous'; + reviewability: 'full-text' | 'mixed' | 'metadata-only'; + }; + contributors?: Array<{ + actorKey: string; + agentId?: string; + memberName?: string; + eventCount: number; + noticeCount: number; + touchedFileCount: number; + visibleFileCount: number; + toolUseCount: number; + cumulativeLinesAdded: number; + cumulativeLinesRemoved: number; + firstTimestamp: string; + lastTimestamp: string; + }>; } /** Результат парсинга всех границ задач из JSONL файла */ @@ -206,6 +275,8 @@ export interface TaskBoundariesResult { export interface TaskChangeSetV2 extends TaskChangeSet { scope: TaskChangeScope; warnings: string[]; + diffStatCompleteness?: 'complete' | 'partial'; + provenance?: TaskChangeProvenance; } // ── Phase 4: Enhanced Features types ── diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9eb2ee92..bbbe2ac1 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -450,7 +450,11 @@ export interface TeamTask { } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ -export type TaskChangePresenceState = 'has_changes' | '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). */ diff --git a/src/shared/utils/taskChangePresence.ts b/src/shared/utils/taskChangePresence.ts new file mode 100644 index 00000000..e5fd79f0 --- /dev/null +++ b/src/shared/utils/taskChangePresence.ts @@ -0,0 +1,15 @@ +import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types'; + +export function resolveTaskChangePresenceFromResult( + data: Pick +): Exclude | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + if ((data.warnings?.length ?? 0) > 0) { + return 'needs_attention'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} diff --git a/test/fixtures/team/task-change-ledger/binary/manifest.json b/test/fixtures/team/task-change-ledger/binary/manifest.json new file mode 100644 index 00000000..acee5960 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "binary", + "taskId": "fixture-binary", + "description": "Metadata-only binary fixture generated from a shell snapshot mutation.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [ + "Before content unavailable for fixtures/blob.bin: binary file." + ], + "relativePaths": [ + "fixtures/blob.bin" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json b/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json new file mode 100644 index 00000000..26ff43d9 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-binary","updatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d new file mode 100644 index 00000000..5bd8bb89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json new file mode 100644 index 00000000..075b5ded --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-binary","generatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.852Z","endTimestamp":"2026-04-21T13:29:00.852Z","toolUseIds":["tool-binary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"mixed"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/fixtures/blob.bin","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","linesAdded":0,"linesRemoved":0,"diffStatKnown":false,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","latestBeforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"latestAfterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"contentAvailability":"hash-only","reviewability":"partial-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1},"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"partial","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl new file mode 100644 index 00000000..63d0164f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-binary","taskRef":"fixture-binary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-jtt7uons2f7","agentId":"alice@test","memberName":"alice","toolUseId":"tool-binary","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","timestamp":"2026-04-21T13:29:00.852Z","toolStatus":"succeeded","before":null,"after":{"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4,"blobRef":"sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d"},"beforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"afterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"linesAdded":1,"linesRemoved":0,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."],"eventId":"6395549e93913abdd0c26120b21a911902c2fa85f5aa84b50f91ca6a2949444a"} diff --git a/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin b/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin new file mode 100644 index 00000000..5bd8bb89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/manifest.json b/test/fixtures/team/task-change-ledger/copy/manifest.json new file mode 100644 index 00000000..d2ac02fb --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "copy", + "taskId": "fixture-copy", + "description": "Copy relation fixture generated from a committed copy detected via git diff.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/copy.ts" + ], + "relationKinds": [ + "copy" + ] + } +} diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json b/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json new file mode 100644 index 00000000..b88f2ffa --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-copy","updatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json new file mode 100644 index 00000000..d1c10e7c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-copy","generatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.535Z","endTimestamp":"2026-04-21T13:29:00.535Z","toolUseIds":["tool-copy"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z"}]},"files":[{"changeKey":"copy:src/base.ts->src/copy.ts","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","displayPath":"src/copy.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl new file mode 100644 index 00000000..6797688d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-copy","taskRef":"fixture-copy","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-o2uv73v5zw","agentId":"alice@test","memberName":"alice","toolUseId":"tool-copy","source":"shell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","timestamp":"2026-04-21T13:29:00.535Z","toolStatus":"succeeded","before":null,"after":{"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28,"blobRef":"sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"20f28b7e49e9019165c3e3b57d0104b6b2107447f44df8ba88c3033d6520cd93"} diff --git a/test/fixtures/team/task-change-ledger/copy/project/src/base.ts b/test/fixtures/team/task-change-ledger/copy/project/src/base.ts new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/src/base.ts @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts b/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json b/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json new file mode 100644 index 00000000..f7e9e212 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "generation-mismatch", + "taskId": "fixture-generation-mismatch", + "description": "Fixture with intentionally mismatched freshness metadata to force journal fallback.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/mismatch.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json new file mode 100644 index 00000000..102846de --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 2, + "source": "task-change-ledger", + "taskId": "fixture-generation-mismatch", + "updatedAt": "2026-04-21T13:29:01.353Z", + "journalStamp": { + "events": { + "bytes": 1123, + "mtimeMs": 1776778141346.9556, + "tailSha256": "mismatched-tail-sha256" + } + }, + "eventCount": 1, + "noticeCount": 0, + "integrity": "ok", + "bundleSchemaVersion": 2, + "bundleKind": "summary" +} diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 new file mode 100644 index 00000000..e729cf1d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 @@ -0,0 +1 @@ +export const mismatch = 1; diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json new file mode 100644 index 00000000..9b656a4c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-generation-mismatch","generatedAt":"2026-04-21T13:29:01.353Z","journalStamp":{"events":{"bytes":1123,"mtimeMs":1776778141346.9556,"tailSha256":"80e3ed6cc6bfad47ed0862a64b2aeeb13d78959cc727bbcccc2f0b8d77c5b0d3"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.341Z","endTimestamp":"2026-04-21T13:29:01.341Z","toolUseIds":["tool-generation-mismatch"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/mismatch.ts","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl new file mode 100644 index 00000000..76edf49f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-generation-mismatch","taskRef":"fixture-generation-mismatch","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-blrpcg3sdoq","agentId":"alice@test","memberName":"alice","toolUseId":"tool-generation-mismatch","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","timestamp":"2026-04-21T13:29:01.341Z","toolStatus":"succeeded","before":null,"after":{"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27,"blobRef":"sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"linesAdded":1,"linesRemoved":0,"eventId":"7bd0e6e9290680d76da0498ff931f64285a2f3acfc293ccf0f5756693943c897"} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/manifest.json b/test/fixtures/team/task-change-ledger/missing-blob/manifest.json new file mode 100644 index 00000000..60c8b2b1 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "missing-blob", + "taskId": "fixture-missing-blob", + "description": "Fixture with a missing pre-change text blob to validate metadata-only downgrade paths.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/missing.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json new file mode 100644 index 00000000..1ac27afb --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-missing-blob","updatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 new file mode 100644 index 00000000..a8edd625 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 @@ -0,0 +1 @@ +export const missing = 2; diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json new file mode 100644 index 00000000..80361842 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-missing-blob","generatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.162Z","endTimestamp":"2026-04-21T13:29:01.162Z","toolUseIds":["tool-missing-blob"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/src/missing.ts","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","latestAfterHash":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","latestBeforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"latestAfterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl new file mode 100644 index 00000000..6df88a29 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-missing-blob","taskRef":"fixture-missing-blob","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-koi1xm1bwwb","agentId":"alice@test","memberName":"alice","toolUseId":"tool-missing-blob","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","timestamp":"2026-04-21T13:29:01.162Z","toolStatus":"succeeded","before":{"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26,"blobRef":"sha256/65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76"},"after":{"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26,"blobRef":"sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74"},"beforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"afterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"linesAdded":1,"linesRemoved":1,"eventId":"010b63907849617acbf3138427d82382e19dcdfd1cfe03f711d27a88ed6ccbab"} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts b/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts new file mode 100644 index 00000000..a8edd625 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts @@ -0,0 +1 @@ +export const missing = 2; diff --git a/test/fixtures/team/task-change-ledger/notices-only/manifest.json b/test/fixtures/team/task-change-ledger/notices-only/manifest.json new file mode 100644 index 00000000..8df7e37b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "notices-only", + "taskId": "fixture-notices-only", + "description": "Warning-only ledger fixture with ambiguous multi-scope attribution and no file events.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 0, + "warnings": [ + "Task change ledger skipped attribution because multiple task scopes were active." + ], + "relativePaths": [], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json new file mode 100644 index 00000000..e59ec5b9 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only-other","updatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json new file mode 100644 index 00000000..e1a2a7f5 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only","updatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json new file mode 100644 index 00000000..ac0c0d4b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only-other","generatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json new file mode 100644 index 00000000..e75f2418 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only","generatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl new file mode 100644 index 00000000..6cbdfe9c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-notices-only-other","taskRef":"fixture-notices-only-other","taskRefKind":"canonical","phase":"review","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"56b59b7d7ade431d01e079f65a23ebe31fa0e8a2472976edf7a85629a2640526"} diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl new file mode 100644 index 00000000..693506b1 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-notices-only","taskRef":"fixture-notices-only","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"8c1d4d4642d7a26f4e7d9959d6908c2d6fe14d1ad49df10dccc7f3b4ebdf95e5"} diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json b/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json new file mode 100644 index 00000000..259833a4 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "recovered-journal", + "taskId": "fixture-recovered-journal", + "description": "Recovered journal fixture generated after replaying malformed journal lines.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [ + "Task change ledger recovered from malformed journal lines." + ], + "relativePaths": [ + "src/ok.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json new file mode 100644 index 00000000..1bc15d60 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-recovered-journal","updatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"eventCount":1,"noticeCount":0,"integrity":"recovered","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 new file mode 100644 index 00000000..3d6576ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 @@ -0,0 +1 @@ +export const ok = true; diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 new file mode 100644 index 00000000..a646fb9d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 @@ -0,0 +1 @@ +export const recovered = true; diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json new file mode 100644 index 00000000..e2daceda --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-recovered-journal","generatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"integrity":"recovered","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger with recovery from malformed journal lines"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.263Z","endTimestamp":"2026-04-21T13:29:01.263Z","toolUseIds":["tool-recovered-initial"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/ok.ts","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Task change ledger recovered from malformed journal lines."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl new file mode 100644 index 00000000..8e8f5706 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl @@ -0,0 +1,3 @@ +{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-initial","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","timestamp":"2026-04-21T13:29:01.263Z","toolStatus":"succeeded","before":null,"after":{"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24,"blobRef":"sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"linesAdded":1,"linesRemoved":0,"eventId":"e7b3eca1462d1e2496e50ce01273857230234ae89fbd4177b58ed7dbe7b8c3a5"} + +{"bad-json"{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-trigger","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/recovered.ts","relativePath":"src/recovered.ts","timestamp":"2026-04-21T13:29:01.273Z","toolStatus":"succeeded","before":null,"after":{"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31,"blobRef":"sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31},"linesAdded":1,"linesRemoved":0,"eventId":"e0410d756f74335ccfa9601554096d676c6f72f88e31cf038e2f73e046633cc6"} diff --git a/test/fixtures/team/task-change-ledger/rename/manifest.json b/test/fixtures/team/task-change-ledger/rename/manifest.json new file mode 100644 index 00000000..87275990 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "rename", + "taskId": "fixture-rename", + "description": "Rename relation fixture generated from a committed rename shell snapshot.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/new.ts" + ], + "relationKinds": [ + "rename" + ] + } +} diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json b/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json new file mode 100644 index 00000000..3a91f1af --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-rename","updatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 new file mode 100644 index 00000000..4009f0ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 @@ -0,0 +1 @@ +export const renamed = true; diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json new file mode 100644 index 00000000..4a7317ae --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-rename","generatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"integrity":"ok","eventCount":2,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.967Z","endTimestamp":"2026-04-21T13:28:59.967Z","toolUseIds":["tool-rename"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":2,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":2,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z"}]},"files":[{"changeKey":"rename:src/old.ts->src/new.ts","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","displayPath":"src/new.ts","linesAdded":0,"linesRemoved":0,"diffStatKnown":true,"eventCount":2,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z","latestOperation":"delete","createdInTask":false,"deletedInTask":false,"baselineExists":false,"finalExists":false,"latestBeforeHash":null,"latestAfterHash":null,"latestBeforeState":{"exists":false},"latestAfterState":{"exists":false},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl new file mode 100644 index 00000000..96c43826 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl @@ -0,0 +1,2 @@ +{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":null,"after":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"83c57985d61c25bc69b72df159a8f89688dd05731c93f6df854310ab8fffa171"} +{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"delete","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/old.ts","relativePath":"src/old.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"after":null,"beforeState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"afterState":{"exists":false},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":0,"linesRemoved":1,"eventId":"417d115a2d185ba5c288253beb73d4697f59021e5bb8c5f6553b2c68c4d7c6e7"} diff --git a/test/fixtures/team/task-change-ledger/rename/project/src/new.ts b/test/fixtures/team/task-change-ledger/rename/project/src/new.ts new file mode 100644 index 00000000..4009f0ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/src/new.ts @@ -0,0 +1 @@ +export const renamed = true; diff --git a/test/fixtures/team/task-change-ledger/v2-summary/manifest.json b/test/fixtures/team/task-change-ledger/v2-summary/manifest.json new file mode 100644 index 00000000..f3f9366b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "v2-summary", + "taskId": "fixture-v2-summary", + "description": "Simple bundle v2 summary fixture generated from an exact file write.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/summary.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json new file mode 100644 index 00000000..10ab20b5 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-v2-summary","updatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf new file mode 100644 index 00000000..347ce2ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf @@ -0,0 +1 @@ +export const summary = 1; diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json new file mode 100644 index 00000000..f1ada803 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-v2-summary","generatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.479Z","endTimestamp":"2026-04-21T13:28:59.479Z","toolUseIds":["tool-summary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/summary.ts","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl new file mode 100644 index 00000000..c65e978f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-v2-summary","taskRef":"fixture-v2-summary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-yas25omycm8","agentId":"alice@test","memberName":"alice","toolUseId":"tool-summary","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","timestamp":"2026-04-21T13:28:59.479Z","toolStatus":"succeeded","before":null,"after":{"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26,"blobRef":"sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"linesAdded":1,"linesRemoved":0,"eventId":"2192b24e939f42bd7936f4fbe620d66c76c88208cdf64ef3c5a3f501939f3839"} diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index f7045c35..31a8ffe5 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -732,6 +732,52 @@ describe('ChangeExtractorService', () => { ); }); + it('writes needs_attention presence entries for warning-only task diff results', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => + makeTaskChangeResult(TASK_ID, { + content: '', + confidence: 'fallback', + warning: 'Ledger skipped attribution because multiple task scopes were active.', + }) + ), + }; + const { service } = createService({ + logPaths: [], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(0); + expect(result.warnings).toEqual([ + 'Ledger skipped attribution because multiple task scopes were active.', + ]); + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + }), + expect.objectContaining({ + taskId: TASK_ID, + presence: 'needs_attention', + }) + ); + }); + it('does not write no_changes presence entries for uncertain empty task diff results', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/FileContentResolver.test.ts b/test/main/services/team/FileContentResolver.test.ts index 78d2d580..57890697 100644 --- a/test/main/services/team/FileContentResolver.test.ts +++ b/test/main/services/team/FileContentResolver.test.ts @@ -126,6 +126,45 @@ describe('FileContentResolver', () => { expect(content.contentSource).toBe('ledger-snapshot'); }); + it('does not synthesize empty text for metadata-only ledger lifecycle states', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + readFile.mockRejectedValue(new Error('ENOENT')); + + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn().mockResolvedValue([]) } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/binary-create.bin', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/binary-create.bin', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: null, + beforeHash: null, + afterHash: 'hash', + operation: 'create', + beforeState: { exists: false, unavailableReason: 'binary file' }, + afterState: { exists: true, sha256: 'hash', unavailableReason: 'binary file' }, + }, + }, + ]); + + expect(content.originalFullContent).toBeNull(); + expect(content.modifiedFullContent).toBeNull(); + expect(content.contentSource).toBe('unavailable'); + }); + it('reuses cached content only when disk bytes and snippets are unchanged', async () => { const fsPromises = await import('fs/promises'); const readFile = fsPromises.readFile as unknown as ReturnType; diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index c94fae64..1b41d5f4 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -581,6 +581,77 @@ describe('ReviewApplierService', () => { expect(writeFile).not.toHaveBeenCalled(); expect(unlink).not.toHaveBeenCalled(); }); + + it('ledger exact partial reject stays in the strict ledger lane and applies inverse hunk patch', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + const filePath = '/tmp/exact-ledger.ts'; + const original = 'const value = 1;\nconst keep = true;\n'; + const modified = 'const value = 2;\nconst keep = true;\n'; + readFile.mockResolvedValue(modified); + writeFile.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'pending', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'exact-ledger.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Edit', + type: 'edit', + oldString: 'const value = 1;\n', + newString: 'const value = 2;\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-exact', + confidence: 'exact', + originalFullContent: original, + modifiedFullContent: modified, + beforeHash: sha(original), + afterHash: sha(modified), + operation: 'modify', + beforeState: { exists: true, sha256: sha(original) }, + afterState: { exists: true, sha256: sha(modified) }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: original, + modifiedFullContent: modified, + contentSource: 'ledger-exact', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8'); + }); }); function sha(content: string): string { diff --git a/test/main/services/team/ReviewDecisionStore.test.ts b/test/main/services/team/ReviewDecisionStore.test.ts new file mode 100644 index 00000000..4a21e50e --- /dev/null +++ b/test/main/services/team/ReviewDecisionStore.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let teamsBasePath: string; + +vi.mock('@main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => teamsBasePath, +})); + +describe('ReviewDecisionStore', () => { + beforeEach(async () => { + teamsBasePath = await mkdtemp(path.join(tmpdir(), 'review-decision-store-')); + }); + + afterEach(async () => { + await rm(teamsBasePath, { recursive: true, force: true }); + }); + + it('stores exact-scope decision variants without last-write-wins overwrite', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + }); + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:b:src:two', + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + }); + + await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toEqual({ + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + hunkContextHashesByFile: undefined, + }); + await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({ + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('clears only the exact v2 scope file and leaves sibling variants intact', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + }); + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:b:src:two', + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + }); + + await store.clear('demo', 'task-123', 'task:123:req:a:src:one'); + + await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toBeNull(); + await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({ + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('still dual-reads legacy coarse files for matching scope tokens', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + const legacyDir = path.join(teamsBasePath, 'demo', 'review-decisions'); + await mkdir(legacyDir, { recursive: true }); + await writeFile( + path.join(legacyDir, 'task-123.json'), + JSON.stringify({ + scopeToken: 'task:123:req:legacy:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + updatedAt: '2026-04-21T10:00:00.000Z', + }), + 'utf8' + ); + + await expect( + store.load('demo', 'task-123', 'task:123:req:legacy:src:one') + ).resolves.toEqual({ + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('writes versioned v2 payloads under the scoped directory', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: {}, + fileDecisions: {}, + }); + + const scopeDir = path.join( + teamsBasePath, + 'demo', + 'review-decisions', + 'v2', + encodeURIComponent('task-123') + ); + const entries = await fsEntries(scopeDir); + expect(entries).toHaveLength(1); + + const payload = JSON.parse(await readFile(path.join(scopeDir, entries[0]!), 'utf8')) as { + version?: number; + scopeKey?: string; + scopeToken?: string; + }; + expect(payload.version).toBe(2); + expect(payload.scopeKey).toBe('task-123'); + expect(payload.scopeToken).toBe('task:123:req:a:src:one'); + }); +}); + +async function fsEntries(dirPath: string): Promise { + try { + return await (await import('fs/promises')).readdir(dirPath); + } catch { + return []; + } +} diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 5879b345..4f5f4cb9 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -8,6 +9,10 @@ import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerRead const TASK_ID = 'task-1'; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + describe('TaskChangeLedgerReader', () => { let tmpDir: string | null = null; @@ -56,6 +61,41 @@ describe('TaskChangeLedgerReader', () => { expect(result?.scope.toolUseIds).toEqual(['tool-1']); }); + it('reads ledger artifacts stored under Windows-safe task id segments', async () => { + tmpDir = await fsTempDir(); + const taskId = 'CON'; + const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: 0, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'high', + warnings: ['reserved segment safe path'], + events: [], + }), + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId, + projectDir: tmpDir, + includeDetails: true, + }); + + expect(result?.warnings).toContain('reserved segment safe path'); + }); + it('maps ledger state and rename relation into snippets', async () => { tmpDir = await makeLedgerBundle({ events: [ @@ -178,6 +218,132 @@ describe('TaskChangeLedgerReader', () => { expect(result?.files[0]?.linesAdded).toBe(3); expect(result?.files[0]?.linesRemoved).toBe(2); }); + + it('falls back to journal summary when bundle and freshness describe different generations', async () => { + tmpDir = await fsTempDir(); + const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); + const eventsDir = path.join(tmpDir, '.board-task-changes', 'events'); + const freshnessDir = path.join(tmpDir, '.board-task-change-freshness'); + await mkdir(bundleDir, { recursive: true }); + await mkdir(eventsDir, { recursive: true }); + await mkdir(freshnessDir, { recursive: true }); + + await writeFile( + path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 2, + source: 'task-change-ledger', + bundleKind: 'summary', + taskId: TASK_ID, + generatedAt: '2026-03-01T10:00:00.000Z', + journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'bundle' } }, + integrity: 'ok', + eventCount: 1, + noticeCount: 0, + scope: { + confidence: { tier: 1, label: 'high', reason: 'bundle' }, + memberName: 'bundle-agent', + agentIds: ['bundle-agent'], + startTimestamp: '2026-03-01T10:00:00.000Z', + endTimestamp: '2026-03-01T10:00:00.000Z', + toolUseIds: ['bundle-tool'], + toolUseCount: 1, + phaseSet: ['work'], + visibleFileCount: 1, + contributors: [], + }, + files: [ + { + changeKey: 'path:/repo/stale.ts', + filePath: '/repo/stale.ts', + relativePath: 'stale.ts', + linesAdded: 1, + linesRemoved: 0, + diffStatKnown: true, + eventCount: 1, + firstTimestamp: '2026-03-01T10:00:00.000Z', + lastTimestamp: '2026-03-01T10:00:00.000Z', + latestOperation: 'modify', + createdInTask: false, + deletedInTask: false, + latestBeforeHash: null, + latestAfterHash: null, + contentAvailability: 'metadata-only', + reviewability: 'metadata-only', + agentIds: ['bundle-agent'], + }, + ], + totalLinesAdded: 1, + totalLinesRemoved: 0, + diffStatCompleteness: 'complete', + totalFiles: 1, + confidence: 'high', + warningCount: 0, + warnings: [], + }), + 'utf8' + ); + + await writeFile( + path.join(freshnessDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 2, + source: 'task-change-ledger', + taskId: TASK_ID, + updatedAt: '2026-03-01T10:00:01.000Z', + journalStamp: { events: { bytes: 20, mtimeMs: 2, tailSha256: 'freshness' } }, + eventCount: 1, + noticeCount: 0, + integrity: 'ok', + bundleSchemaVersion: 2, + bundleKind: 'summary', + }), + 'utf8' + ); + + await writeFile( + path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`), + `${JSON.stringify({ + schemaVersion: 1, + eventId: 'event-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'journal-tool', + source: 'file_edit', + operation: 'modify', + confidence: 'exact', + workspaceRoot: '/repo', + filePath: '/repo/journal.ts', + relativePath: 'journal.ts', + timestamp: '2026-03-01T10:00:02.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + oldString: 'const a = 1;\n', + newString: 'const a = 2;\n', + linesAdded: 1, + linesRemoved: 1, + })}\n`, + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.files[0]?.filePath).toBe('/repo/journal.ts'); + expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.'); + }); }); async function makeLedgerBundle(params: { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 79ae0f0a..f3e49ace 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2656,6 +2656,82 @@ describe('TeamDataService', () => { expect(getMessages).not.toHaveBeenCalled(); }); + it('propagates persisted needs_attention presence through lightweight presence reads', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 2, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTaskChangePresence('my-team'); + + expect(data).toEqual({ 'task-1': 'needs_attention' }); + }); + it('persists standalone slash metadata when sending directly to the live lead', async () => { const appendSentMessage = vi.fn((payload) => payload); const service = new TeamDataService( diff --git a/test/main/services/team/TeamLogSourceTracker.test.ts b/test/main/services/team/TeamLogSourceTracker.test.ts index 6008cf55..47d3e2db 100644 --- a/test/main/services/team/TeamLogSourceTracker.test.ts +++ b/test/main/services/team/TeamLogSourceTracker.test.ts @@ -1,13 +1,21 @@ +import { createHash } from 'crypto'; import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker'; +import { + shouldIgnoreLogSourceWatcherPath, + TeamLogSourceTracker, +} from '../../../../src/main/services/team/TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; import type { TeamChangeEvent } from '../../../../src/shared/types'; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + describe('TeamLogSourceTracker', () => { let tempDir: string | null = null; @@ -150,4 +158,68 @@ describe('TeamLogSourceTracker', () => { await tracker.disableTracking('demo', 'stall_monitor'); }); + + it('emits the task id from Windows-safe hashed task-change freshness files', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-')); + + const logsFinder = { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'change_presence'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const taskId = 'CON'; + const signalDir = path.join(tempDir, '.board-task-change-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + }); + }); + expect(emitter.mock.calls).not.toContainEqual([ + expect.objectContaining({ type: 'task-log-change', taskId: safeTaskIdSegment(taskId) }), + ]); + + await tracker.disableTracking('demo', 'change_presence'); + }); + + it('ignores internal ledger artifact paths but keeps freshness signals visible', () => { + const projectDir = '/tmp/demo-project'; + + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-changes', 'events', 'task.jsonl') + ) + ).toBe(true); + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-changes', 'locks', 'task.lock', 'owner.json') + ) + ).toBe(true); + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-change-freshness', 'task.json') + ) + ).toBe(false); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts index 66bf671d..3b1d3969 100644 --- a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -7,6 +8,10 @@ import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/tea const tempDirs: string[] = []; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + afterEach(async () => { await Promise.all( tempDirs.splice(0).map(async (dirPath) => { @@ -54,4 +59,28 @@ describe('TeamTaskLogFreshnessReader', () => { transcriptFileBasename: 'session-a.jsonl', }); }); + + it('reads Windows-safe hashed freshness files for reserved task ids', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-')); + tempDirs.push(projectDir); + const signalDir = path.join(projectDir, '.board-task-log-freshness'); + await fs.mkdir(signalDir, { recursive: true }); + + await fs.writeFile( + path.join(signalDir, `${safeTaskIdSegment('CON')}.json`), + JSON.stringify({ + taskId: 'CON', + updatedAt: '2026-04-19T12:00:00.000Z', + transcriptFile: 'session-con.jsonl', + }), + 'utf8' + ); + + const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, ['CON']); + + expect(signals.get('CON')?.filePath).toBe( + path.join(signalDir, `${safeTaskIdSegment('CON')}.json`) + ); + expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl'); + }); }); diff --git a/test/main/services/team/taskChangeLedgerFixtureUtils.ts b/test/main/services/team/taskChangeLedgerFixtureUtils.ts new file mode 100644 index 00000000..ed87c88e --- /dev/null +++ b/test/main/services/team/taskChangeLedgerFixtureUtils.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +const DEFAULT_PROJECT_ROOT_TOKEN = '__PROJECT_ROOT__'; +const FIXTURE_ROOT = path.join(process.cwd(), 'test', 'fixtures', 'team', 'task-change-ledger'); + +export type TaskChangeLedgerFixtureManifest = { + schemaVersion: number; + name: string; + taskId: string; + description: string; + projectRootToken?: string; + expected?: { + totalFiles?: number; + warnings?: string[]; + relativePaths?: string[]; + relationKinds?: Array<'rename' | 'copy'>; + }; +}; + +export type MaterializedTaskChangeLedgerFixture = { + rootDir: string; + projectDir: string; + manifest: TaskChangeLedgerFixtureManifest; + cleanup: () => Promise; +}; + +function replaceTokenInValue(value: T, token: string, replacement: string): T { + if (typeof value === 'string') { + return value.split(token).join(replacement) as T; + } + if (Array.isArray(value)) { + return value.map((item) => replaceTokenInValue(item, token, replacement)) as T; + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + replaceTokenInValue(item, token, replacement), + ]) + ) as T; + } + return value; +} + +async function rewriteProjectRootTokens(rootDir: string, token: string, projectDir: string): Promise { + const jsonStringReplacement = JSON.stringify(projectDir).slice(1, -1); + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + await rewriteProjectRootTokens(entryPath, token, projectDir); + continue; + } + if (!['.json', '.jsonl'].includes(path.extname(entry.name))) { + continue; + } + const raw = await fs.readFile(entryPath, 'utf8'); + await fs.writeFile(entryPath, raw.split(token).join(jsonStringReplacement), 'utf8'); + } +} + +export async function materializeTaskChangeLedgerFixture( + fixtureName: string +): Promise { + const sourceDir = path.join(FIXTURE_ROOT, fixtureName); + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), `task-change-ledger-${fixtureName}-`)); + await fs.cp(sourceDir, rootDir, { recursive: true }); + + const manifestPath = path.join(rootDir, 'manifest.json'); + const manifest = JSON.parse( + await fs.readFile(manifestPath, 'utf8') + ) as TaskChangeLedgerFixtureManifest; + const projectDir = path.join(rootDir, 'project'); + const token = manifest.projectRootToken ?? DEFAULT_PROJECT_ROOT_TOKEN; + + await rewriteProjectRootTokens(rootDir, token, projectDir); + + return { + rootDir, + projectDir, + manifest: replaceTokenInValue(manifest, token, projectDir), + cleanup: async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }, + }; +} diff --git a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts new file mode 100644 index 00000000..0d24cf1e --- /dev/null +++ b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts @@ -0,0 +1,421 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; +import { FileContentResolver } from '@main/services/team/FileContentResolver'; +import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; +import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader'; +import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; + +import { materializeTaskChangeLedgerFixture } from './taskChangeLedgerFixtureUtils'; + +const TEAM_NAME = 'team-a'; +const SUMMARY_OPTIONS = { + owner: 'alice', + status: 'completed', + stateBucket: 'completed' as const, + summaryOnly: true, +}; + +async function writeTaskFile(baseDir: string, taskId: string, projectPath: string): Promise { + const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${taskId}.json`); + await fs.mkdir(path.dirname(taskPath), { recursive: true }); + await fs.writeFile( + taskPath, + JSON.stringify( + { + id: taskId, + owner: 'alice', + status: 'completed', + createdAt: '2026-03-01T09:55:00.000Z', + updatedAt: '2026-03-01T10:10:00.000Z', + projectPath, + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + historyEvents: [], + }, + null, + 2 + ), + 'utf8' + ); +} + +function createLedgerBackedChangeExtractorService(params: { + projectDir: string; + taskChangePresenceRepository?: { upsertEntry: ReturnType }; + teamLogSourceTracker?: { + ensureTracking: ReturnType< + typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> + >; + }; +}) { + const findLogFileRefsForTask = vi.fn(async () => { + throw new Error('fallback log reconstruction should not run for ledger fixtures'); + }); + const computeTaskChanges = vi.fn(async () => { + throw new Error('worker path should not run for ledger fixtures'); + }); + const service = new ChangeExtractorService( + { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: params.projectDir, + projectPath: params.projectDir, + })), + findLogFileRefsForTask, + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => { + throw new Error('inline parser should not run for ledger fixtures'); + }), + } as any, + { getConfig: vi.fn(async () => ({ projectPath: params.projectDir })) } as any, + undefined, + { + isAvailable: vi.fn(() => true), + computeTaskChanges, + } as any + ); + + if (params.taskChangePresenceRepository && params.teamLogSourceTracker) { + service.setTaskChangePresenceServices( + params.taskChangePresenceRepository as any, + params.teamLogSourceTracker as any + ); + } + + return { service, findLogFileRefsForTask, computeTaskChanges }; +} + +describe('task change ledger golden fixtures', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + setClaudeBasePathOverride(null); + vi.restoreAllMocks(); + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } + }); + + it('reads rename and copy fixtures as grouped ledger changes', async () => { + const renameFixture = await materializeTaskChangeLedgerFixture('rename'); + const copyFixture = await materializeTaskChangeLedgerFixture('copy'); + cleanups.push(renameFixture.cleanup, copyFixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const rename = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: renameFixture.manifest.taskId, + projectDir: renameFixture.projectDir, + projectPath: renameFixture.projectDir, + includeDetails: false, + }); + const copy = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: copyFixture.manifest.taskId, + projectDir: copyFixture.projectDir, + projectPath: copyFixture.projectDir, + includeDetails: false, + }); + + expect(rename?.files).toHaveLength(1); + expect(rename?.files[0]?.changeKey).toBe('rename:src/old.ts->src/new.ts'); + expect(rename?.files[0]?.filePath).toBe(path.join(renameFixture.projectDir, 'src', 'new.ts')); + expect(rename?.files[0]?.ledgerSummary?.relation).toEqual({ + kind: 'rename', + oldPath: 'src/old.ts', + newPath: 'src/new.ts', + }); + + expect(copy?.files).toHaveLength(1); + expect(copy?.files[0]?.changeKey).toBe('copy:src/base.ts->src/copy.ts'); + expect(copy?.files[0]?.isNewFile).toBe(true); + expect(copy?.files[0]?.filePath).toBe(path.join(copyFixture.projectDir, 'src', 'copy.ts')); + expect(copy?.files[0]?.ledgerSummary?.relation).toEqual({ + kind: 'copy', + oldPath: 'src/base.ts', + newPath: 'src/copy.ts', + }); + }); + + it('returns warning-only notice fixtures without synthesizing fake file changes', async () => { + const fixture = await materializeTaskChangeLedgerFixture('notices-only'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + + expect(result).not.toBeNull(); + expect(result?.files).toEqual([]); + expect(result?.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + }); + + it('falls back when bundle freshness is intentionally mismatched', async () => { + const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + }); + + it('uses journal tail hash, not only size and mtime, when freshness is missing', async () => { + const fixture = await materializeTaskChangeLedgerFixture('v2-summary'); + cleanups.push(fixture.cleanup); + const taskId = fixture.manifest.taskId; + const eventPath = path.join( + fixture.projectDir, + '.board-task-changes', + 'events', + `${encodeURIComponent(taskId)}.jsonl` + ); + const freshnessSignalPath = path.join( + fixture.projectDir, + '.board-task-change-freshness', + `${encodeURIComponent(taskId)}.json` + ); + const originalStat = await fs.stat(eventPath); + const raw = await fs.readFile(eventPath, 'utf8'); + const mutated = raw.replace( + /"eventId":"([0-9a-f])([0-9a-f]+)"/, + (_match, first: string, rest: string) => + `"eventId":"${first === 'a' ? 'b' : 'a'}${rest}"` + ); + expect(mutated).not.toBe(raw); + expect(mutated.length).toBe(raw.length); + await fs.writeFile(eventPath, mutated, 'utf8'); + await fs.utimes(eventPath, originalStat.atime, originalStat.mtime); + await fs.unlink(freshnessSignalPath); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + }); + + it('surfaces recovered-journal warnings from real recovered artifacts', async () => { + const fixture = await materializeTaskChangeLedgerFixture('recovered-journal'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain('Task change ledger recovered from malformed journal lines.'); + }); + + it('keeps missing-blob fixture unavailable instead of synthesizing empty text', async () => { + const fixture = await materializeTaskChangeLedgerFixture('missing-blob'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + expect(resolved.originalFullContent).toBeNull(); + expect(resolved.modifiedFullContent).toBe('export const missing = 2;\n'); + expect(resolved.contentSource).toBe('ledger-snapshot'); + }); + + it('rejects grouped copy fixtures by deleting only the copied path', async () => { + const fixture = await materializeTaskChangeLedgerFixture('copy'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + const service = new ReviewApplierService(); + const result = await service.applyReviewDecisions( + { + teamName: TEAM_NAME, + decisions: [ + { + filePath: file!.filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + file!.filePath, + { + ...file!, + ...resolved, + }, + ], + ]) + ); + + expect(result).toMatchObject({ applied: 1, conflicts: 0 }); + await expect(fs.stat(path.join(fixture.projectDir, 'src', 'copy.ts'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(fs.readFile(path.join(fixture.projectDir, 'src', 'base.ts'), 'utf8')).resolves.toBe( + 'export const copied = true;\n' + ); + }); + + it('requires manual review when a fixture is missing original ledger text', async () => { + const fixture = await materializeTaskChangeLedgerFixture('missing-blob'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + const service = new ReviewApplierService(); + const result = await service.applyReviewDecisions( + { + teamName: TEAM_NAME, + decisions: [ + { + filePath: file!.filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + file!.filePath, + { + ...file!, + ...resolved, + }, + ], + ]) + ); + + expect(result.applied).toBe(0); + expect(result.errors[0]?.code).toBe('manual-review-required'); + }); + + it('uses ledger fixtures as the primary source in ChangeExtractorService', async () => { + const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch'); + cleanups.push(fixture.cleanup); + const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-ledger-')); + cleanups.push(async () => { + await fs.rm(claudeBaseDir, { recursive: true, force: true }); + }); + setClaudeBasePathOverride(claudeBaseDir); + await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir); + + const { service, findLogFileRefsForTask, computeTaskChanges } = + createLedgerBackedChangeExtractorService({ + projectDir: fixture.projectDir, + }); + + const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(1); + expect(result.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + expect(computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('records needs_attention presence from warning-only ledger fixtures', async () => { + const fixture = await materializeTaskChangeLedgerFixture('notices-only'); + cleanups.push(fixture.cleanup); + const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-')); + cleanups.push(async () => { + await fs.rm(claudeBaseDir, { recursive: true, force: true }); + }); + setClaudeBasePathOverride(claudeBaseDir); + await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'fixture-project-fingerprint', + logSourceGeneration: 'fixture-log-generation', + })); + const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({ + projectDir: fixture.projectDir, + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS); + + expect(result.files).toEqual([]); + expect(result.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'fixture-project-fingerprint', + logSourceGeneration: 'fixture-log-generation', + }), + expect.objectContaining({ + taskId: fixture.manifest.taskId, + presence: 'needs_attention', + }) + ); + }); +}); diff --git a/test/main/services/team/taskChangePresenceCacheSchema.test.ts b/test/main/services/team/taskChangePresenceCacheSchema.test.ts new file mode 100644 index 00000000..817e977c --- /dev/null +++ b/test/main/services/team/taskChangePresenceCacheSchema.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizePersistedTaskChangePresenceIndex, + toPersistedTaskChangePresenceIndex, +} from '../../../../src/main/services/team/cache/taskChangePresenceCacheSchema'; +import { + LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, +} from '../../../../src/main/services/team/cache/taskChangePresenceCacheTypes'; + +describe('taskChangePresenceCacheSchema', () => { + it('dual-reads legacy v1 payloads and normalizes them to the current schema version', () => { + const normalized = normalizePersistedTaskChangePresenceIndex({ + version: LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(normalized).toEqual({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + }); + + it('preserves needs_attention when normalizing the current schema payload', () => { + const normalized = normalizePersistedTaskChangePresenceIndex({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(normalized?.entries['task-1']?.presence).toBe('needs_attention'); + }); + + it('serializes all new writes as schema version 2', () => { + const serialized = toPersistedTaskChangePresenceIndex({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(serialized.version).toBe(TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION); + expect(serialized.entries['task-1']?.presence).toBe('needs_attention'); + }); +}); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 8ef2d282..47ad82b3 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -11,6 +11,9 @@ const hoisted = vi.hoisted(() => ({ getFileContent: vi.fn(), applyDecisions: vi.fn(), saveEditedFile: vi.fn(), + loadDecisions: vi.fn(), + saveDecisions: vi.fn(), + clearDecisions: vi.fn(), checkConflict: vi.fn(), rejectHunks: vi.fn(), rejectFile: vi.fn(), @@ -26,6 +29,9 @@ vi.mock('@renderer/api', () => ({ getFileContent: hoisted.getFileContent, applyDecisions: hoisted.applyDecisions, saveEditedFile: hoisted.saveEditedFile, + loadDecisions: hoisted.loadDecisions, + saveDecisions: hoisted.saveDecisions, + clearDecisions: hoisted.clearDecisions, checkConflict: hoisted.checkConflict, rejectHunks: hoisted.rejectHunks, rejectFile: hoisted.rejectFile, @@ -181,7 +187,7 @@ describe('changeReviewSlice task changes', () => { totalLinesRemoved: 0, teamName: 'team-a', taskId: '1', - confidence: 'fallback', + confidence: 'high', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', @@ -192,7 +198,7 @@ describe('changeReviewSlice task changes', () => { endTimestamp: '', toolUseIds: [], filePaths: [], - confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + confidence: { tier: 1, label: 'high', reason: 'Confirmed empty summary' }, }, warnings: [], }); @@ -202,6 +208,9 @@ describe('changeReviewSlice task changes', () => { await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_B); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + expect( + store.getState().taskChangePresenceByKey[buildTaskChangePresenceKey('team-a', '1', OPTIONS_A)] + ).toBe('no_changes'); }); it('updates selected team task changePresence after a positive summary check', async () => { @@ -285,6 +294,53 @@ describe('changeReviewSlice task changes', () => { ); }); + it('treats warning-only summaries as needs_attention and rechecks after invalidation', async () => { + const store = createSliceStore(); + const teamName = 'team-a'; + const taskId = 'presence-warning'; + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName, + taskId, + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'Ambiguous scope skipped' }, + }, + warnings: ['Ledger skipped attribution because multiple task scopes were active.'], + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'ledger-warning-only', + }, + }); + + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + teamName, + taskId, + 'needs_attention' + ); + expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention'); + + store.getState().invalidateTaskChangePresence([cacheKey]); + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + }); + it('downgrades stale known presence to unknown for fallback empty summaries', async () => { const store = createSliceStore(); store.setState({ @@ -554,8 +610,10 @@ describe('changeReviewSlice task changes', () => { expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3); expect( - store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)] - ).toBe(true); + store.getState().taskChangePresenceByKey[ + buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) + ] + ).toBe('has_changes'); }); it('warms task summaries with bounded concurrency', async () => { @@ -708,7 +766,16 @@ describe('changeReviewSlice task changes', () => { summaryOnly: true, forceFresh: true, }); - expect(store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]).toBe(false); + expect( + store.getState().taskChangePresenceByKey[ + buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) + ] + ).toBeUndefined(); + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + teamName, + taskId, + 'unknown' + ); }); it('clears resolved file content state when fetchAgentChanges installs a new change set', async () => { @@ -741,7 +808,7 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); - expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); + expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(5); expect(store.getState().fileContentVersionByPath).toEqual({}); }); @@ -777,7 +844,7 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); - expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'accepted' }); + expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(2); expect(store.getState().fileContentVersionByPath).toEqual({}); }); @@ -884,6 +951,93 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('normalizes persisted legacy file-path review decisions onto changeKey entries', async () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeTaskChangeSet('task-ledger', '/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + }); + + hoisted.loadDecisions.mockResolvedValueOnce({ + hunkDecisions: { '/repo/new.ts:0': 'rejected' }, + fileDecisions: { '/repo/new.ts': 'rejected' }, + hunkContextHashesByFile: { '/repo/new.ts': { 0: 'ctx-rename' } }, + }); + + await store.getState().loadDecisionsFromDisk('team-a', 'task-task-ledger', 'scope-token'); + + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + expect(store.getState().hunkContextHashesByFile).toEqual({ + [changeKey]: { 0: 'ctx-rename' }, + }); + }); + + it('stores fresh decisions under changeKey for grouped ledger files', () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileChunkCounts: { '/repo/new.ts': 1 }, + }); + + const originalIndex = store.getState().setHunkDecision('/repo/new.ts', 0, 'rejected'); + store.getState().setFileDecision('/repo/new.ts', 'rejected'); + + expect(originalIndex).toBe(0); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + }); + + it('stores grouped copy decisions under the copy changeKey', () => { + const store = createSliceStore(); + const changeKey = 'copy:/repo/base.ts->/repo/copy.ts'; + const ledgerFile = { + ...makeFile('/repo/copy.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/copy.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileChunkCounts: { '/repo/copy.ts': 1 }, + }); + + const originalIndex = store.getState().setHunkDecision('/repo/copy.ts', 0, 'accepted'); + store.getState().setFileDecision('/repo/copy.ts', 'accepted'); + + expect(originalIndex).toBe(0); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'accepted' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'accepted' }); + }); + it('invalidates resolved file content without clearing draft or review decisions', async () => { const store = createSliceStore(); @@ -922,6 +1076,49 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('invalidates review-key hunk hashes for grouped ledger files without clearing decisions', () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, + fileDecisions: { [changeKey]: 'rejected' }, + fileChunkCounts: { '/repo/new.ts': 2 }, + hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, + fileContents: { + '/repo/new.ts': { + ...ledgerFile, + originalFullContent: 'before', + modifiedFullContent: 'after', + contentSource: 'ledger-exact', + }, + }, + fileContentsLoading: { '/repo/new.ts': true }, + editedContents: { '/repo/new.ts': 'draft' }, + reviewExternalChangesByFile: { '/repo/new.ts': { type: 'change' } }, + fileContentVersionByPath: {}, + }); + + store.getState().invalidateResolvedFileContent('/repo/new.ts'); + + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + expect(store.getState().editedContents).toEqual({ '/repo/new.ts': 'draft' }); + expect(store.getState().fileContentVersionByPath['/repo/new.ts']).toBe(1); + }); + it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => { const store = createSliceStore(); @@ -1074,6 +1271,45 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('clears review-key hunk hashes after saveEditedFile for grouped ledger files', async () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + hoisted.saveEditedFile.mockResolvedValueOnce(undefined); + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileContents: { + '/repo/new.ts': { + ...ledgerFile, + originalFullContent: 'before', + modifiedFullContent: 'draft-before-save', + contentSource: 'ledger-exact', + }, + }, + fileChunkCounts: { '/repo/new.ts': 2 }, + hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, + editedContents: { '/repo/new.ts': 'saved-content' }, + fileContentVersionByPath: {}, + }); + + await store.getState().saveEditedFile('/repo/new.ts'); + + expect(hoisted.saveEditedFile).toHaveBeenCalledWith('/repo/new.ts', 'saved-content', undefined); + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().fileChunkCounts).toEqual({}); + expect(store.getState().fileContents['/repo/new.ts']?.modifiedFullContent).toBe('saved-content'); + }); + it('forces re-review when snippets change even if file paths stay the same', async () => { const store = createSliceStore(); const current = makeAgentChangeSet('/repo/file.ts', { newString: 'after' }); diff --git a/test/renderer/utils/reviewDecisionScope.test.ts b/test/renderer/utils/reviewDecisionScope.test.ts new file mode 100644 index 00000000..1cd9d4e1 --- /dev/null +++ b/test/renderer/utils/reviewDecisionScope.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { buildReviewDecisionScopeToken } from '../../../src/renderer/utils/reviewDecisionScope'; + +describe('buildReviewDecisionScopeToken', () => { + it('includes task request signature so filtered task variants do not collide', () => { + const baseChangeSet = { + teamName: 'demo', + taskId: 'task-1', + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'high' as const, + computedAt: '2026-04-21T10:00:00.000Z', + scope: { + taskId: 'task-1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1 as const, label: 'high' as const, reason: 'ok' }, + }, + warnings: [], + provenance: { + sourceKind: 'ledger' as const, + sourceFingerprint: 'fp-1', + }, + }; + + const tokenA = buildReviewDecisionScopeToken({ + mode: 'task', + taskId: 'task-1', + requestSignature: '{"status":"in_progress"}', + changeSet: baseChangeSet, + }); + const tokenB = buildReviewDecisionScopeToken({ + mode: 'task', + taskId: 'task-1', + requestSignature: '{"status":"completed"}', + changeSet: baseChangeSet, + }); + + expect(tokenA).not.toBe(tokenB); + }); +}); diff --git a/test/renderer/utils/reviewKey.test.ts b/test/renderer/utils/reviewKey.test.ts new file mode 100644 index 00000000..12e369ac --- /dev/null +++ b/test/renderer/utils/reviewKey.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + getReviewKeyForFilePath, + normalizePersistedReviewState, +} from '../../../src/renderer/utils/reviewKey'; + +describe('reviewKey path normalization', () => { + it('maps slash variants of Windows file paths to the same review key', () => { + const files = [{ filePath: 'C:\\Repo\\src\\file.ts', changeKey: 'path:c:/repo/src/file.ts' }]; + + expect(getReviewKeyForFilePath(files, 'c:/repo/src/file.ts')).toBe('path:c:/repo/src/file.ts'); + }); + + it('normalizes persisted legacy Windows path decisions onto changeKey entries', () => { + const files = [{ filePath: 'C:/Repo/src/file.ts', changeKey: 'path:c:/repo/src/file.ts' }]; + const state = normalizePersistedReviewState(files, { + fileDecisions: { 'c:\\repo\\src\\file.ts': 'rejected' }, + hunkDecisions: { 'c:\\repo\\src\\file.ts:2': 'accepted' }, + hunkContextHashesByFile: { 'c:\\repo\\src\\file.ts': { 2: 'ctx' } }, + }); + + expect(state.fileDecisions).toEqual({ 'path:c:/repo/src/file.ts': 'rejected' }); + expect(state.hunkDecisions).toEqual({ 'path:c:/repo/src/file.ts:2': 'accepted' }); + expect(state.hunkContextHashesByFile).toEqual({ + 'path:c:/repo/src/file.ts': { 2: 'ctx' }, + }); + }); +}); From f40ea4f738ec466b4afdb69fecb7d29c0fdd23a0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 17:21:44 +0300 Subject: [PATCH 08/19] feat(agent-teams): add derived task queue agenda --- agent-teams-controller/src/internal/agenda.js | 814 +++++++++ .../src/internal/boardLock.js | 42 + agent-teams-controller/src/internal/kanban.js | 53 +- agent-teams-controller/src/internal/review.js | 399 ++-- .../src/internal/taskStore.js | 50 +- agent-teams-controller/src/internal/tasks.js | 162 +- agent-teams-controller/src/mcpToolCatalog.js | 18 + .../test/controller.test.js | 168 +- mcp-server/src/agent-teams-controller.d.ts | 6 + mcp-server/src/tools/index.ts | 2 + mcp-server/src/tools/leadTools.ts | 32 + mcp-server/src/tools/taskTools.ts | 46 +- mcp-server/test/stdio.e2e.test.ts | 1606 ++++++++++++++++- mcp-server/test/tools.test.ts | 138 +- src/main/services/team/TeamDataService.ts | 52 +- .../services/team/TeamProvisioningService.ts | 102 +- src/types/agent-teams-controller.d.ts | 29 + .../services/team/TeamDataService.test.ts | 59 + .../team/TeamProvisioningService.test.ts | 10 +- ...eamProvisioningServiceLiveMessages.test.ts | 1 + .../TeamProvisioningServicePrepare.test.ts | 70 +- .../TeamProvisioningServicePrompts.test.ts | 18 + .../team/TeamProvisioningServiceRelay.test.ts | 1 + 23 files changed, 3530 insertions(+), 348 deletions(-) create mode 100644 agent-teams-controller/src/internal/agenda.js create mode 100644 agent-teams-controller/src/internal/boardLock.js create mode 100644 mcp-server/src/tools/leadTools.ts diff --git a/agent-teams-controller/src/internal/agenda.js b/agent-teams-controller/src/internal/agenda.js new file mode 100644 index 00000000..2a032ee2 --- /dev/null +++ b/agent-teams-controller/src/internal/agenda.js @@ -0,0 +1,814 @@ +const kanbanStore = require('./kanbanStore.js'); +const taskStore = require('./taskStore.js'); +const runtimeHelpers = require('./runtimeHelpers.js'); +const { withTeamBoardLock } = require('./boardLock.js'); + +const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']); +const REVIEW_COLUMNS = new Set(['review', 'approved']); +const INVENTORY_KANBAN_COLUMNS = new Set(['review', 'approved']); + +function normalizeName(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function normalizeKey(value) { + return normalizeName(value).toLowerCase(); +} + +function normalizeReviewState(value) { + const normalized = normalizeName(value); + return REVIEW_STATES.has(normalized) ? normalized : 'none'; +} + +function formatTaskLabel(task) { + return `#${task.displayId || task.id}`; +} + +function isLeadCandidate(member) { + if (!member || typeof member !== 'object') return false; + if (typeof member.agentType === 'string' && member.agentType.trim() === 'team-lead') { + return true; + } + if (typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) { + return true; + } + return normalizeKey(member.name) === 'team-lead'; +} + +function buildQueueRoster(paths) { + const resolved = runtimeHelpers.resolveTeamMembers(paths); + const membersByKey = new Map(); + + for (const member of resolved.members || []) { + const key = normalizeKey(member && member.name); + if (!key) continue; + membersByKey.set(key, member); + } + + const leadCandidates = (resolved.members || []).filter(isLeadCandidate); + const uniqueLeadName = leadCandidates.length === 1 ? normalizeName(leadCandidates[0].name) : ''; + const inferredLeadName = normalizeName(runtimeHelpers.inferLeadName(paths)); + const canonicalLeadName = + uniqueLeadName || + (membersByKey.get(normalizeKey(inferredLeadName)) && + normalizeName(membersByKey.get(normalizeKey(inferredLeadName)).name)) || + ''; + const leadAliases = new Set(['team-lead']); + if (canonicalLeadName) { + leadAliases.add(normalizeKey(canonicalLeadName)); + leadAliases.add('lead'); + } + + return { + membersByKey, + removedNames: resolved.removedNames || new Set(), + leadAliases, + leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean), + canonicalLeadName, + leadHeaderName: uniqueLeadName || '', + }; +} + +function resolveQueueActor(value, roster) { + const normalized = normalizeName(value); + if (!normalized) return null; + + const key = normalizeKey(normalized); + if (roster.removedNames.has(key)) { + return null; + } + + if (roster.leadAliases.has(key) && roster.canonicalLeadName) { + return { kind: 'lead', memberName: roster.canonicalLeadName }; + } + + const member = roster.membersByKey.get(key); + if (!member) return null; + + if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) { + return { kind: 'lead', memberName: roster.canonicalLeadName }; + } + + return { kind: 'member', memberName: normalizeName(member.name) }; +} + +function areSameActors(left, right) { + if (!left || !right || left.kind !== right.kind) return false; + if (left.kind === 'lead') return true; + return normalizeKey(left.memberName) === normalizeKey(right.memberName); +} + +function resolveReviewStateFromHistory(task) { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if ( + event.type === 'review_requested' || + event.type === 'review_changes_requested' || + event.type === 'review_approved' || + event.type === 'review_started' + ) { + return { + state: normalizeReviewState(event.to), + source: `history_${event.type}`, + }; + } + if (event.type === 'status_changed' && event.to === 'in_progress') { + return { + state: 'none', + source: 'history_status_reset', + }; + } + } + return null; +} + +function resolveEffectiveReviewState(task, kanbanEntry) { + const historyState = resolveReviewStateFromHistory(task); + if (historyState) { + return historyState; + } + + const persisted = normalizeReviewState(task.reviewState); + if (persisted !== 'none') { + return { + state: persisted, + source: 'task_review_state', + }; + } + + if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) { + return { + state: normalizeReviewState(kanbanEntry.column), + source: 'kanban_column', + }; + } + + return { + state: 'none', + source: 'none', + }; +} + +function resolveLegacyKanbanReviewer(task, roster, options = {}) { + const reviewState = normalizeName(options.reviewState); + const kanbanEntry = options.kanbanEntry; + if (reviewState !== 'review' || !kanbanEntry || kanbanEntry.column !== 'review') { + return null; + } + + const legacyReviewer = normalizeName(kanbanEntry.reviewer); + if (!legacyReviewer) { + return null; + } + + const actor = resolveQueueActor(legacyReviewer, roster); + if (actor) { + return { actor, source: 'legacy_kanban_reviewer', invalidValue: null }; + } + + return { + actor: null, + source: 'legacy_kanban_reviewer_invalid', + invalidValue: legacyReviewer, + }; +} + +function resolveCurrentCycleReviewer(task, roster, options = {}) { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + + if (event.type === 'review_started') { + const actor = resolveQueueActor(event.actor, roster); + if (actor) { + return { actor, source: 'history_review_started_actor', invalidValue: null }; + } + return { + actor: null, + source: 'history_review_started_invalid', + invalidValue: normalizeName(event.actor) || null, + }; + } + + if (event.type === 'review_requested') { + const reviewer = resolveQueueActor(event.reviewer, roster); + if (reviewer) { + return { actor: reviewer, source: 'history_review_requested_reviewer', invalidValue: null }; + } + return { + actor: null, + source: 'history_review_requested_invalid', + invalidValue: normalizeName(event.reviewer) || null, + }; + } + + if (event.type === 'review_approved' || event.type === 'review_changes_requested') { + break; + } + + if (event.type === 'status_changed' && event.to === 'in_progress') { + break; + } + + if (event.type === 'task_created') { + break; + } + } + + const legacyFallback = resolveLegacyKanbanReviewer(task, roster, options); + if (legacyFallback) { + return legacyFallback; + } + + return { actor: null, source: 'none', invalidValue: null }; +} + +function compareTasksByFreshness(left, right) { + const leftTs = Date.parse(normalizeName(left.updatedAt) || normalizeName(left.createdAt) || '') || 0; + const rightTs = Date.parse(normalizeName(right.updatedAt) || normalizeName(right.createdAt) || '') || 0; + if (leftTs !== rightTs) return rightTs - leftTs; + + const byDisplay = String(left.displayId || left.id).localeCompare(String(right.displayId || right.id), undefined, { + numeric: true, + sensitivity: 'base', + }); + if (byDisplay !== 0) return byDisplay; + + return String(left.id).localeCompare(String(right.id), undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +function buildBoardState(paths, teamName) { + const taskRows = taskStore.listTaskRows(paths); + const kanbanState = kanbanStore.readKanbanState(paths, teamName); + const roster = buildQueueRoster(paths); + const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task])); + + return { + tasks: [...taskRows.tasks].sort(compareTasksByFreshness), + tasksById, + kanbanState, + roster, + anomalies: taskRows.anomalies.map((anomaly) => ({ + code: anomaly.code, + detail: anomaly.detail, + ...(anomaly.taskId ? { taskId: anomaly.taskId } : {}), + })), + }; +} + +function buildWatchers(ownerActor, reviewerActor, actionOwner) { + const watchers = new Set(); + + if (ownerActor && ownerActor.kind === 'member') { + watchers.add(ownerActor.memberName); + } + if (reviewerActor && reviewerActor.kind === 'member') { + watchers.add(reviewerActor.memberName); + } + if (actionOwner && actionOwner.kind === 'member') { + watchers.delete(actionOwner.memberName); + } + + return [...watchers]; +} + +function getLastMeaningfulEventAt(task) { + const timestamps = []; + if (normalizeName(task.updatedAt)) timestamps.push(task.updatedAt); + if (normalizeName(task.createdAt)) timestamps.push(task.createdAt); + + const comments = Array.isArray(task.comments) ? task.comments : []; + for (const comment of comments) { + if (normalizeName(comment && comment.createdAt)) { + timestamps.push(comment.createdAt); + } + } + + timestamps.sort((left, right) => { + const leftTs = Date.parse(left) || 0; + const rightTs = Date.parse(right) || 0; + return rightTs - leftTs; + }); + + return timestamps[0] || undefined; +} + +function buildAgendaItem(task, boardState) { + const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined; + const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry); + const reviewerResult = resolveCurrentCycleReviewer(task, boardState.roster, { + reviewState: reviewStateResult.state, + kanbanEntry, + }); + const ownerActor = resolveQueueActor(task.owner, boardState.roster); + const hasOwnerField = normalizeName(task.owner).length > 0; + const hasMissingOwner = !hasOwnerField; + const hasInvalidOwner = hasOwnerField && !ownerActor; + const reviewActor = reviewerResult.actor; + const reviewActorIsInvalid = reviewStateResult.state === 'review' && !reviewActor; + const hasSelfReview = Boolean(ownerActor && reviewActor && areSameActors(ownerActor, reviewActor)); + + const brokenDependencyIds = []; + const waitingDependencyIds = []; + const blockedByIds = Array.isArray(task.blockedBy) ? task.blockedBy.map(String) : []; + for (const dependencyId of blockedByIds) { + const dependency = boardState.tasksById.get(dependencyId); + if (!dependency || dependency.status === 'deleted') { + brokenDependencyIds.push(dependencyId); + continue; + } + if (dependency.status !== 'completed') { + waitingDependencyIds.push(dependencyId); + } + } + + let actionOwner = { kind: 'none' }; + let nextAction = 'none'; + let queueCategory = 'done'; + let reasonCode = 'completed_no_followup'; + const derivedFrom = [reviewStateResult.source, reviewerResult.source].filter( + (value) => value && value !== 'none' + ); + + if (task.status === 'deleted') { + reasonCode = 'terminal_deleted'; + } else if (reviewStateResult.state === 'approved') { + reasonCode = 'terminal_approved'; + } else if (task.needsClarification === 'user') { + actionOwner = { kind: 'user' }; + nextAction = 'clarify_with_user'; + queueCategory = 'waiting'; + reasonCode = 'waiting_user_clarification'; + derivedFrom.push('clarification_flag'); + } else if (task.needsClarification === 'lead') { + actionOwner = { kind: 'lead' }; + nextAction = 'clarify_with_lead'; + queueCategory = 'oversight'; + reasonCode = 'waiting_lead_clarification'; + derivedFrom.push('clarification_flag'); + } else if (hasMissingOwner) { + actionOwner = { kind: 'lead' }; + nextAction = 'assign_owner'; + queueCategory = 'oversight'; + reasonCode = 'owner_missing'; + derivedFrom.push('owner_status'); + } else if (hasInvalidOwner) { + actionOwner = { kind: 'lead' }; + nextAction = 'assign_owner'; + queueCategory = 'oversight'; + reasonCode = 'owner_invalid'; + derivedFrom.push('owner_status'); + } else if (reviewStateResult.state === 'review') { + if (hasSelfReview) { + actionOwner = { kind: 'lead' }; + nextAction = 'assign_reviewer'; + queueCategory = 'oversight'; + reasonCode = 'self_review_invalid'; + derivedFrom.push('self_review_invalid'); + } else if (reviewActorIsInvalid) { + actionOwner = { kind: 'lead' }; + nextAction = 'assign_reviewer'; + queueCategory = 'oversight'; + reasonCode = 'review_reviewer_missing'; + derivedFrom.push('history_reviewer_invalid'); + } else if (reviewActor) { + actionOwner = reviewActor.kind === 'lead' + ? { kind: 'lead' } + : { kind: 'member', memberName: reviewActor.memberName }; + nextAction = 'review'; + queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable'; + reasonCode = + reviewerResult.source === 'history_review_started_actor' + ? 'review_in_progress' + : 'review_requested_waiting_pickup'; + } + } else if (brokenDependencyIds.length > 0) { + actionOwner = { kind: 'lead' }; + nextAction = 'repair_dependencies'; + queueCategory = 'oversight'; + reasonCode = 'dependency_broken'; + derivedFrom.push('dependency_graph'); + } else if (waitingDependencyIds.length > 0) { + actionOwner = { kind: 'none' }; + nextAction = 'wait_dependency'; + queueCategory = 'waiting'; + reasonCode = 'dependency_waiting'; + derivedFrom.push('dependency_graph'); + } else if (reviewStateResult.state === 'needsFix') { + actionOwner = + ownerActor.kind === 'lead' + ? { kind: 'lead' } + : { kind: 'member', memberName: ownerActor.memberName }; + nextAction = 'apply_changes'; + queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable'; + reasonCode = 'needs_fix'; + } else if (task.status === 'in_progress') { + actionOwner = + ownerActor.kind === 'lead' + ? { kind: 'lead' } + : { kind: 'member', memberName: ownerActor.memberName }; + nextAction = 'execute'; + queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable'; + reasonCode = 'owner_executing'; + } else if (task.status === 'pending') { + actionOwner = + ownerActor.kind === 'lead' + ? { kind: 'lead' } + : { kind: 'member', memberName: ownerActor.memberName }; + nextAction = 'execute'; + queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable'; + reasonCode = 'owner_ready'; + } + + const watchers = buildWatchers(ownerActor, reviewActor, actionOwner); + + return { + taskId: task.id, + displayId: task.displayId, + subject: task.subject, + status: task.status, + reviewState: reviewStateResult.state, + actionOwner, + nextAction, + queueCategory, + reasonCode, + ...(normalizeName(task.owner) ? { owner: task.owner } : {}), + reviewer: + reviewActor && reviewActor.kind === 'member' + ? reviewActor.memberName + : reviewActor && reviewActor.kind === 'lead' + ? reviewActor.memberName + : null, + ...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}), + ...(watchers.length > 0 ? { watchers } : {}), + ...(task.needsClarification ? { needsClarification: task.needsClarification } : {}), + ...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}), + derivedFrom, + _fullTask: task, + }; +} + +function compareAgendaItems(left, right) { + const leftTs = Date.parse(normalizeName(left.lastMeaningfulEventAt)) || 0; + const rightTs = Date.parse(normalizeName(right.lastMeaningfulEventAt)) || 0; + if (leftTs !== rightTs) return rightTs - leftTs; + + const byDisplay = String(left.displayId || left.taskId).localeCompare(String(right.displayId || right.taskId), undefined, { + numeric: true, + sensitivity: 'base', + }); + if (byDisplay !== 0) return byDisplay; + + return String(left.taskId).localeCompare(String(right.taskId), undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +function buildAgendaSnapshot(paths, teamName, actor) { + return withTeamBoardLock(paths, () => { + const boardState = buildBoardState(paths, teamName); + const items = boardState.tasks.map((task) => buildAgendaItem(task, boardState)); + const actionable = []; + const awareness = []; + + for (const item of items) { + if (actor.kind === 'member') { + const memberKey = normalizeKey(actor.memberName); + const isActionable = + item.actionOwner.kind === 'member' && + normalizeKey(item.actionOwner.memberName) === memberKey; + const isRelevant = + isActionable || + normalizeKey(item.owner) === memberKey || + normalizeKey(item.reviewer) === memberKey || + (Array.isArray(item.watchers) && item.watchers.some((entry) => normalizeKey(entry) === memberKey)); + + if (isActionable) { + actionable.push(item); + } else if (isRelevant) { + awareness.push(item); + } + continue; + } + + if (item.actionOwner.kind === 'lead') { + actionable.push(item); + } else if ( + item.actionOwner.kind === 'user' || + item.reasonCode === 'dependency_waiting' || + item.reasonCode === 'review_in_progress' || + item.reasonCode === 'review_requested_waiting_pickup' + ) { + awareness.push(item); + } + } + + actionable.sort(compareAgendaItems); + awareness.sort(compareAgendaItems); + + return { + actor, + actionable, + awareness, + anomalies: boardState.anomalies, + counters: { + actionable: actionable.length, + awareness: awareness.length, + blocked: items.filter( + (item) => + item.reasonCode === 'dependency_waiting' || item.reasonCode === 'dependency_broken' + ).length, + waitingOnUser: items.filter((item) => item.reasonCode === 'waiting_user_clarification').length, + waitingOnLead: items.filter((item) => item.reasonCode === 'waiting_lead_clarification').length, + reviewNeeded: items.filter( + (item) => + item.reasonCode === 'review_reviewer_missing' || + item.reasonCode === 'review_requested_waiting_pickup' || + item.reasonCode === 'review_in_progress' || + item.reasonCode === 'self_review_invalid' + ).length, + anomalies: boardState.anomalies.length, + }, + }; + }); +} + +function buildInventoryRow(task, reviewState) { + return { + id: task.id, + displayId: task.displayId, + subject: task.subject, + status: task.status, + ...(normalizeName(task.owner) ? { owner: task.owner } : {}), + reviewState, + ...(task.needsClarification ? { needsClarification: task.needsClarification } : {}), + ...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}), + ...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}), + ...(Array.isArray(task.related) && task.related.length > 0 ? { related: task.related } : {}), + commentCount: Array.isArray(task.comments) ? task.comments.length : 0, + ...(normalizeName(task.createdAt) ? { createdAt: task.createdAt } : {}), + ...(normalizeName(task.updatedAt) ? { updatedAt: task.updatedAt } : {}), + }; +} + +function matchesInventoryFilters(row, filters) { + if (normalizeName(filters.owner) && normalizeKey(row.owner) !== normalizeKey(filters.owner)) { + return false; + } + if (normalizeName(filters.status) && row.status !== filters.status) { + return false; + } + if (normalizeName(filters.reviewState) && row.reviewState !== filters.reviewState) { + return false; + } + if (normalizeName(filters.kanbanColumn)) { + const kanbanColumn = filters.kanbanColumn; + if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.reviewState !== kanbanColumn) { + return false; + } + } + if (normalizeName(filters.relatedTo)) { + const related = Array.isArray(row.related) ? row.related : []; + if (!related.includes(filters.relatedTo)) { + return false; + } + } + if (normalizeName(filters.blockedBy)) { + const blockedBy = Array.isArray(row.blockedBy) ? row.blockedBy : []; + if (!blockedBy.includes(filters.blockedBy)) { + return false; + } + } + return true; +} + +function listTaskInventory(paths, teamName, filters = {}) { + return withTeamBoardLock(paths, () => { + const boardState = buildBoardState(paths, teamName); + const resolvedRelatedTo = normalizeName(filters.relatedTo) + ? taskStore.resolveTaskRef(paths, filters.relatedTo) + : ''; + const resolvedBlockedBy = normalizeName(filters.blockedBy) + ? taskStore.resolveTaskRef(paths, filters.blockedBy) + : ''; + const limit = + typeof filters.limit === 'number' && Number.isFinite(filters.limit) + ? Math.max(1, Math.floor(filters.limit)) + : null; + + const rows = boardState.tasks + .map((task) => { + const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined; + const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state; + return buildInventoryRow(task, reviewState); + }) + .filter((row) => + matchesInventoryFilters(row, { + ...filters, + ...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}), + ...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}), + }) + ); + + return limit == null ? rows : rows.slice(0, limit); + }); +} + +function formatActionOwner(actionOwner) { + if (actionOwner.kind === 'member') return `@${actionOwner.memberName}`; + if (actionOwner.kind === 'lead') return 'lead'; + if (actionOwner.kind === 'user') return 'user'; + return 'none'; +} + +function formatAgendaLine(item) { + const reviewSuffix = item.reviewState !== 'none' ? `, review=${item.reviewState}` : ''; + const meta = [ + `next=${item.nextAction}`, + `owner=${normalizeName(item.owner) || 'none'}`, + `actionOwner=${formatActionOwner(item.actionOwner)}`, + `reason=${item.reasonCode}`, + ]; + if (normalizeName(item.reviewer)) { + meta.push(`reviewer=${item.reviewer}`); + } + if (item.needsClarification) { + meta.push(`clarification=${item.needsClarification}`); + } + return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${item.subject} (${meta.join(', ')})`; +} + +function appendExpandedTaskContext(lines, item) { + const task = item._fullTask; + if (!task || typeof task !== 'object') return; + + if (normalizeName(task.description)) { + lines.push(` Description: ${task.description}`); + } + + const comments = Array.isArray(task.comments) ? task.comments : []; + if (comments.length === 0) return; + + lines.push(' Comments:'); + for (const comment of comments.slice(-5)) { + const author = normalizeName(comment && comment.author) || 'unknown'; + const text = normalizeName(comment && comment.text) || '(empty comment)'; + lines.push(` - ${author}: ${text}`); + } +} + +function formatAnomalyLine(anomaly) { + const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : ''; + return `- ${anomaly.code}${ref}: ${anomaly.detail}`; +} + +function formatTaskBriefing(paths, teamName, memberName) { + const snapshot = buildAgendaSnapshot(paths, teamName, { + kind: 'member', + memberName: normalizeName(memberName), + }); + const lines = [ + `Task briefing for ${memberName}:`, + `Primary queue for ${memberName}. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.`, + `Use task_list only to search/browse inventory rows, not as your working queue.`, + ]; + + if (snapshot.anomalies.length > 0) { + lines.push('', 'Board warnings:'); + for (const anomaly of snapshot.anomalies) { + lines.push(formatAnomalyLine(anomaly)); + } + } + + if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) { + lines.push('', `No actionable or awareness tasks for ${memberName}.`); + return lines.join('\n'); + } + + if (snapshot.actionable.length > 0) { + lines.push('', 'Actionable:'); + for (const item of snapshot.actionable) { + lines.push(formatAgendaLine(item)); + if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') { + appendExpandedTaskContext(lines, item); + } + } + } + + if (snapshot.awareness.length > 0) { + lines.push('', 'Awareness:'); + for (const item of snapshot.awareness) { + lines.push(formatAgendaLine(item)); + } + } + + lines.push( + '', + `Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}` + ); + + return lines.join('\n'); +} + +function bucketLeadItems(items) { + const buckets = { + assign_owner: [], + assign_reviewer: [], + clarify_with_lead: [], + repair_dependencies: [], + lead_owned: [], + }; + + for (const item of items) { + if (item.nextAction === 'assign_owner') { + buckets.assign_owner.push(item); + continue; + } + if (item.nextAction === 'assign_reviewer') { + buckets.assign_reviewer.push(item); + continue; + } + if (item.nextAction === 'clarify_with_lead') { + buckets.clarify_with_lead.push(item); + continue; + } + if (item.nextAction === 'repair_dependencies') { + buckets.repair_dependencies.push(item); + continue; + } + buckets.lead_owned.push(item); + } + + return buckets; +} + +function formatLeadBriefing(paths, teamName) { + const roster = buildQueueRoster(paths); + const leadHeaderName = roster.leadHeaderName ? ` for ${roster.leadHeaderName}` : ''; + const snapshot = buildAgendaSnapshot(paths, teamName, { kind: 'lead' }); + const buckets = bucketLeadItems(snapshot.actionable); + const lines = [ + `Lead queue${leadHeaderName} on team "${teamName}":`, + `Primary lead queue. Sections below already represent lead-owned actions or watch-only context.`, + `Use task_list only for search, filtering, and drill-down inventory lookups.`, + ]; + + if (snapshot.anomalies.length > 0) { + lines.push('', 'Board anomalies:'); + for (const anomaly of snapshot.anomalies) { + lines.push(formatAnomalyLine(anomaly)); + } + } + + const sections = [ + ['Needs owner assignment:', buckets.assign_owner], + ['Needs reviewer assignment:', buckets.assign_reviewer], + ['Needs clarification from lead:', buckets.clarify_with_lead], + ['Dependency repair:', buckets.repair_dependencies], + ['Lead-owned follow-up:', buckets.lead_owned], + [ + 'Waiting on user:', + snapshot.awareness.filter((item) => item.reasonCode === 'waiting_user_clarification'), + ], + [ + 'Watching:', + snapshot.awareness.filter((item) => item.reasonCode !== 'waiting_user_clarification'), + ], + ]; + + let renderedAnySection = false; + for (const [title, items] of sections) { + if (!items || items.length === 0) continue; + renderedAnySection = true; + lines.push('', title); + for (const item of items) { + lines.push(formatAgendaLine(item)); + } + } + + if (!renderedAnySection && snapshot.anomalies.length === 0) { + lines.push('', 'No lead action items.'); + } + + lines.push( + '', + `Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}` + ); + + return lines.join('\n'); +} + +module.exports = { + buildAgendaSnapshot, + formatLeadBriefing, + formatTaskBriefing, + listTaskInventory, + resolveCurrentCycleReviewer, + resolveEffectiveReviewState, +}; diff --git a/agent-teams-controller/src/internal/boardLock.js b/agent-teams-controller/src/internal/boardLock.js new file mode 100644 index 00000000..24bac176 --- /dev/null +++ b/agent-teams-controller/src/internal/boardLock.js @@ -0,0 +1,42 @@ +const path = require('path'); + +const { withFileLockSync } = require('./fileLock.js'); + +const reentrantLockDepthByScope = new Map(); + +function getTeamBoardLockScope(paths) { + return path.join(paths.teamDir, 'board-state'); +} + +function withTeamBoardLock(paths, fn) { + const scope = getTeamBoardLockScope(paths); + const currentDepth = reentrantLockDepthByScope.get(scope) || 0; + + if (currentDepth > 0) { + reentrantLockDepthByScope.set(scope, currentDepth + 1); + try { + return fn(); + } finally { + const nextDepth = (reentrantLockDepthByScope.get(scope) || 1) - 1; + if (nextDepth <= 0) { + reentrantLockDepthByScope.delete(scope); + } else { + reentrantLockDepthByScope.set(scope, nextDepth); + } + } + } + + return withFileLockSync(scope, () => { + reentrantLockDepthByScope.set(scope, 1); + try { + return fn(); + } finally { + reentrantLockDepthByScope.delete(scope); + } + }); +} + +module.exports = { + getTeamBoardLockScope, + withTeamBoardLock, +}; diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js index fcfd731b..46d49d6d 100644 --- a/agent-teams-controller/src/internal/kanban.js +++ b/agent-teams-controller/src/internal/kanban.js @@ -1,20 +1,25 @@ const kanbanStore = require('./kanbanStore.js'); const tasks = require('./tasks.js'); +const { withTeamBoardLock } = require('./boardLock.js'); function getKanbanState(context) { return kanbanStore.readKanbanState(context.paths, context.teamName); } function setKanbanColumn(context, taskId, column) { - const canonicalTaskId = tasks.resolveTaskId(context, taskId); - kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column)); - return getKanbanState(context); + return withTeamBoardLock(context.paths, () => { + const canonicalTaskId = tasks.resolveTaskId(context, taskId); + kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column)); + return getKanbanState(context); + }); } function clearKanban(context, taskId, options) { - const canonicalTaskId = tasks.resolveTaskId(context, taskId); - kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options); - return getKanbanState(context); + return withTeamBoardLock(context.paths, () => { + const canonicalTaskId = tasks.resolveTaskId(context, taskId); + kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options); + return getKanbanState(context); + }); } function listReviewers(context) { @@ -22,29 +27,35 @@ function listReviewers(context) { } function addReviewer(context, reviewer) { - const state = getKanbanState(context); - const next = new Set(state.reviewers); - next.add(String(reviewer)); - kanbanStore.writeKanbanState(context.paths, context.teamName, { - ...state, - reviewers: [...next], + return withTeamBoardLock(context.paths, () => { + const state = getKanbanState(context); + const next = new Set(state.reviewers); + next.add(String(reviewer)); + kanbanStore.writeKanbanState(context.paths, context.teamName, { + ...state, + reviewers: [...next], + }); + return listReviewers(context); }); - return listReviewers(context); } function removeReviewer(context, reviewer) { - const state = getKanbanState(context); - const next = state.reviewers.filter((entry) => entry !== reviewer); - kanbanStore.writeKanbanState(context.paths, context.teamName, { - ...state, - reviewers: next, + return withTeamBoardLock(context.paths, () => { + const state = getKanbanState(context); + const next = state.reviewers.filter((entry) => entry !== reviewer); + kanbanStore.writeKanbanState(context.paths, context.teamName, { + ...state, + reviewers: next, + }); + return listReviewers(context); }); - return listReviewers(context); } function updateColumnOrder(context, columnId, orderedTaskIds) { - const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId)); - return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds); + return withTeamBoardLock(context.paths, () => { + const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId)); + return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds); + }); } module.exports = { diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index cfc3e257..d66c40b9 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -2,8 +2,16 @@ const kanban = require('./kanban.js'); const messages = require('./messages.js'); const runtimeHelpers = require('./runtimeHelpers.js'); const tasks = require('./tasks.js'); +const { withTeamBoardLock } = require('./boardLock.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); +function warnNonCritical(message, error) { + if (typeof console === 'undefined' || typeof console.warn !== 'function') { + return; + } + console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`); +} + function getReviewer(context, flags) { if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) { return flags.reviewer.trim(); @@ -32,76 +40,115 @@ function getCurrentReviewState(task) { return 'none'; } -function startReview(context, taskId, flags = {}) { - const task = tasks.getTask(context, taskId); - if (task.status === 'deleted') { - throw new Error(`Task #${task.displayId || task.id} is deleted`); - } - - const from = - typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer'; - const prevReviewState = getCurrentReviewState(task); - - // Idempotent: already in review → return ok without duplicate history event - if (prevReviewState === 'review') { - return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; - } - - try { - kanban.setKanbanColumn(context, task.id, 'review'); - tasks.updateTask(context, task.id, (t) => { - t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { - type: 'review_started', - from: prevReviewState, - to: 'review', - actor: from, - }); - t.reviewState = 'review'; - return t; - }); - return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; - } catch (error) { - try { - kanban.clearKanban(context, task.id); - } catch { - // Best-effort rollback +function getLatestReviewLifecycleEvent(task) { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if ( + e.type === 'review_requested' || + e.type === 'review_changes_requested' || + e.type === 'review_approved' || + e.type === 'review_started' + ) { + return e; + } + if (e.type === 'status_changed' && e.to === 'in_progress') { + return e; + } + if (e.type === 'task_created') { + return e; } - throw error; } + return null; +} + +function startReview(context, taskId, flags = {}) { + return withTeamBoardLock(context.paths, () => { + const task = tasks.getTask(context, taskId); + if (task.status === 'deleted') { + throw new Error(`Task #${task.displayId || task.id} is deleted`); + } + + const from = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer'; + const latestReviewEvent = getLatestReviewLifecycleEvent(task); + const prevReviewState = getCurrentReviewState(task); + + if (latestReviewEvent && latestReviewEvent.type === 'review_started') { + return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; + } + + try { + kanban.setKanbanColumn(context, task.id, 'review'); + tasks.updateTask(context, task.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_started', + from: prevReviewState, + to: 'review', + actor: from, + }); + t.reviewState = 'review'; + return t; + }); + return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; + } catch (error) { + try { + kanban.clearKanban(context, task.id); + } catch (rollbackError) { + warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError); + } + throw error; + } + }); } function requestReview(context, taskId, flags = {}) { - const task = tasks.getTask(context, taskId); - if (task.status !== 'completed') { - throw new Error(`Task #${task.displayId || task.id} must be completed before review`); - } - - const from = - typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; - const reviewer = getReviewer(context, flags); - const leadSessionId = resolveLeadSessionId(context, flags); - const prevReviewState = getCurrentReviewState(task); - - try { - kanban.setKanbanColumn(context, task.id, 'review'); - - // Append review_requested event - tasks.updateTask(context, task.id, (t) => { - t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { - type: 'review_requested', - from: prevReviewState, - to: 'review', - ...(reviewer ? { reviewer } : {}), - actor: from, - }); - t.reviewState = 'review'; - return t; - }); - - if (!reviewer) { - return tasks.getTask(context, task.id); + const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => { + const currentTask = tasks.getTask(context, taskId); + if (currentTask.status !== 'completed') { + throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`); } + const nextFrom = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; + const nextReviewer = getReviewer(context, flags); + const prevReviewState = getCurrentReviewState(currentTask); + + try { + kanban.setKanbanColumn(context, currentTask.id, 'review'); + tasks.updateTask(context, currentTask.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_requested', + from: prevReviewState, + to: 'review', + ...(nextReviewer ? { reviewer: nextReviewer } : {}), + actor: nextFrom, + }); + t.reviewState = 'review'; + return t; + }); + } catch (error) { + try { + kanban.clearKanban(context, currentTask.id); + } catch (rollbackError) { + warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError); + } + throw error; + } + + return { + task: tasks.getTask(context, currentTask.id), + reviewer: nextReviewer, + from: nextFrom, + leadSessionId: resolveLeadSessionId(context, flags), + }; + }); + + if (!reviewer) { + return task; + } + + try { messages.sendMessage(context, { to: reviewer, from, @@ -119,122 +166,158 @@ function requestReview(context, taskId, flags = {}) { source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), }); - return tasks.getTask(context, task.id); } catch (error) { - try { - kanban.clearKanban(context, task.id); - } catch { - // Best-effort rollback: keep the original error. - } - throw error; + warnNonCritical(`[review] reviewer notification failed for task ${task.id}`, error); } + + return task; } function approveReview(context, taskId, flags = {}) { - const task = tasks.getTask(context, taskId); - const from = - typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; - const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; - const suppressTaskComment = flags.suppressTaskComment === true; - const leadSessionId = resolveLeadSessionId(context, flags); - const prevReviewState = getCurrentReviewState(task); + const result = withTeamBoardLock(context.paths, () => { + const currentTask = tasks.getTask(context, taskId); + const nextFrom = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; + const nextNote = + typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; + const suppressTaskComment = flags.suppressTaskComment === true; + const prevReviewState = getCurrentReviewState(currentTask); - // Idempotent: already approved → skip duplicate comment/event, only add note if new - if (prevReviewState === 'approved') { - return { ok: true, taskId: task.id, displayId: task.displayId, column: 'approved', alreadyApproved: true }; - } + if (prevReviewState === 'approved') { + return { + alreadyApproved: true, + payload: { + ok: true, + taskId: currentTask.id, + displayId: currentTask.displayId, + column: 'approved', + alreadyApproved: true, + }, + }; + } - kanban.setKanbanColumn(context, task.id, 'approved'); - - // Append review_approved event - tasks.updateTask(context, task.id, (t) => { - t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { - type: 'review_approved', - from: prevReviewState, - to: 'approved', - ...(note ? { note } : {}), - actor: from, + kanban.setKanbanColumn(context, currentTask.id, 'approved'); + tasks.updateTask(context, currentTask.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_approved', + from: prevReviewState, + to: 'approved', + ...(nextNote ? { note: nextNote } : {}), + actor: nextFrom, + }); + t.reviewState = 'approved'; + return t; }); - t.reviewState = 'approved'; - return t; + + if (!suppressTaskComment) { + tasks.addTaskComment(context, currentTask.id, { + text: nextNote, + from: nextFrom, + type: 'review_approved', + notifyOwner: false, + }); + } + + return { + alreadyApproved: false, + payload: tasks.getTask(context, currentTask.id), + from: nextFrom, + note: nextNote, + leadSessionId: resolveLeadSessionId(context, flags), + shouldNotifyOwner: + (flags.notify === true || flags['notify-owner'] === true) && Boolean(currentTask.owner), + }; }); - if (!suppressTaskComment) { - tasks.addTaskComment(context, task.id, { - text: note, - from, - type: 'review_approved', - notifyOwner: false, - }); + if (result.alreadyApproved) { + return result.payload; } - if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) { + const { payload: task, from, note, leadSessionId, shouldNotifyOwner } = result; + + if (shouldNotifyOwner && task.owner) { + try { + messages.sendMessage(context, { + to: task.owner, + from, + text: + note && note !== 'Approved' + ? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}` + : `@${from} **approved** task #${task.displayId || task.id}`, + summary: `Approved #${task.displayId || task.id}`, + source: 'system_notification', + ...(leadSessionId ? { leadSessionId } : {}), + }); + } catch (error) { + warnNonCritical(`[review] owner approval notification failed for task ${task.id}`, error); + } + } + + return task; +} + +function requestChanges(context, taskId, flags = {}) { + const { task, from, comment, leadSessionId } = withTeamBoardLock(context.paths, () => { + const currentTask = tasks.getTask(context, taskId); + if (!currentTask.owner) { + throw new Error(`No owner found for task ${String(taskId)}`); + } + + const nextFrom = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; + const nextComment = + typeof flags.comment === 'string' && flags.comment.trim() + ? flags.comment.trim() + : 'Reviewer requested changes.'; + const prevReviewState = getCurrentReviewState(currentTask); + + tasks.updateTask(context, currentTask.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_changes_requested', + from: prevReviewState, + to: 'needsFix', + ...(nextComment ? { note: nextComment } : {}), + actor: nextFrom, + }); + t.reviewState = 'needsFix'; + return t; + }); + + kanban.clearKanban(context, currentTask.id, { nextReviewState: 'needsFix' }); + tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom); + tasks.addTaskComment(context, currentTask.id, { + text: nextComment, + from: nextFrom, + type: 'review_request', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), + notifyOwner: false, + }); + + return { + task: tasks.getTask(context, currentTask.id), + from: nextFrom, + comment: nextComment, + leadSessionId: resolveLeadSessionId(context, flags), + }; + }); + + try { messages.sendMessage(context, { to: task.owner, from, text: - note && note !== 'Approved' - ? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}` - : `@${from} **approved** task #${task.displayId || task.id}`, - summary: `Approved #${task.displayId || task.id}`, + `@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` + + 'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), + summary: `Fix request for #${task.displayId || task.id}`, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), }); + } catch (error) { + warnNonCritical(`[review] owner fix-request notification failed for task ${task.id}`, error); } - return tasks.getTask(context, task.id); -} - -function requestChanges(context, taskId, flags = {}) { - const task = tasks.getTask(context, taskId); - if (!task.owner) { - throw new Error(`No owner found for task ${String(taskId)}`); - } - - const from = - typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; - const comment = - typeof flags.comment === 'string' && flags.comment.trim() - ? flags.comment.trim() - : 'Reviewer requested changes.'; - const leadSessionId = resolveLeadSessionId(context, flags); - const prevReviewState = getCurrentReviewState(task); - - // Append review_changes_requested event before status change - tasks.updateTask(context, task.id, (t) => { - t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { - type: 'review_changes_requested', - from: prevReviewState, - to: 'needsFix', - ...(comment ? { note: comment } : {}), - actor: from, - }); - t.reviewState = 'needsFix'; - return t; - }); - - kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' }); - tasks.setTaskStatus(context, task.id, 'pending', from); - tasks.addTaskComment(context, task.id, { - text: comment, - from, - type: 'review_request', - ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), - notifyOwner: false, - }); - messages.sendMessage(context, { - to: task.owner, - from, - text: - `@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` + - 'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.', - ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), - summary: `Fix request for #${task.displayId || task.id}`, - source: 'system_notification', - ...(leadSessionId ? { leadSessionId } : {}), - }); - - return tasks.getTask(context, task.id); + return task; } module.exports = { diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index f90dd817..09ad7383 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -72,10 +72,12 @@ function normalizeTaskReviewState(value) { return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none'; } -function listRawTasks(paths) { +function listTaskRows(paths, options = {}) { ensureDir(paths.tasksDir); const entries = fs.readdirSync(paths.tasksDir); - const out = []; + const includeDeleted = options.includeDeleted === true; + const tasks = []; + const anomalies = []; for (const fileName of entries) { if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; @@ -84,13 +86,25 @@ function listRawTasks(paths) { if (!rawTask) continue; if (rawTask.metadata && rawTask.metadata._internal === true) continue; try { - out.push(normalizeTask(rawTask, filePath)); - } catch { - // Skip unreadable task rows. + const task = normalizeTask(rawTask, filePath); + if (includeDeleted || task.status !== 'deleted') { + tasks.push(task); + } + } catch (error) { + const taskId = + typeof rawTask?.id === 'string' || typeof rawTask?.id === 'number' + ? String(rawTask.id) + : path.basename(fileName, '.json'); + anomalies.push({ + code: 'unreadable_task', + taskId, + filePath, + detail: error instanceof Error ? error.message : 'Unreadable task row', + }); } } - out.sort((a, b) => { + tasks.sort((a, b) => { const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { numeric: true, sensitivity: 'base', @@ -102,12 +116,15 @@ function listRawTasks(paths) { }); }); - return out; + return { tasks, anomalies }; +} + +function listRawTasks(paths) { + return listTaskRows(paths, { includeDeleted: true }).tasks; } function listTasks(paths, options = {}) { - const includeDeleted = options.includeDeleted === true; - return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted'); + return listTaskRows(paths, options).tasks; } function resolveTaskRef(paths, taskRef, options = {}) { @@ -479,30 +496,18 @@ function addTaskComment(paths, taskRef, text, options = {}) { }; let inserted = false; - let clarificationCleared = false; const task = updateTask(paths, taskRef, (currentTask) => { const comments = Array.isArray(currentTask.comments) ? currentTask.comments : []; if (comments.some((entry) => entry.id === comment.id)) { return currentTask; } - const authorName = normalizeMemberName(comment.author); - const ownerName = normalizeMemberName(currentTask.owner); - if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) { - delete currentTask.needsClarification; - clarificationCleared = true; - } - if (currentTask.needsClarification === 'user' && authorName === 'user') { - delete currentTask.needsClarification; - clarificationCleared = true; - } - currentTask.comments = comments.concat([comment]); inserted = true; return currentTask; }); - return { comment, task, inserted, clarificationCleared }; + return { comment, task, inserted, clarificationCleared: false }; } function setNeedsClarification(paths, taskRef, value) { @@ -808,6 +813,7 @@ module.exports = { deriveDisplayId, formatTaskBriefing, linkTask, + listTaskRows, listTasks, readTask, removeTaskAttachment, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 1060ca7f..d7b8cc29 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -2,6 +2,9 @@ const taskStore = require('./taskStore.js'); const runtimeHelpers = require('./runtimeHelpers.js'); const messages = require('./messages.js'); const processStore = require('./processStore.js'); +const kanbanStore = require('./kanbanStore.js'); +const agenda = require('./agenda.js'); +const { withTeamBoardLock } = require('./boardLock.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); function normalizeActorName(value) { @@ -42,6 +45,13 @@ function quoteMarkdown(text) { .join('\n'); } +function warnNonCritical(message, error) { + if (typeof console === 'undefined' || typeof console.warn !== 'function') { + return; + } + console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`); +} + function buildAssignmentMessage(context, task, options = {}) { const description = typeof options.description === 'string' && options.description.trim() ? @@ -112,15 +122,19 @@ function maybeNotifyAssignedOwner(context, task, options = {}) { } const summary = options.summary || `New task #${task.displayId || task.id} assigned`; - messages.sendMessage(context, { - member: owner, - from: sender, - text: buildAssignmentMessage(context, task, options), - taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, - summary, - source: 'system_notification', - ...(leadSessionId ? { leadSessionId } : {}), - }); + try { + messages.sendMessage(context, { + member: owner, + from: sender, + text: buildAssignmentMessage(context, task, options), + taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, + summary, + source: 'system_notification', + ...(leadSessionId ? { leadSessionId } : {}), + }); + } catch (error) { + warnNonCritical(`[tasks] assignment notification failed for task ${task.id}`, error); + } } function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { @@ -157,7 +171,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { } function createTask(context, input) { - const task = taskStore.createTask(context.paths, input); + const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input)); if (input && input.notifyOwner !== false) { maybeNotifyAssignedOwner(context, task, { description: input.description, @@ -219,23 +233,21 @@ function resolveTaskId(context, taskRef) { } function setTaskStatus(context, taskId, status, actor) { - return taskStore.setTaskStatus(context.paths, taskId, status, actor); + return withTeamBoardLock(context.paths, () => + taskStore.setTaskStatus(context.paths, taskId, status, actor) + ); } function startTask(context, taskId, actor) { - const task = setTaskStatus(context, taskId, 'in_progress', actor); - // Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened - try { - const kanbanStore = require('./kanbanStore.js'); + return withTeamBoardLock(context.paths, () => { + const task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor); const state = kanbanStore.readKanbanState(context.paths, context.teamName); if (state.tasks[task.id]) { delete state.tasks[task.id]; kanbanStore.writeKanbanState(context.paths, context.teamName, state); } - } catch { - // Best-effort: task status already updated, kanban cleanup failure is non-fatal - } - return task; + return task; + }); } function notifyUnblockedOwners(context, completedTask) { @@ -303,8 +315,8 @@ function completeTask(context, taskId, actor) { const task = setTaskStatus(context, taskId, 'completed', actor); try { notifyUnblockedOwners(context, task); - } catch { - // Best-effort: task completion succeeded, notification failure is non-fatal + } catch (error) { + warnNonCritical(`[tasks] dependency-resolution follow-up failed for task ${task.id}`, error); } return task; } @@ -318,8 +330,14 @@ function restoreTask(context, taskId, actor) { } function setTaskOwner(context, taskId, owner) { - const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); - const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner); + const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => { + const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); + const after = taskStore.setTaskOwner(context.paths, taskId, owner); + return { + previousTask: before, + updatedTask: after, + }; + }); if ( owner != null && @@ -335,19 +353,23 @@ function setTaskOwner(context, taskId, owner) { } function updateTaskFields(context, taskId, fields) { - return taskStore.updateTaskFields(context.paths, taskId, fields); + return withTeamBoardLock(context.paths, () => + taskStore.updateTaskFields(context.paths, taskId, fields) + ); } function addTaskComment(context, taskId, flags) { - const result = taskStore.addTaskComment(context.paths, taskId, flags.text, { - author: typeof flags.from === 'string' && flags.from.trim() ? - flags.from.trim() : runtimeHelpers.inferLeadName(context.paths), - ...(flags.id ? { id: flags.id } : {}), - ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), - ...(flags.type ? { type: flags.type } : {}), - ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), - ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), - }); + const result = withTeamBoardLock(context.paths, () => + taskStore.addTaskComment(context.paths, taskId, flags.text, { + author: typeof flags.from === 'string' && flags.from.trim() ? + flags.from.trim() : runtimeHelpers.inferLeadName(context.paths), + ...(flags.id ? { id: flags.id } : {}), + ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), + ...(flags.type ? { type: flags.type } : {}), + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), + ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), + }) + ); try { maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, { @@ -355,12 +377,7 @@ function addTaskComment(context, taskId, flags) { notifyOwner: flags.notifyOwner, }); } catch (notifyError) { - // Best-effort: comment is already persisted, notification failure must not fail the call - if (typeof console !== 'undefined' && console.warn) { - console.warn( - `[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}` - ); - } + warnNonCritical(`[tasks] owner notification failed for task ${taskId}`, notifyError); } return { @@ -376,7 +393,9 @@ function addTaskComment(context, taskId, flags) { function attachTaskFile(context, taskId, flags) { const canonicalTaskId = resolveTaskId(context, taskId); const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); - const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta); + const task = withTeamBoardLock(context.paths, () => + taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta) + ); return { ...saved.meta, task, @@ -386,7 +405,9 @@ function attachTaskFile(context, taskId, flags) { function attachCommentFile(context, taskId, commentId, flags) { const canonicalTaskId = resolveTaskId(context, taskId); const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); - const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta); + const task = withTeamBoardLock(context.paths, () => + taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta) + ); return { ...saved.meta, task, @@ -394,27 +415,45 @@ function attachCommentFile(context, taskId, commentId, flags) { } function addTaskAttachmentMeta(context, taskId, meta) { - return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta); + return withTeamBoardLock(context.paths, () => + taskStore.addTaskAttachmentMeta(context.paths, taskId, meta) + ); } function removeTaskAttachment(context, taskId, attachmentId) { - return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId); + return withTeamBoardLock(context.paths, () => + taskStore.removeTaskAttachment(context.paths, taskId, attachmentId) + ); } function setNeedsClarification(context, taskId, value) { - return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value)); + return withTeamBoardLock(context.paths, () => + taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value)) + ); } function linkTask(context, taskId, targetId, linkType) { - return taskStore.linkTask(context.paths, taskId, targetId, String(linkType)); + return withTeamBoardLock(context.paths, () => + taskStore.linkTask(context.paths, taskId, targetId, String(linkType)) + ); } function unlinkTask(context, taskId, targetId, linkType) { - return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType)); + return withTeamBoardLock(context.paths, () => + taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType)) + ); } async function taskBriefing(context, memberName) { - return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName)); + return agenda.formatTaskBriefing(context.paths, context.teamName, String(memberName)); +} + +async function leadBriefing(context) { + return agenda.formatLeadBriefing(context.paths, context.teamName); +} + +function listTaskInventory(context, filters = {}) { + return agenda.listTaskInventory(context.paths, context.teamName, filters); } function getSystemLocale() { @@ -505,10 +544,11 @@ function buildMemberTaskProtocol(teamName) { - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. - After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results. Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next." - - After task_complete, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer: + - After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known. + Example: { teamName: "${teamName}", taskId: "", from: "", reviewer: "" } - Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists. - If no team member has a reviewer role, skip review_request — the task stays completed. + Do NOT infer mandatory review just from free-form teammate roles like "reviewer", "qa", or "tech-lead". + If review is not explicitly requested yet or the reviewer is still undecided, leave the task completed and wait. 3b. When you BEGIN reviewing a task, FIRST call review_start to ensure it appears in the REVIEW column: { teamName: "${teamName}", taskId: "", from: "" } This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review. @@ -543,16 +583,19 @@ function buildMemberTaskProtocol(teamName) { { teamName: "${teamName}", taskId: "", text: "question / blocker / missing info", from: "" } c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly. IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board. - d) The flag is auto-cleared when the lead adds a task comment on your task. - If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: + d) The clarification flag is durable until it is cleared explicitly. + When the blocker is truly resolved, clear the flag yourself with: { teamName: "${teamName}", taskId: "", value: "clear" } e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user. 13. DEPENDENCY AWARENESS: When your task has blockedBy dependencies, check if they are completed before starting. When you complete a task that blocks others, blocked task owners are notified automatically via a task comment. 14. TASK QUEUE DISCIPLINE: - - Use task_briefing as a compact queue view of your assigned tasks. + - task_briefing is your primary working queue for assigned tasks. + - Use task_list only to search/browse inventory rows. Do NOT use task_list as your working queue. - task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose. + - Act only on Actionable items from task_briefing. + - Awareness items are watch-only context. Do NOT start work from Awareness unless the lead reroutes the task or you become the actionOwner first. - Finish existing in_progress tasks first. - A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are still busy on another task, blocked, or still need more context, immediately add a short task comment on that waiting task with the reason and your best ETA or what you are waiting on. - Keep any task you have not actually started in pending/TODO (use task_set_status pending if it was moved too early). @@ -710,10 +753,11 @@ async function memberBriefing(context, memberName) { '', `Bootstrap flow:`, `1. Use this briefing as your durable rules source.`, - `2. Use task_briefing as your compact queue view whenever you need to see assigned work.`, - `3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`, - `4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`, - `5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.` + `2. Use task_briefing as your primary working queue whenever you need to see assigned work. Use task_list only to search/browse inventory rows, not as your working queue.`, + `3. Act only on Actionable items in task_briefing. Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.`, + `4. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`, + `5. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`, + `6. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.` ); lines.push( @@ -754,7 +798,9 @@ module.exports = { getTaskComment, linkTask, listDeletedTasks, + listTaskInventory, listTasks, + leadBriefing, removeTaskAttachment, resolveTaskId, restoreTask, @@ -770,6 +816,6 @@ module.exports = { taskBriefing, unlinkTask, updateTask: (context, taskRef, updater) => - taskStore.updateTask(context.paths, taskRef, updater), + withTeamBoardLock(context.paths, () => taskStore.updateTask(context.paths, taskRef, updater)), updateTaskFields, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index 3146bad4..211b1485 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -18,6 +18,8 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [ 'task_unlink', ]; +const AGENT_TEAMS_LEAD_TOOL_NAMES = ['lead_briefing']; + const AGENT_TEAMS_REVIEW_TOOL_NAMES = [ 'review_approve', 'review_request', @@ -57,6 +59,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [ teammateOperational: true, toolNames: AGENT_TEAMS_TASK_TOOL_NAMES, }, + { + id: 'lead', + teammateOperational: false, + toolNames: AGENT_TEAMS_LEAD_TOOL_NAMES, + }, { id: 'kanban', teammateOperational: false, @@ -100,8 +107,17 @@ const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS. const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`); +const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES = [ + ...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ...AGENT_TEAMS_LEAD_TOOL_NAMES, +]; + +const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES = + AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`); + module.exports = { AGENT_TEAMS_TASK_TOOL_NAMES, + AGENT_TEAMS_LEAD_TOOL_NAMES, AGENT_TEAMS_REVIEW_TOOL_NAMES, AGENT_TEAMS_MESSAGE_TOOL_NAMES, AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, @@ -112,4 +128,6 @@ module.exports = { AGENT_TEAMS_REGISTERED_TOOL_NAMES, AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, }; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 2632bdd8..843b0a89 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -146,6 +146,11 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Implement carefully'); expect(briefing).toContain('Working directory: /tmp/project-x'); expect(briefing).toContain('Task briefing for bob:'); + expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.'); + expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); + expect(briefing).toContain( + 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' + ); }); it('resolves member briefing from members.meta.json when config members are missing', async () => { @@ -280,7 +285,7 @@ describe('agent-teams-controller API', () => { expect(rows[1].id).toBe(registered.id); }); - it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing by review-aware sections', async () => { + it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing into actionable and awareness queues', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -355,24 +360,29 @@ describe('agent-teams-controller API', () => { expect(ownerInbox[3].text).toContain('task_add_comment'); const briefing = await controller.tasks.taskBriefing('bob'); - expect(briefing).toContain('In progress:'); + expect(briefing).toContain( + 'Primary queue for bob. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' + ); + expect(briefing).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); + expect(briefing).toContain('Actionable:'); expect(briefing).toContain(`#${activeTask.displayId}`); expect(briefing).toContain('Description: Resume immediately'); expect(briefing).toContain('Resumed work with latest context.'); - expect(briefing).toContain('Needs fixes after review:'); expect(briefing).toContain(`#${needsFixTask.displayId}`); - expect(briefing).toContain('Pending:'); + expect(briefing).toContain('reason=needs_fix'); expect(briefing).toContain(`#${pendingTask.displayId}`); expect(briefing).not.toContain('Description: Do this later'); - expect(briefing).toContain('Review:'); + expect(briefing).toContain('Awareness:'); expect(briefing).toContain(`#${reviewTask.displayId}`); - expect(briefing).toContain('Completed:'); + expect(briefing).toContain('reason=review_reviewer_missing'); expect(briefing).toContain(`#${completedTask.displayId}`); expect(briefing).not.toContain( 'Completed task description should stay out of compact rows' ); - expect(briefing).toContain('Approved (last 10):'); expect(briefing).toContain(`#${approvedTask.displayId}`); + expect(briefing).toContain('Counters: actionable=4, awareness=3'); }); it('reconciles stale kanban rows and linked inbox comments idempotently', () => { @@ -584,6 +594,31 @@ describe('agent-teams-controller API', () => { expect(startedEvents).toHaveLength(1); }); + it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Queued for review', owner: 'bob' }); + + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); + const started = controller.review.startReview(task.id, { from: 'alice' }); + + expect(started.ok).toBe(true); + const reloaded = controller.tasks.getTask(task.id); + const requestedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_requested'); + const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started'); + expect(requestedEvents).toHaveLength(1); + expect(startedEvents).toHaveLength(1); + expect(startedEvents[0].from).toBe('review'); + expect(startedEvents[0].to).toBe('review'); + expect(startedEvents[0].actor).toBe('alice'); + + const reviewerBriefing = await controller.tasks.taskBriefing('alice'); + expect(reviewerBriefing).toContain(`#${task.displayId}`); + expect(reviewerBriefing).toContain('reason=review_in_progress'); + expect(reviewerBriefing).toContain('reviewer=alice'); + }); + it('throws when starting review on a deleted task', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -639,7 +674,7 @@ describe('agent-teams-controller API', () => { expect(rows[0].leadSessionId).toBe('lead-session-1'); }); - it('does not wake owner for self-comments and clears user clarification when user replies', () => { + it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); const task = controller.tasks.createTask({ @@ -662,12 +697,16 @@ describe('agent-teams-controller API', () => { text: 'Please use the safer option.', }); - expect(replied.task.needsClarification).toBeUndefined(); + expect(replied.task.needsClarification).toBe('user'); const reloaded = controller.tasks.getTask(task.id); - expect(reloaded.needsClarification).toBeUndefined(); + expect(reloaded.needsClarification).toBe('user'); const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')); expect(rows).toHaveLength(1); expect(rows[0].text).toContain('Please use the safer option.'); + + const cleared = controller.tasks.setNeedsClarification(task.id, 'clear'); + expect(cleared.needsClarification).toBeUndefined(); + expect(controller.tasks.getTask(task.id).needsClarification).toBeUndefined(); }); it('wakes lead owner on comment from another member', () => { @@ -757,7 +796,7 @@ describe('agent-teams-controller API', () => { expect(rows.at(-1).leadSessionId).toBe('lead-session-1'); }); - it('limits approved briefing section to the latest 10 tasks by freshness', async () => { + it('keeps approved tasks in awareness ordered by freshness', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -772,11 +811,112 @@ describe('agent-teams-controller API', () => { ); const briefing = await controller.tasks.taskBriefing('bob'); - expect(briefing).toContain('Approved (last 10):'); + expect(briefing).toContain('Awareness:'); expect(briefing).toContain(`#${approvedTasks[11].displayId}`); expect(briefing).toContain(`#${approvedTasks[2].displayId}`); - expect(briefing).not.toContain(`#${approvedTasks[1].displayId}`); - expect(briefing).not.toContain(`#${approvedTasks[0].displayId}`); + expect(briefing).toContain(`#${approvedTasks[1].displayId}`); + expect(briefing).toContain(`#${approvedTasks[0].displayId}`); + expect(briefing.indexOf(`#${approvedTasks[11].displayId}`)).toBeLessThan( + briefing.indexOf(`#${approvedTasks[0].displayId}`) + ); + }); + + it('builds derived lead briefing and filtered task inventory', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const queuedTask = controller.tasks.createTask({ + subject: 'Queued implementation', + owner: 'bob', + notifyOwner: false, + }); + const unassignedTask = controller.tasks.createTask({ + subject: 'Needs owner', + notifyOwner: false, + }); + const reviewTask = controller.tasks.createTask({ + subject: 'Needs review pickup', + owner: 'bob', + notifyOwner: false, + }); + + controller.tasks.completeTask(reviewTask.id, 'bob'); + controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'alice' }); + + const leadBriefing = await controller.tasks.leadBriefing(); + expect(leadBriefing).toContain('Lead queue for alice on team "my-team":'); + expect(leadBriefing).toContain( + 'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.' + ); + expect(leadBriefing).toContain( + 'Use task_list only for search, filtering, and drill-down inventory lookups.' + ); + expect(leadBriefing).toContain('Needs owner assignment:'); + expect(leadBriefing).toContain(`#${unassignedTask.displayId}`); + expect(leadBriefing).toContain('Lead-owned follow-up:'); + expect(leadBriefing).toContain(`#${reviewTask.displayId}`); + + const reviewInventory = controller.tasks.listTaskInventory({ reviewState: 'review' }); + expect(reviewInventory).toHaveLength(1); + expect(reviewInventory[0].id).toBe(reviewTask.id); + + const ownerPendingInventory = controller.tasks.listTaskInventory({ + owner: 'bob', + status: 'pending', + }); + expect(ownerPendingInventory.map((task) => task.id)).toEqual([queuedTask.id]); + }); + + it('uses legacy kanban reviewer as a migration fallback for active review tasks', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members.push({ name: 'carol', role: 'reviewer' }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const reviewTask = controller.tasks.createTask({ + subject: 'Legacy review assignment', + owner: 'bob', + status: 'completed', + reviewState: 'review', + notifyOwner: false, + }); + + fs.writeFileSync( + path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'), + JSON.stringify( + { + teamName: 'my-team', + reviewers: [], + tasks: { + [reviewTask.id]: { + column: 'review', + reviewer: 'carol', + movedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + null, + 2 + ) + ); + + const reviewerBriefing = await controller.tasks.taskBriefing('carol'); + expect(reviewerBriefing).toContain( + 'Primary queue for carol. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' + ); + expect(reviewerBriefing).toContain('Actionable:'); + expect(reviewerBriefing).toContain(`#${reviewTask.displayId}`); + expect(reviewerBriefing).toContain('reviewer=carol'); + + const leadBriefing = await controller.tasks.leadBriefing(); + expect(leadBriefing).toContain( + 'Use task_list only for search, filtering, and drill-down inventory lookups.' + ); + expect(leadBriefing).toContain('Watching:'); + expect(leadBriefing).toContain(`#${reviewTask.displayId}`); + expect(leadBriefing).not.toContain('review_reviewer_missing'); }); it('marks stale processes stopped during listing and supports unregister', () => { diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 994ba2d3..e2c03985 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -9,6 +9,7 @@ declare module 'agent-teams-controller' { getTask(taskId: string): unknown; getTaskComment(taskId: string, commentId: string): { comment: Record; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } }; listTasks(): unknown[]; + listTaskInventory(filters?: Record): unknown[]; listDeletedTasks(): unknown[]; resolveTaskId(taskRef: string): string; setTaskStatus(taskId: string, status: string, actor?: string): unknown; @@ -27,6 +28,7 @@ declare module 'agent-teams-controller' { linkTask(taskId: string, targetId: string, linkType: string): unknown; unlinkTask(taskId: string, targetId: string, linkType: string): unknown; memberBriefing(memberName: string): Promise; + leadBriefing(): Promise; taskBriefing(memberName: string): Promise; } @@ -111,6 +113,7 @@ declare module 'agent-teams-controller' { export type AgentTeamsMcpToolGroupId = | 'task' + | 'lead' | 'kanban' | 'review' | 'message' @@ -125,6 +128,7 @@ declare module 'agent-teams-controller' { } export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_LEAD_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; @@ -135,4 +139,6 @@ declare module 'agent-teams-controller' { export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[]; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index e765bdb2..4d8a17f7 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -7,6 +7,7 @@ const { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } = import { registerCrossTeamTools } from './crossTeamTools'; import { registerKanbanTools } from './kanbanTools'; +import { registerLeadTools } from './leadTools'; import { registerMessageTools } from './messageTools'; import { registerProcessTools } from './processTools'; import { registerReviewTools } from './reviewTools'; @@ -15,6 +16,7 @@ import { registerTaskTools } from './taskTools'; const REGISTRATION_BY_GROUP = { task: registerTaskTools, + lead: registerLeadTools, kanban: registerKanbanTools, review: registerReviewTools, message: registerMessageTools, diff --git a/mcp-server/src/tools/leadTools.ts b/mcp-server/src/tools/leadTools.ts new file mode 100644 index 00000000..ed986169 --- /dev/null +++ b/mcp-server/src/tools/leadTools.ts @@ -0,0 +1,32 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; + +import { getController } from '../controller'; + +const toolContextSchema = { + teamName: z.string().min(1), + claudeDir: z.string().min(1).optional(), +}; + +const ALWAYS_LOAD_META = { + 'anthropic/alwaysLoad': true, +} as const; + +export function registerLeadTools(server: Pick) { + server.addTool({ + name: 'lead_briefing', + description: 'Get the compact operational lead queue for a team', + _meta: ALWAYS_LOAD_META, + parameters: z.object({ + ...toolContextSchema, + }), + execute: async ({ teamName, claudeDir }) => ({ + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.leadBriefing(), + }, + ], + }), + }); +} diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index a25450aa..f4fc62a2 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { z } from 'zod'; import { agentBlocks, getController } from '../controller'; -import { jsonTextContent, taskWriteResult, slimTask, slimTaskForList } from '../utils/format'; +import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format'; /** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */ const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text); @@ -19,6 +19,18 @@ const ALWAYS_LOAD_META = { } as const; const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); +const inventoryTaskStatusSchema = z.enum(['pending', 'in_progress', 'completed']); +const reviewStateSchema = z.enum(['none', 'review', 'needsFix', 'approved']); +const inventoryKanbanColumnSchema = z.enum(['review', 'approved']); +const DEFAULT_TASK_LIST_LIMIT = 50; +const MAX_TASK_LIST_LIMIT = 200; + +function normalizeTaskListLimit(limit: number | undefined): number { + if (limit == null) { + return DEFAULT_TASK_LIST_LIMIT; + } + return Math.min(Math.max(1, Math.floor(limit)), MAX_TASK_LIST_LIMIT); +} /** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */ const USER_ORIGINATED_SOURCES = new Set(['user_sent']); @@ -299,14 +311,40 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_list', - description: 'List tasks for a team', + description: + 'List compact active task inventory/search rows for a team. Deleted tasks are excluded. Use it to browse, filter, and drill into inventory, not as a primary working queue. Defaults to 50 rows and caps at 200 rows; use filters or a smaller limit to narrow results. Supports stable conjunctive filters for owner, active status, reviewState, review overlay column, and task relationships.', parameters: z.object({ ...toolContextSchema, + owner: z.string().min(1).optional(), + status: inventoryTaskStatusSchema.optional(), + reviewState: reviewStateSchema.optional(), + kanbanColumn: inventoryKanbanColumnSchema.optional(), + relatedTo: z.string().min(1).optional(), + blockedBy: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), }), - execute: async ({ teamName, claudeDir }) => + execute: async ({ + teamName, + claudeDir, + owner, + status, + reviewState, + kanbanColumn, + relatedTo, + blockedBy, + limit, + }) => await Promise.resolve( jsonTextContent( - (getController(teamName, claudeDir).tasks.listTasks() as Record[]).map(slimTaskForList) + getController(teamName, claudeDir).tasks.listTaskInventory({ + ...(owner ? { owner } : {}), + ...(status ? { status } : {}), + ...(reviewState ? { reviewState } : {}), + ...(kanbanColumn ? { kanbanColumn } : {}), + ...(relatedTo ? { relatedTo } : {}), + ...(blockedBy ? { blockedBy } : {}), + limit: normalizeTaskListLimit(limit), + }) ) ), }); diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts index f3c5d4e8..e9b46b32 100644 --- a/mcp-server/test/stdio.e2e.test.ts +++ b/mcp-server/test/stdio.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; @@ -27,6 +27,7 @@ async function writeTeamConfig(claudeDir: string, teamName: string) { members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'teammate', role: 'developer' }, + { name: 'bob', agentType: 'teammate', role: 'reviewer' }, ], }, null, @@ -36,6 +37,84 @@ async function writeTeamConfig(claudeDir: string, teamName: string) { ); } +async function writeBulkTaskRows(claudeDir: string, teamName: string, count: number) { + const tasksDir = path.join(claudeDir, 'tasks', teamName); + await mkdir(tasksDir, { recursive: true }); + + await Promise.all( + Array.from({ length: count }, async (_, index) => { + const ordinal = String(index + 1).padStart(3, '0'); + const id = `bulk-${ordinal}`; + const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(); + await writeFile( + path.join(tasksDir, `${id}.json`), + JSON.stringify( + { + id, + displayId: id, + subject: `Bulk inventory task ${ordinal}`, + description: `Large description that must not be returned in task_list row ${ordinal}`, + owner: index % 2 === 0 ? 'alice' : 'bob', + status: index % 3 === 0 ? 'completed' : 'pending', + reviewState: 'none', + commentCount: 99, + comments: [ + { + id: `comment-${ordinal}`, + author: 'alice', + text: 'Large comment that must not be returned in task_list rows', + }, + ], + historyEvents: [{ type: 'task_created', status: 'pending', timestamp }], + workIntervals: [{ startedAt: timestamp }], + createdAt: timestamp, + updatedAt: timestamp, + }, + null, + 2 + ), + 'utf8' + ); + }) + ); +} + +async function writeInventoryTaskRow( + claudeDir: string, + teamName: string, + task: { + id: string; + owner: string; + subject: string; + status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + createdAt: string; + } +) { + const tasksDir = path.join(claudeDir, 'tasks', teamName); + await mkdir(tasksDir, { recursive: true }); + await writeFile( + path.join(tasksDir, `${task.id}.json`), + JSON.stringify( + { + id: task.id, + displayId: task.id, + subject: task.subject, + description: `Drill-down description for ${task.subject}`, + owner: task.owner, + status: task.status ?? 'pending', + reviewState: 'none', + comments: [], + historyEvents: [{ type: 'task_created', status: task.status ?? 'pending', timestamp: task.createdAt }], + createdAt: task.createdAt, + updatedAt: task.createdAt, + }, + null, + 2 + ), + 'utf8' + ); +} + class McpStdIoClient { private readonly child: ChildProcessWithoutNullStreams; private stdoutBuffer = ''; @@ -137,13 +216,22 @@ describe('agent-teams-mcp stdio e2e', () => { expect(init).toHaveProperty('result'); const tools = (await client.listTools()) as { - result?: { tools?: Array<{ name: string }> }; + result?: { tools?: Array<{ name: string; description?: string }> }; }; - const toolNames = (tools.result?.tools ?? []).map((tool) => tool.name); + const registeredTools = tools.result?.tools ?? []; + const toolNames = registeredTools.map((tool) => tool.name); + const taskListTool = registeredTools.find((tool) => tool.name === 'task_list'); expect(toolNames).toContain('task_create'); expect(toolNames).toContain('task_start'); + expect(toolNames).toContain('task_briefing'); + expect(toolNames).toContain('member_briefing'); expect(toolNames).toContain('review_approve'); + expect(toolNames).toContain('lead_briefing'); + expect(taskListTool?.description).toContain( + 'Use it to browse, filter, and drill into inventory, not as a primary working queue.' + ); + expect(taskListTool?.description).toContain('Deleted tasks are excluded.'); const createResult = await client.callTool( 'task_create', @@ -152,6 +240,7 @@ describe('agent-teams-mcp stdio e2e', () => { teamName: 'e2e-team', subject: 'Smoke task', owner: 'alice', + description: 'Smoke task description', }, 3 ); @@ -175,6 +264,1517 @@ describe('agent-teams-mcp stdio e2e', () => { expect(startedTask.status).toBe('in_progress'); expect(startedTask.id).toBe(createdTask.id); + + const commentResult = await client.callTool( + 'task_add_comment', + { + claudeDir, + teamName: 'e2e-team', + taskId: createdTask.id, + text: 'Working through the smoke task.', + from: 'alice', + }, + 5 + ); + const commentPayload = parseJsonToolResult((commentResult as { result: unknown }).result); + expect(commentPayload.task.id).toBe(createdTask.id); + expect(commentPayload.comment.text).toBe('Working through the smoke task.'); + + const reviewCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'e2e-team', + subject: 'Review task', + owner: 'alice', + }, + 6 + ); + const reviewTask = parseJsonToolResult((reviewCreateResult as { result: unknown }).result); + + const completeResult = await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'e2e-team', + taskId: reviewTask.id, + actor: 'alice', + }, + 7 + ); + const completedTask = parseJsonToolResult((completeResult as { result: unknown }).result); + expect(completedTask.status).toBe('completed'); + + const reviewRequestResult = await client.callTool( + 'review_request', + { + claudeDir, + teamName: 'e2e-team', + taskId: reviewTask.id, + from: 'team-lead', + reviewer: 'bob', + }, + 8 + ); + const reviewRequestedTask = parseJsonToolResult( + (reviewRequestResult as { result: unknown }).result + ); + expect(reviewRequestedTask.reviewState).toBe('review'); + + const unassignedCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'e2e-team', + subject: 'Needs owner assignment', + }, + 9 + ); + const unassignedTask = parseJsonToolResult( + (unassignedCreateResult as { result: unknown }).result + ); + expect(unassignedTask.owner).toBeUndefined(); + + const taskBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'e2e-team', + memberName: 'alice', + }, + 10 + ); + const taskBriefingText = ( + ((taskBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(taskBriefingText).toContain('Task briefing for alice:'); + expect(taskBriefingText).toContain( + 'Primary queue for alice. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' + ); + expect(taskBriefingText).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); + expect(taskBriefingText).toContain('Actionable:'); + expect(taskBriefingText).toContain(`#${createdTask.displayId}`); + expect(taskBriefingText).toContain('reason=owner_executing'); + expect(taskBriefingText).toContain('Description: Smoke task description'); + expect(taskBriefingText).toContain('Working through the smoke task.'); + expect(taskBriefingText).toContain('Awareness:'); + expect(taskBriefingText).toContain(`#${reviewTask.displayId}`); + + const memberBriefingResult = await client.callTool( + 'member_briefing', + { + claudeDir, + teamName: 'e2e-team', + memberName: 'alice', + }, + 11 + ); + const memberBriefingText = ( + ((memberBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(memberBriefingText).toContain( + 'Use task_briefing as your primary working queue whenever you need to see assigned work.' + ); + expect(memberBriefingText).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); + expect(memberBriefingText).toContain( + 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' + ); + + const reviewerTaskBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'e2e-team', + memberName: 'bob', + }, + 12 + ); + const reviewerTaskBriefingText = ( + ((reviewerTaskBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(reviewerTaskBriefingText).toContain('Task briefing for bob:'); + expect(reviewerTaskBriefingText).toContain('Actionable:'); + expect(reviewerTaskBriefingText).toContain(`#${reviewTask.displayId}`); + expect(reviewerTaskBriefingText).toContain('reviewer=bob'); + expect(reviewerTaskBriefingText).toContain('reason=review_requested_waiting_pickup'); + + const leadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'e2e-team', + }, + 13 + ); + const leadBriefingText = ( + ((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadBriefingText).toContain('Lead queue for team-lead on team "e2e-team":'); + expect(leadBriefingText).toContain( + 'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.' + ); + expect(leadBriefingText).toContain( + 'Use task_list only for search, filtering, and drill-down inventory lookups.' + ); + expect(leadBriefingText).toContain('Needs owner assignment:'); + expect(leadBriefingText).toContain(`#${unassignedTask.displayId}`); + expect(leadBriefingText).toContain('reason=owner_missing'); + expect(leadBriefingText).toContain('Watching:'); + expect(leadBriefingText).toContain(`#${reviewTask.displayId}`); + + const inventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'e2e-team', + }, + 14 + ); + const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result); + const reviewInventoryRow = inventoryRows.find( + (row: { id: string }) => row.id === reviewTask.id + ) as Record | undefined; + const unassignedInventoryRow = inventoryRows.find( + (row: { id: string }) => row.id === unassignedTask.id + ) as Record | undefined; + expect(reviewInventoryRow).toMatchObject({ + id: reviewTask.id, + subject: 'Review task', + owner: 'alice', + status: 'completed', + reviewState: 'review', + }); + expect(reviewInventoryRow?.description).toBeUndefined(); + expect(reviewInventoryRow?.comments).toBeUndefined(); + expect(reviewInventoryRow?.historyEvents).toBeUndefined(); + expect(reviewInventoryRow?.workIntervals).toBeUndefined(); + expect(unassignedInventoryRow).toMatchObject({ + id: unassignedTask.id, + subject: 'Needs owner assignment', + status: 'pending', + reviewState: 'none', + }); + expect(unassignedInventoryRow?.owner).toBeUndefined(); + + const filteredListResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'e2e-team', + reviewState: 'review', + kanbanColumn: 'review', + }, + 15 + ); + const filteredTasks = parseJsonToolResult((filteredListResult as { result: unknown }).result); + expect(filteredTasks).toHaveLength(1); + expect(filteredTasks[0]).toMatchObject({ + id: reviewTask.id, + status: 'completed', + reviewState: 'review', + owner: 'alice', + }); + } finally { + await client.close(); + } + }); + + it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => { + await writeTeamConfig(claudeDir, 'bulk-inventory-team'); + await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const tools = (await client.listTools()) as { + result?: { tools?: Array<{ name: string; description?: string }> }; + }; + const taskListTool = tools.result?.tools?.find((tool) => tool.name === 'task_list'); + expect(taskListTool?.description).toContain('Defaults to 50 rows and caps at 200 rows'); + + const defaultInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'bulk-inventory-team', + }, + 21 + ); + const defaultRows = parseJsonToolResult( + (defaultInventoryResult as { result: unknown }).result + ) as Array>; + expect(defaultRows).toHaveLength(50); + for (const row of defaultRows) { + expect(row.description).toBeUndefined(); + expect(row.comments).toBeUndefined(); + expect(row.historyEvents).toBeUndefined(); + expect(row.workIntervals).toBeUndefined(); + expect(row.commentCount).toBe(1); + } + + const drillDownResult = await client.callTool( + 'task_get', + { + claudeDir, + teamName: 'bulk-inventory-team', + taskId: defaultRows[0].id, + }, + 21_1 + ); + const drillDownTask = parseJsonToolResult( + (drillDownResult as { result: unknown }).result + ) as Record; + expect(drillDownTask.id).toBe(defaultRows[0].id); + expect(drillDownTask.description).toContain('Large description that must not be returned'); + expect(drillDownTask.comments).toHaveLength(1); + expect(drillDownTask.historyEvents).toHaveLength(1); + expect(drillDownTask.workIntervals).toHaveLength(1); + + const smallLimitResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'bulk-inventory-team', + limit: 7, + }, + 22 + ); + const smallLimitRows = parseJsonToolResult( + (smallLimitResult as { result: unknown }).result + ) as Array>; + expect(smallLimitRows).toHaveLength(7); + + const filteredLimitResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'bulk-inventory-team', + owner: 'bob', + limit: 5, + }, + 23 + ); + const filteredLimitRows = parseJsonToolResult( + (filteredLimitResult as { result: unknown }).result + ) as Array>; + expect(filteredLimitRows).toHaveLength(5); + expect(filteredLimitRows.every((row) => row.owner === 'bob')).toBe(true); + + const overLimitResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'bulk-inventory-team', + limit: 999, + }, + 24 + ); + const overLimitRows = parseJsonToolResult( + (overLimitResult as { result: unknown }).result + ) as Array>; + expect(overLimitRows).toHaveLength(200); + } finally { + await client.close(); + } + }); + + it('applies task_list filters before default caps over stdio', async () => { + await writeTeamConfig(claudeDir, 'filter-before-cap-team'); + + for (let index = 0; index < 60; index += 1) { + const ordinal = String(index + 1).padStart(3, '0'); + await writeInventoryTaskRow(claudeDir, 'filter-before-cap-team', { + id: `new-alice-${ordinal}`, + owner: 'alice', + subject: `New alice task ${ordinal}`, + createdAt: new Date(Date.UTC(2026, 0, 2, 0, 0, index)).toISOString(), + }); + } + + for (let index = 0; index < 3; index += 1) { + const ordinal = String(index + 1).padStart(3, '0'); + await writeInventoryTaskRow(claudeDir, 'filter-before-cap-team', { + id: `old-bob-${ordinal}`, + owner: 'bob', + subject: `Old bob task ${ordinal}`, + createdAt: new Date(Date.UTC(2025, 11, 31, 0, 0, index)).toISOString(), + }); + } + + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const defaultInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'filter-before-cap-team', + }, + 31 + ); + const defaultRows = parseJsonToolResult( + (defaultInventoryResult as { result: unknown }).result + ) as Array>; + expect(defaultRows).toHaveLength(50); + expect(defaultRows.some((row) => row.owner === 'bob')).toBe(false); + + const filteredInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'filter-before-cap-team', + owner: 'bob', + }, + 32 + ); + const filteredRows = parseJsonToolResult( + (filteredInventoryResult as { result: unknown }).result + ) as Array>; + expect(filteredRows).toHaveLength(3); + expect(filteredRows.map((row) => row.id).sort()).toEqual([ + 'old-bob-001', + 'old-bob-002', + 'old-bob-003', + ]); + expect(filteredRows.every((row) => row.owner === 'bob')).toBe(true); + } finally { + await client.close(); + } + }); + + it('keeps task_list as active inventory and leaves deleted drill-down to task_get over stdio', async () => { + await writeTeamConfig(claudeDir, 'deleted-inventory-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const tools = (await client.listTools()) as { + result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> }; + }; + const taskListTool = tools.result?.tools?.find((tool) => tool.name === 'task_list'); + expect(taskListTool?.description).toContain('Deleted tasks are excluded.'); + expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"pending"'); + expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"in_progress"'); + expect(JSON.stringify(taskListTool?.inputSchema)).toContain('"completed"'); + expect(JSON.stringify(taskListTool?.inputSchema)).not.toContain('"deleted"'); + + const deletedStatusListResult = (await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'deleted-inventory-team', + status: 'deleted', + }, + 40 + )) as { error?: { code?: number; message?: string } }; + expect(deletedStatusListResult.error?.code).toBe(-32602); + expect(deletedStatusListResult.error?.message).toContain( + 'expected one of "pending"|"in_progress"|"completed"' + ); + + const createResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'deleted-inventory-team', + subject: 'Deleted task should not be inventory', + owner: 'alice', + }, + 41 + ); + const task = parseJsonToolResult((createResult as { result: unknown }).result); + + await client.callTool( + 'task_set_status', + { + claudeDir, + teamName: 'deleted-inventory-team', + taskId: task.id, + status: 'deleted', + actor: 'alice', + }, + 42 + ); + + const inventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'deleted-inventory-team', + owner: 'alice', + }, + 43 + ); + const inventoryRows = parseJsonToolResult( + (inventoryResult as { result: unknown }).result + ) as Array>; + expect(inventoryRows.find((row) => row.id === task.id)).toBeUndefined(); + + const drillDownResult = await client.callTool( + 'task_get', + { + claudeDir, + teamName: 'deleted-inventory-team', + taskId: task.id, + }, + 44 + ); + const drillDownTask = parseJsonToolResult( + (drillDownResult as { result: unknown }).result + ) as Record; + expect(drillDownTask.id).toBe(task.id); + expect(drillDownTask.status).toBe('deleted'); + expect(drillDownTask.deletedAt).toEqual(expect.any(String)); + } finally { + await client.close(); + } + }); + + it('preserves legacy kanban reviewer fallback over stdio for old boards without review history reviewer', async () => { + await writeTeamConfig(claudeDir, 'legacy-review-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const createResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'legacy-review-team', + subject: 'Legacy review fallback', + owner: 'alice', + }, + 21 + ); + const createdTask = parseJsonToolResult((createResult as { result: unknown }).result); + + const completeResult = await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'legacy-review-team', + taskId: createdTask.id, + actor: 'alice', + }, + 22 + ); + const completedTask = parseJsonToolResult((completeResult as { result: unknown }).result); + expect(completedTask.status).toBe('completed'); + + const taskPath = path.join(claudeDir, 'tasks', 'legacy-review-team', `${createdTask.id}.json`); + const persistedTask = JSON.parse(await readFile(taskPath, 'utf8')) as { + reviewState?: string; + historyEvents?: Array>; + }; + persistedTask.reviewState = 'review'; + persistedTask.historyEvents = (Array.isArray(persistedTask.historyEvents) + ? persistedTask.historyEvents + : [] + ).filter( + (event) => + event.type !== 'review_requested' && + event.type !== 'review_started' && + event.type !== 'review_approved' && + event.type !== 'review_changes_requested' + ); + await writeFile(taskPath, JSON.stringify(persistedTask, null, 2), 'utf8'); + + const kanbanPath = path.join(claudeDir, 'teams', 'legacy-review-team', 'kanban-state.json'); + await writeFile( + kanbanPath, + JSON.stringify( + { + teamName: 'legacy-review-team', + reviewers: [], + tasks: { + [createdTask.id]: { + column: 'review', + reviewer: 'bob', + movedAt: '2026-01-01T00:00:00.000Z', + }, + }, + columnOrder: { + review: [createdTask.id], + }, + }, + null, + 2 + ), + 'utf8' + ); + + const reviewerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'legacy-review-team', + memberName: 'bob', + }, + 23 + ); + const reviewerBriefingText = ( + ((reviewerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(reviewerBriefingText).toContain('Task briefing for bob:'); + expect(reviewerBriefingText).toContain('Actionable:'); + expect(reviewerBriefingText).toContain(`#${createdTask.displayId}`); + expect(reviewerBriefingText).toContain('reviewer=bob'); + expect(reviewerBriefingText).not.toContain('review_reviewer_missing'); + + const leadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'legacy-review-team', + }, + 24 + ); + const leadBriefingText = ( + ((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadBriefingText).toContain('Lead queue for team-lead on team "legacy-review-team":'); + expect(leadBriefingText).toContain('Watching:'); + expect(leadBriefingText).toContain(`#${createdTask.displayId}`); + expect(leadBriefingText).not.toContain('review_reviewer_missing'); + + const inventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'legacy-review-team', + reviewState: 'review', + kanbanColumn: 'review', + }, + 25 + ); + const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result); + expect(inventoryRows).toHaveLength(1); + expect(inventoryRows[0]).toMatchObject({ + id: createdTask.id, + owner: 'alice', + reviewState: 'review', + status: 'completed', + }); + } finally { + await client.close(); + } + }); + + it('surfaces reviewer-assignment gaps and needs-fix review roundtrip over stdio', async () => { + await writeTeamConfig(claudeDir, 'review-roundtrip-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const noReviewerCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'review-roundtrip-team', + subject: 'Needs reviewer assignment', + owner: 'alice', + }, + 31 + ); + const noReviewerTask = parseJsonToolResult( + (noReviewerCreateResult as { result: unknown }).result + ); + + await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: noReviewerTask.id, + actor: 'alice', + }, + 32 + ); + + const noReviewerRequestResult = await client.callTool( + 'review_request', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: noReviewerTask.id, + from: 'team-lead', + }, + 33 + ); + const noReviewerRequestedTask = parseJsonToolResult( + (noReviewerRequestResult as { result: unknown }).result + ); + expect(noReviewerRequestedTask.reviewState).toBe('review'); + + const leadAssignmentBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'review-roundtrip-team', + }, + 34 + ); + const leadAssignmentBriefingText = ( + ((leadAssignmentBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadAssignmentBriefingText).toContain('Needs reviewer assignment:'); + expect(leadAssignmentBriefingText).toContain(`#${noReviewerTask.displayId}`); + expect(leadAssignmentBriefingText).toContain('reason=review_reviewer_missing'); + + const reviewerEmptyBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'review-roundtrip-team', + memberName: 'bob', + }, + 35 + ); + const reviewerEmptyBriefingText = ( + ((reviewerEmptyBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(reviewerEmptyBriefingText).toContain('No actionable or awareness tasks for bob.'); + expect(reviewerEmptyBriefingText).not.toContain(`#${noReviewerTask.displayId}`); + + const roundtripCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'review-roundtrip-team', + subject: 'Needs fixes after review', + owner: 'alice', + description: 'Roundtrip description', + }, + 36 + ); + const roundtripTask = parseJsonToolResult((roundtripCreateResult as { result: unknown }).result); + + await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: roundtripTask.id, + actor: 'alice', + }, + 37 + ); + + await client.callTool( + 'review_request', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: roundtripTask.id, + from: 'team-lead', + reviewer: 'bob', + }, + 38 + ); + + await client.callTool( + 'review_start', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: roundtripTask.id, + from: 'bob', + }, + 39 + ); + + const reviewerActiveBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'review-roundtrip-team', + memberName: 'bob', + }, + 40 + ); + const reviewerActiveBriefingText = ( + ((reviewerActiveBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(reviewerActiveBriefingText).toContain(`#${roundtripTask.displayId}`); + expect(reviewerActiveBriefingText).toContain('reason=review_in_progress'); + expect(reviewerActiveBriefingText).toContain('reviewer=bob'); + + const changesResult = await client.callTool( + 'review_request_changes', + { + claudeDir, + teamName: 'review-roundtrip-team', + taskId: roundtripTask.id, + from: 'bob', + comment: 'Please fix the failing edge case.', + }, + 41 + ); + const changedTask = parseJsonToolResult((changesResult as { result: unknown }).result); + expect(changedTask.status).toBe('pending'); + expect(changedTask.reviewState).toBe('needsFix'); + + const ownerNeedsFixBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'review-roundtrip-team', + memberName: 'alice', + }, + 42 + ); + const ownerNeedsFixBriefingText = ( + ((ownerNeedsFixBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(ownerNeedsFixBriefingText).toContain(`#${roundtripTask.displayId}`); + expect(ownerNeedsFixBriefingText).toContain('Actionable:'); + expect(ownerNeedsFixBriefingText).toContain('reason=needs_fix'); + expect(ownerNeedsFixBriefingText).toContain('Description: Roundtrip description'); + expect(ownerNeedsFixBriefingText).toContain('Please fix the failing edge case.'); + + const needsFixInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'review-roundtrip-team', + owner: 'alice', + reviewState: 'needsFix', + status: 'pending', + }, + 43 + ); + const needsFixInventoryRows = parseJsonToolResult( + (needsFixInventoryResult as { result: unknown }).result + ); + expect(needsFixInventoryRows).toHaveLength(1); + expect(needsFixInventoryRows[0]).toMatchObject({ + id: roundtripTask.id, + owner: 'alice', + reviewState: 'needsFix', + status: 'pending', + }); + + const finalLeadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'review-roundtrip-team', + }, + 44 + ); + const finalLeadBriefingText = ( + ((finalLeadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(finalLeadBriefingText).toContain(`#${noReviewerTask.displayId}`); + expect(finalLeadBriefingText).not.toContain(`#${roundtripTask.displayId}`); + } finally { + await client.close(); + } + }); + + it('surfaces self-review invalid as lead-owned and supports relationship inventory filters over stdio', async () => { + await writeTeamConfig(claudeDir, 'inventory-filters-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const baseCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'inventory-filters-team', + subject: 'Base task', + owner: 'alice', + }, + 51 + ); + const baseTask = parseJsonToolResult((baseCreateResult as { result: unknown }).result); + + const blockedCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'inventory-filters-team', + subject: 'Blocked task', + owner: 'alice', + blockedBy: [baseTask.id], + }, + 52 + ); + const blockedTask = parseJsonToolResult((blockedCreateResult as { result: unknown }).result); + + const relatedCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'inventory-filters-team', + subject: 'Related task', + owner: 'alice', + related: [baseTask.id], + }, + 53 + ); + const relatedTask = parseJsonToolResult((relatedCreateResult as { result: unknown }).result); + + const blockedInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'inventory-filters-team', + blockedBy: `#${baseTask.displayId}`, + }, + 54 + ); + const blockedInventoryRows = parseJsonToolResult( + (blockedInventoryResult as { result: unknown }).result + ); + expect(blockedInventoryRows).toHaveLength(1); + expect(blockedInventoryRows[0]).toMatchObject({ + id: blockedTask.id, + subject: 'Blocked task', + blockedBy: [baseTask.id], + }); + + const relatedInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'inventory-filters-team', + relatedTo: `#${baseTask.displayId}`, + }, + 55 + ); + const relatedInventoryRows = parseJsonToolResult( + (relatedInventoryResult as { result: unknown }).result + ); + expect(relatedInventoryRows).toHaveLength(1); + expect(relatedInventoryRows[0]).toMatchObject({ + id: relatedTask.id, + subject: 'Related task', + related: [baseTask.id], + }); + + const blockedOwnerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'inventory-filters-team', + memberName: 'alice', + }, + 55_1 + ); + const blockedOwnerBriefingText = ( + ((blockedOwnerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(blockedOwnerBriefingText).toContain(`#${baseTask.displayId}`); + expect(blockedOwnerBriefingText).toContain('Actionable:'); + expect(blockedOwnerBriefingText).toContain(`#${blockedTask.displayId}`); + expect(blockedOwnerBriefingText).toContain('Awareness:'); + expect(blockedOwnerBriefingText).toContain('reason=dependency_waiting'); + + await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'inventory-filters-team', + taskId: baseTask.id, + actor: 'alice', + }, + 55_2 + ); + + const unblockedOwnerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'inventory-filters-team', + memberName: 'alice', + }, + 55_3 + ); + const unblockedOwnerBriefingText = ( + ((unblockedOwnerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(unblockedOwnerBriefingText).toContain(`#${blockedTask.displayId}`); + expect(unblockedOwnerBriefingText).toContain('Actionable:'); + expect(unblockedOwnerBriefingText).toContain('reason=owner_ready'); + expect(unblockedOwnerBriefingText).not.toContain('reason=dependency_waiting'); + + const selfReviewCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'inventory-filters-team', + subject: 'Self review should be invalid', + owner: 'alice', + }, + 56 + ); + const selfReviewTask = parseJsonToolResult( + (selfReviewCreateResult as { result: unknown }).result + ); + + await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'inventory-filters-team', + taskId: selfReviewTask.id, + actor: 'alice', + }, + 57 + ); + + await client.callTool( + 'review_request', + { + claudeDir, + teamName: 'inventory-filters-team', + taskId: selfReviewTask.id, + from: 'team-lead', + reviewer: 'alice', + }, + 58 + ); + + const leadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'inventory-filters-team', + }, + 59 + ); + const leadBriefingText = ( + ((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadBriefingText).toContain('Needs reviewer assignment:'); + expect(leadBriefingText).toContain(`#${selfReviewTask.displayId}`); + expect(leadBriefingText).toContain('reason=self_review_invalid'); + + const ownerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'inventory-filters-team', + memberName: 'alice', + }, + 60 + ); + const ownerBriefingText = ( + ((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(ownerBriefingText).toContain(`#${selfReviewTask.displayId}`); + expect(ownerBriefingText).toContain('Awareness:'); + expect(ownerBriefingText).toContain('reason=self_review_invalid'); + } finally { + await client.close(); + } + }); + + it('routes clarification flags into owner awareness and lead sections over stdio', async () => { + await writeTeamConfig(claudeDir, 'clarification-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const leadClarificationCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'clarification-team', + subject: 'Need lead answer', + owner: 'alice', + }, + 71 + ); + const leadClarificationTask = parseJsonToolResult( + (leadClarificationCreateResult as { result: unknown }).result + ); + + const userClarificationCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'clarification-team', + subject: 'Need user answer', + owner: 'alice', + }, + 72 + ); + const userClarificationTask = parseJsonToolResult( + (userClarificationCreateResult as { result: unknown }).result + ); + + await client.callTool( + 'task_set_clarification', + { + claudeDir, + teamName: 'clarification-team', + taskId: leadClarificationTask.id, + value: 'lead', + }, + 73 + ); + + await client.callTool( + 'task_set_clarification', + { + claudeDir, + teamName: 'clarification-team', + taskId: userClarificationTask.id, + value: 'user', + }, + 74 + ); + + const ownerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'clarification-team', + memberName: 'alice', + }, + 75 + ); + const ownerBriefingText = ( + ((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(ownerBriefingText).toContain('Awareness:'); + expect(ownerBriefingText).toContain(`#${leadClarificationTask.displayId}`); + expect(ownerBriefingText).toContain('reason=waiting_lead_clarification'); + expect(ownerBriefingText).toContain('clarification=lead'); + expect(ownerBriefingText).toContain(`#${userClarificationTask.displayId}`); + expect(ownerBriefingText).toContain('reason=waiting_user_clarification'); + expect(ownerBriefingText).toContain('clarification=user'); + + const leadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'clarification-team', + }, + 76 + ); + const leadBriefingText = ( + ((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadBriefingText).toContain('Needs clarification from lead:'); + expect(leadBriefingText).toContain(`#${leadClarificationTask.displayId}`); + expect(leadBriefingText).toContain('reason=waiting_lead_clarification'); + expect(leadBriefingText).toContain('Waiting on user:'); + expect(leadBriefingText).toContain(`#${userClarificationTask.displayId}`); + expect(leadBriefingText).toContain('reason=waiting_user_clarification'); + + const inventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'clarification-team', + owner: 'alice', + status: 'pending', + }, + 77 + ); + const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result); + const leadClarificationRow = inventoryRows.find( + (row: { id: string }) => row.id === leadClarificationTask.id + ) as Record | undefined; + const userClarificationRow = inventoryRows.find( + (row: { id: string }) => row.id === userClarificationTask.id + ) as Record | undefined; + expect(leadClarificationRow?.needsClarification).toBe('lead'); + expect(userClarificationRow?.needsClarification).toBe('user'); + + await client.callTool( + 'task_set_clarification', + { + claudeDir, + teamName: 'clarification-team', + taskId: leadClarificationTask.id, + value: 'clear', + }, + 78 + ); + + const ownerAfterClearBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'clarification-team', + memberName: 'alice', + }, + 79 + ); + const ownerAfterClearBriefingText = ( + ((ownerAfterClearBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(ownerAfterClearBriefingText).toContain('Actionable:'); + expect(ownerAfterClearBriefingText).toContain(`#${leadClarificationTask.displayId}`); + expect(ownerAfterClearBriefingText).toContain('reason=owner_ready'); + expect(ownerAfterClearBriefingText).not.toContain('reason=waiting_lead_clarification'); + expect(ownerAfterClearBriefingText).toContain(`#${userClarificationTask.displayId}`); + expect(ownerAfterClearBriefingText).toContain('reason=waiting_user_clarification'); + + const leadAfterClearBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'clarification-team', + }, + 80 + ); + const leadAfterClearBriefingText = ( + ((leadAfterClearBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadAfterClearBriefingText).not.toContain(`#${leadClarificationTask.displayId}`); + expect(leadAfterClearBriefingText).toContain('Waiting on user:'); + expect(leadAfterClearBriefingText).toContain(`#${userClarificationTask.displayId}`); + } finally { + await client.close(); + } + }); + + it('routes lead-owned work and approved terminal awareness over stdio', async () => { + await writeTeamConfig(claudeDir, 'terminal-routing-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const leadOwnedCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'terminal-routing-team', + subject: 'Lead-owned follow-up task', + owner: 'team-lead', + }, + 81 + ); + const leadOwnedTask = parseJsonToolResult( + (leadOwnedCreateResult as { result: unknown }).result + ); + + const leadOwnedBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'terminal-routing-team', + }, + 82 + ); + const leadOwnedBriefingText = ( + ((leadOwnedBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadOwnedBriefingText).toContain('Lead-owned follow-up:'); + expect(leadOwnedBriefingText).toContain(`#${leadOwnedTask.displayId}`); + expect(leadOwnedBriefingText).toContain('owner=team-lead'); + expect(leadOwnedBriefingText).toContain('actionOwner=lead'); + expect(leadOwnedBriefingText).toContain('reason=owner_ready'); + + const unrelatedMemberBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'terminal-routing-team', + memberName: 'alice', + }, + 83 + ); + const unrelatedMemberBriefingText = ( + ((unrelatedMemberBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(unrelatedMemberBriefingText).toContain('No actionable or awareness tasks for alice.'); + expect(unrelatedMemberBriefingText).not.toContain(`#${leadOwnedTask.displayId}`); + + const approvedCreateResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'terminal-routing-team', + subject: 'Approved terminal task', + owner: 'alice', + description: 'This should become terminal awareness, not work.', + }, + 84 + ); + const approvedTask = parseJsonToolResult((approvedCreateResult as { result: unknown }).result); + + await client.callTool( + 'task_complete', + { + claudeDir, + teamName: 'terminal-routing-team', + taskId: approvedTask.id, + actor: 'alice', + }, + 85 + ); + + await client.callTool( + 'review_request', + { + claudeDir, + teamName: 'terminal-routing-team', + taskId: approvedTask.id, + from: 'team-lead', + reviewer: 'bob', + }, + 86 + ); + + await client.callTool( + 'review_start', + { + claudeDir, + teamName: 'terminal-routing-team', + taskId: approvedTask.id, + from: 'bob', + }, + 87 + ); + + const approveResult = await client.callTool( + 'review_approve', + { + claudeDir, + teamName: 'terminal-routing-team', + taskId: approvedTask.id, + from: 'bob', + note: 'Approved through stdio e2e.', + notifyOwner: true, + }, + 88 + ); + const approvedPayload = parseJsonToolResult((approveResult as { result: unknown }).result); + expect(approvedPayload.reviewState).toBe('approved'); + + const ownerBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'terminal-routing-team', + memberName: 'alice', + }, + 89 + ); + const ownerBriefingText = ( + ((ownerBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(ownerBriefingText).not.toContain('Actionable:'); + expect(ownerBriefingText).toContain('Awareness:'); + expect(ownerBriefingText).toContain(`#${approvedTask.displayId}`); + expect(ownerBriefingText).toContain('review=approved'); + expect(ownerBriefingText).toContain('actionOwner=none'); + expect(ownerBriefingText).toContain('reason=terminal_approved'); + expect(ownerBriefingText).not.toContain('Description: This should become terminal awareness'); + + const leadAfterApprovalBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'terminal-routing-team', + }, + 90 + ); + const leadAfterApprovalBriefingText = ( + ((leadAfterApprovalBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadAfterApprovalBriefingText).toContain(`#${leadOwnedTask.displayId}`); + expect(leadAfterApprovalBriefingText).not.toContain(`#${approvedTask.displayId}`); + + const approvedInventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'terminal-routing-team', + reviewState: 'approved', + kanbanColumn: 'approved', + }, + 91 + ); + const approvedInventoryRows = parseJsonToolResult( + (approvedInventoryResult as { result: unknown }).result + ); + expect(approvedInventoryRows).toHaveLength(1); + expect(approvedInventoryRows[0]).toMatchObject({ + id: approvedTask.id, + owner: 'alice', + status: 'completed', + reviewState: 'approved', + }); + expect(approvedInventoryRows[0].description).toBeUndefined(); + expect(approvedInventoryRows[0].comments).toBeUndefined(); + expect(approvedInventoryRows[0].historyEvents).toBeUndefined(); + } finally { + await client.close(); + } + }); + + it('routes invalid owners and broken dependencies to lead over stdio', async () => { + await writeTeamConfig(claudeDir, 'repair-team'); + const client = new McpStdIoClient(serverPath, workspaceRoot); + + try { + await client.initialize(); + + const orphanedTaskResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'repair-team', + subject: 'Owner became invalid', + owner: 'alice', + }, + 81 + ); + const orphanedTask = parseJsonToolResult((orphanedTaskResult as { result: unknown }).result); + + const membersMetaPath = path.join(claudeDir, 'teams', 'repair-team', 'members.meta.json'); + await writeFile( + membersMetaPath, + JSON.stringify( + { + version: 1, + members: [{ name: 'alice', removedAt: 1_776_772_800_000 }], + }, + null, + 2 + ), + 'utf8' + ); + + const dependencyTaskResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'repair-team', + subject: 'Dependency to be deleted', + owner: 'bob', + }, + 82 + ); + const dependencyTask = parseJsonToolResult( + (dependencyTaskResult as { result: unknown }).result + ); + + const blockedTaskResult = await client.callTool( + 'task_create', + { + claudeDir, + teamName: 'repair-team', + subject: 'Broken dependency task', + owner: 'bob', + blockedBy: [dependencyTask.id], + }, + 83 + ); + const blockedTask = parseJsonToolResult((blockedTaskResult as { result: unknown }).result); + + await client.callTool( + 'task_set_status', + { + claudeDir, + teamName: 'repair-team', + taskId: dependencyTask.id, + status: 'deleted', + actor: 'bob', + }, + 84 + ); + + const leadBriefingResult = await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'repair-team', + }, + 85 + ); + const leadBriefingText = ( + ((leadBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(leadBriefingText).toContain('Needs owner assignment:'); + expect(leadBriefingText).toContain(`#${orphanedTask.displayId}`); + expect(leadBriefingText).toContain('reason=owner_invalid'); + expect(leadBriefingText).toContain('Dependency repair:'); + expect(leadBriefingText).toContain(`#${blockedTask.displayId}`); + expect(leadBriefingText).toContain('reason=dependency_broken'); + + const bobBriefingResult = await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'repair-team', + memberName: 'bob', + }, + 86 + ); + const bobBriefingText = ( + ((bobBriefingResult as { result: { content?: Array<{ text?: string }> } }).result + ?.content?.[0]?.text as string | undefined) ?? '' + ); + expect(bobBriefingText).toContain('Awareness:'); + expect(bobBriefingText).toContain(`#${blockedTask.displayId}`); + expect(bobBriefingText).toContain('reason=dependency_broken'); + expect(bobBriefingText).not.toContain('reason=owner_ready'); + + const inventoryResult = await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'repair-team', + owner: 'bob', + }, + 87 + ); + const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result); + const blockedRow = inventoryRows.find( + (row: { id: string }) => row.id === blockedTask.id + ) as Record | undefined; + expect(blockedRow).toMatchObject({ + id: blockedTask.id, + owner: 'bob', + blockedBy: [dependencyTask.id], + status: 'pending', + }); } finally { await client.close(); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 2a27ba3a..56b3de14 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -6,6 +6,7 @@ import path from 'path'; import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools'; type RegisteredTool = { + description?: string; name: string; parameters?: { safeParse: (value: unknown) => { success: boolean } }; execute: (args: Record) => Promise | unknown; @@ -484,7 +485,12 @@ describe('agent-teams-mcp tools', () => { const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0] ?.text; expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).'); - expect(memberBriefingText).toContain('Use task_briefing as your compact queue view'); + expect(memberBriefingText).toContain( + 'Use task_briefing as your primary working queue whenever you need to see assigned work.' + ); + expect(memberBriefingText).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); expect(memberBriefingText).toContain('Review MCP adapter'); }); @@ -593,14 +599,19 @@ describe('agent-teams-mcp tools', () => { memberName: 'alice', })) as { content: Array<{ text: string }> }; const briefingText = briefing.content[0]?.text ?? ''; - expect(briefingText).toContain('In progress:'); + expect(briefingText).toContain( + 'Primary queue for alice. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' + ); + expect(briefingText).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); + expect(briefingText).toContain('Actionable:'); expect(briefingText).toContain(`#${activeTask.displayId}`); expect(briefingText).toContain('Description: This one is already in progress'); expect(briefingText).toContain('Investigating the active task now.'); - expect(briefingText).toContain('Pending:'); expect(briefingText).toContain(`#${queuedTask.displayId}`); expect(briefingText).not.toContain('Pending description should stay out of briefing details'); - expect(briefingText).toContain('Completed:'); + expect(briefingText).toContain('Awareness:'); expect(briefingText).toContain(`#${completedTask.displayId}`); expect(briefingText).not.toContain('Completed description should also stay compact'); @@ -618,6 +629,9 @@ describe('agent-teams-mcp tools', () => { ); expect(memberBriefingText).toContain('reason and your best ETA or what you are waiting on'); expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.'); + expect(memberBriefingText).toContain( + 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' + ); expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'); expect(memberBriefingText).toContain('Task briefing for alice:'); expect(memberBriefingText).toContain(`#${activeTask.displayId}`); @@ -662,6 +676,95 @@ describe('agent-teams-mcp tools', () => { ); }); + it('returns compact lead_briefing output and filtered task_list inventory', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'lead-queue'; + writeTeamConfig(claudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + { name: 'bob', role: 'reviewer' }, + ], + }); + + const queuedTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Queued work', + owner: 'alice', + }) + ); + const unassignedTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Unassigned work', + }) + ); + const reviewTask = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Awaiting reviewer', + owner: 'alice', + }) + ); + + await getTool('task_complete').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + actor: 'alice', + }); + await getTool('review_request').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + from: 'lead', + reviewer: 'bob', + }); + + const leadBriefing = (await getTool('lead_briefing').execute({ + claudeDir, + teamName, + })) as { content: Array<{ text: string }> }; + const leadBriefingText = leadBriefing.content[0]?.text ?? ''; + expect(leadBriefingText).toContain('Lead queue for lead on team "lead-queue":'); + expect(leadBriefingText).toContain( + 'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.' + ); + expect(leadBriefingText).toContain( + 'Use task_list only for search, filtering, and drill-down inventory lookups.' + ); + expect(leadBriefingText).toContain('Needs owner assignment:'); + expect(leadBriefingText).toContain(`#${unassignedTask.displayId}`); + expect(leadBriefingText).toContain('Watching:'); + expect(leadBriefingText).toContain(`#${reviewTask.displayId}`); + + const reviewInventory = parseJsonToolResult( + await getTool('task_list').execute({ + claudeDir, + teamName, + reviewState: 'review', + kanbanColumn: 'review', + }) + ); + expect(reviewInventory).toHaveLength(1); + expect(reviewInventory[0].id).toBe(reviewTask.id); + + const ownerPendingInventory = parseJsonToolResult( + await getTool('task_list').execute({ + claudeDir, + teamName, + owner: 'alice', + status: 'pending', + }) + ); + expect(ownerPendingInventory).toHaveLength(1); + expect(ownerPendingInventory[0].id).toBe(queuedTask.id); + }); + it('covers review_request_changes and full process lifecycle tools', async () => { const claudeDir = makeClaudeDir(); const teamName = 'beta'; @@ -915,7 +1018,13 @@ describe('agent-teams-mcp tools', () => { expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox'); }); - it('write operations return slim task (no comments/historyEvents arrays)', async () => { + it('write operations return slim task and task_list returns allowlisted inventory rows', async () => { + expect(getTool('task_list').description).toContain( + 'Use it to browse, filter, and drill into inventory, not as a primary working queue.' + ); + expect(getTool('task_list').description).toContain('Deleted tasks are excluded.'); + expect(getTool('task_list').description).toContain('Defaults to 50 rows and caps at 200 rows'); + const claudeDir = makeClaudeDir(); const teamName = 'slim-check'; @@ -980,20 +1089,27 @@ describe('agent-teams-mcp tools', () => { expect(completed.status).toBe('completed'); expect(completed.comments).toBeUndefined(); - // task_list: uses blocklist, includes description but not comments array + // task_list: explicit inventory shape only const listed = parseJsonToolResult( await getTool('task_list').execute({ claudeDir, teamName }) ); const listedTask = listed.find((t: { id: string }) => t.id === task.id); expect(listedTask).toBeDefined(); - expect(listedTask.subject).toBe('Slim task test'); - expect(listedTask.commentCount).toBe(1); + expect(listedTask).toEqual({ + id: task.id, + displayId: task.displayId, + subject: 'Slim task test', + status: 'completed', + owner: 'lead', + reviewState: 'none', + commentCount: 1, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(listedTask.description).toBeUndefined(); expect(listedTask.comments).toBeUndefined(); expect(listedTask.historyEvents).toBeUndefined(); expect(listedTask.workIntervals).toBeUndefined(); - // task_list preserves non-heavy fields - expect(listedTask.status).toBeDefined(); - expect(listedTask.id).toBeDefined(); // task_get: still returns full task with comments const full = parseJsonToolResult( diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a0413574..54a652ac 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -241,7 +241,7 @@ export class TeamDataService { kanbanTaskState?: KanbanState['tasks'][string] ): TeamTaskWithKanban { const reviewState = this.resolveTaskReviewState(task); - const reviewer = kanbanTaskState?.reviewer ?? this.resolveReviewerFromHistory(task) ?? null; + const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null; return { ...task, reviewState, @@ -251,23 +251,45 @@ export class TeamDataService { } /** - * Extract reviewer name from task history events as a fallback - * when kanban state doesn't have it (e.g. review done via MCP agent-teams). + * Extract reviewer name from the current review cycle history. + * For legacy boards that stored reviewer only in kanban state, preserve that + * value as a migration fallback while the task is still actively in review. */ - private resolveReviewerFromHistory(task: TeamTask): string | null { - if (!task.historyEvents?.length) return null; - for (let i = task.historyEvents.length - 1; i >= 0; i--) { - const event = task.historyEvents[i]; - if (event.type === 'review_approved' && event.actor) { - return event.actor; - } - if (event.type === 'review_started' && event.actor) { - return event.actor; - } - if (event.type === 'review_requested' && event.reviewer) { - return event.reviewer; + private resolveReviewerFromHistory( + task: TeamTask, + kanbanTaskState?: KanbanState['tasks'][string], + reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task) + ): string | null { + if (task.historyEvents?.length) { + for (let i = task.historyEvents.length - 1; i >= 0; i--) { + const event = task.historyEvents[i]; + if (event.type === 'review_started' && event.actor) { + return event.actor; + } + if (event.type === 'review_requested' && event.reviewer) { + return event.reviewer; + } + if (event.type === 'review_approved' || event.type === 'review_changes_requested') { + break; + } + if (event.type === 'status_changed' && event.to === 'in_progress') { + break; + } + if (event.type === 'task_created') { + break; + } } } + + if ( + reviewState === 'review' && + kanbanTaskState?.column === 'review' && + typeof kanbanTaskState.reviewer === 'string' && + kanbanTaskState.reviewer.trim().length > 0 + ) { + return kanbanTaskState.reviewer.trim(); + } + return null; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1f1e069b..2e7e3a5a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -214,11 +214,16 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } = - agentTeamsControllerModule; +const { + AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + createController, + protocols, +} = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; +const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; const VERIFY_POLL_MS = 500; const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250; const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000; @@ -1581,14 +1586,27 @@ function extractBootstrapFailureReason(text: string): string | null { (lower.includes('член') || lower.includes('member') || lower.includes('inbox'))) || lower.includes('member_briefing tool is not available') || lower.includes('member_briefing tool not found') || + lower.includes('lead_briefing tool is not available') || + lower.includes('lead_briefing tool not found') || lower.includes('no such tool available: mcp__agent_teams__member_briefing') || + lower.includes('no such tool available: mcp__agent_teams__lead_briefing') || lower.includes('agent calls that include team_name must also include name') || (lower.includes('member_briefing') && (lower.includes('not available') || lower.includes('not found') || lower.includes('lookup failure') || lower.includes('validation error') || - lower.includes('api error'))) || + lower.includes('api error') || + lower.includes('empty content') || + lower.includes('unspecified error'))) || + (lower.includes('lead_briefing') && + (lower.includes('not available') || + lower.includes('not found') || + lower.includes('lookup failure') || + lower.includes('validation error') || + lower.includes('api error') || + lower.includes('empty content') || + lower.includes('unspecified error'))) || lower.includes('model is not supported') || lower.includes('model is not available') || lower.includes('model not available') || @@ -1767,7 +1785,9 @@ After member_briefing succeeds: - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. - Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. -- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. +- When you later receive work or reconnect after a restart, use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. +- Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. +- Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. - If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin. - CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. - CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. @@ -1838,7 +1858,8 @@ ${actionModeProtocol} - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - - Use task_briefing as your compact queue view. + - Use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. + - Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. - After that, prioritize tasks marked Needs fixes after review, then normal pending tasks. - Before you start any needsFix or pending task, call task_get for that specific task. @@ -2016,7 +2037,7 @@ function buildDeterministicCreateBootstrapSpec( ...(request.skipPermissions === false ? { permissionSeedTools: [ - ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, 'Edit', 'Write', 'NotebookEdit', @@ -2065,7 +2086,7 @@ function buildDeterministicLaunchBootstrapSpec( ...(request.skipPermissions === false ? { permissionSeedTools: [ - ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, 'Edit', 'Write', 'NotebookEdit', @@ -2149,13 +2170,16 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string ` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`, ` - When splitting, make each task have a clear completion criterion and a single accountable owner.`, ``, - `IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, + `IMPORTANT: The board MCP supports these domains: lead, task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, ``, `Task board operations — use MCP tools directly:`, + `- FIRST inspect the compact lead queue: lead_briefing { teamName: "${teamName}" }`, + ` lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.`, `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, `- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "", commentId: "" }`, ` When a teammate reports "#abcd1234 done ... task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }", use that taskId and commentId to fetch the full result text.`, - `- List all tasks: task_list { teamName: "${teamName}" }`, + `- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed|deleted", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "", blockedBy?: "", limit?: }`, + ` task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue.`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, @@ -2214,9 +2238,9 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Set createdBy when creating tasks so workflow history shows who created the task.`, ``, `Clarification handling (CRITICAL — MANDATORY for correct task board state):`, - `- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer, auto-clears the flag, and wakes the owner.`, + `- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer on the board.`, `- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`, - `- If you somehow reply via SendMessage before commenting, add the missing task comment immediately, and if needed also clear the flag manually:`, + `- Clarification flags are not assumed to auto-clear. After the blocker is truly resolved, clear the flag explicitly with:`, ` task_set_clarification { teamName: "${teamName}", taskId: "", value: "clear" }`, `- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`, ` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`, @@ -6975,7 +6999,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, }); if (request.skipPermissions === false) { - await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } child = spawnCli(claudePath, spawnArgs, { @@ -7623,7 +7647,7 @@ export class TeamProvisioningService { try { if (request.skipPermissions === false) { - await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } child = spawnCli(claudePath, launchArgs, { cwd: request.cwd, @@ -12174,28 +12198,25 @@ export class TeamProvisioningService { return added; } - private async seedTeammateOperationalPermissionRules( + private async seedLeadBootstrapPermissionRules( teamName: string, projectCwd: string ): Promise { const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); try { - // FACT: Teammates need both MCP tools AND standard file tools (Write/Edit). - // FACT: Standard tools use "setMode: acceptEdits" permission_suggestions, but - // we can't change subprocess session mode — so we pre-add them as allow rules. const allTools = [ - ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, 'Edit', 'Write', 'NotebookEdit', ]; const added = await this.addPermissionRulesToSettings(settingsPath, allTools, 'allow'); logger.info( - `[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)` + `[${teamName}] Seeded lead bootstrap MCP rules in ${settingsPath} (${added} added)` ); } catch (error) { logger.warn( - `[${teamName}] Failed to seed teammate operational MCP rules: ${ + `[${teamName}] Failed to seed lead bootstrap MCP rules: ${ error instanceof Error ? error.message : String(error) }` ); @@ -14950,11 +14971,15 @@ export class TeamProvisioningService { }); }; - await request('initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' }, - }); + await request( + 'initialize', + { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' }, + }, + MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS + ); await notify('notifications/initialized'); const toolsList = await request('tools/list', {}); @@ -14964,6 +14989,12 @@ export class TeamProvisioningService { if (!memberBriefingTool) { throw new Error('agent-teams MCP started but tools/list did not include member_briefing'); } + const leadBriefingTool = (toolsList.tools ?? []).find( + (tool) => tool.name === 'lead_briefing' + ); + if (!leadBriefingTool) { + throw new Error('agent-teams MCP started but tools/list did not include lead_briefing'); + } const memberBriefing = await request('tools/call', { name: 'member_briefing', @@ -14985,6 +15016,27 @@ export class TeamProvisioningService { if (briefingText.trim().length === 0) { throw new Error('agent-teams MCP returned empty content for member_briefing'); } + + const leadBriefing = await request('tools/call', { + name: 'lead_briefing', + arguments: { + claudeDir: fixture.claudeDir, + teamName: fixture.teamName, + }, + }); + + if (leadBriefing.isError) { + throw new Error( + leadBriefing.content?.[0]?.text ?? + 'agent-teams MCP returned an unspecified error for lead_briefing' + ); + } + + const leadBriefingText = + leadBriefing.content?.find((item) => item.type === 'text')?.text ?? ''; + if (leadBriefingText.trim().length === 0) { + throw new Error('agent-teams MCP returned empty content for lead_briefing'); + } } catch (error) { const detail = buildCombinedLogs('', stderrBuffer).trim(); const errorText = diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 4d60e345..5915d875 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -7,7 +7,22 @@ declare module 'agent-teams-controller' { export interface ControllerTaskApi { createTask(flags: Record): unknown; getTask(taskId: string): unknown; + getTaskComment( + taskId: string, + commentId: string + ): { + comment: Record; + task: { + id: string; + displayId: string; + subject: string; + status: string; + owner: string | null; + commentCount: number; + }; + }; listTasks(): unknown[]; + listTaskInventory(filters?: Record): unknown[]; listDeletedTasks(): unknown[]; resolveTaskId(taskRef: string): string; setTaskStatus(taskId: string, status: string, actor?: string): unknown; @@ -25,6 +40,8 @@ declare module 'agent-teams-controller' { setNeedsClarification(taskId: string, value: string | null): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown; unlinkTask(taskId: string, targetId: string, linkType: string): unknown; + memberBriefing(memberName: string): Promise; + leadBriefing(): Promise; taskBriefing(memberName: string): Promise; } @@ -42,6 +59,7 @@ declare module 'agent-teams-controller' { requestReview(taskId: string, flags?: Record): unknown; approveReview(taskId: string, flags?: Record): unknown; requestChanges(taskId: string, flags?: Record): unknown; + startReview(taskId: string, flags?: Record): unknown; } export interface ControllerMessageApi { @@ -67,6 +85,12 @@ declare module 'agent-teams-controller' { getCrossTeamOutbox(): unknown; } + export interface ControllerRuntimeApi { + launchTeam(flags: Record): Promise; + stopTeam(flags?: Record): Promise; + getRuntimeState(flags?: Record): Promise; + } + export interface AgentBlocksApi { AGENT_BLOCK_TAG: string; AGENT_BLOCK_OPEN: string; @@ -84,6 +108,7 @@ declare module 'agent-teams-controller' { processes: ControllerProcessApi; maintenance: ControllerMaintenanceApi; crossTeam: ControllerCrossTeamApi; + runtime: ControllerRuntimeApi; } /** Context-free protocol text builders, shared across lead and member prompts. */ @@ -95,6 +120,7 @@ declare module 'agent-teams-controller' { export type AgentTeamsMcpToolGroupId = | 'task' + | 'lead' | 'kanban' | 'review' | 'message' @@ -114,6 +140,7 @@ declare module 'agent-teams-controller' { export const protocols: ProtocolsApi; export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_LEAD_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; @@ -124,4 +151,6 @@ declare module 'agent-teams-controller' { export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[]; } diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index f3e49ace..b2961311 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1182,6 +1182,65 @@ describe('TeamDataService', () => { }); }); + it('preserves legacy kanban reviewer for tasks still in review without review history', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [ + { name: 'lead', role: 'team lead' }, + { name: 'bob', role: 'developer' }, + { name: 'carol', role: 'reviewer' }, + ], + })), + } as never, + { + getTasks: vi.fn(async () => [ + { + id: 'task-legacy-review', + subject: 'Legacy review task', + status: 'completed', + owner: 'bob', + reviewState: 'review', + historyEvents: [], + }, + ]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-legacy-review': { + column: 'review', + reviewer: 'carol', + movedAt: '2026-03-01T10:00:00.000Z', + }, + }, + })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-legacy-review', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'carol', + }); + }); + it('propagates leadSessionId for kanban-driven review transitions', async () => { const requestReviewMock = vi.fn(); const approveReviewMock = vi.fn(); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b73b144f..4f1a40af 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -131,7 +131,10 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; import { killProcessByPid } from '@main/utils/processKill'; import { encodePath } from '@main/utils/pathDecoder'; -import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; +import { + AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, +} from 'agent-teams-controller'; import { killTmuxPaneForCurrentPlatformSync, listTmuxPanePidsForCurrentPlatform, @@ -2073,7 +2076,7 @@ describe('TeamProvisioningService', () => { } }); - it('pre-seeds teammate operational MCP permissions before createTeam spawn', async () => { + it('pre-seeds lead bootstrap MCP permissions before createTeam spawn', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); vi.mocked(spawnCli).mockImplementation(() => { @@ -2124,8 +2127,9 @@ describe('TeamProvisioningService', () => { permissions?: { allow?: string[] }; }; expect(settings.permissions?.allow).toEqual( - expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES]) + expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES]) ); + expect(settings.permissions?.allow).toContain('mcp__agent-teams__lead_briefing'); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); }); diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 50342e05..b0914870 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -95,6 +95,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => { }); vi.mock('agent-teams-controller', () => ({ + AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], createController: ({ teamName }: { teamName: string }) => ({ messages: { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 74ce7c7d..b3cf5d29 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -137,23 +137,19 @@ function writeMcpConfig( function writeMockMcpServer( targetDir: string, - variant: 'missing-member-briefing' | 'member-briefing-error' + variant: + | 'missing-member-briefing' + | 'missing-lead-briefing' + | 'member-briefing-error' + | 'lead-briefing-error' ): string { const scriptPath = path.join(targetDir, `mock-mcp-${variant}.js`); const tools = variant === 'missing-member-briefing' - ? [{ name: 'task_create' }] - : [{ name: 'member_briefing' }]; - const toolCallResult = - variant === 'member-briefing-error' - ? { - content: [{ type: 'text', text: 'mock member_briefing failure' }], - isError: true, - } - : { - content: [{ type: 'text', text: 'ok' }], - isError: false, - }; + ? [{ name: 'lead_briefing' }] + : variant === 'missing-lead-briefing' + ? [{ name: 'member_briefing' }] + : [{ name: 'member_briefing' }, { name: 'lead_briefing' }]; fs.writeFileSync( scriptPath, @@ -192,10 +188,26 @@ process.stdin.on('data', (chunk) => { continue; } if (message.method === 'tools/call') { + const toolName = message.params?.name; + const toolCallResult = + (${JSON.stringify(variant)} === 'member-briefing-error' && toolName === 'member_briefing') + ? { + content: [{ type: 'text', text: 'mock member_briefing failure' }], + isError: true, + } + : (${JSON.stringify(variant)} === 'lead-briefing-error' && toolName === 'lead_briefing') + ? { + content: [{ type: 'text', text: 'mock lead_briefing failure' }], + isError: true, + } + : { + content: [{ type: 'text', text: 'ok' }], + isError: false, + }; send({ jsonrpc: '2.0', id: message.id, - result: ${JSON.stringify(toolCallResult)}, + result: toolCallResult, }); } } @@ -1099,6 +1111,21 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ).rejects.toThrow('tools/list did not include member_briefing'); }); + it('fails validation when tools/list does not include lead_briefing', async () => { + const svc = new TeamProvisioningService(); + const mockServerPath = writeMockMcpServer(tempRoot, 'missing-lead-briefing'); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': { + command: process.execPath, + args: [mockServerPath], + }, + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).rejects.toThrow('tools/list did not include lead_briefing'); + }); + it('fails validation when member_briefing itself returns an MCP error', async () => { const svc = new TeamProvisioningService(); const mockServerPath = writeMockMcpServer(tempRoot, 'member-briefing-error'); @@ -1114,4 +1141,19 @@ describe('TeamProvisioningService prepare/auth behavior', () => { (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) ).rejects.toThrow('mock member_briefing failure'); }); + + it('fails validation when lead_briefing itself returns an MCP error', async () => { + const svc = new TeamProvisioningService(); + const mockServerPath = writeMockMcpServer(tempRoot, 'lead-briefing-error'); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': { + command: process.execPath, + args: [mockServerPath], + }, + }); + + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).rejects.toThrow('mock lead_briefing failure'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 40bb2ae7..bc45d050 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -544,6 +544,15 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => role: 'developer', }); + expect(prompt).toContain( + 'When you later receive work or reconnect after a restart, use task_briefing as your primary working queue.' + ); + expect(prompt).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); + expect(prompt).toContain( + 'Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.' + ); expect(prompt).toContain( 'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.' ); @@ -676,6 +685,15 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('task_create_from_message'); expect(prompt).toContain('task_set_owner'); expect(prompt).toContain('cross_team_send'); + expect(prompt).toContain( + 'lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.' + ); + expect(prompt).toContain( + 'Browse/search compact inventory rows only: task_list' + ); + expect(prompt).toContain( + 'task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead\'s working queue.' + ); expect(prompt).toContain( 'review_request already notifies the reviewer' ); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 6ab47e2d..a0957f36 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -113,6 +113,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => { }); vi.mock('agent-teams-controller', () => ({ + AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], createController: ({ teamName }: { teamName: string }) => ({ messages: { From e944e2c937f4d4f260c835b8ff5b7c86d563c1f5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 17:31:24 +0300 Subject: [PATCH 09/19] ci(task-change-ledger): add windows smoke checks --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f241265e..312e897c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,3 +98,30 @@ jobs: - name: Test run: pnpm test:workspace + + task-change-ledger-windows: + name: Task change ledger Windows smoke + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Enable Windows long paths + shell: pwsh + run: git config --global core.longpaths true + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Test task change ledger + run: pnpm test:task-change-ledger diff --git a/package.json b/package.json index 52d6acf6..4901dab5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:noise": "tsx test/test-noise-filtering.ts", "test:task-filtering": "tsx test/test-task-filtering.ts", "test": "vitest run", + "test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", From 95b62d6013d87c564eb2b6f4ff15ba5bf1ea64c3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 18:02:40 +0300 Subject: [PATCH 10/19] fix(task-change-ledger): read long hashed task artifacts --- .../services/team/TaskChangeLedgerReader.ts | 57 ++++++++++++++----- .../TeamTaskLogFreshnessReader.ts | 4 +- .../team/TaskChangeLedgerReader.test.ts | 35 ++++++++++++ .../TeamTaskLogFreshnessReader.test.ts | 25 ++++++++ 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index d3dde5ee..b9b7ec49 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -26,6 +26,7 @@ const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2; const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; +const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120; function isWindowsReservedArtifactSegment(segment: string): boolean { const stem = segment.split('.')[0]?.toUpperCase() ?? ''; @@ -42,7 +43,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean { function encodeTaskId(taskId: string): string { const encoded = encodeURIComponent(taskId); - return isWindowsReservedArtifactSegment(encoded) + return isWindowsReservedArtifactSegment(encoded) || + encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` : encoded; } @@ -424,7 +426,11 @@ export class TaskChangeLedgerReader { ) { return { bundle, - provenance: this.buildLedgerProvenance(journalStamp, bundle.integrity, bundle.schemaVersion), + provenance: this.buildLedgerProvenance( + journalStamp, + bundle.integrity, + bundle.schemaVersion + ), mode: 'validated', }; } @@ -625,7 +631,10 @@ export class TaskChangeLedgerReader { return { entries, recovered }; } - private bundleMatchesFreshness(bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2): boolean { + private bundleMatchesFreshness( + bundle: LedgerSummaryBundleV2, + freshness: LedgerFreshnessV2 + ): boolean { return ( JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && bundle.eventCount === freshness.eventCount && @@ -791,7 +800,10 @@ export class TaskChangeLedgerReader { scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); diffStatCompleteness = params.bundle.diffStatCompleteness; } else { - const fallback = this.buildFallbackFilesFromGroupedSnippets(groupedSnippets, params.projectPath); + const fallback = this.buildFallbackFilesFromGroupedSnippets( + groupedSnippets, + params.projectPath + ); files = fallback.files; totalLinesAdded = fallback.totalLinesAdded; totalLinesRemoved = fallback.totalLinesRemoved; @@ -801,11 +813,18 @@ export class TaskChangeLedgerReader { : params.journal.events.some((event) => event.confidence === 'medium') ? 'medium' : 'high'; - scope = this.buildFallbackScope(params.taskId, files, params.journal.events, params.journal.notices); + scope = this.buildFallbackScope( + params.taskId, + files, + params.journal.events, + params.journal.notices + ); diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) ? 'complete' : 'partial'; - warnings.push('Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'); + warnings.push( + 'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.' + ); } return { @@ -972,7 +991,11 @@ export class TaskChangeLedgerReader { return { taskId, memberName: - scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', + scope.memberName || + scope.primaryMemberName || + scope.primaryAgentId || + scope.primaryActorKey || + '', startLine: 0, endLine: 0, startTimestamp: scope.startTimestamp, @@ -1004,7 +1027,10 @@ export class TaskChangeLedgerReader { ); } - private async readContentRef(projectDir: string, ref: LedgerContentRef | null): Promise { + private async readContentRef( + projectDir: string, + ref: LedgerContentRef | null + ): Promise { if (!ref?.blobRef) { return null; } @@ -1116,7 +1142,10 @@ export class TaskChangeLedgerReader { } private buildFallbackFilesFromGroupedSnippets( - grouped: Map, + grouped: Map< + string, + { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } + >, projectPath?: string ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { const files: FileChangeSummary[] = []; @@ -1157,9 +1186,7 @@ export class TaskChangeLedgerReader { ...(relation ? { relation } : {}), latestOperation: entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? - (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' - ? 'create' - : 'modify'), + (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'), }, timeline: this.buildTimeline(displayPath, entry.snippets), }); @@ -1347,7 +1374,11 @@ export class TaskChangeLedgerReader { return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; } - private relativePath(filePath: string, projectPath?: string, explicitRelativePath?: string): string { + private relativePath( + filePath: string, + projectPath?: string, + explicitRelativePath?: string + ): string { if (explicitRelativePath) { return explicitRelativePath.replace(/\\/g, '/'); } diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts index db5bc230..85555063 100644 --- a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -8,6 +8,7 @@ import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes'; const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; +const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120; interface ParsedFreshnessSignal { taskId: string; @@ -30,7 +31,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean { function encodeTaskId(taskId: string): string { const encoded = encodeURIComponent(taskId); - return isWindowsReservedArtifactSegment(encoded) + return isWindowsReservedArtifactSegment(encoded) || + encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` : encoded; } diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 4f5f4cb9..9bfe082d 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -96,6 +96,41 @@ describe('TaskChangeLedgerReader', () => { expect(result?.warnings).toContain('reserved segment safe path'); }); + it('reads ledger artifacts stored under hashed long task id segments', async () => { + tmpDir = await fsTempDir(); + const taskId = `task-${'x'.repeat(180)}`; + const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: 0, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'high', + warnings: ['long task id safe path'], + events: [], + }), + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId, + projectDir: tmpDir, + includeDetails: true, + }); + + expect(result?.warnings).toContain('long task id safe path'); + }); + it('maps ledger state and rename relation into snippets', async () => { tmpDir = await makeLedgerBundle({ events: [ diff --git a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts index 3b1d3969..4c29533e 100644 --- a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts @@ -83,4 +83,29 @@ describe('TeamTaskLogFreshnessReader', () => { ); expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl'); }); + + it('reads hashed freshness files for very long task ids', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-')); + tempDirs.push(projectDir); + const signalDir = path.join(projectDir, '.board-task-log-freshness'); + await fs.mkdir(signalDir, { recursive: true }); + const taskId = `task-${'x'.repeat(180)}`; + + await fs.writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ + taskId, + updatedAt: '2026-04-19T12:00:00.000Z', + transcriptFile: 'session-long.jsonl', + }), + 'utf8' + ); + + const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, [taskId]); + + expect(signals.get(taskId)?.filePath).toBe( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`) + ); + expect(signals.get(taskId)?.transcriptFileBasename).toBe('session-long.jsonl'); + }); }); From 728603d788deb55507dc0b6106f75860073037f1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 18:06:35 +0300 Subject: [PATCH 11/19] test(teams): stabilize runtime launch validation mocks --- ...TeamProvisioningServicePostCompact.test.ts | 53 ++++++++++++++++++- .../services/team/TeammateToolTracker.test.ts | 4 +- .../utils/teamModelAvailability.test.ts | 40 ++++++++++++-- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index 4ce12284..ff2d20c2 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -23,6 +23,7 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ vi.mock('@main/utils/childProcess', () => ({ spawnCli: vi.fn(), + execCli: vi.fn(), killProcessTree: vi.fn(), })); @@ -39,7 +40,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { spawnCli } from '@main/utils/childProcess'; +import { execCli, spawnCli } from '@main/utils/childProcess'; import { setAppDataBasePath } from '@main/utils/pathDecoder'; function createFakeChild() { @@ -78,6 +79,56 @@ async function setupRunningTeam(teamName: string) { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child, writeSpy } = createFakeChild(); vi.mocked(spawnCli).mockReturnValue(child as any); + vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { + const providerIndex = args.indexOf('--provider'); + const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic'; + if (args[0] === 'model' && args[1] === 'list') { + return { + stdout: JSON.stringify({ + providers: { + [providerId ?? 'anthropic']: { + defaultModel: providerId === 'codex' ? 'gpt-5.4' : 'opus[1m]', + models: + providerId === 'codex' + ? ['gpt-5.4'] + : ['opus[1m]', 'opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + }, + }, + }), + stderr: '', + }; + } + if (args[0] === 'runtime' && args[1] === 'status') { + return { + stdout: JSON.stringify({ + providers: { + [providerId ?? 'anthropic']: { + runtimeCapabilities: + providerId === 'codex' + ? { + reasoningEffort: { + supported: true, + values: ['minimal', 'low', 'medium', 'high', 'xhigh'], + configPassthrough: true, + }, + } + : { + fastMode: { + supported: false, + available: false, + reason: 'Test runtime does not expose fast mode.', + source: 'test', + }, + }, + modelCatalog: null, + }, + }, + }), + stderr: '', + }; + } + return { stdout: '{}', stderr: '' }; + }); const svc = new TeamProvisioningService(); (svc as any).buildProvisioningEnv = vi.fn(async () => ({ diff --git a/test/main/services/team/TeammateToolTracker.test.ts b/test/main/services/team/TeammateToolTracker.test.ts index a41e0ccc..acc9a25f 100644 --- a/test/main/services/team/TeammateToolTracker.test.ts +++ b/test/main/services/team/TeammateToolTracker.test.ts @@ -25,7 +25,7 @@ async function createSubagentLog( return filePath; } -async function waitForCondition(check: () => void, attempts = 20): Promise { +async function waitForCondition(check: () => void, attempts = 100): Promise { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { @@ -161,6 +161,8 @@ describe('TeammateToolTracker', () => { tracker.handleLogSourceChange('my-team'); await waitForCondition(() => { expect(events).toHaveLength(1); + const fileState = (tracker as any).stateByTeam.get('my-team')?.filesByPath.get(filePath); + expect(fileState?.lineCarry).toBe(resultLine.slice(0, splitAt).trim()); }); await appendFile(filePath, `${resultLine.slice(splitAt)}\n`, 'utf8'); diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 2c9b03b1..134a3d18 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -140,11 +140,41 @@ describe('teamModelAvailability', () => { it('keeps both Anthropic Opus 4.7 and explicit Opus 4.6 in the fallback selector options', () => { expect(getAvailableTeamProviderModelOptions('anthropic')).toEqual([ - { value: '', label: 'Default', badgeLabel: 'Default' }, - { value: 'opus', label: 'Opus 4.7', badgeLabel: 'Opus 4.7' }, - { value: 'claude-opus-4-6', label: 'Opus 4.6', badgeLabel: 'Opus 4.6' }, - { value: 'sonnet', label: 'Sonnet 4.6', badgeLabel: 'Sonnet 4.6' }, - { value: 'haiku', label: 'Haiku 4.5', badgeLabel: 'Haiku 4.5' }, + { + value: '', + label: 'Default', + badgeLabel: 'Default', + availabilityStatus: undefined, + availabilityReason: undefined, + }, + { + value: 'opus', + label: 'Opus 4.7', + badgeLabel: 'Opus 4.7', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'claude-opus-4-6', + label: 'Opus 4.6', + badgeLabel: 'Opus 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'sonnet', + label: 'Sonnet 4.6', + badgeLabel: 'Sonnet 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'haiku', + label: 'Haiku 4.5', + badgeLabel: 'Haiku 4.5', + availabilityStatus: 'available', + availabilityReason: null, + }, ]); }); From e0d32d59ffa79b087ecc8950f8dec2ed6153b4f6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 18:19:29 +0300 Subject: [PATCH 12/19] fix(task-change-ledger): normalize fixture paths on Windows --- .../services/team/TaskChangeLedgerReader.ts | 21 ++++++++--- .../team/taskChangeLedgerFixtureUtils.ts | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index b9b7ec49..00d301bd 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -943,8 +943,9 @@ export class TaskChangeLedgerReader { private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { const displayPath = file.displayPath ?? file.filePath; + const filePath = this.normalizeLedgerFilePath(file.filePath); return { - filePath: file.filePath, + filePath, relativePath: this.relativePath(displayPath, projectPath, file.relativePath), snippets: [], linesAdded: file.linesAdded, @@ -1001,7 +1002,7 @@ export class TaskChangeLedgerReader { startTimestamp: scope.startTimestamp, endTimestamp: scope.endTimestamp, toolUseIds: scope.toolUseIds, - filePaths: files.map((file) => file.filePath), + filePaths: files.map((file) => this.normalizeLedgerFilePath(file.filePath)), confidence: scope.confidence, ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), @@ -1049,9 +1050,10 @@ export class TaskChangeLedgerReader { beforeContent: string | null, afterContent: string | null ): SnippetDiff { + const filePath = this.normalizeLedgerFilePath(event.filePath); return { toolUseId: event.toolUseId, - filePath: event.filePath, + filePath, toolName: this.mapToolName(event.source), type: this.mapSnippetType(event), oldString: event.oldString ?? beforeContent ?? '', @@ -1371,7 +1373,18 @@ export class TaskChangeLedgerReader { return null; } - return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; + return this.normalizeLedgerFilePath( + `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}` + ); + } + + private normalizeLedgerFilePath(filePath: string): string { + const slashPath = filePath.replace(/\\/g, '/'); + const isWindowsAbsolute = /^[A-Za-z]:\//.test(slashPath) || slashPath.startsWith('//'); + if (path.isAbsolute(filePath) || isWindowsAbsolute) { + return path.normalize(filePath); + } + return slashPath; } private relativePath( diff --git a/test/main/services/team/taskChangeLedgerFixtureUtils.ts b/test/main/services/team/taskChangeLedgerFixtureUtils.ts index ed87c88e..02e88f63 100644 --- a/test/main/services/team/taskChangeLedgerFixtureUtils.ts +++ b/test/main/services/team/taskChangeLedgerFixtureUtils.ts @@ -61,6 +61,41 @@ async function rewriteProjectRootTokens(rootDir: string, token: string, projectD } } +function shouldNormalizeLfFixtureFile(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + return ( + /\.(json|jsonl|md|txt|ts|tsx|js|jsx)$/.test(normalizedPath) || + normalizedPath.includes('/.board-task-changes/blobs/sha256/') + ); +} + +function looksBinary(buffer: Buffer): boolean { + for (const byte of buffer) { + if (byte === 0) return true; + if (byte < 9 || (byte > 13 && byte < 32)) return true; + } + return false; +} + +async function normalizeFixtureTextLineEndings(rootDir: string): Promise { + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + await normalizeFixtureTextLineEndings(entryPath); + continue; + } + if (!shouldNormalizeLfFixtureFile(entryPath)) { + continue; + } + const raw = await fs.readFile(entryPath); + if (!raw.includes(13) || looksBinary(raw)) { + continue; + } + await fs.writeFile(entryPath, raw.toString('utf8').replace(/\r\n?/g, '\n'), 'utf8'); + } +} + export async function materializeTaskChangeLedgerFixture( fixtureName: string ): Promise { @@ -76,6 +111,7 @@ export async function materializeTaskChangeLedgerFixture( const token = manifest.projectRootToken ?? DEFAULT_PROJECT_ROOT_TOKEN; await rewriteProjectRootTokens(rootDir, token, projectDir); + await normalizeFixtureTextLineEndings(rootDir); return { rootDir, From 1c5fce5f0a4d293114e4d7fc4b0faee40e31528c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 18:27:50 +0300 Subject: [PATCH 13/19] fix(task-change-ledger): preserve synthetic slash paths on Windows --- src/main/services/team/TaskChangeLedgerReader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 00d301bd..7f2cda58 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -1381,7 +1381,7 @@ export class TaskChangeLedgerReader { private normalizeLedgerFilePath(filePath: string): string { const slashPath = filePath.replace(/\\/g, '/'); const isWindowsAbsolute = /^[A-Za-z]:\//.test(slashPath) || slashPath.startsWith('//'); - if (path.isAbsolute(filePath) || isWindowsAbsolute) { + if (isWindowsAbsolute || (process.platform !== 'win32' && path.isAbsolute(filePath))) { return path.normalize(filePath); } return slashPath; From e20d74d95ec9397a76882038963c112b026c4d2b Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 18:42:26 +0300 Subject: [PATCH 14/19] test(teams): run cmd mcp preflight through shell on Windows --- .../services/team/TeamProvisioningServicePrepare.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index b3cf5d29..08473785 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -224,7 +224,12 @@ function spawnRealCli( args: readonly string[], options?: Parameters[2] ) { - return options ? spawn(command, [...args], options) : spawn(command, [...args]); + const spawnOptions = options ?? {}; + const needsWindowsCommandShell = process.platform === 'win32' && /\.(bat|cmd)$/i.test(command); + return spawn(command, [...args], { + ...spawnOptions, + ...(needsWindowsCommandShell ? { shell: true } : {}), + }); } async function removeTempRoot(dirPath: string): Promise { From 2e87e127743b04cd2c5e125d30d8c1787cdc66c3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 19:02:15 +0300 Subject: [PATCH 15/19] fix(ci): satisfy runtime catalog lint gate --- .../domain/normalizeCodexAppServerModel.ts | 22 +++++++--- .../services/team/ReviewApplierService.ts | 40 +++++++++++-------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts index 075c81e4..ab7e50b3 100644 --- a/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts +++ b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts @@ -1,15 +1,27 @@ -import { normalizeCodexReasoningEffort, CODEX_REASONING_EFFORTS } from './codexReasoningEffort'; +import { CODEX_REASONING_EFFORTS, normalizeCodexReasoningEffort } from './codexReasoningEffort'; -import type { CodexAppServerModel } from '@main/services/infrastructure/codexAppServer'; import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types'; +export interface CodexAppServerModelLike { + id?: string; + model?: string; + displayName?: string; + hidden?: boolean; + supportedReasoningEfforts?: unknown[]; + defaultReasoningEffort?: unknown; + inputModalities?: unknown; + supportsPersonality?: boolean; + isDefault?: boolean; + upgrade?: unknown; +} + export interface NormalizedCodexModelCatalogResult { models: CliProviderModelCatalogItem[]; defaultModelId: string | null; diagnostics: string[]; } -function normalizeModelId(model: CodexAppServerModel): string | null { +function normalizeModelId(model: CodexAppServerModelLike): string | null { const id = model.id?.trim() || model.model?.trim() || null; return id && id.length > 0 ? id : null; } @@ -26,7 +38,7 @@ function normalizeEffortOption(option: unknown): CliProviderReasoningEffort | nu return null; } -function normalizeEfforts(model: CodexAppServerModel): CliProviderReasoningEffort[] { +function normalizeEfforts(model: CodexAppServerModelLike): CliProviderReasoningEffort[] { const efforts = model.supportedReasoningEfforts?.flatMap((option) => { const normalized = normalizeEffortOption(option); return normalized ? [normalized] : []; @@ -82,7 +94,7 @@ function asBadgeLabel(modelId: string): string { } export function normalizeCodexAppServerModels( - models: readonly CodexAppServerModel[] | undefined, + models: readonly CodexAppServerModelLike[] | undefined, options: { includeHidden?: boolean; } = {} diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 95fe585c..86050daf 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -27,6 +27,15 @@ type LedgerApplyOutcome = | { handled: true; status: 'applied' | 'skipped' } | { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode }; +type CurrentTextReadResult = + | { missing: true; content: '' } + | { missing: false; content: string } + | { missing: false; content: ''; error: string }; + +function getCurrentTextReadError(result: CurrentTextReadResult): string | null { + return 'error' in result ? result.error : null; +} + /** * Service for applying reject decisions from code review. * @@ -518,12 +527,13 @@ export class ReviewApplierService { if (current.missing) { return { handled: true, status: 'applied' }; } - if (current.error) { + const currentError = getCurrentTextReadError(current); + if (currentError) { return { handled: true, status: 'error', code: 'io-error', - error: current.error, + error: currentError, }; } if (!afterHash) { @@ -570,12 +580,13 @@ export class ReviewApplierService { } const current = await this.readCurrentText(filePath); if (!current.missing) { + const currentError = getCurrentTextReadError(current); return { handled: true, status: 'conflict', code: 'conflict', error: - current.error || 'File exists on disk; refusing to overwrite while rejecting delete.', + currentError || 'File exists on disk; refusing to overwrite while rejecting delete.', }; } try { @@ -672,12 +683,13 @@ export class ReviewApplierService { const newCurrent = await this.readCurrentText(newFilePath); if (!newCurrent.missing) { - if (newCurrent.error) { + const newCurrentError = getCurrentTextReadError(newCurrent); + if (newCurrentError) { return { handled: true, status: 'error', code: 'io-error', - error: newCurrent.error, + error: newCurrentError, }; } if (this.hashText(newCurrent.content) !== newHash) { @@ -692,12 +704,13 @@ export class ReviewApplierService { const oldCurrent = await this.readCurrentText(oldFilePath); if (!oldCurrent.missing) { - if (oldCurrent.error) { + const oldCurrentError = getCurrentTextReadError(oldCurrent); + if (oldCurrentError) { return { handled: true, status: 'error', code: 'io-error', - error: oldCurrent.error, + error: oldCurrentError, }; } if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) { @@ -781,14 +794,15 @@ export class ReviewApplierService { }, }; } - if (current.error) { + const currentError = getCurrentTextReadError(current); + if (currentError) { return { ok: false, outcome: { handled: true, status: 'error', code: 'io-error', - error: current.error, + error: currentError, }, }; } @@ -806,13 +820,7 @@ export class ReviewApplierService { return { ok: true }; } - private async readCurrentText( - filePath: string - ): Promise< - | { missing: true; content: ''; error?: undefined } - | { missing: false; content: string; error?: undefined } - | { missing: false; content: ''; error: string } - > { + private async readCurrentText(filePath: string): Promise { try { return { missing: false, content: await readFile(filePath, 'utf8') }; } catch (err) { From 5e31bd1c062f7e9f2a6f82de350a395157418ef0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 20:22:27 +0300 Subject: [PATCH 16/19] feat(opencode): add team runtime integration --- .../src/internal/runtime.js | 97 ++ agent-teams-controller/src/mcpToolCatalog.js | 9 +- .../test/controller.test.js | 71 + mcp-server/src/agent-teams-controller.d.ts | 4 + mcp-server/src/tools/runtimeTools.ts | 180 +++ mcp-server/test/tools.test.ts | 63 + src/main/http/teams.ts | 134 +- src/main/index.ts | 100 +- src/main/ipc/teams.ts | 26 +- .../infrastructure/CliInstallerService.ts | 22 +- .../services/infrastructure/FileWatcher.ts | 11 + .../runtime/ClaudeMultimodelBridgeService.ts | 371 +++++- .../runtime/ProviderConnectionService.ts | 11 +- .../services/runtime/buildRuntimeBaseEnv.ts | 10 +- .../services/runtime/providerRuntimeEnv.ts | 25 +- src/main/services/team/TeamDataService.ts | 3 +- .../services/team/TeamMcpConfigBuilder.ts | 6 +- src/main/services/team/TeamMemberResolver.ts | 26 +- src/main/services/team/TeamMetaStore.ts | 2 +- .../services/team/TeamProvisioningService.ts | 1069 ++++++++++++++- src/main/services/team/index.ts | 55 + .../team/memberUpdateNotifications.ts | 8 +- .../bridge/OpenCodeBridgeCommandClient.ts | 267 ++++ .../bridge/OpenCodeBridgeCommandContract.ts | 813 ++++++++++++ .../OpenCodeBridgeCommandLedgerStore.ts | 438 +++++++ .../bridge/OpenCodeBridgeHandshakeClient.ts | 112 ++ .../bridge/OpenCodeReadinessBridge.ts | 413 ++++++ ...enCodeStateChangingBridgeCommandService.ts | 283 ++++ .../capabilities/OpenCodeApiCapabilities.ts | 555 ++++++++ .../opencode/config/OpenCodeManagedOverlay.ts | 401 ++++++ .../delivery/RuntimeDeliveryJournal.ts | 482 +++++++ .../delivery/RuntimeDeliveryService.ts | 306 +++++ .../e2e/OpenCodeProductionE2EEvidence.ts | 527 ++++++++ .../e2e/OpenCodeProductionE2EEvidenceStore.ts | 73 ++ .../events/OpenCodeEventNormalizer.ts | 413 ++++++ .../mcp/OpenCodeMcpToolAvailability.ts | 421 ++++++ .../opencode/permissions/RuntimePermission.ts | 936 ++++++++++++++ .../readiness/OpenCodeTeamLaunchReadiness.ts | 378 ++++++ .../store/OpenCodeLaunchTransactionStore.ts | 399 ++++++ .../OpenCodeRuntimeManifestEvidenceReader.ts | 48 + .../store/RuntimeRunTombstoneStore.ts | 313 +++++ .../opencode/store/RuntimeStoreManifest.ts | 1144 +++++++++++++++++ .../team/opencode/store/VersionedJsonStore.ts | 292 +++++ .../opencode/version/OpenCodeVersionPolicy.ts | 284 ++++ .../runtime/OpenCodeTeamRuntimeAdapter.ts | 499 +++++++ .../team/runtime/TeamRuntimeAdapter.ts | 184 +++ src/main/services/team/runtime/index.ts | 29 + .../stream/BoardTaskLogStreamService.ts | 17 +- .../OpenCodeTaskLogAttributionService.ts | 293 +++++ .../stream/OpenCodeTaskLogAttributionStore.ts | 481 +++++++ .../stream/OpenCodeTaskLogStreamSource.ts | 1122 ++++++++++++++++ .../components/dashboard/CliStatusBanner.tsx | 12 +- .../extensions/ExtensionStoreView.tsx | 2 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 24 + .../settings/sections/CliStatusSection.tsx | 8 +- .../sidebar/DateGroupedSessions.tsx | 1 + .../sidebar/SessionFiltersPopover.tsx | 1 + .../ProvisioningProviderStatusList.tsx | 15 +- .../team/dialogs/TeamModelSelector.tsx | 86 +- .../team/dialogs/editTeamRuntimeChanges.ts | 11 +- .../team/taskLogs/TaskLogStreamSection.tsx | 39 +- .../services/createTeamDraftStorage.ts | 10 +- .../store/slices/cliInstallerSlice.ts | 10 +- src/renderer/store/slices/teamSlice.ts | 2 +- .../utils/bootstrapPromptSanitizer.ts | 11 +- src/renderer/utils/memberHelpers.ts | 2 + src/renderer/utils/providerBackendIdentity.ts | 4 +- src/renderer/utils/teamModelAvailability.ts | 5 + src/renderer/utils/teamModelCatalog.ts | 37 +- src/renderer/utils/teamModelContext.ts | 6 +- .../constants/opencodeTaskLogAttribution.ts | 1 + src/shared/types/cliInstaller.ts | 3 +- src/shared/types/team.ts | 23 +- .../utils/__tests__/teamProvider.test.ts | 14 +- src/shared/utils/opencodeModelRef.ts | 78 ++ src/shared/utils/teamProvider.ts | 18 +- src/shared/utils/toolSummary.ts | 2 + src/types/agent-teams-controller.d.ts | 4 + .../CliInstallerService.test.ts | 182 +++ .../infrastructure/FileWatcher.test.ts | 22 + .../ClaudeMultimodelBridgeService.test.ts | 323 ++++- .../runtime/providerRuntimeEnv.test.ts | 1 + .../team/BoardTaskLogStreamService.test.ts | 55 + .../team/OpenCodeApiCapabilities.test.ts | 223 ++++ .../team/OpenCodeBridgeCommandClient.test.ts | 269 ++++ .../OpenCodeBridgeCommandContract.test.ts | 303 +++++ .../OpenCodeBridgeCommandLedgerStore.test.ts | 234 ++++ .../team/OpenCodeEventNormalizer.test.ts | 240 ++++ .../OpenCodeLaunchTransactionStore.test.ts | 258 ++++ .../team/OpenCodeManagedOverlay.test.ts | 158 +++ .../team/OpenCodeMcpToolAvailability.test.ts | 224 ++++ .../OpenCodeProductionE2EEvidence.test.ts | 209 +++ .../team/OpenCodeProductionGate.live.test.ts | 427 ++++++ .../team/OpenCodeReadinessBridge.test.ts | 405 ++++++ ...eStateChangingBridgeCommandService.test.ts | 368 ++++++ .../OpenCodeTaskLogAttributionService.test.ts | 219 ++++ .../OpenCodeTaskLogAttributionStore.test.ts | 313 +++++ .../team/OpenCodeTaskLogStreamSource.test.ts | 1110 ++++++++++++++++ .../team/OpenCodeTeamLaunchReadiness.test.ts | 432 +++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 255 ++++ .../team/OpenCodeVersionPolicy.test.ts | 375 ++++++ .../team/RuntimeDeliveryService.test.ts | 356 +++++ .../services/team/RuntimePermission.test.ts | 510 ++++++++ .../team/RuntimeRunTombstoneStore.test.ts | 159 +++ .../team/RuntimeStoreManifest.test.ts | 361 ++++++ .../TeamProvisioningServicePrepare.test.ts | 34 + .../team/TeamRuntimeAdapterRegistry.test.ts | 128 ++ .../services/team/VersionedJsonStore.test.ts | 128 ++ .../cli/CliStatusVisibility.test.ts | 2 +- .../extensions/skills/SkillsPanel.test.ts | 2 +- .../ProviderRuntimeSettingsDialog.test.ts | 90 ++ .../components/team/TeamModelSelector.test.ts | 9 + .../TeamModelSelectorDisabledState.test.ts | 122 +- .../providerPrepareDiagnostics.test.ts | 34 +- .../taskLogs/TaskLogStreamSection.test.ts | 109 ++ test/renderer/store/cliInstallerSlice.test.ts | 22 +- .../utils/teamModelAvailability.test.ts | 62 + 117 files changed, 23210 insertions(+), 178 deletions(-) create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts create mode 100644 src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts create mode 100644 src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts create mode 100644 src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts create mode 100644 src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts create mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts create mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts create mode 100644 src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts create mode 100644 src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts create mode 100644 src/main/services/team/opencode/permissions/RuntimePermission.ts create mode 100644 src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts create mode 100644 src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts create mode 100644 src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts create mode 100644 src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts create mode 100644 src/main/services/team/opencode/store/RuntimeStoreManifest.ts create mode 100644 src/main/services/team/opencode/store/VersionedJsonStore.ts create mode 100644 src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts create mode 100644 src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts create mode 100644 src/main/services/team/runtime/TeamRuntimeAdapter.ts create mode 100644 src/main/services/team/runtime/index.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts create mode 100644 src/shared/constants/opencodeTaskLogAttribution.ts create mode 100644 src/shared/utils/opencodeModelRef.ts create mode 100644 test/main/services/team/OpenCodeApiCapabilities.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandClient.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandContract.test.ts create mode 100644 test/main/services/team/OpenCodeBridgeCommandLedgerStore.test.ts create mode 100644 test/main/services/team/OpenCodeEventNormalizer.test.ts create mode 100644 test/main/services/team/OpenCodeLaunchTransactionStore.test.ts create mode 100644 test/main/services/team/OpenCodeManagedOverlay.test.ts create mode 100644 test/main/services/team/OpenCodeMcpToolAvailability.test.ts create mode 100644 test/main/services/team/OpenCodeProductionE2EEvidence.test.ts create mode 100644 test/main/services/team/OpenCodeProductionGate.live.test.ts create mode 100644 test/main/services/team/OpenCodeReadinessBridge.test.ts create mode 100644 test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogAttributionService.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogAttributionStore.test.ts create mode 100644 test/main/services/team/OpenCodeTaskLogStreamSource.test.ts create mode 100644 test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts create mode 100644 test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts create mode 100644 test/main/services/team/OpenCodeVersionPolicy.test.ts create mode 100644 test/main/services/team/RuntimeDeliveryService.test.ts create mode 100644 test/main/services/team/RuntimePermission.test.ts create mode 100644 test/main/services/team/RuntimeRunTombstoneStore.test.ts create mode 100644 test/main/services/team/RuntimeStoreManifest.test.ts create mode 100644 test/main/services/team/TeamRuntimeAdapterRegistry.test.ts create mode 100644 test/main/services/team/VersionedJsonStore.test.ts diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js index 821a87c4..6cb7f1f0 100644 --- a/agent-teams-controller/src/internal/runtime.js +++ b/agent-teams-controller/src/internal/runtime.js @@ -218,6 +218,29 @@ function shouldWaitForStop(flags = {}) { return true; } +function compactRuntimeToolBody(context, flags = {}, fields) { + const body = { teamName: context.teamName }; + for (const field of fields) { + if (flags[field] !== undefined) { + body[field] = flags[field]; + } + } + return body; +} + +async function postRuntimeTool(context, flags = {}, toolPath, body) { + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/${toolPath}`, + { + method: 'POST', + body, + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000), + } + ); +} + async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) { const startedAt = Date.now(); let lastProgress = null; @@ -331,8 +354,82 @@ async function getRuntimeState(context, flags = {}) { return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`); } +async function runtimeBootstrapCheckin(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'bootstrap-checkin', + compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'diagnostics', + 'metadata', + ]) + ); +} + +async function runtimeDeliverMessage(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'deliver-message', + compactRuntimeToolBody(context, flags, [ + 'idempotencyKey', + 'runId', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + 'createdAt', + 'summary', + 'taskRefs', + ]) + ); +} + +async function runtimeTaskEvent(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'task-event', + compactRuntimeToolBody(context, flags, [ + 'idempotencyKey', + 'runId', + 'memberName', + 'runtimeSessionId', + 'taskId', + 'event', + 'createdAt', + 'summary', + 'metadata', + ]) + ); +} + +async function runtimeHeartbeat(context, flags = {}) { + return postRuntimeTool( + context, + flags, + 'heartbeat', + compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'status', + 'metadata', + ]) + ); +} + module.exports = { launchTeam, stopTeam, getRuntimeState, + runtimeBootstrapCheckin, + runtimeDeliverMessage, + runtimeTaskEvent, + runtimeHeartbeat, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index 211b1485..bcdeacf3 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -51,7 +51,14 @@ const AGENT_TEAMS_KANBAN_TOOL_NAMES = [ 'kanban_set_column', ]; -const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop']; +const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [ + 'team_launch', + 'team_stop', + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +]; const AGENT_TEAMS_MCP_TOOL_GROUPS = [ { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 843b0a89..967b28a0 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1069,6 +1069,77 @@ describe('agent-teams-controller API', () => { } }); + it('forwards OpenCode runtime MCP calls to the app control API', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/bootstrap-checkin') { + return { body: { ok: true, state: 'accepted' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/deliver-message') { + return { body: { ok: true, state: 'delivered' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/task-event') { + return { body: { ok: true, state: 'recorded' } }; + } + if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/heartbeat') { + return { body: { ok: true, state: 'accepted' } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + await controller.runtime.runtimeDeliverMessage({ + controlUrl: server.baseUrl, + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'bob', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + await controller.runtime.runtimeTaskEvent({ + controlUrl: server.baseUrl, + idempotencyKey: 'idem-task-1', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + taskId: 'task-1', + event: 'started', + }); + await controller.runtime.runtimeHeartbeat({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(calls.map((call) => call.url)).toEqual([ + '/api/teams/my-team/opencode/runtime/bootstrap-checkin', + '/api/teams/my-team/opencode/runtime/deliver-message', + '/api/teams/my-team/opencode/runtime/task-event', + '/api/teams/my-team/opencode/runtime/heartbeat', + ]); + expect(calls[0].body).toEqual({ + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index e2c03985..bdc6f469 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -76,6 +76,10 @@ declare module 'agent-teams-controller' { launchTeam(flags: Record): Promise; stopTeam(flags?: Record): Promise; getRuntimeState(flags?: Record): Promise; + runtimeBootstrapCheckin(flags: Record): Promise; + runtimeDeliverMessage(flags: Record): Promise; + runtimeTaskEvent(flags: Record): Promise; + runtimeHeartbeat(flags: Record): Promise; } export interface AgentTeamsController { diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts index cb53fcdc..6dd065e9 100644 --- a/mcp-server/src/tools/runtimeTools.ts +++ b/mcp-server/src/tools/runtimeTools.ts @@ -11,6 +11,22 @@ const toolContextSchema = { waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), }; +const runtimeMetadataSchema = z.record(z.string(), z.unknown()).optional(); +const runtimeDiagnosticsSchema = z.array(z.string().min(1)).optional(); +const runtimeIdentitySchema = { + ...toolContextSchema, + runId: z.string().min(1), + memberName: z.string().min(1), + runtimeSessionId: z.string().min(1), +}; +const runtimeDeliveryTargetSchema = z.union([ + z.literal('user'), + z.object({ + memberName: z.string().min(1), + teamName: z.string().min(1).optional(), + }), +]); + export function registerRuntimeTools(server: Pick) { server.addTool({ name: 'team_launch', @@ -75,4 +91,168 @@ export function registerRuntimeTools(server: Pick) { }) ), }); + + server.addTool({ + name: 'runtime_bootstrap_checkin', + description: 'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary', + parameters: z.object({ + ...runtimeIdentitySchema, + observedAt: z.string().min(1).optional(), + diagnostics: runtimeDiagnosticsSchema, + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({ + runId, + memberName, + runtimeSessionId, + ...(observedAt ? { observedAt } : {}), + ...(diagnostics ? { diagnostics } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_deliver_message', + description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination', + parameters: z.object({ + ...toolContextSchema, + idempotencyKey: z.string().min(1), + runId: z.string().min(1), + fromMemberName: z.string().min(1), + runtimeSessionId: z.string().min(1), + to: runtimeDeliveryTargetSchema, + text: z.string().min(1), + createdAt: z.string().min(1).optional(), + summary: z.string().optional(), + taskRefs: z.array(z.unknown()).optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + idempotencyKey, + runId, + fromMemberName, + runtimeSessionId, + to, + text, + createdAt, + summary, + taskRefs, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({ + idempotencyKey, + runId, + fromMemberName, + runtimeSessionId, + to, + text, + ...(createdAt ? { createdAt } : {}), + ...(summary ? { summary } : {}), + ...(taskRefs ? { taskRefs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_task_event', + description: 'Record an idempotent OpenCode runtime task event for app-side attribution', + parameters: z.object({ + ...toolContextSchema, + idempotencyKey: z.string().min(1), + runId: z.string().min(1), + memberName: z.string().min(1), + runtimeSessionId: z.string().min(1).optional(), + taskId: z.string().min(1), + event: z.string().min(1), + createdAt: z.string().min(1).optional(), + summary: z.string().optional(), + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + idempotencyKey, + runId, + memberName, + runtimeSessionId, + taskId, + event, + createdAt, + summary, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeTaskEvent({ + idempotencyKey, + runId, + memberName, + ...(runtimeSessionId ? { runtimeSessionId } : {}), + taskId, + event, + ...(createdAt ? { createdAt } : {}), + ...(summary ? { summary } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); + + server.addTool({ + name: 'runtime_heartbeat', + description: 'Refresh OpenCode member runtime liveness in the app-owned launch state', + parameters: z.object({ + ...runtimeIdentitySchema, + observedAt: z.string().min(1).optional(), + status: z.enum(['alive', 'idle', 'busy']).optional(), + metadata: runtimeMetadataSchema, + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + runId, + memberName, + runtimeSessionId, + observedAt, + status, + metadata, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.runtimeHeartbeat({ + runId, + memberName, + runtimeSessionId, + ...(observedAt ? { observedAt } : {}), + ...(status ? { status } : {}), + ...(metadata ? { metadata } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ), + }); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 56b3de14..b5bbc643 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -215,6 +215,69 @@ describe('agent-teams-mcp tools', () => { } }); + it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + return { body: { ok: true, state: 'accepted' } }; + }); + + try { + await getTool('runtime_bootstrap_checkin').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + }); + await getTool('runtime_deliver_message').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'alice', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + await getTool('runtime_task_event').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + idempotencyKey: 'idem-task-1', + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + taskId: 'task-1', + event: 'started', + }); + await getTool('runtime_heartbeat').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'alice', + runtimeSessionId: 'ses-1', + }); + + expect(calls.map((call) => call.url)).toEqual([ + '/api/teams/alpha/opencode/runtime/bootstrap-checkin', + '/api/teams/alpha/opencode/runtime/deliver-message', + '/api/teams/alpha/opencode/runtime/task-event', + '/api/teams/alpha/opencode/runtime/heartbeat', + ]); + expect(calls[1].body).toEqual({ + teamName: 'alpha', + idempotencyKey: 'idem-1', + runId: 'run-oc', + fromMemberName: 'alice', + runtimeSessionId: 'ses-1', + to: 'user', + text: 'hello', + }); + } finally { + await server.close(); + } + }); + it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); const statePath = path.join(claudeDir, 'team-control-api.json'); diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index c02e5ccc..321e8102 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -6,6 +6,7 @@ import { } from '@shared/utils/effortLevels'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { isAbsolute } from 'path'; import type { HttpServices } from './index'; @@ -33,6 +34,9 @@ function getStatusCode(error: unknown, fallback: number = 500): number { if (error instanceof HttpFeatureUnavailableError) { return 501; } + if (error instanceof Error && error.name === 'RuntimeStaleEvidenceError') { + return 409; + } return fallback; } @@ -110,15 +114,15 @@ function assertOptionalFastMode(value: unknown): TeamFastMode | undefined { function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; const providerId = - payload.providerId === 'codex' - ? 'codex' - : payload.providerId === 'gemini' - ? 'gemini' - : payload.providerId == null || payload.providerId === 'anthropic' - ? 'anthropic' - : (() => { - throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini'); - })(); + payload.providerId == null + ? 'anthropic' + : isTeamProviderId(payload.providerId) + ? payload.providerId + : (() => { + throw new HttpBadRequestError( + 'providerId must be anthropic, codex, gemini, or opencode' + ); + })(); const prompt = assertOptionalString(payload.prompt, 'prompt'); const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId'); const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId); @@ -169,6 +173,18 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest }; } +function withRuntimeTeamName(teamName: string, body: unknown): Record { + const payload = + body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : {}; + const bodyTeamName = typeof payload.teamName === 'string' ? payload.teamName.trim() : ''; + if (bodyTeamName && bodyTeamName !== teamName) { + throw new HttpBadRequestError('runtime body teamName must match route teamName'); + } + return { ...payload, teamName }; +} + export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { app.post<{ Params: { teamName: string }; Body: LaunchBody }>( '/api/teams/:teamName/launch', @@ -283,4 +299,104 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); } }); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/bootstrap-checkin', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeBootstrapCheckin( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/bootstrap-checkin:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/deliver-message', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).deliverOpenCodeRuntimeMessage( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/deliver-message:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/task-event', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeTaskEvent( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/task-event:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/opencode/runtime/heartbeat', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getTeamProvisioningService(services).recordOpenCodeRuntimeHeartbeat( + withRuntimeTeamName(validatedTeamName.value!, request.body) + ) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/opencode/runtime/heartbeat:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); } diff --git a/src/main/index.ts b/src/main/index.ts index 7a6c373f..8fa27bdb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,7 +47,10 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + resolveAgentTeamsMcpLaunchSpec, + TeamMcpConfigBuilder, +} from '@main/services/team/TeamMcpConfigBuilder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { @@ -112,6 +115,18 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; +import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; +import { + createOpenCodeBridgeCommandLeaseStore, + createOpenCodeBridgeCommandLedgerStore, +} from './services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; +import { + createOpenCodeBridgeClientIdentity, + OpenCodeBridgeCommandHandshakePort, +} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; +import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; +import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -130,11 +145,14 @@ import { BoardTaskExactLogsService, BoardTaskLogStreamService, BranchStatusService, + ClaudeBinaryResolver, CliInstallerService, configManager, LocalFileSystemProvider, MemberStatsComputer, NotificationManager, + OpenCodeReadinessBridge, + OpenCodeTeamRuntimeAdapter, PtyTerminalService, ServiceContext, ServiceContextRegistry, @@ -142,6 +160,7 @@ import { TaskBoundaryParser, TeamDataService, TeamLogSourceTracker, + TeamRuntimeAdapterRegistry, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, @@ -155,6 +174,7 @@ import { import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; +import type { OpenCodeTeamLaunchMode } from './services/team'; const logger = createLogger('App'); startEventLoopLagMonitor(); @@ -179,6 +199,83 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) — suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); +function resolveOpenCodeTeamLaunchModeFromEnv(): OpenCodeTeamLaunchMode { + const raw = process.env.CLAUDE_TEAM_OPENCODE_LAUNCH_MODE?.trim().toLowerCase(); + if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { + return raw; + } + if (process.env.CLAUDE_TEAM_OPENCODE_DOGFOOD === '1') { + return 'dogfood'; + } + return 'disabled'; +} + +async function createOpenCodeRuntimeAdapterRegistry(): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved'); + return new TeamRuntimeAdapterRegistry(); + } + + const bridgeEnv = { ...process.env }; + try { + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const mcpEntry = mcpLaunchSpec.args[0]; + if (mcpEntry) { + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath, + tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), + env: bridgeEnv, + }); + const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); + const clientIdentity = createOpenCodeBridgeClientIdentity({ + appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0', + gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null, + buildId: process.env.VITE_BUILD_ID ?? process.env.BUILD_ID ?? null, + }); + const stateChangingCommands = new OpenCodeStateChangingBridgeCommandService({ + expectedClientIdentity: clientIdentity, + handshakePort: new OpenCodeBridgeCommandHandshakePort({ + bridge: bridgeClient, + clientIdentity, + }), + leaseStore: createOpenCodeBridgeCommandLeaseStore({ + filePath: join(bridgeControlDir, 'command-leases.json'), + }), + ledger: createOpenCodeBridgeCommandLedgerStore({ + filePath: join(bridgeControlDir, 'command-ledger.json'), + }), + bridge: bridgeClient, + manifestReader: new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), + }), + }); + return new TeamRuntimeAdapterRegistry([ + new OpenCodeTeamRuntimeAdapter( + new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({ + filePath: join(bridgeControlDir, 'production-e2e-evidence.json'), + }), + }), + { + launchMode: resolveOpenCodeTeamLaunchModeFromEnv(), + } + ), + ]); +} + // --- Team display name cache (avoid listTeams() on every notification) --- const TEAM_DISPLAY_NAME_TTL_MS = 30_000; const teamDisplayNameCache = new Map(); @@ -838,6 +935,7 @@ async function initializeServices(): Promise { teamDataService = new TeamDataService(); teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); + teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()); // Startup GC: remove stale MCP config files from previous sessions (best-effort) void new TeamMcpConfigBuilder().gcStaleConfigs(); void teamDataService diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 3d86f5aa..e1b551d4 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -97,6 +97,7 @@ import { } from '@shared/utils/effortLevels'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, @@ -1124,16 +1125,14 @@ function isValidEffort(value: unknown, providerId?: TeamProviderId | null): valu function parseOptionalMemberProviderId( value: unknown -): - | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined } - | { valid: false; error: string } { +): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } { if (value === undefined || value === null || value === '') { return { valid: true, value: undefined }; } - if (value === 'anthropic' || value === 'codex' || value === 'gemini') { + if (isTeamProviderId(value)) { return { valid: true, value }; } - return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; + return { valid: false, error: 'member providerId must be anthropic, codex, gemini, or opencode' }; } function parseOptionalProviderBackendId( @@ -1701,7 +1700,7 @@ async function handlePrepareProvisioning( ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; - let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; + let validatedProviderIds: TeamProviderId[] | undefined; let validatedSelectedModels: string[] | undefined; let validatedLimitContext: boolean | undefined; if (cwd !== undefined) { @@ -1714,8 +1713,8 @@ async function handlePrepareProvisioning( } } if (providerId !== undefined) { - if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') { - return { success: false, error: 'providerId must be anthropic, codex, or gemini' }; + if (!isTeamProviderId(providerId)) { + return { success: false, error: 'providerId must be anthropic, codex, gemini, or opencode' }; } validatedProviderId = providerId; } @@ -1723,10 +1722,13 @@ async function handlePrepareProvisioning( if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } - const normalized: ('anthropic' | 'codex' | 'gemini')[] = []; + const normalized: TeamProviderId[] = []; for (const entry of providerIds) { - if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { - return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; + if (!isTeamProviderId(entry)) { + return { + success: false, + error: 'providerIds entries must be anthropic, codex, gemini, or opencode', + }; } if (!normalized.includes(entry)) { normalized.push(entry); @@ -3283,7 +3285,7 @@ async function handleReplaceMembers( name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; }[] = []; diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index d5fc7beb..9d78e8aa 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -153,6 +153,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat ...provider, modelVerificationState: provider.modelVerificationState ?? 'idle', modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null, + detailMessage: provider.detailMessage ?? null, modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], runtimeCapabilities: provider.runtimeCapabilities ? structuredClone(provider.runtimeCapabilities) @@ -763,15 +764,18 @@ export class CliInstallerService { return null; } - const providerStatus = await this.multimodelBridgeService.getProviderStatus( - binaryPath, - providerId - ); - const nextProviderStatus = this.applyProviderModelAvailabilityToProvider( - binaryPath, - versionProbe.version, - providerStatus - ); + const providerStatus = + providerId === 'opencode' + ? await this.multimodelBridgeService.verifyProviderStatus(binaryPath, providerId) + : await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId); + const nextProviderStatus = + providerId === 'opencode' + ? await this.multimodelBridgeService.verifyOpenCodeModels(binaryPath, providerStatus) + : this.applyProviderModelAvailabilityToProvider( + binaryPath, + versionProbe.version, + providerStatus + ); this.updateLatestProviderStatus(nextProviderStatus); if (this.latestStatusSnapshot) { this.publishStatusSnapshot(this.latestStatusSnapshot); diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index fe90e437..30dcd444 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -18,6 +18,7 @@ import { getTeamsBasePath, getTodosBasePath, } from '@main/utils/pathDecoder'; +import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution'; import { createLogger } from '@shared/utils/logger'; import { EventEmitter } from 'events'; import * as fs from 'fs'; @@ -1007,6 +1008,16 @@ export class FileWatcher extends EventEmitter { detail: relative, }; this.emit('team-change', event); + return; + } + + if (relative === OPENCODE_TASK_LOG_ATTRIBUTION_FILE) { + const event: TeamChangeEvent = { + type: 'log-source-change', + teamName, + detail: relative, + }; + this.emit('team-change', event); } } diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index c4bd3ab1..0c6f572e 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,6 +1,7 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; import { createDefaultCliExtensionCapabilities, createLegacyRuntimeFallbackCliExtensionCapabilities, @@ -10,12 +11,18 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types'; +import type { + CliProviderId, + CliProviderModelAvailability, + CliProviderReasoningEffort, + CliProviderStatus, +} from '@shared/types'; const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +const OPENCODE_MODEL_VERIFY_TIMEOUT_MS = 60_000; interface RuntimeExtensionCapabilityResponse { status?: 'supported' | 'read-only' | 'unsupported'; @@ -94,6 +101,7 @@ interface ProviderStatusCommandResponse { verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; canLoginFromUi?: boolean; statusMessage?: string | null; + detailMessage?: string | null; capabilities?: { teamLaunch?: boolean; oneShot?: boolean; @@ -179,7 +187,141 @@ interface UnifiedRuntimeStatusResponse { >; } -const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; +interface OpenCodeRuntimeVerifyResponse { + schemaVersion?: number; + providerId?: 'opencode'; + snapshot?: { + detected?: boolean; + hostHealthy?: boolean; + probeError?: string | null; + diagnostics?: string[]; + host?: { + version?: string | null; + resolvedConfigFingerprint?: string | null; + } | null; + profile?: { + profileRootKey?: string; + projectBehaviorFingerprint?: string; + managedConfigFingerprint?: string; + } | null; + config?: { + default_agent?: string; + share?: string | null; + snapshot?: boolean; + autoupdate?: boolean | string; + } | null; + } | null; +} + +export interface OpenCodeRuntimeTranscriptResponse { + schemaVersion?: number; + providerId?: 'opencode'; + transcript?: { + sessionId?: string; + durableState?: string; + staleReason?: string | null; + messageCount?: number; + toolCallCount?: number; + errorCount?: number; + latestAssistantText?: string | null; + latestAssistantPreview?: string | null; + messages?: unknown[]; + diagnostics?: string[]; + logProjection?: { + sessionId?: string; + durableState?: string; + sourceMessageCount?: number; + projectedMessageCount?: number; + syntheticMessageCount?: number; + toolCallCount?: number; + errorCount?: number; + diagnostics?: string[]; + messages?: OpenCodeRuntimeTranscriptLogMessage[]; + } | null; + } | null; +} + +export type OpenCodeRuntimeTranscriptLogContentBlock = + | { + type: 'text'; + text: string; + } + | { + type: 'thinking'; + thinking: string; + signature: string; + } + | { + type: 'tool_use'; + id: string; + name: string; + input: Record; + } + | { + type: 'tool_result'; + tool_use_id: string; + content: string | OpenCodeRuntimeTranscriptLogContentBlock[]; + is_error?: boolean; + }; + +export interface OpenCodeRuntimeTranscriptLogToolCall { + id: string; + name: string; + input: Record; + isTask: boolean; + taskDescription?: string; + taskSubagentType?: string; +} + +export interface OpenCodeRuntimeTranscriptLogToolResult { + toolUseId: string; + content: string | OpenCodeRuntimeTranscriptLogContentBlock[]; + isError: boolean; +} + +export interface OpenCodeRuntimeTranscriptLogMessage { + uuid: string; + parentUuid: string | null; + type: 'assistant' | 'user' | 'system'; + timestamp: string; + role?: string; + content: OpenCodeRuntimeTranscriptLogContentBlock[] | string; + model?: string; + agentName?: string; + isMeta: boolean; + sessionId: string; + toolCalls: OpenCodeRuntimeTranscriptLogToolCall[]; + toolResults: OpenCodeRuntimeTranscriptLogToolResult[]; + sourceToolUseID?: string; + sourceToolAssistantUUID?: string; + subtype?: string; + level?: string; +} + +interface OpenCodeRuntimeVerifyModelResponse { + schemaVersion?: number; + providerId?: 'opencode'; + result?: { + modelId?: string; + outcome?: 'available' | 'unavailable' | 'unknown'; + reason?: string | null; + } | null; +} + +const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode']; + +function getProviderDisplayName(providerId: CliProviderId): string { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'opencode': + return 'OpenCode'; + } +} function extractJsonObject(raw: string): T { const trimmed = raw.trim(); @@ -198,17 +340,17 @@ function extractJsonObject(raw: string): T { function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus { return { providerId, - displayName: - providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + displayName: getProviderDisplayName(providerId), supported: false, authenticated: false, authMethod: null, verificationState: 'unknown', modelVerificationState: 'idle', statusMessage: null, + detailMessage: null, models: [], modelAvailability: [], - canLoginFromUi: true, + canLoginFromUi: providerId !== 'opencode', capabilities: { teamLaunch: false, oneShot: false, @@ -428,6 +570,7 @@ export class ClaudeMultimodelBridgeService { authMethod: runtimeStatus.authMethod ?? null, verificationState: runtimeStatus.verificationState ?? 'unknown', statusMessage: runtimeStatus.statusMessage ?? null, + detailMessage: runtimeStatus.detailMessage ?? null, canLoginFromUi: runtimeStatus.canLoginFromUi !== false, capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, @@ -514,6 +657,7 @@ export class ClaudeMultimodelBridgeService { authMethod: null, verificationState: 'error', statusMessage: issue, + detailMessage: null, backend: null, }; } @@ -525,6 +669,94 @@ export class ClaudeMultimodelBridgeService { return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); } + private async getOpenCodeVerifySnapshot( + binaryPath: string + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + const { stdout } = await execCli( + binaryPath, + ['runtime', 'verify', '--json', '--provider', 'opencode'], + { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + return parsed.providerId === 'opencode' ? (parsed.snapshot ?? null) : null; + } + + private mergeOpenCodeVerification( + provider: CliProviderStatus, + snapshot: OpenCodeRuntimeVerifyResponse['snapshot'] + ): CliProviderStatus { + if (!snapshot) { + return provider; + } + + const diagnostics = snapshot.diagnostics ?? []; + const diagnosticsSummary = diagnostics.slice(0, 2).join(' - '); + const liveIssuesPresent = + snapshot.detected === false || + snapshot.hostHealthy !== true || + Boolean(snapshot.probeError) || + diagnostics.length > 0; + + const detailParts = [ + provider.detailMessage ?? null, + snapshot.host?.resolvedConfigFingerprint + ? `live ${snapshot.host.resolvedConfigFingerprint.slice(0, 12)}` + : null, + snapshot.profile?.managedConfigFingerprint + ? `managed ${snapshot.profile.managedConfigFingerprint.slice(0, 12)}` + : null, + snapshot.profile?.projectBehaviorFingerprint + ? `behavior ${snapshot.profile.projectBehaviorFingerprint.slice(0, 12)}` + : null, + diagnosticsSummary || null, + ].filter((value): value is string => Boolean(value)); + + const nextDiagnostics = [ + ...(provider.externalRuntimeDiagnostics ?? []), + { + id: 'opencode-live-host', + label: 'OpenCode live host', + detected: snapshot.hostHealthy === true, + statusMessage: snapshot.hostHealthy === true ? 'Healthy' : 'Unavailable', + detailMessage: snapshot.probeError ?? null, + }, + { + id: 'opencode-managed-runtime', + label: 'OpenCode managed runtime', + detected: !liveIssuesPresent, + statusMessage: liveIssuesPresent + ? 'Live verification found runtime drift' + : 'Managed runtime verified', + detailMessage: diagnosticsSummary || null, + }, + ]; + + return { + ...provider, + verificationState: liveIssuesPresent ? 'error' : 'verified', + statusMessage: liveIssuesPresent + ? (snapshot.probeError ?? + diagnostics[0] ?? + 'OpenCode live verification found runtime drift') + : provider.statusMessage, + detailMessage: detailParts.length > 0 ? detailParts.join(' - ') : provider.detailMessage, + externalRuntimeDiagnostics: nextDiagnostics, + backend: provider.backend + ? { + ...provider.backend, + authMethodDetail: + snapshot.config?.default_agent === 'teammate' + ? 'managed teammate agent' + : (provider.backend.authMethodDetail ?? null), + } + : provider.backend, + }; + } + async getProviderStatus( binaryPath: string, providerId: CliProviderId @@ -565,6 +797,134 @@ export class ClaudeMultimodelBridgeService { ); } + async verifyProviderStatus( + binaryPath: string, + providerId: CliProviderId + ): Promise { + const provider = await this.getProviderStatus(binaryPath, providerId); + if (providerId !== 'opencode') { + return provider; + } + + try { + const snapshot = await this.getOpenCodeVerifySnapshot(binaryPath); + return this.mergeOpenCodeVerification(provider, snapshot); + } catch (error) { + logger.warn( + `OpenCode live verification unavailable: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { + ...provider, + verificationState: 'error', + statusMessage: 'OpenCode live verification failed', + detailMessage: error instanceof Error ? error.message : String(error), + }; + } + } + + async getOpenCodeTranscript( + binaryPath: string, + params: { + teamId: string; + memberName: string; + limit?: number; + } + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + const args = [ + 'runtime', + 'transcript', + '--json', + '--provider', + 'opencode', + '--team', + params.teamId, + '--member', + params.memberName, + ]; + if (typeof params.limit === 'number') { + args.push('--limit', String(params.limit)); + } + + const { stdout } = await execCli(binaryPath, args, { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + }); + const parsed = extractJsonObject(stdout); + return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null; + } + + private async verifyOpenCodeModel( + binaryPath: string, + modelId: string + ): Promise { + const { env } = await this.buildCliEnv(binaryPath); + try { + const { stdout } = await execCli( + binaryPath, + ['runtime', 'verify-model', '--json', '--provider', 'opencode', '--model', modelId], + { + timeout: OPENCODE_MODEL_VERIFY_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + const outcome = parsed.providerId === 'opencode' ? parsed.result?.outcome : undefined; + const reason = parsed.providerId === 'opencode' ? (parsed.result?.reason ?? null) : null; + + return { + modelId, + status: + outcome === 'available' + ? 'available' + : outcome === 'unavailable' + ? 'unavailable' + : 'unknown', + reason, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + return { + modelId, + status: 'unknown', + reason: error instanceof Error ? error.message : String(error), + checkedAt: new Date().toISOString(), + }; + } + } + + async verifyOpenCodeModels( + binaryPath: string, + provider: CliProviderStatus + ): Promise { + const visibleModels = filterVisibleProviderRuntimeModels(provider.providerId, provider.models); + if ( + provider.providerId !== 'opencode' || + provider.supported !== true || + provider.authenticated !== true || + visibleModels.length === 0 + ) { + return { + ...provider, + modelVerificationState: 'idle', + modelAvailability: [], + }; + } + + const modelAvailability: CliProviderModelAvailability[] = []; + for (const modelId of visibleModels) { + modelAvailability.push(await this.verifyOpenCodeModel(binaryPath, modelId)); + } + + return { + ...provider, + modelVerificationState: 'verified', + modelAvailability, + }; + } + private async buildGeminiStatus(binaryPath: string): Promise { const provider = createDefaultProviderStatus('gemini'); const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini'); @@ -686,6 +1046,7 @@ export class ClaudeMultimodelBridgeService { authMethod: runtimeStatus.authMethod ?? null, verificationState: runtimeStatus.verificationState ?? 'unknown', statusMessage: runtimeStatus.statusMessage ?? null, + detailMessage: runtimeStatus.detailMessage ?? null, canLoginFromUi: runtimeStatus.canLoginFromUi !== false, capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 05133652..13c98862 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -44,6 +44,11 @@ const PROVIDER_CAPABILITIES: Record< supportsApiKey: true, configurableAuthModes: [], }, + opencode: { + supportsOAuth: false, + supportsApiKey: false, + configurableAuthModes: [], + }, }; const PROVIDER_API_KEY_ENV_VARS: Partial> = { @@ -184,7 +189,7 @@ export class ProviderConnectionService { async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId); } return nextEnv; @@ -238,7 +243,7 @@ export class ProviderConnectionService { async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId); } return nextEnv; @@ -308,7 +313,7 @@ export class ProviderConnectionService { async getConfiguredConnectionIssues( env: NodeJS.ProcessEnv, - providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'], + providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'], runtimeBackendOverrides?: Partial> ): Promise>> { const issues: Partial> = {}; diff --git a/src/main/services/runtime/buildRuntimeBaseEnv.ts b/src/main/services/runtime/buildRuntimeBaseEnv.ts index 568d0fa3..0d11060d 100644 --- a/src/main/services/runtime/buildRuntimeBaseEnv.ts +++ b/src/main/services/runtime/buildRuntimeBaseEnv.ts @@ -6,7 +6,7 @@ import { configManager } from '../infrastructure/ConfigManager'; import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv, - resolveTeamProviderId, + resolveRuntimeProviderId, } from './providerRuntimeEnv'; import type { CliProviderId, TeamProviderId } from '@shared/types'; @@ -68,19 +68,19 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): { }; } - const resolvedProviderId = resolveTeamProviderId(options.providerId); + const runtimeProviderId = resolveRuntimeProviderId(options.providerId); applyProviderRuntimeEnv(env, options.providerId); - if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) { + if (runtimeProviderId === 'codex' && options.providerBackendId?.trim()) { env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim(); } - if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) { + if (runtimeProviderId === 'gemini' && options.providerBackendId?.trim()) { env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim(); } return { env, - resolvedProviderId, + resolvedProviderId: runtimeProviderId, }; } diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts index 26ea3771..f16d6559 100644 --- a/src/main/services/runtime/providerRuntimeEnv.ts +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -1,6 +1,8 @@ import { ConfigManager } from '../infrastructure/ConfigManager'; -import type { TeamProviderId } from '@shared/types'; +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type RuntimeEnvProviderId = CliProviderId | TeamProviderId; const PROVIDER_ROUTING_ENV_KEYS = [ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', @@ -32,10 +34,9 @@ export function applyConfiguredRuntimeBackendsEnv( export function applyProviderRuntimeEnv( env: NodeJS.ProcessEnv, - providerId: TeamProviderId | undefined + providerId: RuntimeEnvProviderId | undefined ): NodeJS.ProcessEnv { - const resolvedProvider: TeamProviderId = - providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; + const resolvedProvider = resolveRuntimeProviderId(providerId); for (const key of PROVIDER_ROUTING_ENV_KEYS) { env[key] = undefined; @@ -52,6 +53,18 @@ export function applyProviderRuntimeEnv( return env; } -export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { - return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +export function resolveRuntimeProviderId( + providerId: RuntimeEnvProviderId | undefined +): CliProviderId { + if (providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode') { + return providerId; + } + + return 'anthropic'; +} + +export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { + return providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode' + ? providerId + : 'anthropic'; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 54a652ac..d736bb37 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -78,6 +78,7 @@ import type { TeamMember, TeamMemberActivityMeta, TeamProcess, + TeamProviderId, TeamSummary, TeamTask, TeamTaskStatus, @@ -1315,7 +1316,7 @@ export class TeamDataService { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; }[]; diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 0a15034d..7d088e80 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; -interface McpLaunchSpec { +export interface McpLaunchSpec { command: string; args: string[]; } @@ -202,7 +202,7 @@ async function resolvePackagedServerEntry(): Promise { } } -async function resolveMcpLaunchSpec(): Promise { +export async function resolveAgentTeamsMcpLaunchSpec(): Promise { const checked: string[] = []; // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath @@ -250,7 +250,7 @@ async function resolveMcpLaunchSpec(): Promise { export class TeamMcpConfigBuilder { async writeConfigFile(_projectPath?: string): Promise { - const launchSpec = await resolveMcpLaunchSpec(); + const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); const configDir = getMcpConfigsBasePath(); const configPath = path.join( configDir, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 9e819f66..3f4ab861 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -5,8 +5,15 @@ import { } from '@shared/utils/teamMemberName'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; -import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; +import type { + TeamConfig, + TeamMember, + TeamMemberSnapshot, + TeamProviderId, + TeamTaskWithKanban, +} from '@shared/types'; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ @@ -121,7 +128,7 @@ export class TeamMemberResolver { agentType?: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; color?: string; @@ -131,17 +138,10 @@ export class TeamMemberResolver { if (Array.isArray(config.members)) { for (const m of config.members) { if (typeof m?.name === 'string' && m.name.trim() !== '') { - const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' }; + const configMember = m as TeamMember & { provider?: TeamProviderId }; const providerId = - configMember.providerId === 'anthropic' || - configMember.providerId === 'codex' || - configMember.providerId === 'gemini' - ? configMember.providerId - : configMember.provider === 'anthropic' || - configMember.provider === 'codex' || - configMember.provider === 'gemini' - ? configMember.provider - : undefined; + normalizeOptionalTeamProviderId(configMember.providerId) ?? + normalizeOptionalTeamProviderId(configMember.provider); configMemberMap.set(m.name.trim(), { agentId: configMember.agentId, agentType: configMember.agentType, @@ -164,7 +164,7 @@ export class TeamMemberResolver { agentType?: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; effort?: TeamMember['effort']; color?: string; diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 57f727a5..a7d3feba 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -21,7 +21,7 @@ export interface TeamMetaFile { color?: string; cwd: string; prompt?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; providerBackendId?: string; model?: string; effort?: string; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2e7e3a5a..733ba278 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -119,6 +119,7 @@ import { } from './TeamBootstrapStateReader'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; +import { TeamInboxWriter } from './TeamInboxWriter'; import { createPersistedLaunchSnapshot, snapshotFromRuntimeMemberStatuses, @@ -129,6 +130,26 @@ import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; +import { + TeamRuntimeAdapterRegistry, + type TeamLaunchRuntimeAdapter, + type TeamRuntimeLaunchInput, + type TeamRuntimeLaunchResult, + type TeamRuntimeMemberLaunchEvidence, +} from './runtime'; +import { + RuntimeDeliveryDestinationRegistry, + RuntimeDeliveryReconciler, + RuntimeDeliveryService, + type RuntimeDeliveryDestinationPort, +} from './opencode/delivery/RuntimeDeliveryService'; +import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; +import { getOpenCodeTeamRuntimeDirectory } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { + createRuntimeRunTombstoneStore, + type RuntimeEvidenceKind, +} from './opencode/store/RuntimeRunTombstoneStore'; +import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; @@ -173,6 +194,20 @@ interface RelayInboxMessageView { isCoarseNoise: boolean; } +interface OpenCodeRuntimeControlAck { + ok: true; + providerId: 'opencode'; + teamName: string; + runId: string; + state: 'accepted' | 'delivered' | 'duplicate' | 'recorded'; + memberName?: string; + runtimeSessionId?: string; + idempotencyKey?: string; + location?: unknown; + diagnostics: string[]; + observedAt: string; +} + import type { CliProviderModelCatalog, ActiveToolCall, @@ -185,9 +220,11 @@ import type { MemberSpawnLivenessSource, MemberSpawnStatus, MemberSpawnStatusEntry, + PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, ProviderModelLaunchIdentity, + PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot, @@ -224,6 +261,63 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; + +function asRuntimeRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('OpenCode runtime payload must be an object'); + } + return value as Record; +} + +function requireRuntimeString(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode runtime payload missing ${fieldName}`); + } + return value.trim(); +} + +function optionalRuntimeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeRuntimeIso(value: unknown, fallback: string = nowIso()): string { + const raw = optionalRuntimeString(value); + if (!raw) { + return fallback; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; +} + +function normalizeRuntimeStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : []; +} + +function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined { + const refs = normalizeRuntimeStringArray(value); + return refs.length > 0 + ? refs.map((ref) => ({ + teamName, + taskId: ref, + displayId: ref, + })) + : undefined; +} + +function mergeRuntimeDiagnostics( + previous: string[] | undefined, + incoming: unknown, + fallback?: string +): string[] | undefined { + const merged = [ + ...(previous ?? []), + ...normalizeRuntimeStringArray(incoming), + ...(fallback ? [fallback] : []), + ].filter((value) => value.trim().length > 0); + return merged.length > 0 ? [...new Set(merged)] : undefined; +} const VERIFY_POLL_MS = 500; const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250; const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000; @@ -516,6 +610,8 @@ function resolveRequestedLaunchModel(params: { function getTeamProviderLabel(providerId: TeamProviderId): string { switch (providerId) { + case 'opencode': + return 'OpenCode'; case 'codex': return 'Codex'; case 'gemini': @@ -555,6 +651,8 @@ function getCanonicalSendMessageToolRule(to: string): string { function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; switch (providerId) { + case 'opencode': + return null; case 'gemini': return runtimeConfig.gemini; case 'codex': @@ -565,6 +663,33 @@ function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null } } +function isOpenCodeLegacyProvisioningRequest(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): boolean { + return ( + normalizeOptionalTeamProviderId(request.providerId) === 'opencode' || + (request.members ?? []).some( + (member) => + normalizeOptionalTeamProviderId(member.providerId) === 'opencode' || + normalizeOptionalTeamProviderId(member.provider) === 'opencode' + ) + ); +} + +function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): void { + if (!isOpenCodeLegacyProvisioningRequest(request)) { + return; + } + throw new Error( + 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' + + 'Use the gated OpenCode runtime adapter once production launch is enabled.' + ); +} + function mergeProvisioningWarnings( existing: string[] | undefined, nextWarning: string | null @@ -1408,9 +1533,7 @@ function formatWorkflowBlock(workflow: string, indent: string): string { type TeamMemberInput = TeamCreateRequest['members'][number]; function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { - return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic' - ? providerId - : undefined; + return normalizeOptionalTeamProviderId(providerId); } function buildEffectiveTeamMemberSpec( @@ -2963,6 +3086,11 @@ export class TeamProvisioningService { private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); + private readonly runtimeAdapterProgressByRunId = new Map(); + private readonly runtimeAdapterRunByTeam = new Map< + string, + { runId: string; providerId: TeamProviderId; cwd?: string } + >(); private readonly retainedClaudeLogsByTeam = new Map(); private readonly persistedTranscriptClaudeLogsCache = new Map< string, @@ -2998,6 +3126,7 @@ export class TeamProvisioningService { private toolApprovalSettingsByTeam = new Map(); private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; private crossTeamSender: | ((request: { @@ -3017,9 +3146,11 @@ export class TeamProvisioningService { private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), - _sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), + private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(), - private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() + private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), + private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(), + private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore() ) { this.memberLogsFinder = new TeamMemberLogsFinder( this.configReader, @@ -3029,6 +3160,10 @@ export class TeamProvisioningService { this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); } + setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { + this.runtimeAdapterRegistry = registry; + } + setCrossTeamSender( sender: | ((request: { @@ -3405,6 +3540,31 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private getOpenCodeRuntimeAdapter(): TeamLaunchRuntimeAdapter | null { + if (!this.runtimeAdapterRegistry?.has('opencode')) { + return null; + } + return this.runtimeAdapterRegistry.get('opencode'); + } + + private shouldRouteOpenCodeToRuntimeAdapter(request: { + providerId?: TeamProviderId; + members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[]; + }): boolean { + return ( + isOpenCodeLegacyProvisioningRequest(request) && this.getOpenCodeRuntimeAdapter() !== null + ); + } + + private setRuntimeAdapterProgress( + progress: TeamProvisioningProgress, + onProgress?: (progress: TeamProvisioningProgress) => void + ): TeamProvisioningProgress { + this.runtimeAdapterProgressByRunId.set(progress.runId, progress); + onProgress?.(progress); + return progress; + } + private async getPersistedTranscriptClaudeLogs( teamName: string ): Promise { @@ -4352,6 +4512,446 @@ export class TeamProvisioningService { return this.getAliveRunId(teamName); } + async recordOpenCodeRuntimeBootstrapCheckin(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); + const observedAt = normalizeRuntimeIso(payload.observedAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'bootstrap_checkin', + }); + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics: payload.diagnostics, + reason: 'OpenCode runtime bootstrap check-in accepted', + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'accepted', + memberName, + runtimeSessionId, + diagnostics: [], + observedAt, + }; + } + + async deliverOpenCodeRuntimeMessage(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'delivery_call', + }); + + const delivery = this.createOpenCodeRuntimeDeliveryService(teamName); + const ack = await delivery.deliver({ + ...payload, + teamName, + runId, + providerId: 'opencode', + createdAt: normalizeRuntimeIso(payload.createdAt), + }); + + if (!ack.ok) { + throw new Error(`OpenCode runtime delivery rejected: ${ack.reason}`); + } + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: ack.delivered ? 'delivered' : 'duplicate', + idempotencyKey: ack.idempotencyKey, + location: ack.location, + diagnostics: ack.reason ? [ack.reason] : [], + observedAt: normalizeRuntimeIso(payload.createdAt), + }; + } + + async recordOpenCodeRuntimeTaskEvent(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const taskId = requireRuntimeString(payload.taskId, 'taskId'); + const event = requireRuntimeString(payload.event, 'event'); + const idempotencyKey = requireRuntimeString(payload.idempotencyKey, 'idempotencyKey'); + const runtimeSessionId = optionalRuntimeString(payload.runtimeSessionId); + const observedAt = normalizeRuntimeIso(payload.createdAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'delivery_call', + }); + + const writeResult = await this.openCodeTaskLogAttributionStore.upsertTaskRecord(teamName, { + taskId, + memberName, + scope: 'member_session_window', + ...(runtimeSessionId ? { sessionId: runtimeSessionId } : {}), + since: observedAt, + source: 'launch_runtime', + }); + this.teamChangeEmitter?.({ + type: 'task-log-change', + teamName, + runId, + taskId, + detail: `opencode-runtime-task-event:${event}`, + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'recorded', + memberName, + ...(runtimeSessionId ? { runtimeSessionId } : {}), + idempotencyKey, + diagnostics: [writeResult], + observedAt, + }; + } + + async recordOpenCodeRuntimeHeartbeat(raw: unknown): Promise { + const payload = asRuntimeRecord(raw); + const teamName = requireRuntimeString(payload.teamName, 'teamName'); + const runId = requireRuntimeString(payload.runId, 'runId'); + const memberName = requireRuntimeString(payload.memberName, 'memberName'); + const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); + const observedAt = normalizeRuntimeIso(payload.observedAt); + + await this.assertOpenCodeRuntimeEvidenceAccepted({ + teamName, + runId, + evidenceKind: 'heartbeat', + }); + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics: undefined, + reason: `OpenCode runtime heartbeat accepted${optionalRuntimeString(payload.status) ? ` (${optionalRuntimeString(payload.status)})` : ''}`, + }); + + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'accepted', + memberName, + runtimeSessionId, + diagnostics: [], + observedAt, + }; + } + + private async assertOpenCodeRuntimeEvidenceAccepted(input: { + teamName: string; + runId: string; + evidenceKind: RuntimeEvidenceKind; + }): Promise { + const store = createRuntimeRunTombstoneStore({ + filePath: path.join( + getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), input.teamName), + 'opencode-run-tombstones.json' + ), + }); + await store.assertEvidenceAccepted({ + teamName: input.teamName, + runId: input.runId, + currentRunId: this.getTrackedRunId(input.teamName), + evidenceKind: input.evidenceKind, + }); + } + + private async updateOpenCodeRuntimeMemberLiveness(input: { + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + observedAt: string; + diagnostics: unknown; + reason: string; + }): Promise { + const previous = await this.launchStateStore.read(input.teamName); + const expectedMembers = previous?.expectedMembers.length + ? previous.expectedMembers + : this.readPersistedRuntimeMembers(input.teamName) + .map((member) => (typeof member.name === 'string' ? member.name.trim() : '')) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })); + const previousMember = previous?.members[input.memberName]; + const nextMember: PersistedTeamLaunchMemberState = { + name: input.memberName, + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + firstSpawnAcceptedAt: previousMember?.firstSpawnAcceptedAt ?? input.observedAt, + lastHeartbeatAt: input.observedAt, + lastRuntimeAliveAt: input.observedAt, + lastEvaluatedAt: input.observedAt, + sources: { + ...(previousMember?.sources ?? {}), + nativeHeartbeat: true, + processAlive: true, + }, + diagnostics: mergeRuntimeDiagnostics( + previousMember?.diagnostics, + input.diagnostics, + input.reason + ), + }; + const snapshot = createPersistedLaunchSnapshot({ + teamName: input.teamName, + expectedMembers: [...new Set([...expectedMembers, input.memberName])], + leadSessionId: previous?.leadSessionId, + launchPhase: previous?.launchPhase ?? 'active', + members: { + ...(previous?.members ?? {}), + [input.memberName]: nextMember, + }, + updatedAt: input.observedAt, + }); + await this.launchStateStore.write(input.teamName, snapshot); + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + runId: input.runId, + detail: input.memberName, + }); + } + + private createOpenCodeRuntimeDeliveryService(teamName: string): RuntimeDeliveryService { + const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); + const journal = createRuntimeDeliveryJournalStore({ + filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), + }); + return new RuntimeDeliveryService( + { + getCurrentRunId: async (candidateTeamName) => this.getTrackedRunId(candidateTeamName), + }, + journal, + new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), + { + append: async (event) => { + logger.warn(`[${event.teamName}] ${event.message}`); + }, + }, + { + emit: (event) => { + this.teamChangeEmitter?.({ + type: event.type as TeamChangeEvent['type'], + teamName: event.teamName, + detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined, + }); + }, + } + ); + } + + private createOpenCodeRuntimeDeliveryPorts(): RuntimeDeliveryDestinationPort[] { + const userMessagesPort: RuntimeDeliveryDestinationPort = { + kind: 'user_sent_messages', + write: async ({ envelope, destinationMessageId }) => { + await this.sentMessagesStore.appendMessage(envelope.teamName, { + from: envelope.fromMemberName, + to: 'user', + text: envelope.text, + timestamp: envelope.createdAt, + read: true, + summary: envelope.summary ?? undefined, + messageId: destinationMessageId, + source: 'lead_process', + leadSessionId: envelope.runtimeSessionId, + taskRefs: runtimeTaskRefs(envelope.teamName, envelope.taskRefs), + }); + return { + kind: 'user_sent_messages', + teamName: envelope.teamName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'user_sent_messages') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.sentMessagesStore.readMessages(destination.teamName); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'user_sent_messages', + teamName: destination.teamName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName }) => ({ + type: 'lead-message', + teamName, + data: { detail: 'opencode-runtime-delivery' }, + }), + }; + + const memberInboxPort: RuntimeDeliveryDestinationPort = { + kind: 'member_inbox', + write: async ({ envelope, destinationMessageId }) => { + if (typeof envelope.to !== 'object' || !('memberName' in envelope.to)) { + throw new Error('Runtime delivery member destination missing memberName'); + } + const memberName = envelope.to.memberName; + await this.inboxWriter.sendMessage(envelope.teamName, { + member: memberName, + from: envelope.fromMemberName, + to: memberName, + text: envelope.text, + timestamp: envelope.createdAt, + messageId: destinationMessageId, + summary: envelope.summary ?? undefined, + source: 'inbox', + leadSessionId: envelope.runtimeSessionId, + taskRefs: runtimeTaskRefs(envelope.teamName, envelope.taskRefs), + }); + return { + kind: 'member_inbox', + teamName: envelope.teamName, + memberName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'member_inbox') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.inboxReader.getMessagesFor( + destination.teamName, + destination.memberName + ); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'member_inbox', + teamName: destination.teamName, + memberName: destination.memberName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName, location }) => ({ + type: 'inbox', + teamName, + data: { + detail: + location.kind === 'member_inbox' ? `inboxes/${location.memberName}.json` : 'inboxes', + }, + }), + }; + + const crossTeamPort: RuntimeDeliveryDestinationPort = { + kind: 'cross_team_outbox', + write: async ({ envelope, destinationMessageId }) => { + if (typeof envelope.to !== 'object' || !('teamName' in envelope.to)) { + throw new Error('Runtime delivery cross-team destination missing teamName'); + } + if (!this.crossTeamSender) { + throw new Error('Cross-team sender is not configured'); + } + await this.crossTeamSender({ + fromTeam: envelope.teamName, + fromMember: envelope.fromMemberName, + toTeam: envelope.to.teamName, + text: envelope.text, + summary: envelope.summary ?? undefined, + messageId: destinationMessageId, + timestamp: envelope.createdAt, + conversationId: envelope.idempotencyKey, + }); + return { + kind: 'cross_team_outbox', + fromTeamName: envelope.teamName, + toTeamName: envelope.to.teamName, + toMemberName: envelope.to.memberName, + messageId: destinationMessageId, + }; + }, + verify: async ({ destination, destinationMessageId }) => { + if (destination.kind !== 'cross_team_outbox') { + return { found: false, location: null, diagnostics: ['destination kind mismatch'] }; + } + const messages = await this.sentMessagesStore.readMessages(destination.fromTeamName); + const found = messages.some((message) => message.messageId === destinationMessageId); + return { + found, + location: found + ? { + kind: 'cross_team_outbox', + fromTeamName: destination.fromTeamName, + toTeamName: destination.toTeamName, + toMemberName: destination.toMemberName, + messageId: destinationMessageId, + } + : null, + diagnostics: [], + }; + }, + buildChangeEvent: ({ teamName }) => ({ + type: 'inbox', + teamName, + data: { detail: 'cross-team-outbox' }, + }), + }; + + return [userMessagesPort, memberInboxPort, crossTeamPort]; + } + + async recoverOpenCodeRuntimeDeliveryJournal(teamName: string): Promise<{ recovered: true }> { + const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); + const journal = createRuntimeDeliveryJournalStore({ + filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), + }); + const reconciler = new RuntimeDeliveryReconciler( + journal, + new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), + { + append: async (event) => { + logger.warn(`[${event.teamName}] ${event.message}`); + }, + } + ); + await reconciler.reconcileTeam(teamName); + return { recovered: true }; + } + getLeadActivityState(teamName: string): { state: 'active' | 'idle' | 'offline'; runId: string | null; @@ -5529,6 +6129,33 @@ export class TeamProvisioningService { ); for (const providerId of providerIds) { + if (providerId === 'opencode') { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + blockingMessages.push( + 'OpenCode team launch is not enabled yet. Production launch requires the gated OpenCode runtime adapter.' + ); + continue; + } + + const prepare = await adapter.prepare({ + runId: `prepare-${randomUUID()}`, + teamName: '__prepare_opencode__', + cwd: targetCwd, + providerId: 'opencode', + model: selectedModelIds[0], + skipPermissions: true, + expectedMembers: [], + previousLaunchState: null, + }); + details.push(...prepare.diagnostics); + warnings.push(...prepare.warnings); + if (!prepare.ok) { + blockingMessages.push(`OpenCode: ${prepare.reason}`); + } + continue; + } + const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId)); if (!probeResult?.claudePath) { @@ -6720,6 +7347,10 @@ export class TeamProvisioningService { return { runId: existingProvisioningRunId }; } assertAppDeterministicBootstrapEnabled(); + if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { + return this.createOpenCodeTeamThroughRuntimeAdapter(request, onProgress); + } + assertOpenCodeNotLaunchedThroughLegacyProvisioning(request); // Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock) const pendingKey = `pending-${randomUUID()}`; @@ -7105,6 +7736,316 @@ export class TeamProvisioningService { } } + private async createOpenCodeTeamThroughRuntimeAdapter( + request: TeamCreateRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); + for (const probe of teamsBasePathsToProbe) { + const configPath = path.join(probe.basePath, request.teamName, 'config.json'); + if (await this.pathExists(configPath)) { + const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`; + throw new Error(`Team already exists${suffix}`); + } + } + + await ensureCwdExists(request.cwd); + const effectiveMembers = buildEffectiveTeamMemberSpecs(request.members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + const teamDir = path.join(getTeamsBasePath(), request.teamName); + const tasksDir = path.join(getTasksBasePath(), request.teamName); + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.mkdir(tasksDir, { recursive: true }); + await this.teamMetaStore.writeMeta(request.teamName, { + displayName: request.displayName, + description: request.description, + color: request.color, + cwd: request.cwd, + prompt: request.prompt, + providerId: request.providerId, + providerBackendId: request.providerBackendId, + model: request.model, + effort: request.effort, + skipPermissions: request.skipPermissions, + worktree: request.worktree, + extraCliArgs: request.extraCliArgs, + limitContext: request.limitContext, + createdAt: Date.now(), + }); + const membersToWrite = applyDistinctProvisioningMemberColors( + effectiveMembers.map((member) => ({ + name: member.name.trim(), + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + agentType: 'general-purpose' as const, + joinedAt: Date.now(), + })) + ); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); + await this.writeOpenCodeTeamConfig(request, effectiveMembers); + + return this.runOpenCodeTeamRuntimeAdapterLaunch({ + request, + members: effectiveMembers, + prompt: request.prompt?.trim() ?? '', + sourceWarning: undefined, + onProgress, + }); + } + + private async launchOpenCodeTeamThroughRuntimeAdapter( + request: TeamLaunchRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); + const configRaw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!configRaw) { + throw new Error(`Team "${request.teamName}" not found — config.json does not exist`); + } + await ensureCwdExists(request.cwd); + const { members, warning } = await this.resolveLaunchExpectedMembers( + request.teamName, + configRaw + ); + const effectiveMembers = buildEffectiveTeamMemberSpecs(members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + await this.updateConfigProjectPath(request.teamName, request.cwd); + + let existingTasks: TeamTask[] = []; + try { + existingTasks = await new TeamTaskReader().getTasks(request.teamName); + } catch (error) { + logger.warn( + `[${request.teamName}] Failed to read tasks for OpenCode launch prompt: ${String(error)}` + ); + } + const prompt = buildDeterministicLaunchHydrationPrompt( + request, + effectiveMembers, + existingTasks, + false + ); + + return this.runOpenCodeTeamRuntimeAdapterLaunch({ + request, + members: effectiveMembers, + prompt, + sourceWarning: warning, + onProgress, + }); + } + + private async runOpenCodeTeamRuntimeAdapterLaunch(input: { + request: TeamCreateRequest | TeamLaunchRequest; + members: TeamCreateRequest['members']; + prompt: string; + sourceWarning?: string; + onProgress: (progress: TeamProvisioningProgress) => void; + }): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + throw new Error('OpenCode runtime adapter is not registered'); + } + + const runId = randomUUID(); + const startedAt = nowIso(); + const initialProgress: TeamProvisioningProgress = { + runId, + teamName: input.request.teamName, + state: 'validating', + message: 'Validating OpenCode team launch gate', + startedAt, + updatedAt: startedAt, + warnings: input.sourceWarning ? [input.sourceWarning] : undefined, + }; + this.provisioningRunByTeam.set(input.request.teamName, runId); + this.setRuntimeAdapterProgress(initialProgress, input.onProgress); + this.resetTeamScopedTransientStateForNewRun(input.request.teamName); + const previousLaunchState = await this.launchStateStore.read(input.request.teamName); + await this.clearPersistedLaunchState(input.request.teamName); + const launchInput: TeamRuntimeLaunchInput = { + runId, + teamName: input.request.teamName, + cwd: input.request.cwd, + prompt: input.prompt, + providerId: 'opencode', + model: input.request.model, + effort: input.request.effort, + skipPermissions: input.request.skipPermissions !== false, + expectedMembers: input.members.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + providerId: 'opencode', + model: member.model ?? input.request.model, + effort: member.effort ?? input.request.effort, + cwd: input.request.cwd, + })), + previousLaunchState, + }; + + const launching = this.setRuntimeAdapterProgress( + { + ...initialProgress, + state: 'spawning', + message: 'Starting OpenCode sessions through runtime adapter', + updatedAt: nowIso(), + }, + input.onProgress + ); + + try { + const result = await adapter.launch(launchInput); + await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput); + const success = result.teamLaunchState === 'clean_success'; + const pending = result.teamLaunchState === 'partial_pending'; + const finalProgress = this.setRuntimeAdapterProgress( + { + ...launching, + state: success || pending ? 'ready' : 'failed', + message: success + ? 'OpenCode team launch is ready' + : pending + ? 'OpenCode team launch is waiting for runtime evidence or permissions' + : 'OpenCode team launch failed readiness gate', + messageSeverity: pending + ? 'warning' + : result.teamLaunchState === 'partial_failure' + ? 'error' + : undefined, + updatedAt: nowIso(), + warnings: result.warnings.length > 0 ? result.warnings : launching.warnings, + error: + result.teamLaunchState === 'partial_failure' + ? result.diagnostics.join('\n') || 'OpenCode launch failed' + : undefined, + cliLogsTail: result.diagnostics.join('\n') || undefined, + configReady: true, + }, + input.onProgress + ); + this.runtimeAdapterRunByTeam.set(input.request.teamName, { + runId, + providerId: 'opencode', + cwd: input.request.cwd, + }); + this.aliveRunByTeam.set(input.request.teamName, runId); + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + this.teamChangeEmitter?.({ + type: 'process', + teamName: input.request.teamName, + runId, + detail: finalProgress.state, + }); + return { runId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.setRuntimeAdapterProgress( + { + ...launching, + state: 'failed', + message: 'OpenCode runtime adapter launch failed', + messageSeverity: 'error', + updatedAt: nowIso(), + error: message, + cliLogsTail: message, + }, + input.onProgress + ); + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + throw error; + } + } + + private async writeOpenCodeTeamConfig( + request: TeamCreateRequest, + members: TeamCreateRequest['members'] + ): Promise { + const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); + const config: TeamConfig = { + name: request.displayName?.trim() || request.teamName, + description: request.description, + color: request.color, + projectPath: request.cwd, + members: members.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model, + effort: member.effort, + })), + }; + await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`); + } + + private async persistOpenCodeRuntimeAdapterLaunchResult( + result: TeamRuntimeLaunchResult, + input: TeamRuntimeLaunchInput + ): Promise { + const members: Record = {}; + for (const member of input.expectedMembers) { + const evidence = result.members[member.name]; + members[member.name] = this.toOpenCodePersistedLaunchMember(member.name, evidence); + } + const snapshot = createPersistedLaunchSnapshot({ + teamName: input.teamName, + expectedMembers: input.expectedMembers.map((member) => member.name), + leadSessionId: result.leadSessionId, + launchPhase: result.launchPhase, + members, + }); + await this.launchStateStore.write(input.teamName, snapshot); + return snapshot; + } + + private toOpenCodePersistedLaunchMember( + memberName: string, + evidence: TeamRuntimeMemberLaunchEvidence | undefined + ): PersistedTeamLaunchMemberState { + const now = nowIso(); + const launchState = evidence?.launchState ?? 'failed_to_start'; + return { + name: memberName, + launchState, + agentToolAccepted: evidence?.agentToolAccepted === true, + runtimeAlive: evidence?.runtimeAlive === true, + bootstrapConfirmed: evidence?.bootstrapConfirmed === true, + hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', + hardFailureReason: evidence?.hardFailureReason, + firstSpawnAcceptedAt: evidence?.agentToolAccepted ? now : undefined, + lastHeartbeatAt: evidence?.bootstrapConfirmed ? now : undefined, + lastRuntimeAliveAt: evidence?.runtimeAlive ? now : undefined, + lastEvaluatedAt: now, + sources: { + processAlive: evidence?.runtimeAlive === true, + nativeHeartbeat: evidence?.bootstrapConfirmed === true, + }, + diagnostics: evidence?.diagnostics, + }; + } + async launchTeam( request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void @@ -7123,6 +8064,10 @@ export class TeamProvisioningService { return { runId: existingProvisioningRunId }; } assertAppDeterministicBootstrapEnabled(); + if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { + return this.launchOpenCodeTeamThroughRuntimeAdapter(request, onProgress); + } + assertOpenCodeNotLaunchedThroughLegacyProvisioning(request); // Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock) const pendingKey = `pending-${randomUUID()}`; @@ -7179,6 +8124,10 @@ export class TeamProvisioningService { source, warning, } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw); + assertOpenCodeNotLaunchedThroughLegacyProvisioning({ + providerId: request.providerId, + members: expectedMemberSpecs, + }); const expectedMembers = expectedMemberSpecs.map((m) => m.name); // Extract leadSessionId for session resume on reconnect. @@ -7747,10 +8696,14 @@ export class TeamProvisioningService { async getProvisioningStatus(runId: string): Promise { const run = this.runs.get(runId); - if (!run) { - throw new Error('Unknown runId'); + if (run) { + return run.progress; } - return run.progress; + const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); + if (runtimeProgress) { + return runtimeProgress; + } + throw new Error('Unknown runId'); } async cancelProvisioning(runId: string): Promise { @@ -8593,6 +9546,9 @@ export class TeamProvisioningService { const runId = this.getAliveRunId(teamName); if (!runId) return false; const run = this.runs.get(runId); + if (!run && this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) { + return true; + } return run?.child != null && !run.processKilled && !run.cancelRequested; } @@ -8618,7 +9574,8 @@ export class TeamProvisioningService { teamName, isAlive: this.isTeamAlive(teamName), runId: run?.runId ?? runId ?? null, - progress: run?.progress ?? null, + progress: + run?.progress ?? (runId ? (this.runtimeAdapterProgressByRunId.get(runId) ?? null) : null), }; } @@ -10277,6 +11234,11 @@ export class TeamProvisioningService { } const run = this.runs.get(runId); if (!run) { + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.runId === runId && runtimeRun.providerId === 'opencode') { + void this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); + return; + } this.provisioningRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); return; @@ -10293,6 +11255,83 @@ export class TeamProvisioningService { logger.info(`[${teamName}] Process stopped (SIGKILL)`); } + private async stopOpenCodeRuntimeAdapterTeam(teamName: string, runId: string): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(teamName); + if (!adapter) { + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + this.provisioningRunByTeam.delete(teamName); + return; + } + const startedAt = nowIso(); + const previousProgress = this.runtimeAdapterProgressByRunId.get(runId); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: 'disconnected', + message: 'Stopping OpenCode team through runtime adapter', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: startedAt, + }); + try { + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + const result = await adapter.stop({ + runId, + teamName, + cwd: runtimeRun?.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, + providerId: 'opencode', + reason: 'user_requested', + previousLaunchState, + force: true, + }); + await this.launchStateStore.write( + teamName, + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: previousLaunchState?.expectedMembers ?? [], + leadSessionId: previousLaunchState?.leadSessionId, + launchPhase: 'reconciled', + members: previousLaunchState?.members ?? {}, + }) + ); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: result.stopped ? 'disconnected' : 'failed', + message: result.stopped ? 'OpenCode team stopped' : 'OpenCode team stop failed', + messageSeverity: result.stopped ? undefined : 'error', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: nowIso(), + cliLogsTail: result.diagnostics.join('\n') || undefined, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.setRuntimeAdapterProgress({ + runId, + teamName, + state: 'failed', + message: 'OpenCode team stop failed', + messageSeverity: 'error', + startedAt: previousProgress?.startedAt ?? startedAt, + updatedAt: nowIso(), + error: message, + cliLogsTail: message, + }); + } finally { + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + this.provisioningRunByTeam.delete(teamName); + this.teamChangeEmitter?.({ + type: 'process', + teamName, + runId, + detail: 'stopped', + }); + } + } + private stopPersistentTeamMembers(teamName: string): void { const members = this.readPersistedRuntimeMembers(teamName); if (members.length > 0) { @@ -10301,6 +11340,18 @@ export class TeamProvisioningService { this.killOrphanedTeamAgentProcesses(teamName); } + private readPersistedTeamProjectPath(teamName: string): string | null { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { projectPath?: unknown }; + const projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath.trim() : ''; + return projectPath || null; + } catch { + return null; + } + } + private readPersistedRuntimeMembers(teamName: string): PersistedRuntimeMemberLike[] { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index fda988d3..5db62fbc 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,12 +16,67 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; +export { + isTeamRuntimeProviderId, + OpenCodeTeamRuntimeAdapter, + TeamRuntimeAdapterRegistry, + TEAM_RUNTIME_PROVIDER_IDS, +} from './runtime'; +export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge'; +export type { + OpenCodeTeamLaunchMode, + OpenCodeTeamRuntimeAdapterOptions, + OpenCodeTeamRuntimeBridgePort, + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareFailure, + TeamRuntimePrepareResult, + TeamRuntimePrepareSuccess, + TeamRuntimeProviderId, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileReason, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopReason, + TeamRuntimeStopResult, +} from './runtime'; +export type { + OpenCodeReadinessBridgeCommandBody, + OpenCodeReadinessBridgeCommandExecutor, + OpenCodeReadinessBridgeOptions, +} from './opencode/bridge/OpenCodeReadinessBridge'; export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; +export { OpenCodeTaskLogAttributionService } from './taskLogs/stream/OpenCodeTaskLogAttributionService'; +export type { + OpenCodeTaskLogAttributionBulkWriteOutcome, + OpenCodeTaskLogAttributionMemberWindowInput, + OpenCodeTaskLogAttributionRecordDraft, + OpenCodeTaskLogAttributionRecordWriteOutcome, + OpenCodeTaskLogAttributionReplaceInput, + OpenCodeTaskLogAttributionTaskInput, + OpenCodeTaskLogAttributionTaskSessionInput, + OpenCodeTaskLogAttributionWriter, +} from './taskLogs/stream/OpenCodeTaskLogAttributionService'; +export { + OpenCodeTaskLogAttributionStore, + getOpenCodeTaskLogAttributionPath, +} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; +export type { + OpenCodeTaskLogAttributionReader, + OpenCodeTaskLogAttributionRecord, + OpenCodeTaskLogAttributionScope, + OpenCodeTaskLogAttributionSource, + OpenCodeTaskLogAttributionWriteResult, +} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index bb5acde8..eb0e6e1f 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -1,8 +1,10 @@ +import type { TeamProviderId } from '@shared/types'; + export interface MemberDiffInput { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; removedAt?: number | string | null; } @@ -12,7 +14,7 @@ export interface ReplaceMembersDiff { name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; }[]; removed: string[]; @@ -65,7 +67,7 @@ export function buildReplaceMembersDiff( name: string; role?: string; workflow?: string; - providerId?: 'anthropic' | 'codex' | 'gemini'; + providerId?: TeamProviderId; model?: string; }[] ): ReplaceMembersDiff { diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts new file mode 100644 index 00000000..fd55cca0 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { execCli } from '@main/utils/childProcess'; + +import { + extractRunId, + OPEN_CODE_BRIDGE_SCHEMA_VERSION, + parseSingleBridgeJsonResult, + validateBridgeResultEnvelope, + type OpenCodeBridgeCommandEnvelope, + type OpenCodeBridgeCommandName, + type OpenCodeBridgeDiagnosticEvent, + type OpenCodeBridgeFailure, + type OpenCodeBridgeFailureKind, + type OpenCodeBridgeResult, +} from './OpenCodeBridgeCommandContract'; + +export interface OpenCodeBridgeProcessRunInput { + binaryPath: string; + args: string[]; + cwd: string; + timeoutMs: number; + stdoutLimitBytes: number; + stderrLimitBytes: number; + env: NodeJS.ProcessEnv; +} + +export interface OpenCodeBridgeProcessRunResult { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; +} + +export interface OpenCodeBridgeProcessRunner { + run(input: OpenCodeBridgeProcessRunInput): Promise; +} + +export interface OpenCodeBridgeDiagnosticsSink { + append(event: OpenCodeBridgeDiagnosticEvent): Promise; +} + +export interface OpenCodeBridgeCommandClientOptions { + binaryPath: string; + tempDirectory: string; + processRunner?: OpenCodeBridgeProcessRunner; + diagnostics?: OpenCodeBridgeDiagnosticsSink; + requestIdFactory?: () => string; + diagnosticIdFactory?: () => string; + clock?: () => Date; + env?: NodeJS.ProcessEnv; + keepInputFile?: boolean; +} + +const DEFAULT_STDOUT_LIMIT_BYTES = 1_000_000; +const DEFAULT_STDERR_LIMIT_BYTES = 256_000; + +export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { + async run(input: OpenCodeBridgeProcessRunInput): Promise { + try { + const result = await execCli(input.binaryPath, input.args, { + cwd: input.cwd, + timeout: input.timeoutMs, + maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes, + env: input.env, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: 0, + timedOut: false, + }; + } catch (error) { + const failure = error as NodeJS.ErrnoException & { + stdout?: string | Buffer; + stderr?: string | Buffer; + killed?: boolean; + signal?: string; + }; + const message = failure.message ?? ''; + return { + stdout: bufferToString(failure.stdout), + stderr: bufferToString(failure.stderr) || message, + exitCode: typeof failure.code === 'number' ? failure.code : null, + timedOut: + failure.killed === true || + failure.signal === 'SIGTERM' || + /timed out|timeout/i.test(message), + }; + } + } +} + +export class OpenCodeBridgeCommandClient { + private readonly binaryPath: string; + private readonly tempDirectory: string; + private readonly processRunner: OpenCodeBridgeProcessRunner; + private readonly diagnostics: OpenCodeBridgeDiagnosticsSink | null; + private readonly requestIdFactory: () => string; + private readonly diagnosticIdFactory: () => string; + private readonly clock: () => Date; + private readonly env: NodeJS.ProcessEnv; + private readonly keepInputFile: boolean; + + constructor(options: OpenCodeBridgeCommandClientOptions) { + this.binaryPath = options.binaryPath; + this.tempDirectory = options.tempDirectory; + this.processRunner = options.processRunner ?? new ExecCliOpenCodeBridgeProcessRunner(); + this.diagnostics = options.diagnostics ?? null; + this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`); + this.diagnosticIdFactory = + options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); + this.clock = options.clock ?? (() => new Date()); + this.env = options.env ?? process.env; + this.keepInputFile = options.keepInputFile ?? false; + } + + async execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise> { + const envelope: OpenCodeBridgeCommandEnvelope = { + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: options.requestId ?? this.requestIdFactory(), + command, + cwd: options.cwd, + startedAt: this.clock().toISOString(), + timeoutMs: options.timeoutMs, + body, + }; + const inputPath = await this.writeInputFile(envelope); + + try { + const processResult = await this.processRunner.run({ + binaryPath: this.binaryPath, + args: ['runtime', 'opencode-command', '--json', '--input', inputPath], + cwd: options.cwd, + timeoutMs: options.timeoutMs, + stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, + stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, + env: this.env, + }); + + if (processResult.timedOut) { + return this.contractFailure( + envelope, + 'timeout', + 'OpenCode bridge command timed out', + true, + { + stderr: redactBridgeDiagnosticText(processResult.stderr), + } + ); + } + + if (processResult.exitCode !== 0) { + return this.contractFailure( + envelope, + 'provider_error', + 'OpenCode bridge command failed', + true, + { + exitCode: processResult.exitCode, + stderr: redactBridgeDiagnosticText(processResult.stderr), + } + ); + } + + const parsed = parseSingleBridgeJsonResult(processResult.stdout); + if (!parsed.ok) { + return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { + stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)), + }); + } + + const validation = validateBridgeResultEnvelope(parsed.value, envelope); + if (!validation.ok) { + return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {}); + } + + return parsed.value; + } finally { + if (!this.keepInputFile) { + await fs.unlink(inputPath).catch(() => undefined); + } + } + } + + private async writeInputFile( + envelope: OpenCodeBridgeCommandEnvelope + ): Promise { + await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 }); + const inputPath = path.join(this.tempDirectory, `opencode-command-${envelope.requestId}.json`); + await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + return inputPath; + } + + private async contractFailure( + envelope: OpenCodeBridgeCommandEnvelope, + kind: OpenCodeBridgeFailureKind, + message: string, + retryable: boolean, + details: Record + ): Promise { + const completedAt = this.clock().toISOString(); + const diagnostic: OpenCodeBridgeDiagnosticEvent = { + id: this.diagnosticIdFactory(), + type: + kind === 'timeout' + ? 'opencode_bridge_unknown_outcome' + : 'opencode_bridge_contract_violation', + providerId: 'opencode', + runId: extractRunId(envelope.body) ?? undefined, + severity: retryable ? 'warning' : 'error', + message, + data: details, + createdAt: completedAt, + }; + + await this.diagnostics?.append(diagnostic); + + return { + ok: false, + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: envelope.requestId, + command: envelope.command, + completedAt, + durationMs: Math.max(0, Date.parse(completedAt) - Date.parse(envelope.startedAt)), + error: { + kind, + message, + retryable, + details, + }, + diagnostics: [diagnostic], + }; + } +} + +export function redactBridgeDiagnosticText(value: string): string { + const capped = value.length > 4_000 ? `${value.slice(0, 4_000)}...[truncated]` : value; + return capped + .replace(/(authorization:\s*bearer\s+)[^\s]+/gi, '$1[redacted]') + .replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]'); +} + +function bufferToString(value: string | Buffer | undefined): string { + if (typeof value === 'string') { + return value; + } + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + return ''; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts new file mode 100644 index 00000000..c263abe9 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -0,0 +1,813 @@ +import { createHash } from 'crypto'; + +export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const; + +export type OpenCodeBridgeCommandName = + | 'opencode.handshake' + | 'opencode.commandStatus' + | 'opencode.readiness' + | 'opencode.launchTeam' + | 'opencode.reconcileTeam' + | 'opencode.stopTeam' + | 'opencode.answerPermission' + | 'opencode.listRuntimePermissions' + | 'opencode.getRuntimeTranscript' + | 'opencode.recoverDeliveryJournal'; + +export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production'; + +export type OpenCodeTeamLaunchBridgeState = + | 'blocked' + | 'launching' + | 'ready' + | 'permission_blocked' + | 'failed'; + +export type OpenCodeTeamMemberLaunchBridgeState = + | 'created' + | 'confirmed_alive' + | 'permission_blocked' + | 'failed'; + +export interface OpenCodeTeamBridgeDiagnostic { + code: string; + severity: 'info' | 'warning' | 'error'; + message: string; +} + +export interface OpenCodeTeamBridgeWarning { + code: string; + message: string; +} + +export interface OpenCodeTeamLaunchMemberCommandSpec { + name: string; + role: string; + prompt: string; +} + +export interface OpenCodeLaunchTeamCommandBody { + mode: OpenCodeTeamLaunchMode; + runId: string; + teamId: string; + teamName: string; + projectPath: string; + selectedModel: string; + members: OpenCodeTeamLaunchMemberCommandSpec[]; + leadPrompt: string; + expectedCapabilitySnapshotId: string | null; + manifestHighWatermark: number | null; +} + +export interface OpenCodeTeamMemberLaunchCommandData { + sessionId: string; + launchState: OpenCodeTeamMemberLaunchBridgeState; + model: string; + evidence: Array<{ kind: string; observedAt: string }>; +} + +export interface OpenCodeLaunchTeamCommandData { + runId: string; + teamLaunchState: OpenCodeTeamLaunchBridgeState; + members: Record; + warnings: OpenCodeTeamBridgeWarning[]; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; + idempotencyKey?: string; + manifestHighWatermark?: number | null; + runtimeStoreManifestHighWatermark?: number | null; + durableCheckpoints?: Array<{ name: string; memberName?: string | null; observedAt: string }>; +} + +export interface OpenCodeReconcileTeamCommandBody { + runId: string; + teamId: string; + teamName: string; + projectPath?: string; + expectedCapabilitySnapshotId?: string | null; + manifestHighWatermark?: number | null; + reconcileAttemptId?: string; + expectedMembers: Array<{ name: string; model: string | null }>; + reason: string; +} + +export interface OpenCodeStopTeamCommandBody { + runId: string; + teamId: string; + teamName: string; + projectPath?: string; + expectedCapabilitySnapshotId?: string | null; + manifestHighWatermark?: number | null; + reason: string; + force?: boolean; +} + +export interface OpenCodeStopTeamCommandData { + runId: string; + stopped: boolean; + members: Record; + warnings: OpenCodeTeamBridgeWarning[]; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; + idempotencyKey?: string; + manifestHighWatermark?: number | null; + runtimeStoreManifestHighWatermark?: number | null; +} + +export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator'; + +export type OpenCodeBridgeFailureKind = + | 'unsupported_schema' + | 'unsupported_command' + | 'invalid_input' + | 'runtime_not_ready' + | 'provider_error' + | 'timeout' + | 'contract_violation' + | 'internal_error'; + +export interface OpenCodeBridgeDiagnosticEvent { + id?: string; + type: string; + providerId: 'opencode'; + teamName?: string; + runId?: string; + severity: 'info' | 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface OpenCodeBridgeCommandEnvelope { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + cwd: string; + startedAt: string; + timeoutMs: number; + body: TBody; +} + +export interface OpenCodeBridgeRuntimeSnapshot { + providerId: 'opencode'; + binaryPath: string | null; + binaryFingerprint: string | null; + version: string | null; + capabilitySnapshotId: string | null; +} + +export interface OpenCodeBridgeSuccess { + ok: true; + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + completedAt: string; + durationMs: number; + runtime: OpenCodeBridgeRuntimeSnapshot; + diagnostics: OpenCodeBridgeDiagnosticEvent[]; + data: TData; +} + +export interface OpenCodeBridgeFailure { + ok: false; + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + command: OpenCodeBridgeCommandName; + completedAt: string; + durationMs: number; + error: { + kind: OpenCodeBridgeFailureKind; + message: string; + retryable: boolean; + details?: Record; + }; + diagnostics: OpenCodeBridgeDiagnosticEvent[]; +} + +export type OpenCodeBridgeResult = OpenCodeBridgeSuccess | OpenCodeBridgeFailure; + +export interface OpenCodeBridgePeerIdentity { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + peer: OpenCodeBridgePeerName; + appVersion: string; + gitSha: string | null; + buildId: string | null; + bridgeProtocol: { + minVersion: number; + currentVersion: number; + supportedCommands: OpenCodeBridgeCommandName[]; + }; + runtime: { + providerId: 'opencode'; + binaryPath: string | null; + binaryFingerprint: string | null; + version: string | null; + capabilitySnapshotId: string | null; + runtimeStoreManifestHighWatermark: number | null; + activeRunId: string | null; + }; + featureFlags: { + opencodeTeamLaunch: boolean; + opencodeStateChangingCommands: boolean; + }; +} + +export interface OpenCodeBridgeHandshake { + schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION; + requestId: string; + client: OpenCodeBridgePeerIdentity; + server: OpenCodeBridgePeerIdentity; + agreedProtocolVersion: number; + acceptedCommands: OpenCodeBridgeCommandName[]; + serverTime: string; + identityHash: string; +} + +export interface OpenCodeBridgeCommandPreconditions { + handshakeIdentityHash: string; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; + expectedManifestHighWatermark: number | null; + commandLeaseId: string | null; + idempotencyKey: string; +} + +export interface OpenCodeStateChangingBridgeEnvelope< + TBody, +> extends OpenCodeBridgeCommandEnvelope { + stateChanging: true; + preconditions: OpenCodeBridgeCommandPreconditions; +} + +export interface RuntimeStoreManifestEvidence { + highWatermark: number; + activeRunId?: string | null; + capabilitySnapshotId?: string | null; +} + +const VALID_COMMANDS: ReadonlySet = new Set([ + 'opencode.handshake', + 'opencode.commandStatus', + 'opencode.readiness', + 'opencode.launchTeam', + 'opencode.reconcileTeam', + 'opencode.stopTeam', + 'opencode.answerPermission', + 'opencode.listRuntimePermissions', + 'opencode.getRuntimeTranscript', + 'opencode.recoverDeliveryJournal', +]); + +const VALID_FAILURE_KINDS: ReadonlySet = new Set([ + 'unsupported_schema', + 'unsupported_command', + 'invalid_input', + 'runtime_not_ready', + 'provider_error', + 'timeout', + 'contract_violation', + 'internal_error', +]); + +export function isOpenCodeBridgeCommandName(value: unknown): value is OpenCodeBridgeCommandName { + return typeof value === 'string' && VALID_COMMANDS.has(value as OpenCodeBridgeCommandName); +} + +export function parseSingleBridgeJsonResult( + stdout: string +): { ok: true; value: OpenCodeBridgeResult } | { ok: false; error: string } { + const trimmed = stdout.trim(); + if (!trimmed) { + return { ok: false, error: 'Bridge stdout was empty' }; + } + + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length !== 1) { + return { + ok: false, + error: `Bridge stdout must contain exactly one JSON line, got ${lines.length}`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(lines[0]); + } catch (error) { + return { ok: false, error: `Bridge stdout JSON parse failed: ${stringifyError(error)}` }; + } + + const validation = validateOpenCodeBridgeResultShape(parsed); + if (!validation.ok) { + return { ok: false, error: validation.reason }; + } + + return { ok: true, value: validation.value as OpenCodeBridgeResult }; +} + +export function validateBridgeResultEnvelope( + result: OpenCodeBridgeResult, + envelope: Pick, 'schemaVersion' | 'requestId' | 'command'> +): { ok: true } | { ok: false; reason: string } { + const shape = validateOpenCodeBridgeResultShape(result); + if (!shape.ok) { + return { ok: false, reason: shape.reason }; + } + + if (result.schemaVersion !== envelope.schemaVersion) { + return { ok: false, reason: 'OpenCode bridge schemaVersion mismatch' }; + } + + if (result.requestId !== envelope.requestId) { + return { ok: false, reason: 'OpenCode bridge requestId mismatch' }; + } + + if (result.command !== envelope.command) { + return { ok: false, reason: 'OpenCode bridge command mismatch' }; + } + + return { ok: true }; +} + +export function assertBridgeResultCanMutateState( + result: OpenCodeBridgeResult, + expected: { + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + } +): asserts result is OpenCodeBridgeSuccess { + if (!result.ok) { + throw new Error( + `OpenCode bridge command failed: ${result.error.kind}: ${result.error.message}` + ); + } + + if (result.requestId !== expected.requestId) { + throw new Error('OpenCode bridge requestId mismatch'); + } + + if (result.command !== expected.command) { + throw new Error('OpenCode bridge command mismatch'); + } + + if (extractRunId(result.data) !== expected.runId) { + throw new Error('OpenCode bridge runId mismatch'); + } + + if ( + expected.capabilitySnapshotId !== null && + result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId + ) { + throw new Error('OpenCode bridge capability snapshot mismatch'); + } +} + +export function validateOpenCodeBridgeHandshake(input: { + handshake: OpenCodeBridgeHandshake; + expectedClient: OpenCodeBridgePeerIdentity; + requiredCommand: OpenCodeBridgeCommandName; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + expectedRunId: string | null; +}): { ok: true } | { ok: false; reason: string } { + const shape = validateOpenCodeBridgeHandshakeShape(input.handshake); + if (!shape.ok) { + return shape; + } + + if (input.handshake.client.peer !== input.expectedClient.peer) { + return { ok: false, reason: 'Bridge handshake client peer mismatch' }; + } + + if (stableHash(input.handshake.client) !== stableHash(input.expectedClient)) { + return { ok: false, reason: 'Bridge handshake client identity mismatch' }; + } + + const minimumProtocol = Math.max( + input.handshake.client.bridgeProtocol.minVersion, + input.handshake.server.bridgeProtocol.minVersion + ); + const maximumProtocol = Math.min( + input.handshake.client.bridgeProtocol.currentVersion, + input.handshake.server.bridgeProtocol.currentVersion + ); + + if ( + input.handshake.agreedProtocolVersion < minimumProtocol || + input.handshake.agreedProtocolVersion > maximumProtocol + ) { + return { ok: false, reason: 'Bridge handshake protocol version mismatch' }; + } + + if (!input.handshake.acceptedCommands.includes(input.requiredCommand)) { + return { ok: false, reason: `Bridge server does not accept command ${input.requiredCommand}` }; + } + + if (!input.handshake.server.bridgeProtocol.supportedCommands.includes(input.requiredCommand)) { + return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` }; + } + + if ( + input.expectedCapabilitySnapshotId && + input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId + ) { + return { ok: false, reason: 'Bridge server capability snapshot mismatch' }; + } + + if ( + input.expectedRunId && + input.handshake.server.runtime.activeRunId && + input.handshake.server.runtime.activeRunId !== input.expectedRunId + ) { + return { ok: false, reason: 'Bridge server active run mismatch' }; + } + + const serverHighWatermark = input.handshake.server.runtime.runtimeStoreManifestHighWatermark; + if ( + input.expectedManifestHighWatermark !== null && + serverHighWatermark !== null && + serverHighWatermark < input.expectedManifestHighWatermark + ) { + return { ok: false, reason: 'Bridge server runtime manifest high watermark is stale' }; + } + + const expectedIdentityHash = createOpenCodeBridgeHandshakeIdentityHash(input.handshake); + if (input.handshake.identityHash !== expectedIdentityHash) { + return { ok: false, reason: 'Bridge handshake identity hash mismatch' }; + } + + return { ok: true }; +} + +export function createOpenCodeBridgeHandshakeIdentityHash( + handshake: Omit | OpenCodeBridgeHandshake +): string { + const { identityHash: _ignored, ...hashable } = handshake as OpenCodeBridgeHandshake; + return stableHash(hashable); +} + +export function assertBridgeEvidenceCanCommitToRuntimeStores(input: { + result: OpenCodeBridgeResult; + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + manifest: RuntimeStoreManifestEvidence; + idempotencyKey: string; +}): asserts input is { + result: OpenCodeBridgeSuccess; + requestId: string; + command: OpenCodeBridgeCommandName; + runId: string | null; + capabilitySnapshotId: string | null; + manifest: RuntimeStoreManifestEvidence; + idempotencyKey: string; +} { + assertBridgeResultCanMutateState(input.result, { + requestId: input.requestId, + command: input.command, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + }); + + const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data); + if ( + typeof resultManifestHighWatermark === 'number' && + resultManifestHighWatermark < input.manifest.highWatermark + ) { + throw new Error('Bridge result manifest high watermark is stale'); + } + + if (extractIdempotencyKey(input.result.data) !== input.idempotencyKey) { + throw new Error('Bridge result idempotency key mismatch'); + } +} + +export function createOpenCodeBridgeIdempotencyKey(input: { + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + body: unknown; +}): string { + const scope = [ + 'opencode', + sanitizeKeyPart(input.command), + sanitizeKeyPart(input.teamName), + sanitizeKeyPart(input.runId ?? 'no-run'), + ].join(':'); + return `${scope}:${stableHash(input).slice(0, 32)}`; +} + +export function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +export function stableJsonStringify(value: unknown): string { + return JSON.stringify(normalizeStableJson(value)); +} + +export function extractRunId(value: unknown): string | null { + return ( + extractStringByPath(value, ['runId']) ?? + extractStringByPath(value, ['runtimeRunId']) ?? + extractStringByPath(value, ['runtime', 'runId']) ?? + extractStringByPath(value, ['launch', 'runId']) + ); +} + +export function extractIdempotencyKey(value: unknown): string | null { + return ( + extractStringByPath(value, ['idempotencyKey']) ?? + extractStringByPath(value, ['preconditions', 'idempotencyKey']) ?? + extractStringByPath(value, ['command', 'idempotencyKey']) + ); +} + +export function extractManifestHighWatermark(value: unknown): number | null { + return ( + extractNumberByPath(value, ['runtimeStoreManifestHighWatermark']) ?? + extractNumberByPath(value, ['manifestHighWatermark']) ?? + extractNumberByPath(value, ['manifest', 'highWatermark']) + ); +} + +function validateOpenCodeBridgeResultShape( + value: unknown +): { ok: true; value: OpenCodeBridgeResult } | { ok: false; reason: string } { + if (!isRecord(value)) { + return { ok: false, reason: 'Bridge result must be a JSON object' }; + } + + if (value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) { + return { ok: false, reason: 'Bridge result has unsupported schemaVersion' }; + } + + if (typeof value.ok !== 'boolean') { + return { ok: false, reason: 'Bridge result missing ok boolean' }; + } + + if (typeof value.requestId !== 'string' || !value.requestId.trim()) { + return { ok: false, reason: 'Bridge result missing requestId' }; + } + + if (!isOpenCodeBridgeCommandName(value.command)) { + return { ok: false, reason: 'Bridge result has unsupported command' }; + } + + if (typeof value.completedAt !== 'string' || !value.completedAt.trim()) { + return { ok: false, reason: 'Bridge result missing completedAt' }; + } + + if (!isNonNegativeFiniteNumber(value.durationMs)) { + return { ok: false, reason: 'Bridge result has invalid durationMs' }; + } + + if (!Array.isArray(value.diagnostics) || !value.diagnostics.every(isDiagnosticEvent)) { + return { ok: false, reason: 'Bridge result diagnostics are invalid' }; + } + + if (value.ok) { + if (!isRuntimeSnapshot(value.runtime)) { + return { ok: false, reason: 'Bridge success runtime snapshot is invalid' }; + } + + if (!Object.prototype.hasOwnProperty.call(value, 'data')) { + return { ok: false, reason: 'Bridge success missing data' }; + } + + return { ok: true, value: value as unknown as OpenCodeBridgeSuccess }; + } + + if (!isRecord(value.error)) { + return { ok: false, reason: 'Bridge failure missing error object' }; + } + + if (!VALID_FAILURE_KINDS.has(value.error.kind as OpenCodeBridgeFailureKind)) { + return { ok: false, reason: 'Bridge failure has unsupported error kind' }; + } + + if (typeof value.error.message !== 'string' || !value.error.message.trim()) { + return { ok: false, reason: 'Bridge failure missing error message' }; + } + + if (typeof value.error.retryable !== 'boolean') { + return { ok: false, reason: 'Bridge failure missing retryable boolean' }; + } + + if ( + value.error.details !== undefined && + (value.error.details === null || !isRecord(value.error.details)) + ) { + return { ok: false, reason: 'Bridge failure details must be an object' }; + } + + return { ok: true, value: value as unknown as OpenCodeBridgeFailure }; +} + +function validateOpenCodeBridgeHandshakeShape( + handshake: OpenCodeBridgeHandshake +): { ok: true } | { ok: false; reason: string } { + if (!isRecord(handshake)) { + return { ok: false, reason: 'Bridge handshake must be a JSON object' }; + } + + if (handshake.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) { + return { ok: false, reason: 'Bridge handshake has unsupported schemaVersion' }; + } + + if (typeof handshake.requestId !== 'string' || !handshake.requestId.trim()) { + return { ok: false, reason: 'Bridge handshake missing requestId' }; + } + + if (!isPeerIdentity(handshake.client) || !isPeerIdentity(handshake.server)) { + return { ok: false, reason: 'Bridge handshake peer identity is invalid' }; + } + + if (!Number.isInteger(handshake.agreedProtocolVersion) || handshake.agreedProtocolVersion < 1) { + return { ok: false, reason: 'Bridge handshake protocol version is invalid' }; + } + + if ( + !Array.isArray(handshake.acceptedCommands) || + !handshake.acceptedCommands.every(isOpenCodeBridgeCommandName) + ) { + return { ok: false, reason: 'Bridge handshake accepted commands are invalid' }; + } + + if (typeof handshake.serverTime !== 'string' || !handshake.serverTime.trim()) { + return { ok: false, reason: 'Bridge handshake serverTime is invalid' }; + } + + if (typeof handshake.identityHash !== 'string' || !handshake.identityHash.trim()) { + return { ok: false, reason: 'Bridge handshake identityHash is invalid' }; + } + + return { ok: true }; +} + +function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity { + if (!isRecord(value)) { + return false; + } + + if ( + value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION || + (value.peer !== 'claude_team' && value.peer !== 'agent_teams_orchestrator') || + typeof value.appVersion !== 'string' || + !isNullableString(value.gitSha) || + !isNullableString(value.buildId) + ) { + return false; + } + + const bridgeProtocol = value.bridgeProtocol; + if (!isRecord(bridgeProtocol)) { + return false; + } + + if ( + !Number.isInteger(bridgeProtocol.minVersion) || + !Number.isInteger(bridgeProtocol.currentVersion) || + (bridgeProtocol.minVersion as number) < 1 || + (bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) || + !Array.isArray(bridgeProtocol.supportedCommands) || + !bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) + ) { + return false; + } + + const runtime = value.runtime; + if (!isRecord(runtime) || runtime.providerId !== 'opencode') { + return false; + } + + if ( + !isNullableString(runtime.binaryPath) || + !isNullableString(runtime.binaryFingerprint) || + !isNullableString(runtime.version) || + !isNullableString(runtime.capabilitySnapshotId) || + !isNullableInteger(runtime.runtimeStoreManifestHighWatermark) || + !isNullableString(runtime.activeRunId) + ) { + return false; + } + + const featureFlags = value.featureFlags; + if (!isRecord(featureFlags)) { + return false; + } + + return ( + typeof featureFlags.opencodeTeamLaunch === 'boolean' && + typeof featureFlags.opencodeStateChangingCommands === 'boolean' + ); +} + +function isRuntimeSnapshot(value: unknown): value is OpenCodeBridgeRuntimeSnapshot { + return ( + isRecord(value) && + value.providerId === 'opencode' && + isNullableString(value.binaryPath) && + isNullableString(value.binaryFingerprint) && + isNullableString(value.version) && + isNullableString(value.capabilitySnapshotId) + ); +} + +function isDiagnosticEvent(value: unknown): value is OpenCodeBridgeDiagnosticEvent { + return ( + isRecord(value) && + value.providerId === 'opencode' && + typeof value.type === 'string' && + value.type.trim().length > 0 && + (value.severity === 'info' || value.severity === 'warning' || value.severity === 'error') && + typeof value.message === 'string' && + value.message.trim().length > 0 && + typeof value.createdAt === 'string' && + value.createdAt.trim().length > 0 && + (value.data === undefined || isRecord(value.data)) + ); +} + +function extractStringByPath(value: unknown, pathParts: string[]): string | null { + const nested = getByPath(value, pathParts); + return typeof nested === 'string' && nested.trim() ? nested : null; +} + +function extractNumberByPath(value: unknown, pathParts: string[]): number | null { + const nested = getByPath(value, pathParts); + return isNonNegativeFiniteNumber(nested) ? nested : null; +} + +function getByPath(value: unknown, pathParts: string[]): unknown { + let current = value; + for (const part of pathParts) { + if (!isRecord(current)) { + return undefined; + } + current = current[part]; + } + return current; +} + +function sanitizeKeyPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^a-zA-Z0-9_.-]+/g, '_') + .replace(/^_+|_+$/g, ''); + return sanitized.slice(0, 64) || 'unknown'; +} + +function stableJsonComparableNumber(value: number): number | string { + if (Number.isFinite(value)) { + return value; + } + return String(value); +} + +function normalizeStableJson(value: unknown): unknown { + if (value === null) { + return null; + } + + if (typeof value === 'number') { + return stableJsonComparableNumber(value); + } + + if (typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map(normalizeStableJson); + } + + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + const nested = (value as Record)[key]; + if (nested !== undefined) { + output[key] = normalizeStableJson(nested); + } + } + return output; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isNullableInteger(value: unknown): value is number | null { + return value === null || (Number.isInteger(value) && (value as number) >= 0); +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts new file mode 100644 index 00000000..b49fd7a2 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts @@ -0,0 +1,438 @@ +import { + createOpenCodeBridgeIdempotencyKey, + isOpenCodeBridgeCommandName, + stableHash, + type OpenCodeBridgeCommandName, +} from './OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION = 1; +export const OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION = 1; + +export type OpenCodeBridgeCommandLedgerStatus = + | 'started' + | 'completed' + | 'failed' + | 'unknown_after_timeout'; + +export interface OpenCodeBridgeCommandLedgerEntry { + idempotencyKey: string; + requestId: string; + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + requestHash: string; + responseHash: string | null; + status: OpenCodeBridgeCommandLedgerStatus; + retryable: boolean; + startedAt: string; + completedAt: string | null; + lastError: string | null; +} + +export interface OpenCodeBridgeCommandLease { + leaseId: string; + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + holderPeer: 'claude_team'; + acquiredAt: string; + expiresAt: string; + state: 'active' | 'released' | 'expired'; +} + +export type OpenCodeBridgeLedgerBeginResult = 'started' | 'duplicate_same_payload_completed'; + +export class OpenCodeBridgeCommandLedgerError extends Error { + constructor(message: string) { + super(message); + this.name = 'OpenCodeBridgeCommandLedgerError'; + } +} + +export class OpenCodeBridgeCommandLeaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'OpenCodeBridgeCommandLeaseError'; + } +} + +export class OpenCodeBridgeCommandLedger { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async begin(input: { + idempotencyKey: string; + requestId: string; + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + requestHash: string; + }): Promise { + let outcome: OpenCodeBridgeLedgerBeginResult = 'started'; + + await this.store.updateLocked((entries) => { + const existing = entries.find((entry) => entry.idempotencyKey === input.idempotencyKey); + if (existing) { + if (existing.requestHash !== input.requestHash) { + throw new OpenCodeBridgeCommandLedgerError( + 'OpenCode bridge idempotency key reused with different payload' + ); + } + + if (existing.status === 'unknown_after_timeout') { + throw new OpenCodeBridgeCommandLedgerError( + 'OpenCode bridge command outcome must be reconciled before retry' + ); + } + + if (existing.status === 'started') { + throw new OpenCodeBridgeCommandLedgerError('OpenCode bridge command already started'); + } + + if (existing.status === 'completed') { + outcome = 'duplicate_same_payload_completed'; + return entries; + } + + throw new OpenCodeBridgeCommandLedgerError( + `OpenCode bridge command cannot be retried from status ${existing.status}` + ); + } + + const now = this.clock().toISOString(); + return [ + ...entries, + { + idempotencyKey: input.idempotencyKey, + requestId: input.requestId, + command: input.command, + teamName: input.teamName, + runId: input.runId, + requestHash: input.requestHash, + responseHash: null, + status: 'started', + retryable: false, + startedAt: now, + completedAt: null, + lastError: null, + }, + ]; + }); + + return outcome; + } + + async markCompleted(input: { + idempotencyKey: string; + response: unknown; + completedAt?: Date; + }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + responseHash: stableHash(input.response), + status: 'completed', + retryable: false, + completedAt: (input.completedAt ?? this.clock()).toISOString(), + lastError: null, + })); + } + + async markFailed(input: { + idempotencyKey: string; + error: string; + retryable: boolean; + completedAt?: Date; + }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + status: 'failed', + retryable: input.retryable, + completedAt: (input.completedAt ?? this.clock()).toISOString(), + lastError: input.error, + })); + } + + async markUnknownAfterTimeout(input: { idempotencyKey: string; error: string }): Promise { + await this.updateExisting(input.idempotencyKey, (entry) => ({ + ...entry, + status: 'unknown_after_timeout', + retryable: false, + completedAt: null, + lastError: input.error, + })); + } + + async getByIdempotencyKey( + idempotencyKey: string + ): Promise { + const entries = await this.readRequired(); + return entries.find((entry) => entry.idempotencyKey === idempotencyKey) ?? null; + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + idempotencyKey: string, + updater: (entry: OpenCodeBridgeCommandLedgerEntry) => OpenCodeBridgeCommandLedgerEntry + ): Promise { + let found = false; + await this.store.updateLocked((entries) => + entries.map((entry) => { + if (entry.idempotencyKey !== idempotencyKey) { + return entry; + } + found = true; + return updater(entry); + }) + ); + + if (!found) { + throw new OpenCodeBridgeCommandLedgerError( + `OpenCode bridge command ledger entry not found: ${idempotencyKey}` + ); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export class OpenCodeBridgeCommandLeaseStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly idFactory: () => string, + private readonly clock: () => Date = () => new Date() + ) {} + + async acquire(input: { + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + ttlMs: number; + }): Promise { + let created: OpenCodeBridgeCommandLease | null = null; + + await this.store.updateLocked((leases) => { + const now = this.clock(); + const nowMs = now.getTime(); + const normalized = leases.map((lease) => + lease.state === 'active' && Date.parse(lease.expiresAt) <= nowMs + ? { ...lease, state: 'expired' as const } + : lease + ); + const active = normalized.find( + (lease) => + lease.teamName === input.teamName && + lease.state === 'active' && + Date.parse(lease.expiresAt) > nowMs + ); + + if (active) { + throw new OpenCodeBridgeCommandLeaseError( + `OpenCode bridge command lease already active: ${active.leaseId}` + ); + } + + created = { + leaseId: this.idFactory(), + teamName: input.teamName, + runId: input.runId, + command: input.command, + holderPeer: 'claude_team', + acquiredAt: now.toISOString(), + expiresAt: new Date(nowMs + input.ttlMs).toISOString(), + state: 'active', + }; + + return [...normalized, created]; + }); + + if (!created) { + throw new OpenCodeBridgeCommandLeaseError('OpenCode bridge command lease was not created'); + } + + return created; + } + + async release(leaseId: string): Promise { + let found = false; + await this.store.updateLocked((leases) => + leases.map((lease) => { + if (lease.leaseId !== leaseId) { + return lease; + } + found = true; + return { ...lease, state: 'released' as const }; + }) + ); + + if (!found) { + throw new OpenCodeBridgeCommandLeaseError( + `OpenCode bridge command lease not found: ${leaseId}` + ); + } + } + + async getActive(teamName: string): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + + const nowMs = this.clock().getTime(); + return ( + result.data.find( + (lease) => + lease.teamName === teamName && + lease.state === 'active' && + Date.parse(lease.expiresAt) > nowMs + ) ?? null + ); + } +} + +export function createOpenCodeBridgeCommandLedgerStore(options: { + filePath: string; + clock?: () => Date; +}): OpenCodeBridgeCommandLedger { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeBridgeCommandLedger( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION, + defaultData: () => [], + validate: validateLedgerEntries, + clock, + }), + clock + ); +} + +export function createOpenCodeBridgeCommandLeaseStore(options: { + filePath: string; + idFactory?: () => string; + clock?: () => Date; +}): OpenCodeBridgeCommandLeaseStore { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeBridgeCommandLeaseStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION, + defaultData: () => [], + validate: validateLeases, + clock, + }), + options.idFactory ?? + (() => + createOpenCodeBridgeIdempotencyKey({ + command: 'opencode.commandStatus', + teamName: 'lease', + runId: null, + body: { now: clock().toISOString(), random: Math.random() }, + })), + clock + ); +} + +export function validateLedgerEntries(value: unknown): OpenCodeBridgeCommandLedgerEntry[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode bridge command ledger must be an array'); + } + + const seen = new Set(); + return value.map((entry, index) => { + if (!isLedgerEntry(entry)) { + throw new Error(`Invalid OpenCode bridge command ledger entry at index ${index}`); + } + if (seen.has(entry.idempotencyKey)) { + throw new Error(`Duplicate OpenCode bridge ledger idempotencyKey at index ${index}`); + } + seen.add(entry.idempotencyKey); + return entry; + }); +} + +export function validateLeases(value: unknown): OpenCodeBridgeCommandLease[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode bridge command leases must be an array'); + } + + const seen = new Set(); + return value.map((lease, index) => { + if (!isLease(lease)) { + throw new Error(`Invalid OpenCode bridge command lease at index ${index}`); + } + if (seen.has(lease.leaseId)) { + throw new Error(`Duplicate OpenCode bridge leaseId at index ${index}`); + } + seen.add(lease.leaseId); + return lease; + }); +} + +function isLedgerEntry(value: unknown): value is OpenCodeBridgeCommandLedgerEntry { + return ( + isRecord(value) && + isNonEmptyString(value.idempotencyKey) && + isNonEmptyString(value.requestId) && + isOpenCodeBridgeCommandName(value.command) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNonEmptyString(value.requestHash) && + isNullableString(value.responseHash) && + isLedgerStatus(value.status) && + typeof value.retryable === 'boolean' && + isNonEmptyString(value.startedAt) && + isNullableString(value.completedAt) && + isNullableString(value.lastError) && + Number.isFinite(Date.parse(value.startedAt)) && + (value.completedAt === null || Number.isFinite(Date.parse(value.completedAt))) + ); +} + +function isLease(value: unknown): value is OpenCodeBridgeCommandLease { + return ( + isRecord(value) && + isNonEmptyString(value.leaseId) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isOpenCodeBridgeCommandName(value.command) && + value.holderPeer === 'claude_team' && + isNonEmptyString(value.acquiredAt) && + isNonEmptyString(value.expiresAt) && + Number.isFinite(Date.parse(value.acquiredAt)) && + Number.isFinite(Date.parse(value.expiresAt)) && + (value.state === 'active' || value.state === 'released' || value.state === 'expired') + ); +} + +function isLedgerStatus(value: unknown): value is OpenCodeBridgeCommandLedgerStatus { + return ( + value === 'started' || + value === 'completed' || + value === 'failed' || + value === 'unknown_after_timeout' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts new file mode 100644 index 00000000..e5f7e48a --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts @@ -0,0 +1,112 @@ +import type { + OpenCodeBridgeCommandName, + OpenCodeBridgeHandshake, + OpenCodeBridgePeerIdentity, +} from './OpenCodeBridgeCommandContract'; +import type { + OpenCodeBridgeCommandExecutor, + OpenCodeBridgeHandshakePort, +} from './OpenCodeStateChangingBridgeCommandService'; + +export interface OpenCodeBridgeCommandHandshakePortOptions { + bridge: OpenCodeBridgeCommandExecutor; + clientIdentity: OpenCodeBridgePeerIdentity; + timeoutMs?: number; +} + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 120_000; + +export class OpenCodeBridgeCommandHandshakePort implements OpenCodeBridgeHandshakePort { + private readonly bridge: OpenCodeBridgeCommandExecutor; + private readonly clientIdentity: OpenCodeBridgePeerIdentity; + private readonly timeoutMs: number; + + constructor(options: OpenCodeBridgeCommandHandshakePortOptions) { + this.bridge = options.bridge; + this.clientIdentity = options.clientIdentity; + this.timeoutMs = options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; + } + + async handshake(input: { + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + cwd?: string; + }): Promise { + const result = await this.bridge.execute< + { + client: OpenCodeBridgePeerIdentity; + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + }, + OpenCodeBridgeHandshake + >( + 'opencode.handshake', + { + client: this.clientIdentity, + requiredCommand: input.requiredCommand, + expectedRunId: input.expectedRunId, + expectedCapabilitySnapshotId: input.expectedCapabilitySnapshotId, + expectedManifestHighWatermark: input.expectedManifestHighWatermark, + }, + { + cwd: input.cwd ?? process.cwd(), + timeoutMs: this.timeoutMs, + } + ); + + if (!result.ok) { + throw new Error( + `OpenCode bridge handshake failed: ${result.error.kind}: ${result.error.message}` + ); + } + + return result.data; + } +} + +export function createOpenCodeBridgeClientIdentity(input: { + appVersion: string; + gitSha?: string | null; + buildId?: string | null; +}): OpenCodeBridgePeerIdentity { + return { + schemaVersion: 1, + peer: 'claude_team', + appVersion: input.appVersion, + gitSha: input.gitSha ?? null, + buildId: input.buildId ?? null, + bridgeProtocol: { + minVersion: 1, + currentVersion: 1, + supportedCommands: [ + 'opencode.handshake', + 'opencode.commandStatus', + 'opencode.readiness', + 'opencode.launchTeam', + 'opencode.reconcileTeam', + 'opencode.stopTeam', + 'opencode.answerPermission', + 'opencode.listRuntimePermissions', + 'opencode.getRuntimeTranscript', + 'opencode.recoverDeliveryJournal', + ], + }, + runtime: { + providerId: 'opencode', + binaryPath: null, + binaryFingerprint: null, + version: null, + capabilitySnapshotId: null, + runtimeStoreManifestHighWatermark: null, + activeRunId: null, + }, + featureFlags: { + opencodeTeamLaunch: true, + opencodeStateChangingCommands: true, + }, + }; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts new file mode 100644 index 00000000..33c4a9ba --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -0,0 +1,413 @@ +import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; +import { + buildOpenCodeCanonicalMcpToolId, + REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, +} from '../mcp/OpenCodeMcpToolAvailability'; +import type { + OpenCodeTeamLaunchReadiness, + OpenCodeTeamLaunchReadinessState, +} from '../readiness/OpenCodeTeamLaunchReadiness'; +import { + assertOpenCodeProductionE2EArtifactGate, + type OpenCodeProductionE2EEvidence, +} from '../e2e/OpenCodeProductionE2EEvidence'; +import type { + OpenCodeBridgeCommandName, + OpenCodeBridgeDiagnosticEvent, + OpenCodeBridgeFailureKind, + OpenCodeBridgeResult, + OpenCodeBridgeRuntimeSnapshot, + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData, + OpenCodeReconcileTeamCommandBody, + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData, + OpenCodeTeamLaunchMode, +} from './OpenCodeBridgeCommandContract'; +import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService'; + +export interface OpenCodeReadinessBridgeCommandExecutor { + execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise>; +} + +export interface OpenCodeReadinessBridgeOptions { + timeoutMs?: number; + launchTimeoutMs?: number; + reconcileTimeoutMs?: number; + stopTimeoutMs?: number; + stateChangingCommands?: Pick; + productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort; +} + +export interface OpenCodeProductionE2EEvidenceReadPort { + read(): Promise<{ + ok: boolean; + evidence: OpenCodeProductionE2EEvidence | null; + artifactPath: string; + diagnostics: string[]; + }>; +} + +export interface OpenCodeReadinessBridgeCommandBody { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; +} + +const DEFAULT_READINESS_TIMEOUT_MS = 120_000; +const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000; +const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000; +const DEFAULT_STOP_TIMEOUT_MS = 30_000; + +export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { + private readonly lastRuntimeSnapshotsByProjectPath = new Map< + string, + OpenCodeBridgeRuntimeSnapshot + >(); + + constructor( + private readonly bridge: OpenCodeReadinessBridgeCommandExecutor, + private readonly options: OpenCodeReadinessBridgeOptions = {} + ) {} + + async checkOpenCodeTeamLaunchReadiness( + input: OpenCodeReadinessBridgeCommandBody + ): Promise { + const result = await this.bridge.execute< + OpenCodeReadinessBridgeCommandBody, + OpenCodeTeamLaunchReadiness + >('opencode.readiness', input, { + cwd: input.projectPath, + timeoutMs: this.options.timeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, + }); + + if (result.ok) { + this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime); + return this.applyProductionE2EGate({ + input, + readiness: result.data, + runtime: result.runtime, + }); + } + + this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); + return blockedReadiness({ + state: mapBridgeFailureToReadinessState(result.error.kind), + modelId: input.selectedModel, + diagnostics: [ + `OpenCode readiness bridge failed: ${result.error.kind}: ${result.error.message}`, + ...result.diagnostics.map(formatDiagnosticEvent), + ], + missing: [result.error.message], + }); + } + + private async applyProductionE2EGate(input: { + input: OpenCodeReadinessBridgeCommandBody; + readiness: OpenCodeTeamLaunchReadiness; + runtime: OpenCodeBridgeRuntimeSnapshot; + }): Promise { + const launchMode = input.input.launchMode; + if (launchMode !== 'production' && launchMode !== 'dogfood') { + return input.readiness; + } + if (!input.readiness.launchAllowed) { + return input.readiness; + } + + const evidenceRead = this.options.productionE2eEvidence + ? await this.options.productionE2eEvidence.read() + : { + ok: false, + evidence: null, + artifactPath: '', + diagnostics: ['OpenCode production E2E evidence store is not configured'], + }; + const expectedModel = input.readiness.modelId ?? input.input.selectedModel; + const gate = evidenceRead.ok + ? assertOpenCodeProductionE2EArtifactGate({ + evidence: evidenceRead.evidence, + artifactPath: evidenceRead.artifactPath, + expected: { + opencodeVersion: input.runtime.version, + binaryFingerprint: input.runtime.binaryFingerprint, + capabilitySnapshotId: input.runtime.capabilitySnapshotId, + selectedModel: expectedModel, + requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + ), + }, + }) + : { + ok: false, + diagnostics: evidenceRead.diagnostics, + }; + + if (gate.ok) { + return { + ...input.readiness, + diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]), + supportLevel: 'production_supported', + }; + } + + const diagnostics = dedupe([ + ...input.readiness.diagnostics, + ...evidenceRead.diagnostics, + ...gate.diagnostics, + ]); + if (launchMode === 'dogfood') { + return { + ...input.readiness, + supportLevel: 'supported_e2e_pending', + diagnostics, + }; + } + + return { + ...input.readiness, + state: 'e2e_missing', + launchAllowed: false, + supportLevel: 'supported_e2e_pending', + missing: dedupe([...input.readiness.missing, ...gate.diagnostics]), + diagnostics, + }; + } + + getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null { + return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null; + } + + async launchOpenCodeTeam( + input: OpenCodeLaunchTeamCommandBody + ): Promise { + const result = await this.executeStateChangingCommand< + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData + >('opencode.launchTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId, + cwd: input.projectPath, + timeoutMs: this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS, + }); + return result.ok ? result.data : blockedLaunchData(input.runId, result); + } + + async reconcileOpenCodeTeam( + input: OpenCodeReconcileTeamCommandBody + ): Promise { + const cwd = input.projectPath ?? process.cwd(); + const result = await this.executeStateChangingCommand< + OpenCodeReconcileTeamCommandBody, + OpenCodeLaunchTeamCommandData + >('opencode.reconcileTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, + cwd, + timeoutMs: this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, + }); + return result.ok ? result.data : blockedLaunchData(input.runId, result); + } + + async stopOpenCodeTeam(input: OpenCodeStopTeamCommandBody): Promise { + const cwd = input.projectPath ?? process.cwd(); + const result = await this.executeStateChangingCommand< + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData + >('opencode.stopTeam', input, { + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, + cwd, + timeoutMs: this.options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, + }); + if (result.ok) { + return result.data; + } + return { + runId: input.runId, + stopped: false, + members: {}, + warnings: [], + diagnostics: [ + { + code: result.error.kind, + severity: 'error', + message: `OpenCode stop bridge failed: ${result.error.message}`, + }, + ...result.diagnostics.map((event) => ({ + code: event.type, + severity: event.severity, + message: event.message, + })), + ], + }; + } + + private async executeStateChangingCommand( + command: OpenCodeStateChangingTeamCommandName, + body: TBody, + input: { + teamName: string; + runId: string; + capabilitySnapshotId: string | null; + cwd: string; + timeoutMs: number; + } + ): Promise> { + if (this.options.stateChangingCommands) { + try { + return await this.options.stateChangingCommands.execute({ + command, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: null, + body, + cwd: input.cwd, + timeoutMs: input.timeoutMs, + }); + } catch (error) { + return thrownBridgeFailure(command, input.runId, error); + } + } + + return this.bridge.execute(command, body, { + cwd: input.cwd, + timeoutMs: input.timeoutMs, + }); + } +} + +type OpenCodeStateChangingTeamCommandName = Extract< + OpenCodeBridgeCommandName, + 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' +>; + +function blockedLaunchData( + runId: string, + result: OpenCodeBridgeResult +): OpenCodeLaunchTeamCommandData { + if (result.ok) { + throw new Error('blockedLaunchData expects a failed bridge result'); + } + return { + runId, + teamLaunchState: 'failed', + members: {}, + warnings: [], + diagnostics: [ + { + code: result.error.kind, + severity: 'error', + message: `OpenCode bridge failed: ${result.error.message}`, + }, + ...result.diagnostics.map((event) => ({ + code: event.type, + severity: event.severity, + message: event.message, + })), + ], + }; +} + +function blockedReadiness(input: { + state: OpenCodeTeamLaunchReadinessState; + modelId: string | null; + diagnostics: string[]; + missing: string[]; +}): OpenCodeTeamLaunchReadiness { + return { + state: input.state, + launchAllowed: false, + modelId: input.modelId, + opencodeVersion: null, + installMethod: null, + binaryPath: null, + hostHealthy: false, + appMcpConnected: false, + requiredToolsPresent: false, + permissionBridgeReady: false, + runtimeStoresReady: false, + supportLevel: null, + missing: dedupe(input.missing), + diagnostics: dedupe(input.diagnostics), + evidence: { + capabilitiesReady: false, + mcpToolProofRoute: null, + observedMcpTools: [], + runtimeStoreReadinessReason: null, + }, + }; +} + +function mapBridgeFailureToReadinessState( + kind: OpenCodeBridgeFailureKind +): OpenCodeTeamLaunchReadinessState { + switch (kind) { + case 'runtime_not_ready': + return 'adapter_disabled'; + case 'timeout': + case 'contract_violation': + case 'provider_error': + case 'unsupported_schema': + case 'unsupported_command': + case 'invalid_input': + case 'internal_error': + default: + return 'unknown_error'; + } +} + +function formatDiagnosticEvent(event: OpenCodeBridgeDiagnosticEvent): string { + return `${event.type}: ${event.message}`; +} + +function thrownBridgeFailure( + command: OpenCodeBridgeCommandName, + runId: string, + error: unknown +): OpenCodeBridgeResult { + const message = error instanceof Error ? error.message : String(error); + const completedAt = new Date().toISOString(); + return { + ok: false, + schemaVersion: 1, + requestId: 'opencode-state-changing-bridge-exception', + command, + completedAt, + durationMs: 0, + error: { + kind: 'internal_error', + message, + retryable: false, + }, + diagnostics: [ + { + type: 'opencode_state_changing_bridge_exception', + providerId: 'opencode', + runId, + severity: 'error', + message, + createdAt: completedAt, + }, + ], + }; +} + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts new file mode 100644 index 00000000..0dc1fd30 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'crypto'; + +import { + assertBridgeEvidenceCanCommitToRuntimeStores, + createOpenCodeBridgeIdempotencyKey, + extractRunId, + stableHash, + validateOpenCodeBridgeHandshake, + type OpenCodeBridgeCommandName, + type OpenCodeBridgeCommandPreconditions, + type OpenCodeBridgeDiagnosticEvent, + type OpenCodeBridgeHandshake, + type OpenCodeBridgePeerIdentity, + type OpenCodeBridgeResult, + type RuntimeStoreManifestEvidence, +} from './OpenCodeBridgeCommandContract'; +import { + OpenCodeBridgeCommandLedger, + OpenCodeBridgeCommandLeaseStore, +} from './OpenCodeBridgeCommandLedgerStore'; + +export interface OpenCodeBridgeCommandExecutor { + execute( + command: OpenCodeBridgeCommandName, + body: TBody, + options: { + cwd: string; + timeoutMs: number; + requestId?: string; + stdoutLimitBytes?: number; + stderrLimitBytes?: number; + } + ): Promise>; +} + +export interface OpenCodeBridgeHandshakePort { + handshake(input: { + requiredCommand: OpenCodeBridgeCommandName; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedManifestHighWatermark: number | null; + cwd?: string; + }): Promise; +} + +export interface RuntimeStoreManifestReader { + read(teamName: string): Promise; +} + +export interface OpenCodeStateChangingBridgeDiagnosticsSink { + append(event: OpenCodeBridgeDiagnosticEvent): Promise; +} + +export interface OpenCodeStateChangingBridgeCommandServiceOptions { + expectedClientIdentity: OpenCodeBridgePeerIdentity; + handshakePort: OpenCodeBridgeHandshakePort; + leaseStore: OpenCodeBridgeCommandLeaseStore; + ledger: OpenCodeBridgeCommandLedger; + bridge: OpenCodeBridgeCommandExecutor; + manifestReader: RuntimeStoreManifestReader; + diagnostics?: OpenCodeStateChangingBridgeDiagnosticsSink; + requestIdFactory?: () => string; + diagnosticIdFactory?: () => string; + clock?: () => Date; +} + +export class OpenCodeStateChangingBridgeCommandService { + private readonly expectedClientIdentity: OpenCodeBridgePeerIdentity; + private readonly handshakePort: OpenCodeBridgeHandshakePort; + private readonly leaseStore: OpenCodeBridgeCommandLeaseStore; + private readonly ledger: OpenCodeBridgeCommandLedger; + private readonly bridge: OpenCodeBridgeCommandExecutor; + private readonly manifestReader: RuntimeStoreManifestReader; + private readonly diagnostics: OpenCodeStateChangingBridgeDiagnosticsSink | null; + private readonly requestIdFactory: () => string; + private readonly diagnosticIdFactory: () => string; + private readonly clock: () => Date; + + constructor(options: OpenCodeStateChangingBridgeCommandServiceOptions) { + this.expectedClientIdentity = options.expectedClientIdentity; + this.handshakePort = options.handshakePort; + this.leaseStore = options.leaseStore; + this.ledger = options.ledger; + this.bridge = options.bridge; + this.manifestReader = options.manifestReader; + this.diagnostics = options.diagnostics ?? null; + this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`); + this.diagnosticIdFactory = + options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`); + this.clock = options.clock ?? (() => new Date()); + } + + async execute(input: { + command: OpenCodeBridgeCommandName; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + body: TBody; + cwd: string; + timeoutMs: number; + }): Promise> { + const manifest = await this.manifestReader.read(input.teamName); + const handshake = await this.handshakePort.handshake({ + requiredCommand: input.command, + expectedRunId: input.runId, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedManifestHighWatermark: manifest.highWatermark, + cwd: input.cwd, + }); + const handshakeValidation = validateOpenCodeBridgeHandshake({ + handshake, + expectedClient: this.expectedClientIdentity, + requiredCommand: input.command, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedManifestHighWatermark: manifest.highWatermark, + expectedRunId: input.runId, + }); + + if (!handshakeValidation.ok) { + throw new Error(handshakeValidation.reason); + } + + const idempotencyKey = createOpenCodeBridgeIdempotencyKey({ + command: input.command, + teamName: input.teamName, + runId: input.runId, + body: input.body, + }); + const commandRequestId = this.requestIdFactory(); + const lease = await this.leaseStore.acquire({ + teamName: input.teamName, + runId: input.runId, + command: input.command, + ttlMs: input.timeoutMs + 5_000, + }); + + try { + const bodyWithPreconditions = attachBridgePreconditions(input.body, { + handshakeIdentityHash: handshake.identityHash, + expectedRunId: input.runId, + expectedCapabilitySnapshotId: input.capabilitySnapshotId, + expectedBehaviorFingerprint: input.behaviorFingerprint, + expectedManifestHighWatermark: manifest.highWatermark, + commandLeaseId: lease.leaseId, + idempotencyKey, + }); + + const begin = await this.ledger.begin({ + idempotencyKey, + requestId: commandRequestId, + command: input.command, + teamName: input.teamName, + runId: input.runId, + requestHash: stableHash({ + command: input.command, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: input.behaviorFingerprint, + manifestHighWatermark: manifest.highWatermark, + body: input.body, + }), + }); + + if (begin === 'duplicate_same_payload_completed') { + throw new Error('OpenCode bridge command already completed; recover through commandStatus'); + } + + const result = await this.bridge.execute( + input.command, + bodyWithPreconditions, + { + cwd: input.cwd, + timeoutMs: input.timeoutMs, + requestId: commandRequestId, + } + ); + + if (!result.ok) { + if (result.error.kind === 'timeout') { + await this.ledger.markUnknownAfterTimeout({ + idempotencyKey, + error: result.error.message, + }); + await this.appendUnknownOutcomeDiagnostic({ + result, + teamName: input.teamName, + runId: input.runId, + command: input.command, + idempotencyKey, + leaseId: lease.leaseId, + }); + } else { + await this.ledger.markFailed({ + idempotencyKey, + error: result.error.message, + retryable: result.error.retryable, + }); + } + + await this.leaseStore.release(lease.leaseId); + return result; + } + + try { + assertBridgeEvidenceCanCommitToRuntimeStores({ + result, + requestId: commandRequestId, + command: input.command, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + manifest, + idempotencyKey, + }); + } catch (error) { + await this.ledger.markFailed({ + idempotencyKey, + error: stringifyError(error), + retryable: false, + }); + throw error; + } + await this.ledger.markCompleted({ idempotencyKey, response: result }); + await this.leaseStore.release(lease.leaseId); + return result; + } catch (error) { + await this.leaseStore.release(lease.leaseId).catch(() => undefined); + throw error; + } + } + + private async appendUnknownOutcomeDiagnostic(input: { + result: OpenCodeBridgeResult; + teamName: string; + runId: string | null; + command: OpenCodeBridgeCommandName; + idempotencyKey: string; + leaseId: string; + }): Promise { + const completedAt = this.clock().toISOString(); + await this.diagnostics?.append({ + id: this.diagnosticIdFactory(), + type: 'opencode_bridge_unknown_outcome', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId ?? extractRunId(input.result) ?? undefined, + severity: 'warning', + message: 'OpenCode bridge command timed out; outcome must be reconciled before retry', + data: { + command: input.command, + idempotencyKey: input.idempotencyKey, + leaseId: input.leaseId, + }, + createdAt: completedAt, + }); + } +} + +export function attachBridgePreconditions( + body: TBody, + preconditions: OpenCodeBridgeCommandPreconditions +): TBody & { preconditions: OpenCodeBridgeCommandPreconditions } { + if (isRecord(body)) { + return { + ...body, + preconditions, + } as TBody & { preconditions: OpenCodeBridgeCommandPreconditions }; + } + + return { + payload: body, + preconditions, + } as unknown as TBody & { preconditions: OpenCodeBridgeCommandPreconditions }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts new file mode 100644 index 00000000..388e5af7 --- /dev/null +++ b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts @@ -0,0 +1,555 @@ +import { createHash } from 'crypto'; + +export interface OpenCodeApiEndpointMap { + health: boolean; + sessionCreate: boolean; + sessionGet: boolean; + sessionMessageList: boolean; + sessionPromptAsync: boolean; + sessionAbort: boolean; + sessionStatus: boolean; + permissionList: boolean; + permissionReply: boolean; + permissionLegacySessionRespond: boolean; + sessionEventStream: boolean; + globalEventStream: boolean; + mcpList: boolean; + mcpCreate: boolean; + experimentalToolIds: boolean; + experimentalToolList: boolean; +} + +export type OpenCodeApiEndpointKey = keyof OpenCodeApiEndpointMap; + +export type OpenCodeEndpointEvidence = + | 'openapi' + | 'direct_probe' + | 'undocumented_direct_probe' + | 'real_e2e' + | 'missing'; + +export type OpenCodeApiCapabilitySource = + | 'openapi_doc' + | 'sdk_probe' + | 'direct_probe' + | 'mixed_openapi_direct_probe'; + +export interface OpenCodeApiCapabilities { + version: string | null; + source: OpenCodeApiCapabilitySource; + endpoints: OpenCodeApiEndpointMap; + requiredForTeamLaunch: { + ready: boolean; + missing: string[]; + }; + evidence: Record; + diagnostics: string[]; +} + +export interface OpenCodeApiDiscoverySnapshot { + checkedAt: string; + opencodeVersion: string | null; + baseUrlRedacted: string; + capabilities: OpenCodeApiCapabilities; + openApiHash: string | null; +} + +export interface OpenCodeApiCapabilityDetectorInput { + baseUrl: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +export interface OpenCodeApiDiscoverySnapshotInput { + baseUrl: string; + checkedAt: string; + capabilities: OpenCodeApiCapabilities; + openApiDocument?: unknown; +} + +interface OpenApiDocument { + openapi?: string; + info?: { + version?: unknown; + }; + paths?: Record>; +} + +interface RequiredOpenCodeEndpoint { + key: OpenCodeApiEndpointKey; + method: 'get' | 'post' | 'delete' | 'patch'; + path: RegExp; + label: string; +} + +interface DirectSafeProbe { + method: 'GET'; + path: string; + accept: 'application/json' | 'text/event-stream'; +} + +const OPENAPI_SPEC_CANDIDATES = ['/doc', '/doc.json', '/openapi.json'] as const; + +export const REQUIRED_OPENCODE_ENDPOINTS: RequiredOpenCodeEndpoint[] = [ + { key: 'health', method: 'get', path: /^\/global\/health\/?$/, label: 'GET /global/health' }, + { key: 'sessionCreate', method: 'post', path: /^\/session\/?$/, label: 'POST /session' }, + { + key: 'sessionGet', + method: 'get', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/?$/, + label: 'GET /session/:id', + }, + { + key: 'sessionMessageList', + method: 'get', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/message\/?$/, + label: 'GET /session/:id/message', + }, + { + key: 'sessionPromptAsync', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt_async\/?$/, + label: 'POST /session/:id/prompt_async', + }, + { + key: 'sessionAbort', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/abort\/?$/, + label: 'POST /session/:id/abort', + }, + { + key: 'sessionStatus', + method: 'get', + path: /^\/session\/status\/?$/, + label: 'GET /session/status', + }, + { key: 'permissionList', method: 'get', path: /^\/permission\/?$/, label: 'GET /permission' }, + { + key: 'permissionReply', + method: 'post', + path: /^\/permission\/(?:\{[^}]+\}|:[^/]+)\/reply\/?$/, + label: 'POST /permission/:requestID/reply', + }, + { + key: 'permissionLegacySessionRespond', + method: 'post', + path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/permissions\/(?:\{[^}]+\}|:[^/]+)\/?$/, + label: 'POST /session/:sessionID/permissions/:permissionID', + }, + { key: 'sessionEventStream', method: 'get', path: /^\/event\/?$/, label: 'GET /event' }, + { + key: 'globalEventStream', + method: 'get', + path: /^\/global\/event\/?$/, + label: 'GET /global/event', + }, + { key: 'mcpList', method: 'get', path: /^\/mcp\/?$/, label: 'GET /mcp' }, + { key: 'mcpCreate', method: 'post', path: /^\/mcp\/?$/, label: 'POST /mcp' }, + { + key: 'experimentalToolIds', + method: 'get', + path: /^\/experimental\/tool\/ids\/?$/, + label: 'GET /experimental/tool/ids', + }, + { + key: 'experimentalToolList', + method: 'get', + path: /^\/experimental\/tool\/?$/, + label: 'GET /experimental/tool', + }, +]; + +const DIRECT_SAFE_PROBES: Partial> = { + health: { method: 'GET', path: '/global/health', accept: 'application/json' }, + sessionStatus: { method: 'GET', path: '/session/status', accept: 'application/json' }, + permissionList: { method: 'GET', path: '/permission/', accept: 'application/json' }, + sessionEventStream: { method: 'GET', path: '/event', accept: 'text/event-stream' }, + globalEventStream: { method: 'GET', path: '/global/event', accept: 'text/event-stream' }, + mcpList: { method: 'GET', path: '/mcp', accept: 'application/json' }, + experimentalToolIds: { + method: 'GET', + path: '/experimental/tool/ids', + accept: 'application/json', + }, + experimentalToolList: { + method: 'GET', + path: '/experimental/tool', + accept: 'application/json', + }, +}; + +export async function detectOpenCodeApiCapabilities( + input: OpenCodeApiCapabilityDetectorInput +): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + const timeoutMs = input.timeoutMs ?? 5_000; + const diagnostics: string[] = []; + const endpoints = createEmptyEndpointMap(); + const evidence = createEmptyEvidenceMap(); + + const openApi = await loadOpenApiDocument({ + baseUrl: input.baseUrl, + fetchImpl, + timeoutMs, + diagnostics, + }); + + if (openApi.document?.paths) { + applyOpenApiEndpointEvidence(openApi.document, endpoints, evidence); + } + + await runDirectSafeProbes({ + baseUrl: input.baseUrl, + fetchImpl, + timeoutMs, + docAvailable: Boolean(openApi.document), + endpoints, + evidence, + diagnostics, + }); + + if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) { + diagnostics.push( + 'OpenCode permission response endpoint was not proven by OpenAPI; require real permission E2E before production launch' + ); + } + + const missing = resolveMissingOpenCodeCapabilities(endpoints); + const version = + extractOpenApiVersion(openApi.document) ?? + (await probeOpenCodeHealthVersion(input.baseUrl, fetchImpl, timeoutMs, diagnostics)); + + return { + version, + source: resolveCapabilitySource(openApi.document, evidence), + endpoints, + requiredForTeamLaunch: { + ready: missing.length === 0, + missing, + }, + evidence, + diagnostics, + }; +} + +export function createOpenCodeApiDiscoverySnapshot( + input: OpenCodeApiDiscoverySnapshotInput +): OpenCodeApiDiscoverySnapshot { + return { + checkedAt: input.checkedAt, + opencodeVersion: input.capabilities.version, + baseUrlRedacted: redactUrl(input.baseUrl), + capabilities: input.capabilities, + openApiHash: input.openApiDocument === undefined ? null : stableHash(input.openApiDocument), + }; +} + +export function applyOpenApiEndpointEvidence( + document: OpenApiDocument, + endpoints: OpenCodeApiEndpointMap, + evidence: Record +): void { + for (const [path, methods] of Object.entries(document.paths ?? {})) { + for (const required of REQUIRED_OPENCODE_ENDPOINTS) { + if (required.path.test(path) && Boolean(methods[required.method])) { + endpoints[required.key] = true; + evidence[required.key] = 'openapi'; + } + } + } +} + +export function resolveMissingOpenCodeCapabilities(endpoints: OpenCodeApiEndpointMap): string[] { + const missing: string[] = []; + + for (const endpoint of REQUIRED_OPENCODE_ENDPOINTS) { + if (endpoint.key === 'permissionLegacySessionRespond') { + continue; + } + + if (endpoint.key === 'experimentalToolList') { + continue; + } + + if (endpoint.key === 'permissionReply') { + if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) { + missing.push('POST permission reply route'); + } + continue; + } + + if (endpoint.key === 'experimentalToolIds') { + if (!endpoints.experimentalToolIds && !endpoints.experimentalToolList) { + missing.push('GET OpenCode tool availability route'); + } + continue; + } + + if (!endpoints[endpoint.key]) { + missing.push(endpoint.label); + } + } + + return missing; +} + +export function createEmptyEndpointMap(): OpenCodeApiEndpointMap { + return { + health: false, + sessionCreate: false, + sessionGet: false, + sessionMessageList: false, + sessionPromptAsync: false, + sessionAbort: false, + sessionStatus: false, + permissionList: false, + permissionReply: false, + permissionLegacySessionRespond: false, + sessionEventStream: false, + globalEventStream: false, + mcpList: false, + mcpCreate: false, + experimentalToolIds: false, + experimentalToolList: false, + }; +} + +function createEmptyEvidenceMap(): Record { + return Object.fromEntries( + (Object.keys(createEmptyEndpointMap()) as OpenCodeApiEndpointKey[]).map((key) => [ + key, + 'missing', + ]) + ) as Record; +} + +async function loadOpenApiDocument(input: { + baseUrl: string; + fetchImpl: typeof fetch; + timeoutMs: number; + diagnostics: string[]; +}): Promise<{ document: OpenApiDocument | null; raw: string | null }> { + for (const candidate of OPENAPI_SPEC_CANDIDATES) { + try { + const response = await fetchWithTimeout(input.fetchImpl, buildUrl(input.baseUrl, candidate), { + timeoutMs: input.timeoutMs, + requestInit: { headers: { accept: 'application/json' } }, + }); + const text = await response.text(); + + if (!response.ok) { + input.diagnostics.push(`OpenCode ${candidate} returned HTTP ${response.status}`); + continue; + } + + if (looksLikeHtml(text)) { + input.diagnostics.push(`OpenCode ${candidate} returned HTML, expected OpenAPI JSON`); + continue; + } + + const parsed = JSON.parse(text) as OpenApiDocument; + if (parsed.paths && Object.keys(parsed.paths).length > 0) { + return { document: parsed, raw: text }; + } + + input.diagnostics.push(`OpenCode ${candidate} did not include OpenAPI paths`); + } catch (error) { + input.diagnostics.push(`OpenCode ${candidate} probe failed: ${stringifyError(error)}`); + } + } + + return { document: null, raw: null }; +} + +async function runDirectSafeProbes(input: { + baseUrl: string; + fetchImpl: typeof fetch; + timeoutMs: number; + docAvailable: boolean; + endpoints: OpenCodeApiEndpointMap; + evidence: Record; + diagnostics: string[]; +}): Promise { + for (const [key, probe] of Object.entries(DIRECT_SAFE_PROBES) as Array< + [OpenCodeApiEndpointKey, DirectSafeProbe] + >) { + if (input.endpoints[key]) { + continue; + } + + try { + const response = await fetchWithTimeout( + input.fetchImpl, + buildUrl(input.baseUrl, probe.path), + { + timeoutMs: input.timeoutMs, + requestInit: { + method: probe.method, + headers: { accept: probe.accept }, + }, + } + ); + await cancelResponseBody(response); + + if (!response.ok) { + input.diagnostics.push( + `OpenCode direct probe ${probe.path} returned HTTP ${response.status}` + ); + continue; + } + + input.endpoints[key] = true; + input.evidence[key] = input.docAvailable ? 'undocumented_direct_probe' : 'direct_probe'; + } catch (error) { + input.diagnostics.push( + `OpenCode direct probe ${probe.path} failed: ${stringifyError(error)}` + ); + } + } +} + +async function probeOpenCodeHealthVersion( + baseUrl: string, + fetchImpl: typeof fetch, + timeoutMs: number, + diagnostics: string[] +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, buildUrl(baseUrl, '/global/health'), { + timeoutMs, + requestInit: { headers: { accept: 'application/json' } }, + }); + const text = await response.text(); + if (!response.ok) { + diagnostics.push(`OpenCode health version probe returned HTTP ${response.status}`); + return null; + } + const parsed = JSON.parse(text) as unknown; + return extractHealthVersion(parsed); + } catch (error) { + diagnostics.push(`OpenCode health version probe failed: ${stringifyError(error)}`); + return null; + } +} + +function extractOpenApiVersion(document: OpenApiDocument | null): string | null { + return typeof document?.info?.version === 'string' && document.info.version.trim().length > 0 + ? document.info.version + : null; +} + +function extractHealthVersion(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + if (typeof value.version === 'string' && value.version.trim().length > 0) { + return value.version; + } + if ( + isRecord(value.build) && + typeof value.build.version === 'string' && + value.build.version.trim().length > 0 + ) { + return value.build.version; + } + if ( + isRecord(value.data) && + typeof value.data.version === 'string' && + value.data.version.trim().length > 0 + ) { + return value.data.version; + } + return null; +} + +function resolveCapabilitySource( + document: OpenApiDocument | null, + evidence: Record +): OpenCodeApiCapabilitySource { + if (!document) { + return 'direct_probe'; + } + return Object.values(evidence).some((item) => item === 'undocumented_direct_probe') + ? 'mixed_openapi_direct_probe' + : 'openapi_doc'; +} + +async function fetchWithTimeout( + fetchImpl: typeof fetch, + url: string, + options: { + timeoutMs: number; + requestInit?: RequestInit; + } +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); + try { + return await fetchImpl(url, { + ...options.requestInit, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Best-effort cleanup for SSE probes after headers are proven. + } +} + +function buildUrl(baseUrl: string, path: string): string { + return new URL(path, normalizeBaseUrl(baseUrl)).toString(); +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} + +function looksLikeHtml(text: string): boolean { + return text.trimStart().startsWith('<'); +} + +function redactUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username) { + parsed.username = 'redacted'; + } + if (parsed.password) { + parsed.password = 'redacted'; + } + return parsed.toString(); + } catch { + return ''; + } +} + +function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts new file mode 100644 index 00000000..3afa8f13 --- /dev/null +++ b/src/main/services/team/opencode/config/OpenCodeManagedOverlay.ts @@ -0,0 +1,401 @@ +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface OpenCodeMcpServerConfig { + type: 'local' | 'remote'; + command?: string; + args?: string[]; + url?: string; + enabled: boolean; + environment?: Record; + timeout?: number; +} + +export type OpenCodeBehaviorSourceKind = + | 'global_config' + | 'project_config' + | 'global_plugin_dir' + | 'project_plugin_dir' + | 'project_opencode_dir'; + +export interface OpenCodeBehaviorSource { + kind: OpenCodeBehaviorSourceKind; + pathHash: string; + exists: boolean; + fingerprint: string | null; + fileCount: number; +} + +export interface OpenCodeManagedOverlay { + launchMode: 'project_root_with_inline_overlay'; + projectPath: string; + env: { + OPENCODE_CONFIG_CONTENT: string; + OPENCODE_DISABLE_AUTOUPDATE: '1'; + }; + appMcpServerName: string; + appMcpConfig: OpenCodeMcpServerConfig; + preservedSources: OpenCodeBehaviorSource[]; + diagnostics: string[]; +} + +export interface OpenCodeManagedOverlayBuilderInput { + projectPath: string; + preferredMcpName: string; + appMcpCommand: string; + appMcpArgs: string[]; + appMcpEnv: Record; + mcpTimeoutMs?: number; +} + +export interface OpenCodeBehaviorSourceScannerOptions { + homePath?: string; + maxDirectoryFiles?: number; +} + +const FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS = [ + 'plugin', + 'plugins', + 'agent', + 'command', + 'instructions', + 'formatter', + 'lsp', + 'theme', + 'keybinds', + 'model', + 'mode', + 'provider', + 'tools', + 'skills', +] as const; + +export class OpenCodeManagedOverlayBuilder { + constructor( + private readonly behaviorSourceScanner = new OpenCodeBehaviorSourceScanner(), + private readonly clock: () => Date = () => new Date() + ) {} + + async build(input: OpenCodeManagedOverlayBuilderInput): Promise { + const preservedSources = await this.behaviorSourceScanner.scan(input.projectPath); + const existingMcpNames = await this.behaviorSourceScanner.readDeclaredMcpNames( + input.projectPath + ); + const appMcpServerName = pickAppOwnedMcpServerName(input.preferredMcpName, existingMcpNames); + const overlayConfig = buildManagedOverlayConfig({ + serverName: appMcpServerName, + command: input.appMcpCommand, + args: input.appMcpArgs, + environment: input.appMcpEnv, + timeout: input.mcpTimeoutMs ?? 10_000, + }); + + assertManagedOverlayDoesNotShadowUserConfig(overlayConfig); + + return { + launchMode: 'project_root_with_inline_overlay', + projectPath: input.projectPath, + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig), + OPENCODE_DISABLE_AUTOUPDATE: '1', + }, + appMcpServerName, + appMcpConfig: overlayConfig.mcp[appMcpServerName], + preservedSources, + diagnostics: buildOverlayDiagnostics({ + preferredMcpName: input.preferredMcpName, + appMcpServerName, + existingMcpNames, + preservedSources, + checkedAt: this.clock().toISOString(), + }), + }; + } +} + +export class OpenCodeBehaviorSourceScanner { + private readonly homePath: string; + private readonly maxDirectoryFiles: number; + + constructor(options: OpenCodeBehaviorSourceScannerOptions = {}) { + this.homePath = options.homePath ?? os.homedir(); + this.maxDirectoryFiles = options.maxDirectoryFiles ?? 200; + } + + async scan(projectPath: string): Promise { + const sourceSpecs: Array<{ kind: OpenCodeBehaviorSourceKind; targetPath: string }> = [ + { + kind: 'global_config', + targetPath: path.join(this.homePath, '.config/opencode/opencode.json'), + }, + { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') }, + { kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') }, + { + kind: 'global_plugin_dir', + targetPath: path.join(this.homePath, '.config/opencode/plugins'), + }, + { kind: 'project_plugin_dir', targetPath: path.join(projectPath, '.opencode/plugins') }, + { kind: 'project_opencode_dir', targetPath: path.join(projectPath, '.opencode') }, + ]; + + return Promise.all(sourceSpecs.map((source) => this.fingerprintSource(source))); + } + + async readDeclaredMcpNames(projectPath: string): Promise> { + const configPaths = [ + path.join(this.homePath, '.config/opencode/opencode.json'), + path.join(projectPath, 'opencode.json'), + path.join(projectPath, 'opencode.jsonc'), + path.join(projectPath, '.opencode/opencode.json'), + path.join(projectPath, '.opencode/opencode.jsonc'), + ]; + const names = new Set(); + + for (const configPath of configPaths) { + const config = await this.readConfig(configPath); + const mcp = asRecord(config?.mcp); + for (const name of Object.keys(mcp ?? {})) { + names.add(name); + } + } + + return names; + } + + private async fingerprintSource(input: { + kind: OpenCodeBehaviorSourceKind; + targetPath: string; + }): Promise { + const pathHash = hashText(input.targetPath); + let stat; + try { + stat = await fs.stat(input.targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + kind: input.kind, + pathHash, + exists: false, + fingerprint: null, + fileCount: 0, + }; + } + throw error; + } + + if (stat.isFile()) { + const content = await fs.readFile(input.targetPath); + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:${hashBuffer(content)}`), + fileCount: 1, + }; + } + + if (stat.isDirectory()) { + const entries = await this.listDirectoryFiles(input.targetPath); + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashJson( + entries.map((entry) => ({ + relativePath: entry.relativePath, + size: entry.size, + mtimeMs: entry.mtimeMs, + contentHash: entry.contentHash, + })) + ), + fileCount: entries.length, + }; + } + + return { + kind: input.kind, + pathHash, + exists: true, + fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:unsupported`), + fileCount: 0, + }; + } + + private async listDirectoryFiles(rootPath: string): Promise< + Array<{ + relativePath: string; + size: number; + mtimeMs: number; + contentHash: string; + }> + > { + const results: Array<{ + relativePath: string; + size: number; + mtimeMs: number; + contentHash: string; + }> = []; + + const visit = async (directoryPath: string): Promise => { + if (results.length >= this.maxDirectoryFiles) { + return; + } + + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + if (results.length >= this.maxDirectoryFiles) { + return; + } + + const absolutePath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + await visit(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + + const stat = await fs.stat(absolutePath); + const content = await fs.readFile(absolutePath); + results.push({ + relativePath: path.relative(rootPath, absolutePath), + size: stat.size, + mtimeMs: stat.mtimeMs, + contentHash: hashBuffer(content), + }); + } + }; + + await visit(rootPath); + return results.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + } + + private async readConfig(configPath: string): Promise | null> { + let text: string; + try { + text = await fs.readFile(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } + + try { + const parsed = JSON.parse(stripJsonComments(text)) as unknown; + return asRecord(parsed); + } catch { + return null; + } + } +} + +export function buildManagedOverlayConfig(input: { + serverName: string; + command: string; + args: string[]; + environment: Record; + timeout: number; +}): { mcp: Record } { + return { + mcp: { + [input.serverName]: { + type: 'local', + command: input.command, + args: input.args, + enabled: true, + environment: input.environment, + timeout: input.timeout, + }, + }, + }; +} + +export function assertManagedOverlayDoesNotShadowUserConfig(config: Record): void { + const usedForbiddenKeys = FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS.filter((key) => key in config); + if (usedForbiddenKeys.length > 0) { + throw new Error( + `Managed OpenCode overlay must not set user behavior keys: ${usedForbiddenKeys.join(', ')}` + ); + } +} + +export function pickAppOwnedMcpServerName(preferred: string, existingNames: Set): string { + if (!existingNames.has(preferred)) { + return preferred; + } + + let index = 1; + while (existingNames.has(`${preferred}-runtime-${index}`)) { + index += 1; + } + + return `${preferred}-runtime-${index}`; +} + +function buildOverlayDiagnostics(input: { + preferredMcpName: string; + appMcpServerName: string; + existingMcpNames: Set; + preservedSources: OpenCodeBehaviorSource[]; + checkedAt: string; +}): string[] { + const diagnostics = [ + `OpenCode managed overlay checked at ${input.checkedAt}`, + `OpenCode preserved behavior sources: ${input.preservedSources.filter((source) => source.exists).length}`, + ]; + + if (input.appMcpServerName !== input.preferredMcpName) { + diagnostics.push( + `User OpenCode config already declares MCP server "${input.preferredMcpName}"; managed runtime will use "${input.appMcpServerName}"` + ); + } + + if (input.existingMcpNames.size > 0) { + diagnostics.push( + `OpenCode existing MCP server names observed: ${[...input.existingMcpNames].sort().join(', ')}` + ); + } + + return diagnostics; +} + +function stripJsonComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, (_match, prefix: string) => prefix); +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hashJson(value: unknown): string { + return hashText(stableJsonStringify(value)); +} + +function hashText(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hashBuffer(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} diff --git a/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts b/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts new file mode 100644 index 00000000..c11445d8 --- /dev/null +++ b/src/main/services/team/opencode/delivery/RuntimeDeliveryJournal.ts @@ -0,0 +1,482 @@ +import { stableHash, stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION = 1; + +export type RuntimeDeliveryJournalStatus = + | 'pending' + | 'committed' + | 'failed_retryable' + | 'failed_terminal'; + +export type RuntimeDeliveryDestinationRef = + | { kind: 'user_sent_messages'; teamName: string } + | { kind: 'member_inbox'; teamName: string; memberName: string } + | { + kind: 'cross_team_outbox'; + fromTeamName: string; + toTeamName: string; + toMemberName: string; + }; + +export type RuntimeDeliveryLocation = + | { kind: 'user_sent_messages'; teamName: string; messageId: string } + | { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string } + | { + kind: 'cross_team_outbox'; + fromTeamName: string; + toTeamName: string; + toMemberName: string; + messageId: string; + }; + +export interface RuntimeDeliveryJournalRecord { + idempotencyKey: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + payloadHash: string; + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + committedLocation: RuntimeDeliveryLocation | null; + status: RuntimeDeliveryJournalStatus; + attempts: number; + createdAt: string; + updatedAt: string; + committedAt: string | null; + lastError: string | null; +} + +export interface RuntimeDeliveryJournalBeginInput { + idempotencyKey: string; + payloadHash: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + now: string; +} + +export type RuntimeDeliveryJournalBeginResult = + | { state: 'new'; record: RuntimeDeliveryJournalRecord } + | { state: 'already_committed'; record: RuntimeDeliveryJournalRecord } + | { state: 'resume_pending'; record: RuntimeDeliveryJournalRecord } + | { state: 'payload_conflict'; record: RuntimeDeliveryJournalRecord }; + +export class RuntimeDeliveryJournalStore { + constructor(private readonly store: VersionedJsonStore) {} + + async begin(input: RuntimeDeliveryJournalBeginInput): Promise { + let result: RuntimeDeliveryJournalBeginResult | null = null; + await this.store.updateLocked((records) => { + const existing = records.find((record) => record.idempotencyKey === input.idempotencyKey); + if (existing) { + if (existing.payloadHash !== input.payloadHash) { + result = { state: 'payload_conflict', record: existing }; + return records; + } + + if (existing.status === 'committed') { + result = { state: 'already_committed', record: existing }; + return records; + } + + const resumed = { + ...existing, + attempts: existing.attempts + 1, + status: existing.status === 'failed_terminal' ? existing.status : 'pending', + updatedAt: input.now, + } satisfies RuntimeDeliveryJournalRecord; + result = { state: 'resume_pending', record: resumed }; + return records.map((record) => + record.idempotencyKey === input.idempotencyKey ? resumed : record + ); + } + + const created: RuntimeDeliveryJournalRecord = { + idempotencyKey: input.idempotencyKey, + runId: input.runId, + teamName: input.teamName, + fromMemberName: input.fromMemberName, + providerId: input.providerId, + runtimeSessionId: input.runtimeSessionId, + payloadHash: input.payloadHash, + destination: input.destination, + destinationMessageId: input.destinationMessageId, + committedLocation: null, + status: 'pending', + attempts: 1, + createdAt: input.now, + updatedAt: input.now, + committedAt: null, + lastError: null, + }; + result = { state: 'new', record: created }; + return [...records, created]; + }); + + if (!result) { + throw new Error('Runtime delivery journal begin failed'); + } + return result; + } + + async markCommitted(input: { + idempotencyKey: string; + location: RuntimeDeliveryLocation; + committedAt: string; + }): Promise { + await this.updateExisting(input.idempotencyKey, (record) => ({ + ...record, + committedLocation: input.location, + status: 'committed', + updatedAt: input.committedAt, + committedAt: input.committedAt, + lastError: null, + })); + } + + async markFailed(input: { + idempotencyKey: string; + status: 'failed_retryable' | 'failed_terminal'; + error: string; + updatedAt: string; + }): Promise { + await this.updateExisting(input.idempotencyKey, (record) => ({ + ...record, + status: input.status, + updatedAt: input.updatedAt, + lastError: input.error, + })); + } + + async get(idempotencyKey: string): Promise { + const records = await this.readRequired(); + return records.find((record) => record.idempotencyKey === idempotencyKey) ?? null; + } + + async listRecoverable(teamName: string): Promise { + const records = await this.readRequired(); + return records.filter( + (record) => + record.teamName === teamName && + (record.status === 'pending' || record.status === 'failed_retryable') + ); + } + + async findCommittedByRuntimeSession(input: { + teamName: string; + runId: string; + runtimeSessionId: string; + }): Promise> { + const records = await this.readRequired(); + return new Map( + records + .filter( + (record) => + record.teamName === input.teamName && + record.runId === input.runId && + record.runtimeSessionId === input.runtimeSessionId && + record.status === 'committed' + ) + .map((record) => [record.idempotencyKey, record]) + ); + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + idempotencyKey: string, + updater: (record: RuntimeDeliveryJournalRecord) => RuntimeDeliveryJournalRecord + ): Promise { + let found = false; + await this.store.updateLocked((records) => + records.map((record) => { + if (record.idempotencyKey !== idempotencyKey) { + return record; + } + found = true; + return updater(record); + }) + ); + + if (!found) { + throw new Error(`Runtime delivery journal record not found: ${idempotencyKey}`); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function createRuntimeDeliveryJournalStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimeDeliveryJournalStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeDeliveryJournalStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeDeliveryJournalRecords, + clock, + }) + ); +} + +export function validateRuntimeDeliveryJournalRecords( + value: unknown +): RuntimeDeliveryJournalRecord[] { + if (!Array.isArray(value)) { + throw new Error('Runtime delivery journal must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimeDeliveryJournalRecord(record)) { + throw new Error(`Invalid runtime delivery journal record at index ${index}`); + } + if (seen.has(record.idempotencyKey)) { + throw new Error(`Duplicate runtime delivery idempotency key: ${record.idempotencyKey}`); + } + seen.add(record.idempotencyKey); + return record; + }); +} + +export function hashRuntimeDeliveryEnvelope(envelope: RuntimeDeliveryEnvelope): string { + return `sha256:${stableHash({ + providerId: envelope.providerId, + runId: envelope.runId, + teamName: envelope.teamName, + fromMemberName: envelope.fromMemberName, + runtimeSessionId: envelope.runtimeSessionId, + to: envelope.to, + text: envelope.text, + summary: envelope.summary ?? null, + taskRefs: envelope.taskRefs ?? [], + createdAt: envelope.createdAt, + })}`; +} + +export function buildRuntimeDestinationMessageId(envelope: RuntimeDeliveryEnvelope): string { + return `runtime-delivery-${stableHash({ + idempotencyKey: envelope.idempotencyKey, + runId: envelope.runId, + teamName: envelope.teamName, + }).slice(0, 32)}`; +} + +export type RuntimeDeliveryTarget = + | 'user' + | { memberName: string } + | { teamName: string; memberName: string }; + +export interface RuntimeDeliveryEnvelope { + idempotencyKey: string; + runId: string; + teamName: string; + fromMemberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + to: RuntimeDeliveryTarget; + text: string; + createdAt: string; + summary?: string | null; + taskRefs?: string[]; +} + +export function normalizeRuntimeDeliveryEnvelope(value: unknown): RuntimeDeliveryEnvelope { + if (!isRecord(value)) { + throw new Error('Runtime delivery envelope must be an object'); + } + + const envelope: RuntimeDeliveryEnvelope = { + idempotencyKey: requireNonEmptyString(value.idempotencyKey, 'idempotencyKey'), + runId: requireNonEmptyString(value.runId, 'runId'), + teamName: requireNonEmptyString(value.teamName, 'teamName'), + fromMemberName: requireNonEmptyString(value.fromMemberName, 'fromMemberName'), + providerId: value.providerId === 'opencode' ? 'opencode' : fail('providerId must be opencode'), + runtimeSessionId: requireNonEmptyString(value.runtimeSessionId, 'runtimeSessionId'), + to: normalizeRuntimeDeliveryTarget(value.to), + text: requireNonEmptyString(value.text, 'text'), + createdAt: requireNonEmptyString(value.createdAt, 'createdAt'), + summary: value.summary === undefined || value.summary === null ? null : String(value.summary), + taskRefs: Array.isArray(value.taskRefs) + ? value.taskRefs.filter((item): item is string => typeof item === 'string') + : [], + }; + return envelope; +} + +export function resolveRuntimeDeliveryDestination( + envelope: RuntimeDeliveryEnvelope +): RuntimeDeliveryDestinationRef { + if (envelope.to === 'user') { + return { kind: 'user_sent_messages', teamName: envelope.teamName }; + } + + if ('memberName' in envelope.to && !('teamName' in envelope.to)) { + return { + kind: 'member_inbox', + teamName: envelope.teamName, + memberName: envelope.to.memberName, + }; + } + + return { + kind: 'cross_team_outbox', + fromTeamName: envelope.teamName, + toTeamName: envelope.to.teamName, + toMemberName: envelope.to.memberName, + }; +} + +export function buildLocationFromJournal( + record: RuntimeDeliveryJournalRecord +): RuntimeDeliveryLocation { + if (record.committedLocation) { + return record.committedLocation; + } + + switch (record.destination.kind) { + case 'user_sent_messages': + return { + kind: 'user_sent_messages', + teamName: record.destination.teamName, + messageId: record.destinationMessageId, + }; + case 'member_inbox': + return { + kind: 'member_inbox', + teamName: record.destination.teamName, + memberName: record.destination.memberName, + messageId: record.destinationMessageId, + }; + case 'cross_team_outbox': + return { + kind: 'cross_team_outbox', + fromTeamName: record.destination.fromTeamName, + toTeamName: record.destination.toTeamName, + toMemberName: record.destination.toMemberName, + messageId: record.destinationMessageId, + }; + } +} + +export function runtimeDeliveryEnvelopeStableJson(envelope: RuntimeDeliveryEnvelope): string { + return stableJsonStringify(envelope); +} + +function normalizeRuntimeDeliveryTarget(value: unknown): RuntimeDeliveryTarget { + if (value === 'user') { + return 'user'; + } + if (!isRecord(value)) { + throw new Error('Runtime delivery target must be user or object'); + } + const memberName = requireNonEmptyString(value.memberName, 'to.memberName'); + if (typeof value.teamName === 'string' && value.teamName.trim()) { + return { teamName: value.teamName, memberName }; + } + return { memberName }; +} + +function isRuntimeDeliveryJournalRecord(value: unknown): value is RuntimeDeliveryJournalRecord { + return ( + isRecord(value) && + isNonEmptyString(value.idempotencyKey) && + isNonEmptyString(value.runId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.fromMemberName) && + value.providerId === 'opencode' && + isNonEmptyString(value.runtimeSessionId) && + isNonEmptyString(value.payloadHash) && + isRuntimeDeliveryDestinationRef(value.destination) && + isNonEmptyString(value.destinationMessageId) && + (value.committedLocation === null || isRuntimeDeliveryLocation(value.committedLocation)) && + isRuntimeDeliveryJournalStatus(value.status) && + Number.isInteger(value.attempts) && + (value.attempts as number) >= 1 && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) && + (value.committedAt === null || isNonEmptyString(value.committedAt)) && + (value.lastError === null || typeof value.lastError === 'string') + ); +} + +function isRuntimeDeliveryJournalStatus(value: unknown): value is RuntimeDeliveryJournalStatus { + return ( + value === 'pending' || + value === 'committed' || + value === 'failed_retryable' || + value === 'failed_terminal' + ); +} + +function isRuntimeDeliveryDestinationRef(value: unknown): value is RuntimeDeliveryDestinationRef { + if (!isRecord(value)) { + return false; + } + if (value.kind === 'user_sent_messages') { + return isNonEmptyString(value.teamName); + } + if (value.kind === 'member_inbox') { + return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName); + } + return ( + value.kind === 'cross_team_outbox' && + isNonEmptyString(value.fromTeamName) && + isNonEmptyString(value.toTeamName) && + isNonEmptyString(value.toMemberName) + ); +} + +function isRuntimeDeliveryLocation(value: unknown): value is RuntimeDeliveryLocation { + if (!isRecord(value) || !isNonEmptyString(value.messageId)) { + return false; + } + if (value.kind === 'user_sent_messages') { + return isNonEmptyString(value.teamName); + } + if (value.kind === 'member_inbox') { + return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName); + } + return ( + value.kind === 'cross_team_outbox' && + isNonEmptyString(value.fromTeamName) && + isNonEmptyString(value.toTeamName) && + isNonEmptyString(value.toMemberName) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (!isNonEmptyString(value)) { + throw new Error(`Runtime delivery envelope missing ${field}`); + } + return value; +} + +function fail(message: string): never { + throw new Error(message); +} diff --git a/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts new file mode 100644 index 00000000..583241da --- /dev/null +++ b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts @@ -0,0 +1,306 @@ +import { + buildLocationFromJournal, + buildRuntimeDestinationMessageId, + hashRuntimeDeliveryEnvelope, + normalizeRuntimeDeliveryEnvelope, + resolveRuntimeDeliveryDestination, + type RuntimeDeliveryDestinationRef, + type RuntimeDeliveryEnvelope, + type RuntimeDeliveryJournalRecord, + RuntimeDeliveryJournalStore, + type RuntimeDeliveryLocation, +} from './RuntimeDeliveryJournal'; + +export interface RuntimeDeliveryVerifyResult { + found: boolean; + location: RuntimeDeliveryLocation | null; + diagnostics: string[]; +} + +export interface RuntimeDeliveryDestinationPort { + readonly kind: RuntimeDeliveryDestinationRef['kind']; + + write(input: { + envelope: RuntimeDeliveryEnvelope; + destinationMessageId: string; + }): Promise; + + verify(input: { + destination: RuntimeDeliveryDestinationRef; + destinationMessageId: string; + }): Promise; + + buildChangeEvent(input: { + teamName: string; + location: RuntimeDeliveryLocation; + }): RuntimeDeliveryTeamChangeEvent | null; +} + +export interface RuntimeDeliveryTeamChangeEvent { + type: string; + teamName: string; + data?: Record; +} + +export interface RuntimeDeliveryRunStateReader { + getCurrentRunId(teamName: string): Promise; +} + +export interface RuntimeDeliveryDiagnosticsSink { + append(event: RuntimeDeliveryDiagnosticEvent): Promise; +} + +export interface RuntimeDeliveryDiagnosticEvent { + type: + | 'runtime_delivery_conflict' + | 'runtime_delivery_failed' + | 'runtime_delivery_recovery_needed'; + providerId: 'opencode'; + teamName: string; + runId: string; + severity: 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface RuntimeDeliveryTeamChangeEmitter { + emit(event: RuntimeDeliveryTeamChangeEvent): void; +} + +export type RuntimeDeliveryAck = + | { + ok: true; + delivered: boolean; + reason: null | 'duplicate' | 'duplicate_destination_found'; + idempotencyKey: string; + location: RuntimeDeliveryLocation; + } + | { + ok: false; + delivered: false; + reason: 'stale_run' | 'idempotency_conflict'; + idempotencyKey: string; + }; + +export class RuntimeDeliveryDestinationRegistry { + private readonly ports = new Map< + RuntimeDeliveryDestinationRef['kind'], + RuntimeDeliveryDestinationPort + >(); + + constructor(ports: RuntimeDeliveryDestinationPort[]) { + for (const port of ports) { + if (this.ports.has(port.kind)) { + throw new Error(`Duplicate runtime delivery destination port: ${port.kind}`); + } + this.ports.set(port.kind, port); + } + } + + get(kind: RuntimeDeliveryDestinationRef['kind']): RuntimeDeliveryDestinationPort { + const port = this.ports.get(kind); + if (!port) { + throw new Error(`Runtime delivery destination port not registered: ${kind}`); + } + return port; + } +} + +export class RuntimeDeliveryService { + constructor( + private readonly runState: RuntimeDeliveryRunStateReader, + private readonly journal: RuntimeDeliveryJournalStore, + private readonly destinations: RuntimeDeliveryDestinationRegistry, + private readonly diagnostics: RuntimeDeliveryDiagnosticsSink, + private readonly teamChangeEmitter: RuntimeDeliveryTeamChangeEmitter, + private readonly clock: () => Date = () => new Date() + ) {} + + async deliver(raw: unknown): Promise { + const envelope = normalizeRuntimeDeliveryEnvelope(raw); + const now = this.clock().toISOString(); + const currentRunId = await this.runState.getCurrentRunId(envelope.teamName); + if (currentRunId !== envelope.runId) { + return { + ok: false, + delivered: false, + reason: 'stale_run', + idempotencyKey: envelope.idempotencyKey, + }; + } + + const destination = resolveRuntimeDeliveryDestination(envelope); + const destinationMessageId = buildRuntimeDestinationMessageId(envelope); + const payloadHash = hashRuntimeDeliveryEnvelope(envelope); + const begin = await this.journal.begin({ + idempotencyKey: envelope.idempotencyKey, + payloadHash, + runId: envelope.runId, + teamName: envelope.teamName, + fromMemberName: envelope.fromMemberName, + providerId: envelope.providerId, + runtimeSessionId: envelope.runtimeSessionId, + destination, + destinationMessageId, + now, + }); + + if (begin.state === 'payload_conflict') { + await this.diagnostics.append({ + type: 'runtime_delivery_conflict', + providerId: 'opencode', + teamName: envelope.teamName, + runId: envelope.runId, + severity: 'error', + message: 'Runtime delivery idempotency key was reused with a different payload', + data: { + idempotencyKey: envelope.idempotencyKey, + existingPayloadHash: begin.record.payloadHash, + newPayloadHash: payloadHash, + }, + createdAt: now, + }); + return { + ok: false, + delivered: false, + reason: 'idempotency_conflict', + idempotencyKey: envelope.idempotencyKey, + }; + } + + if (begin.state === 'already_committed') { + return { + ok: true, + delivered: false, + reason: 'duplicate', + idempotencyKey: envelope.idempotencyKey, + location: buildLocationFromJournal(begin.record), + }; + } + + const port = this.destinations.get(destination.kind); + const preExisting = await port.verify({ destination, destinationMessageId }); + if (preExisting.found && preExisting.location) { + await this.journal.markCommitted({ + idempotencyKey: envelope.idempotencyKey, + location: preExisting.location, + committedAt: now, + }); + return { + ok: true, + delivered: false, + reason: 'duplicate_destination_found', + idempotencyKey: envelope.idempotencyKey, + location: preExisting.location, + }; + } + + try { + const location = await port.write({ envelope, destinationMessageId }); + const verified = await port.verify({ destination, destinationMessageId }); + if (!verified.found) { + throw new Error( + `Delivery destination write was not verifiable for ${destinationMessageId}` + ); + } + + const committedLocation = verified.location ?? location; + await this.journal.markCommitted({ + idempotencyKey: envelope.idempotencyKey, + location: committedLocation, + committedAt: this.clock().toISOString(), + }); + + const change = port.buildChangeEvent({ + teamName: envelope.teamName, + location: committedLocation, + }); + if (change) { + this.teamChangeEmitter.emit(change); + } + + return { + ok: true, + delivered: true, + reason: null, + idempotencyKey: envelope.idempotencyKey, + location: committedLocation, + }; + } catch (error) { + await this.journal.markFailed({ + idempotencyKey: envelope.idempotencyKey, + status: 'failed_retryable', + error: stringifyError(error), + updatedAt: this.clock().toISOString(), + }); + await this.diagnostics.append({ + type: 'runtime_delivery_failed', + providerId: 'opencode', + teamName: envelope.teamName, + runId: envelope.runId, + severity: 'warning', + message: 'Runtime delivery failed and remains retryable', + data: { + idempotencyKey: envelope.idempotencyKey, + destination, + error: stringifyError(error), + }, + createdAt: this.clock().toISOString(), + }); + throw error; + } + } +} + +export class RuntimeDeliveryReconciler { + constructor( + private readonly journal: RuntimeDeliveryJournalStore, + private readonly destinations: RuntimeDeliveryDestinationRegistry, + private readonly diagnostics: RuntimeDeliveryDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async reconcileTeam(teamName: string): Promise { + const records = await this.journal.listRecoverable(teamName); + for (const record of records) { + await this.reconcileRecord(record); + } + } + + private async reconcileRecord(record: RuntimeDeliveryJournalRecord): Promise { + const port = this.destinations.get(record.destination.kind); + const verified = await port.verify({ + destination: record.destination, + destinationMessageId: record.destinationMessageId, + }); + + if (verified.found && verified.location) { + await this.journal.markCommitted({ + idempotencyKey: record.idempotencyKey, + location: verified.location, + committedAt: this.clock().toISOString(), + }); + return; + } + + await this.diagnostics.append({ + type: 'runtime_delivery_recovery_needed', + providerId: 'opencode', + teamName: record.teamName, + runId: record.runId, + severity: 'warning', + message: `Runtime delivery ${record.idempotencyKey} is pending and destination write is not visible`, + data: { + destination: record.destination, + attempts: record.attempts, + lastError: record.lastError, + }, + createdAt: this.clock().toISOString(), + }); + } +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts new file mode 100644 index 00000000..c2e76455 --- /dev/null +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -0,0 +1,527 @@ +export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; + +export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [ + 'required_tools_proven', + 'delivery_ready', + 'member_ready', + 'run_ready', +] as const; + +export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [ + 'app_mcp_tools_visible', + 'state_changing_launch_completed', + 'session_records_persisted', + 'bootstrap_confirmed_alive', + 'canonical_log_projection_observed', + 'reconcile_completed', + 'stop_completed', + 'stale_run_rejected', +] as const; + +export type OpenCodeProductionE2ERequiredSignal = + (typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number]; + +export interface OpenCodeProductionE2ECheckpointEvidence { + name: string; + observedAt: string; +} + +export interface OpenCodeProductionE2ESessionEvidence { + memberName: string; + sessionId: string; + launchState: 'confirmed_alive'; +} + +export interface OpenCodeProductionE2EEvidence { + schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION; + evidenceId: string; + createdAt: string; + expiresAt: string; + version: string; + passed: boolean; + artifactPath: string | null; + binaryFingerprint: string; + capabilitySnapshotId: string; + selectedModel: string; + projectPathFingerprint: string | null; + requiredSignals: Record; + mcpTools: { + requiredTools: string[]; + observedTools: string[]; + }; + launch: { + runId: string; + teamId: string; + teamLaunchState: 'ready'; + memberCount: number; + sessions: OpenCodeProductionE2ESessionEvidence[]; + durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[]; + }; + reconcile: { + runId: string; + teamLaunchState: 'ready'; + memberCount: number; + }; + stop: { + runId: string; + stopped: true; + stoppedSessionIds: string[]; + }; + logProjection: { + observed: true; + projectedMessageCount: number; + }; + diagnostics?: string[]; +} + +export interface OpenCodeProductionE2EGateExpectation { + opencodeVersion: string | null; + binaryFingerprint: string | null; + capabilitySnapshotId: string | null; + selectedModel: string | null; + requiredMcpTools?: string[]; +} + +export interface OpenCodeProductionE2EGateResult { + ok: boolean; + diagnostics: string[]; +} + +export function validateOpenCodeProductionE2EEvidence( + value: unknown +): OpenCodeProductionE2EEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence must be an object'); + } + + if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) { + throw new Error('OpenCode production E2E evidence has unsupported schemaVersion'); + } + + const evidence: OpenCodeProductionE2EEvidence = { + schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + evidenceId: requireString(record.evidenceId, 'evidenceId'), + createdAt: requireIsoDate(record.createdAt, 'createdAt'), + expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'), + version: requireString(record.version, 'version'), + passed: requireBoolean(record.passed, 'passed'), + artifactPath: optionalString(record.artifactPath, 'artifactPath'), + binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'), + capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'), + selectedModel: requireString(record.selectedModel, 'selectedModel'), + projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'), + requiredSignals: normalizeRequiredSignals(record.requiredSignals), + mcpTools: normalizeMcpTools(record.mcpTools), + launch: normalizeLaunch(record.launch), + reconcile: normalizeReconcile(record.reconcile), + stop: normalizeStop(record.stop), + logProjection: normalizeLogProjection(record.logProjection), + diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'), + }; + + return evidence; +} + +export function validateNullableOpenCodeProductionE2EEvidence( + value: unknown +): OpenCodeProductionE2EEvidence | null { + if (value === null) { + return null; + } + return validateOpenCodeProductionE2EEvidence(value); +} + +export function assertOpenCodeProductionE2EEvidenceBasics(input: { + evidence: OpenCodeProductionE2EEvidence | null; + testedVersion: string; + now?: Date; + artifactPath?: string | null; +}): OpenCodeProductionE2EGateResult { + const diagnostics: string[] = []; + const now = input.now ?? new Date(); + const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; + + if (!input.evidence) { + return { + ok: false, + diagnostics: [ + 'OpenCode version is capability-compatible but production E2E evidence is missing', + ], + }; + } + + diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); + + if (input.evidence.version !== input.testedVersion) { + diagnostics.push( + `OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}` + ); + } + + return { + ok: diagnostics.length === 0, + diagnostics, + }; +} + +export function assertOpenCodeProductionE2EArtifactGate(input: { + evidence: OpenCodeProductionE2EEvidence | null; + expected: OpenCodeProductionE2EGateExpectation; + now?: Date; + artifactPath?: string | null; +}): OpenCodeProductionE2EGateResult { + const diagnostics: string[] = []; + const now = input.now ?? new Date(); + const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; + + if (!input.evidence) { + return { + ok: false, + diagnostics: [ + 'OpenCode production launch requires a current production E2E evidence artifact', + ], + }; + } + + diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); + diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected)); + + return { + ok: diagnostics.length === 0, + diagnostics, + }; +} + +function collectArtifactShapeDiagnostics( + evidence: OpenCodeProductionE2EEvidence, + now: Date, + artifactPath: string | null +): string[] { + const diagnostics: string[] = []; + const createdAtMs = Date.parse(evidence.createdAt); + const expiresAtMs = Date.parse(evidence.expiresAt); + + if (!evidence.passed) { + diagnostics.push('OpenCode production E2E evidence did not pass'); + } + + if (!artifactPath) { + diagnostics.push('OpenCode production E2E evidence artifact path is missing'); + } + + if (!Number.isFinite(createdAtMs)) { + diagnostics.push('OpenCode production E2E evidence createdAt is invalid'); + } else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) { + diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age'); + } + + if (!Number.isFinite(expiresAtMs)) { + diagnostics.push('OpenCode production E2E evidence expiresAt is invalid'); + } else if (expiresAtMs <= now.getTime()) { + diagnostics.push('OpenCode production E2E evidence is expired'); + } + + const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter( + (signal) => evidence.requiredSignals[signal] !== true + ); + if (missingSignals.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}` + ); + } + + const checkpointNames = new Set( + evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name) + ); + const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter( + (checkpoint) => !checkpointNames.has(checkpoint) + ); + if (missingCheckpoints.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}` + ); + } + + if ( + evidence.launch.memberCount <= 0 || + evidence.launch.sessions.length !== evidence.launch.memberCount + ) { + diagnostics.push( + 'OpenCode production E2E evidence must include confirmed session evidence for every member' + ); + } + + if (evidence.reconcile.runId !== evidence.launch.runId) { + diagnostics.push( + 'OpenCode production E2E reconcile evidence runId does not match launch runId' + ); + } + + if (evidence.reconcile.memberCount !== evidence.launch.memberCount) { + diagnostics.push( + 'OpenCode production E2E reconcile member count does not match launch member count' + ); + } + + if (evidence.stop.runId !== evidence.launch.runId) { + diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId'); + } + + if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) { + diagnostics.push( + 'OpenCode production E2E evidence does not prove every launched session was stopped' + ); + } + + if (evidence.logProjection.projectedMessageCount <= 0) { + diagnostics.push('OpenCode production E2E evidence must include projected log messages'); + } + + const observedTools = new Set(evidence.mcpTools.observedTools); + const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool)); + if (missingTools.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}` + ); + } + + return diagnostics; +} + +function collectExpectedRuntimeDiagnostics( + evidence: OpenCodeProductionE2EEvidence, + expected: OpenCodeProductionE2EGateExpectation +): string[] { + const diagnostics: string[] = []; + + if (!expected.opencodeVersion) { + diagnostics.push('OpenCode production gate cannot verify runtime version'); + } else if (evidence.version !== expected.opencodeVersion) { + diagnostics.push( + `OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}` + ); + } + + if (!expected.binaryFingerprint) { + diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint'); + } else if (evidence.binaryFingerprint !== expected.binaryFingerprint) { + diagnostics.push( + 'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint' + ); + } + + if (!expected.capabilitySnapshotId) { + diagnostics.push('OpenCode production gate cannot verify capability snapshot id'); + } else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) { + diagnostics.push( + 'OpenCode production E2E evidence capability snapshot does not match current runtime' + ); + } + + if (!expected.selectedModel) { + diagnostics.push('OpenCode production gate cannot verify selected raw model id'); + } else if (evidence.selectedModel !== expected.selectedModel) { + diagnostics.push( + `OpenCode production E2E evidence model ${evidence.selectedModel} does not match selected model ${expected.selectedModel}` + ); + } + + const requiredTools = expected.requiredMcpTools ?? []; + if (requiredTools.length > 0) { + const observedTools = new Set(evidence.mcpTools.observedTools); + const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); + if (missingTools.length > 0) { + diagnostics.push( + `OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}` + ); + } + } + + return diagnostics; +} + +function normalizeRequiredSignals( + value: unknown +): Record { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence requiredSignals must be an object'); + } + + return Object.fromEntries( + OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [ + signal, + requireBoolean(record[signal], `requiredSignals.${signal}`), + ]) + ) as Record; +} + +function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence mcpTools must be an object'); + } + return { + requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'), + observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'), + }; +} + +function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence launch must be an object'); + } + if (record.teamLaunchState !== 'ready') { + throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready'); + } + return { + runId: requireString(record.runId, 'launch.runId'), + teamId: requireString(record.teamId, 'launch.teamId'), + teamLaunchState: 'ready', + memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'), + sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession), + durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map( + normalizeCheckpoint + ), + }; +} + +function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence launch session must be an object'); + } + if (record.launchState !== 'confirmed_alive') { + throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive'); + } + return { + memberName: requireString(record.memberName, 'launch.sessions.memberName'), + sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'), + launchState: 'confirmed_alive', + }; +} + +function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence durable checkpoint must be an object'); + } + return { + name: requireString(record.name, 'launch.durableCheckpoints.name'), + observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'), + }; +} + +function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence reconcile must be an object'); + } + if (record.teamLaunchState !== 'ready') { + throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready'); + } + return { + runId: requireString(record.runId, 'reconcile.runId'), + teamLaunchState: 'ready', + memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'), + }; +} + +function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence stop must be an object'); + } + if (record.stopped !== true) { + throw new Error('OpenCode production E2E evidence stop.stopped must be true'); + } + return { + runId: requireString(record.runId, 'stop.runId'), + stopped: true, + stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'), + }; +} + +function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] { + const record = asRecord(value); + if (!record) { + throw new Error('OpenCode production E2E evidence logProjection must be an object'); + } + if (record.observed !== true) { + throw new Error('OpenCode production E2E evidence logProjection.observed must be true'); + } + return { + observed: true, + projectedMessageCount: requirePositiveInteger( + record.projectedMessageCount, + 'logProjection.projectedMessageCount' + ), + }; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`); + } + return value.trim(); +} + +function optionalString(value: unknown, field: string): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`); + } + return value.trim(); +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`OpenCode production E2E evidence ${field} must be boolean`); + } + return value; +} + +function requirePositiveInteger(value: unknown, field: string): number { + if (!Number.isInteger(value) || (value as number) <= 0) { + throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`); + } + return value as number; +} + +function requireIsoDate(value: unknown, field: string): string { + const text = requireString(value, field); + if (!Number.isFinite(Date.parse(text))) { + throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`); + } + return text; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`OpenCode production E2E evidence ${field} must be an array`); + } + return value; +} + +function requireStringArray(value: unknown, field: string): string[] { + return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`)); +} + +function optionalStringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + return requireStringArray(value, field); +} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts new file mode 100644 index 00000000..fd1fa414 --- /dev/null +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; + +import { + OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + validateNullableOpenCodeProductionE2EEvidence, + validateOpenCodeProductionE2EEvidence, + type OpenCodeProductionE2EEvidence, +} from './OpenCodeProductionE2EEvidence'; +import { VersionedJsonStore } from '../store/VersionedJsonStore'; + +export interface OpenCodeProductionE2EEvidenceStoreReadResult { + ok: boolean; + evidence: OpenCodeProductionE2EEvidence | null; + artifactPath: string; + diagnostics: string[]; +} + +export interface OpenCodeProductionE2EEvidenceStoreOptions { + filePath: string; + clock?: () => Date; +} + +export class OpenCodeProductionE2EEvidenceStore { + private readonly filePath: string; + private readonly store: VersionedJsonStore; + + constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) { + this.filePath = options.filePath; + this.store = new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, + defaultData: () => null, + validate: validateNullableOpenCodeProductionE2EEvidence, + clock: options.clock, + quarantineDir: path.dirname(options.filePath), + }); + } + + async read(): Promise { + const result = await this.store.read(); + if (!result.ok) { + return { + ok: false, + evidence: null, + artifactPath: this.filePath, + diagnostics: [ + `OpenCode production E2E evidence store is unreadable: ${result.message}`, + ...(result.quarantinePath + ? [`Quarantined corrupt evidence at ${result.quarantinePath}`] + : []), + ], + }; + } + + return { + ok: true, + evidence: result.data, + artifactPath: this.filePath, + diagnostics: + result.status === 'missing' + ? ['OpenCode production E2E evidence artifact has not been written yet'] + : [], + }; + } + + async write(evidence: OpenCodeProductionE2EEvidence): Promise { + const validated = validateOpenCodeProductionE2EEvidence(evidence); + await this.store.updateLocked(() => ({ + ...validated, + artifactPath: validated.artifactPath ?? this.filePath, + })); + } +} diff --git a/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts b/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts new file mode 100644 index 00000000..7c7a8ea1 --- /dev/null +++ b/src/main/services/team/opencode/events/OpenCodeEventNormalizer.ts @@ -0,0 +1,413 @@ +export type OpenCodeEventScope = 'instance' | 'global'; + +export type OpenCodeNormalizedStatusType = 'idle' | 'busy' | 'retry' | 'error' | 'unknown'; + +export interface OpenCodeNormalizedSessionStatus { + type: OpenCodeNormalizedStatusType; + retryAttempt: number | null; + retryMessage: string | null; + retryNextAt: number | null; + rawShape: 'v1.14' | 'legacy-string' | 'unknown'; + raw: unknown; +} + +export type OpenCodeDurableSessionState = + | 'idle' + | 'running' + | 'retrying' + | 'blocked' + | 'reply_pending' + | 'error' + | 'unknown'; + +export type OpenCodeNormalizedEvent = + | { + kind: 'server_connected' | 'server_heartbeat'; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'session_status'; + sessionId: string; + status: OpenCodeNormalizedSessionStatus; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'session_error'; + sessionId: string | null; + errorName: string | null; + errorMessage: string | null; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_updated'; + sessionId: string; + messageId: string | null; + role: 'assistant' | 'user' | 'system' | 'unknown'; + info: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_updated'; + sessionId: string; + messageId: string | null; + partId: string | null; + partType: string | null; + textSnapshot: string | null; + part: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_delta'; + sessionId: string; + messageId: string; + partId: string; + field: string; + delta: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'message_part_removed'; + sessionId: string; + messageId: string; + partId: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'permission_asked' | 'permission_replied'; + sessionId: string | null; + requestId: string | null; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + } + | { + kind: 'unknown'; + type: string; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; + }; + +export interface OpenCodeSseEventEnvelope { + type: string; + properties: Record; + scope: OpenCodeEventScope; + directory: string | null; + raw: unknown; +} + +export interface OpenCodeDurableStateProjection { + hasPendingPermission: boolean; + hasLatestAssistantError: boolean; + replyPendingSinceMessageId: string | null; +} + +export function normalizeOpenCodeSessionStatus(raw: unknown): OpenCodeNormalizedSessionStatus { + if (typeof raw === 'string') { + return { + type: normalizeLegacyStatusType(raw), + retryAttempt: null, + retryMessage: null, + retryNextAt: null, + rawShape: 'legacy-string', + raw, + }; + } + + const record = asRecord(raw); + const statusType = asString(record?.type); + if ( + statusType === 'idle' || + statusType === 'busy' || + statusType === 'retry' || + statusType === 'error' + ) { + return { + type: statusType, + retryAttempt: asNumber(record?.attempt), + retryMessage: asString(record?.message), + retryNextAt: asNumber(record?.next), + rawShape: 'v1.14', + raw, + }; + } + + return { + type: 'unknown', + retryAttempt: null, + retryMessage: null, + retryNextAt: null, + rawShape: 'unknown', + raw, + }; +} + +export function mapOpenCodeStatusToDurableState( + status: OpenCodeNormalizedSessionStatus | null, + projection: OpenCodeDurableStateProjection +): OpenCodeDurableSessionState { + if (projection.hasPendingPermission) { + return 'blocked'; + } + if (projection.hasLatestAssistantError || status?.type === 'error') { + return 'error'; + } + if (status?.type === 'retry') { + return 'retrying'; + } + if (status?.type === 'busy') { + return 'running'; + } + if (projection.replyPendingSinceMessageId) { + return 'reply_pending'; + } + if (status?.type === 'idle') { + return 'idle'; + } + return 'unknown'; +} + +export function unwrapOpenCodeEventEnvelope(raw: unknown): OpenCodeSseEventEnvelope | null { + const record = asRecord(raw); + if (!record) { + return null; + } + + const directType = asString(record.type); + if (directType) { + return { + type: directType, + properties: asRecord(record.properties) ?? {}, + scope: 'instance', + directory: null, + raw, + }; + } + + const payload = asRecord(record.payload); + const payloadType = asString(payload?.type); + if (!payloadType) { + return null; + } + + return { + type: payloadType, + properties: asRecord(payload?.properties) ?? {}, + scope: 'global', + directory: asString(record.directory), + raw, + }; +} + +export function normalizeOpenCodeEvent(raw: unknown): OpenCodeNormalizedEvent | null { + const event = unwrapOpenCodeEventEnvelope(raw); + if (!event) { + return null; + } + + const props = event.properties; + + if (event.type === 'server.connected' || event.type === 'server.heartbeat') { + return { + kind: event.type === 'server.connected' ? 'server_connected' : 'server_heartbeat', + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.status') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'session_status', + sessionId, + status: normalizeOpenCodeSessionStatus(props.status), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.idle') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'session_status', + sessionId, + status: normalizeOpenCodeSessionStatus({ type: 'idle' }), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'session.error') { + const error = asRecord(props.error); + return { + kind: 'session_error', + sessionId: asString(props.sessionID) ?? asString(props.sessionId), + errorName: asString(error?.name) ?? asString(props.name), + errorMessage: asString(error?.message) ?? asString(props.message), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.updated') { + const info = asRecord(props.info) ?? {}; + const sessionId = + asString(props.sessionID) ?? asString(props.sessionId) ?? asString(info.sessionID); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'message_updated', + sessionId, + messageId: asString(info.id) ?? asString(info.messageID), + role: normalizeMessageRole(asString(info.role)), + info, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.updated') { + const part = asRecord(props.part) ?? {}; + const sessionId = + asString(props.sessionID) ?? asString(props.sessionId) ?? asString(part.sessionID); + if (!sessionId) { + return unknownEvent(event); + } + return { + kind: 'message_part_updated', + sessionId, + messageId: asString(part.messageID) ?? asString(part.messageId), + partId: asString(part.id) ?? asString(part.partID) ?? asString(part.partId), + partType: asString(part.type), + textSnapshot: asStringAllowEmpty(part.text), + part, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.delta') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + const messageId = asString(props.messageID) ?? asString(props.messageId); + const partId = asString(props.partID) ?? asString(props.partId); + const field = asString(props.field); + const delta = asStringAllowEmpty(props.delta); + if (!sessionId || !messageId || !partId || !field || delta === null) { + return unknownEvent(event); + } + return { + kind: 'message_part_delta', + sessionId, + messageId, + partId, + field, + delta, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'message.part.removed') { + const sessionId = asString(props.sessionID) ?? asString(props.sessionId); + const messageId = asString(props.messageID) ?? asString(props.messageId); + const partId = asString(props.partID) ?? asString(props.partId); + if (!sessionId || !messageId || !partId) { + return unknownEvent(event); + } + return { + kind: 'message_part_removed', + sessionId, + messageId, + partId, + scope: event.scope, + directory: event.directory, + raw, + }; + } + + if (event.type === 'permission.asked' || event.type === 'permission.replied') { + return { + kind: event.type === 'permission.asked' ? 'permission_asked' : 'permission_replied', + sessionId: asString(props.sessionID) ?? asString(props.sessionId), + requestId: asString(props.id) ?? asString(props.requestID) ?? asString(props.requestId), + scope: event.scope, + directory: event.directory, + raw, + }; + } + + return unknownEvent(event); +} + +function normalizeLegacyStatusType(raw: string): OpenCodeNormalizedStatusType { + if (raw === 'active') { + return 'busy'; + } + if (raw === 'idle' || raw === 'busy' || raw === 'retry' || raw === 'error') { + return raw; + } + return 'unknown'; +} + +function normalizeMessageRole(role: string | null): 'assistant' | 'user' | 'system' | 'unknown' { + if (role === 'assistant' || role === 'user' || role === 'system') { + return role; + } + return 'unknown'; +} + +function unknownEvent(event: OpenCodeSseEventEnvelope): OpenCodeNormalizedEvent { + return { + kind: 'unknown', + type: event.type, + scope: event.scope, + directory: event.directory, + raw: event.raw, + }; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asStringAllowEmpty(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts new file mode 100644 index 00000000..6c92e8d4 --- /dev/null +++ b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts @@ -0,0 +1,421 @@ +export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +] as const; + +export type RequiredAgentTeamsRuntimeTool = (typeof REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS)[number]; + +export interface OpenCodeToolListItem { + id: string; + description?: string; + parameters?: unknown; +} + +export interface OpenCodeInfrastructureToolClient { + listExperimentalToolIds(): Promise; + listExperimentalTools(input: { + providerId: string; + modelId: string; + }): Promise; +} + +export type OpenCodeMcpToolProofRoute = '/experimental/tool/ids' | '/experimental/tool' | null; + +export interface OpenCodeMcpToolProof { + ok: boolean; + route: OpenCodeMcpToolProofRoute; + canonicalServerName: string; + canonicalExpectedIds: Record; + observedTools: string[]; + missingTools: string[]; + matchedByRequiredTool: Record; + aliasMatchedByRequiredTool: Record; + diagnostics: string[]; +} + +export interface AppMcpRuntimeToolContract { + name: RequiredAgentTeamsRuntimeTool; + requiredInputFields: string[]; + idempotencyField: string | null; + runScoped: boolean; + handlerKind: 'bootstrap' | 'delivery' | 'task_event' | 'heartbeat'; +} + +export interface AppMcpToolDefinition { + name: string; + inputSchema: unknown; +} + +export interface AppMcpRuntimeToolPreflightResult { + ok: boolean; + observedToolNames: string[]; + diagnostics: string[]; +} + +export interface RuntimeDeliverMessageSchemaDiagnostic { + severity: 'error'; + message: string; + missingFields?: string[]; +} + +export const APP_MCP_RUNTIME_TOOL_CONTRACTS: AppMcpRuntimeToolContract[] = [ + { + name: 'runtime_bootstrap_checkin', + requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'], + idempotencyField: null, + runScoped: true, + handlerKind: 'bootstrap', + }, + { + name: 'runtime_deliver_message', + requiredInputFields: [ + 'idempotencyKey', + 'runId', + 'teamName', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + ], + idempotencyField: 'idempotencyKey', + runScoped: true, + handlerKind: 'delivery', + }, + { + name: 'runtime_task_event', + requiredInputFields: ['idempotencyKey', 'runId', 'teamName', 'memberName', 'taskId', 'event'], + idempotencyField: 'idempotencyKey', + runScoped: true, + handlerKind: 'task_event', + }, + { + name: 'runtime_heartbeat', + requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'], + idempotencyField: null, + runScoped: true, + handlerKind: 'heartbeat', + }, +]; + +export class OpenCodeMcpToolAvailabilityProbe { + constructor(private readonly client: OpenCodeInfrastructureToolClient) {} + + async proveRequiredTools(input: { + serverName: string; + requiredTools: string[]; + providerId: string; + modelId: string; + }): Promise { + const idsProof = await this.tryToolIdsProof(input); + if (idsProof.ok) { + return idsProof; + } + + const definitionsProof = await this.tryToolDefinitionsProof(input); + if (definitionsProof.ok) { + return definitionsProof; + } + + return mergeFailedToolProofs({ + serverName: input.serverName, + requiredTools: input.requiredTools, + idsProof, + definitionsProof, + }); + } + + private async tryToolIdsProof(input: { + serverName: string; + requiredTools: string[]; + }): Promise { + try { + const observedTools = await this.client.listExperimentalToolIds(); + return matchRequiredOpenCodeTools({ + route: '/experimental/tool/ids', + serverName: input.serverName, + requiredTools: input.requiredTools, + observedTools, + }); + } catch (error) { + return failedToolProof({ + route: '/experimental/tool/ids', + serverName: input.serverName, + requiredTools: input.requiredTools, + diagnostics: [`OpenCode /experimental/tool/ids unavailable - ${stringifyError(error)}`], + }); + } + } + + private async tryToolDefinitionsProof(input: { + serverName: string; + requiredTools: string[]; + providerId: string; + modelId: string; + }): Promise { + try { + const tools = await this.client.listExperimentalTools({ + providerId: input.providerId, + modelId: input.modelId, + }); + return matchRequiredOpenCodeTools({ + route: '/experimental/tool', + serverName: input.serverName, + requiredTools: input.requiredTools, + observedTools: tools.map((tool) => tool.id), + }); + } catch (error) { + return failedToolProof({ + route: '/experimental/tool', + serverName: input.serverName, + requiredTools: input.requiredTools, + diagnostics: [`OpenCode /experimental/tool unavailable - ${stringifyError(error)}`], + }); + } + } +} + +export function sanitizeOpenCodeMcpToolPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^a-zA-Z0-9_-]/g, '_') + .replace(/_+/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +export function buildOpenCodeCanonicalMcpToolId(serverName: string, toolName: string): string { + return `${sanitizeOpenCodeMcpToolPart(serverName)}_${sanitizeOpenCodeMcpToolPart(toolName)}`; +} + +export function buildOpenCodeToolIdCandidates(serverName: string, toolName: string): string[] { + const dashServerName = serverName.trim(); + const underscoreServerName = sanitizeOpenCodeMcpToolPart(serverName); + const canonical = buildOpenCodeCanonicalMcpToolId(serverName, toolName); + + return unique([ + canonical, + toolName, + `${dashServerName}:${toolName}`, + `${underscoreServerName}:${toolName}`, + `${dashServerName}_${toolName}`, + `${underscoreServerName}_${toolName}`, + `mcp__${dashServerName}__${toolName}`, + `mcp__${underscoreServerName}__${toolName}`, + ]); +} + +export function matchRequiredOpenCodeTools(input: { + route: Exclude; + serverName: string; + requiredTools: string[]; + observedTools: string[]; +}): OpenCodeMcpToolProof { + const observed = new Set(input.observedTools); + const matchedByRequiredTool: Record = {}; + const aliasMatchedByRequiredTool: Record = {}; + const missingTools: string[] = []; + const diagnostics: string[] = []; + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + + for (const requiredTool of input.requiredTools) { + const canonical = canonicalExpectedIds[requiredTool]; + const alias = buildOpenCodeToolIdCandidates(input.serverName, requiredTool).find( + (candidate) => candidate !== canonical && observed.has(candidate) + ); + + matchedByRequiredTool[requiredTool] = observed.has(canonical) ? canonical : null; + aliasMatchedByRequiredTool[requiredTool] = alias ?? null; + + if (!observed.has(canonical)) { + missingTools.push(requiredTool); + diagnostics.push( + alias + ? `OpenCode observed alias ${alias} but missing canonical app MCP tool id ${canonical}` + : `OpenCode missing canonical app MCP tool id ${canonical}` + ); + } + } + + return { + ok: missingTools.length === 0, + route: input.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: unique(input.observedTools).sort(), + missingTools, + matchedByRequiredTool, + aliasMatchedByRequiredTool, + diagnostics, + }; +} + +export function verifyAppMcpRuntimeToolContracts( + tools: AppMcpToolDefinition[] +): AppMcpRuntimeToolPreflightResult { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const diagnostics: string[] = []; + + for (const contract of APP_MCP_RUNTIME_TOOL_CONTRACTS) { + const tool = byName.get(contract.name); + if (!tool) { + diagnostics.push(`App MCP tool missing: ${contract.name}`); + continue; + } + + const schema = asRecord(tool.inputSchema); + const properties = asRecord(schema?.properties); + const required = asStringArray(schema?.required); + + for (const field of contract.requiredInputFields) { + if (!properties?.[field] || !required.includes(field)) { + diagnostics.push(`App MCP tool ${contract.name} missing required field ${field}`); + } + } + + if (contract.idempotencyField && !required.includes(contract.idempotencyField)) { + diagnostics.push( + `App MCP tool ${contract.name} idempotency field ${contract.idempotencyField} is not required` + ); + } + } + + return { + ok: diagnostics.length === 0, + observedToolNames: tools.map((tool) => tool.name).sort(), + diagnostics, + }; +} + +export function assertRuntimeDeliverMessageSchema( + tools: OpenCodeToolListItem[], + serverName = 'agent-teams' +): RuntimeDeliverMessageSchemaDiagnostic[] { + const deliverToolIds = new Set( + buildOpenCodeToolIdCandidates(serverName, 'runtime_deliver_message') + ); + const deliver = tools.find((tool) => deliverToolIds.has(tool.id)); + if (!deliver) { + return [{ severity: 'error', message: 'runtime_deliver_message tool is absent' }]; + } + + const schema = asRecord(deliver.parameters); + const properties = asRecord(schema?.properties); + const required = asStringArray(schema?.required); + const requiredFields = [ + 'idempotencyKey', + 'runId', + 'teamName', + 'fromMemberName', + 'runtimeSessionId', + 'to', + 'text', + ]; + const missingFields = requiredFields.filter( + (field) => !properties?.[field] || !required.includes(field) + ); + + return missingFields.length === 0 + ? [] + : [ + { + severity: 'error', + message: `runtime_deliver_message schema missing required fields: ${missingFields.join(', ')}`, + missingFields, + }, + ]; +} + +function mergeFailedToolProofs(input: { + serverName: string; + requiredTools: string[]; + idsProof: OpenCodeMcpToolProof; + definitionsProof: OpenCodeMcpToolProof; +}): OpenCodeMcpToolProof { + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + const matchedByRequiredTool: Record = {}; + const aliasMatchedByRequiredTool: Record = {}; + + for (const tool of input.requiredTools) { + matchedByRequiredTool[tool] = + input.idsProof.matchedByRequiredTool[tool] ?? + input.definitionsProof.matchedByRequiredTool[tool] ?? + null; + aliasMatchedByRequiredTool[tool] = + input.idsProof.aliasMatchedByRequiredTool[tool] ?? + input.definitionsProof.aliasMatchedByRequiredTool[tool] ?? + null; + } + + return { + ok: false, + route: input.definitionsProof.route ?? input.idsProof.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: unique([ + ...input.idsProof.observedTools, + ...input.definitionsProof.observedTools, + ]).sort(), + missingTools: unique([ + ...input.idsProof.missingTools, + ...input.definitionsProof.missingTools, + ]).sort(), + matchedByRequiredTool, + aliasMatchedByRequiredTool, + diagnostics: [ + ...input.idsProof.diagnostics, + ...input.definitionsProof.diagnostics, + 'OpenCode app-owned MCP server is connected but required runtime tools were not proven available', + ], + }; +} + +function failedToolProof(input: { + route: Exclude; + serverName: string; + requiredTools: string[]; + diagnostics: string[]; +}): OpenCodeMcpToolProof { + const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools); + return { + ok: false, + route: input.route, + canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName), + canonicalExpectedIds, + observedTools: [], + missingTools: [...input.requiredTools], + matchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])), + aliasMatchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])), + diagnostics: input.diagnostics, + }; +} + +function buildCanonicalExpectedIds( + serverName: string, + requiredTools: string[] +): Record { + return Object.fromEntries( + requiredTools.map((tool) => [tool, buildOpenCodeCanonicalMcpToolId(serverName, tool)]) + ); +} + +function unique(items: T[]): T[] { + return [...new Set(items)]; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === 'string'); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/permissions/RuntimePermission.ts b/src/main/services/team/opencode/permissions/RuntimePermission.ts new file mode 100644 index 00000000..a5e03bb9 --- /dev/null +++ b/src/main/services/team/opencode/permissions/RuntimePermission.ts @@ -0,0 +1,936 @@ +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + +export const RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION = 1; + +export type OpenCodePermissionDecision = 'once' | 'always' | 'reject'; + +export type OpenCodeRawPermissionRequest = { + id?: unknown; + requestID?: unknown; + sessionID?: unknown; + permission?: unknown; + patterns?: unknown; + metadata?: unknown; + always?: unknown; + tool?: unknown; + title?: unknown; + kind?: unknown; +}; + +export interface OpenCodeNormalizedPermissionRequest { + requestId: string; + sessionId: string; + permission: string; + patterns: string[]; + alwaysPatterns: string[]; + toolName: string; + toolCallId: string | null; + messageId: string | null; + title: string; + description: string | null; + metadata: Record; + rawShape: 'v1.14' | 'legacy' | 'mixed'; + raw: OpenCodeRawPermissionRequest; +} + +export type RuntimePermissionState = + | 'pending' + | 'answering' + | 'answered' + | 'expired' + | 'stale_run' + | 'provider_missing' + | 'failed_retryable' + | 'failed_terminal'; + +export type RuntimePermissionAnswerOrigin = 'user_click' | 'provider_side_effect_projection'; + +export interface RuntimePermissionRequestRecord { + appRequestId: string; + providerRequestId: string; + runId: string; + teamName: string; + memberName: string; + providerId: 'opencode'; + runtimeSessionId: string; + permission: string; + patterns: string[]; + alwaysPatterns: string[]; + toolName: string; + title: string; + description: string | null; + state: RuntimePermissionState; + rawShape: OpenCodeNormalizedPermissionRequest['rawShape']; + requestedAt: string; + updatedAt: string; + expiresAt: string; + answeredAt: string | null; + decision: OpenCodePermissionDecision | null; + answerOrigin: RuntimePermissionAnswerOrigin | null; + lastError: string | null; +} + +export type OpenCodePermissionReplySideEffect = + | { + kind: 'answered_clicked_request'; + appRequestId: string; + providerRequestId: string; + decision: OpenCodePermissionDecision; + } + | { + kind: 'reject_cancelled_same_session'; + appRequestId: string; + providerRequestId: string; + decision: 'reject'; + } + | { + kind: 'always_auto_allowed_same_session'; + appRequestId: string; + providerRequestId: string; + decision: 'always'; + matchedPatterns: string[]; + }; + +export interface RuntimePermissionAnswerProjectionResult { + affectedAppRequestIds: string[]; + sideEffects: OpenCodePermissionReplySideEffect[]; +} + +export interface RuntimePermissionDiagnosticEvent { + type: + | 'opencode_permission_stale_answer_rejected' + | 'opencode_permission_unmatched_session' + | 'opencode_permission_requests_expired' + | 'opencode_permission_answer_failed'; + providerId: 'opencode'; + teamName: string; + runId: string; + severity: 'info' | 'warning' | 'error'; + message: string; + data?: Record; + createdAt: string; +} + +export interface RuntimePermissionDiagnosticsSink { + append(event: RuntimePermissionDiagnosticEvent): Promise; +} + +export interface RuntimePermissionLaunchStateStore { + read(teamName: string): Promise<{ runId: string | null } | null>; + updateMember( + teamName: string, + memberName: string, + updater: (member: RuntimePermissionLaunchMemberState) => RuntimePermissionLaunchMemberState + ): Promise; +} + +export interface RuntimePermissionLaunchMemberState { + launchState?: string; + bootstrapConfirmed?: boolean; + pendingPermissionRequestIds?: string[]; + lastRuntimeEventAt?: string; +} + +export interface OpenCodePermissionClientPort { + listPendingPermissions(): Promise; + answerPermission(input: { + requestId: string; + sessionId: string; + decision: OpenCodePermissionDecision; + message?: string; + }): Promise; +} + +export interface OpenCodeSessionPermissionRef { + runId: string; + memberName: string; + runtimeSessionId: string; +} + +export interface OpenCodePermissionAnswerResult { + ok: boolean; + requestId: string; + diagnostics: string[]; +} + +export class RuntimePermissionRequestStore { + constructor(private readonly store: VersionedJsonStore) {} + + async upsertPending( + input: RuntimePermissionRequestRecord + ): Promise<'created' | 'updated' | 'unchanged'> { + let outcome: 'created' | 'updated' | 'unchanged' = 'created'; + await this.store.updateLocked((records) => { + const index = records.findIndex((record) => record.appRequestId === input.appRequestId); + if (index < 0) { + return [...records, input]; + } + + const current = records[index]; + if (current.state === 'answered') { + if (current.answerOrigin !== 'provider_side_effect_projection') { + outcome = 'unchanged'; + return records; + } + + const reopened = { + ...current, + ...input, + requestedAt: current.requestedAt, + answeredAt: null, + decision: null, + answerOrigin: null, + lastError: null, + }; + outcome = + stablePermissionRecordJson(current) === stablePermissionRecordJson(reopened) + ? 'unchanged' + : 'updated'; + return records.map((record, recordIndex) => (recordIndex === index ? reopened : record)); + } + + const next = { + ...current, + ...input, + requestedAt: current.requestedAt, + answeredAt: current.answeredAt, + decision: current.decision, + answerOrigin: current.answerOrigin, + lastError: null, + }; + outcome = + stablePermissionRecordJson(current) === stablePermissionRecordJson(next) + ? 'unchanged' + : 'updated'; + return records.map((record, recordIndex) => (recordIndex === index ? next : record)); + }); + return outcome; + } + + async beginAnswer(input: { + appRequestId: string; + runId: string; + now: string; + }): Promise< + | { state: 'locked'; record: RuntimePermissionRequestRecord } + | { state: 'missing' } + | { state: 'stale_run'; record: RuntimePermissionRequestRecord } + | { state: 'already_answered'; record: RuntimePermissionRequestRecord } + | { state: 'already_answering'; record: RuntimePermissionRequestRecord } + > { + let result: + | { state: 'locked'; record: RuntimePermissionRequestRecord } + | { state: 'missing' } + | { state: 'stale_run'; record: RuntimePermissionRequestRecord } + | { state: 'already_answered'; record: RuntimePermissionRequestRecord } + | { state: 'already_answering'; record: RuntimePermissionRequestRecord } + | null = null; + + await this.store.updateLocked((records) => { + const existing = records.find((record) => record.appRequestId === input.appRequestId); + if (!existing) { + result = { state: 'missing' }; + return records; + } + + if (existing.runId !== input.runId) { + result = { state: 'stale_run', record: existing }; + return records; + } + + if (existing.state === 'answered') { + result = { state: 'already_answered', record: existing }; + return records; + } + + if (existing.state === 'answering') { + result = { state: 'already_answering', record: existing }; + return records; + } + + const locked = { + ...existing, + state: 'answering' as const, + updatedAt: input.now, + lastError: null, + }; + result = { state: 'locked', record: locked }; + return records.map((record) => + record.appRequestId === input.appRequestId ? locked : record + ); + }); + + if (!result) { + throw new Error('Runtime permission begin answer failed'); + } + return result; + } + + async markAnsweredWithSideEffects(input: { + appRequestId: string; + decision: OpenCodePermissionDecision; + answeredAt: string; + }): Promise { + let result: RuntimePermissionAnswerProjectionResult | null = null; + await this.store.updateLocked((records) => { + const clicked = records.find((record) => record.appRequestId === input.appRequestId); + if (!clicked) { + throw new Error(`Runtime permission request not found: ${input.appRequestId}`); + } + + const affectedAppRequestIds = new Set([input.appRequestId]); + const sideEffects: OpenCodePermissionReplySideEffect[] = [ + { + kind: 'answered_clicked_request', + appRequestId: clicked.appRequestId, + providerRequestId: clicked.providerRequestId, + decision: input.decision, + }, + ]; + + const nextRecords = records.map((record) => { + if (record.appRequestId === input.appRequestId) { + return answerPermissionRecord({ + record, + decision: input.decision, + answeredAt: input.answeredAt, + answerOrigin: 'user_click', + }); + } + + if (!isProjectableProviderSideEffectPeer(clicked, record)) { + return record; + } + + if (input.decision === 'reject') { + affectedAppRequestIds.add(record.appRequestId); + sideEffects.push({ + kind: 'reject_cancelled_same_session', + appRequestId: record.appRequestId, + providerRequestId: record.providerRequestId, + decision: 'reject', + }); + return answerPermissionRecord({ + record, + decision: 'reject', + answeredAt: input.answeredAt, + answerOrigin: 'provider_side_effect_projection', + }); + } + + if (input.decision === 'always') { + const matchedPatterns = findAlwaysProjectionMatches(clicked, record); + if (matchedPatterns.length === 0) { + return record; + } + + affectedAppRequestIds.add(record.appRequestId); + sideEffects.push({ + kind: 'always_auto_allowed_same_session', + appRequestId: record.appRequestId, + providerRequestId: record.providerRequestId, + decision: 'always', + matchedPatterns, + }); + return answerPermissionRecord({ + record, + decision: 'always', + answeredAt: input.answeredAt, + answerOrigin: 'provider_side_effect_projection', + }); + } + + return record; + }); + + result = { + affectedAppRequestIds: [...affectedAppRequestIds], + sideEffects, + }; + return nextRecords; + }); + + if (!result) { + throw new Error('Runtime permission answer projection failed'); + } + return result; + } + + async markFailed(input: { + appRequestId: string; + state: 'failed_retryable' | 'failed_terminal' | 'provider_missing'; + error: string; + updatedAt: string; + }): Promise { + await this.updateExisting(input.appRequestId, (record) => ({ + ...record, + state: input.state, + updatedAt: input.updatedAt, + lastError: input.error, + })); + } + + async expireMissingProviderRequests(input: { + runId: string; + teamName: string; + visibleProviderRequestIds: Set; + now: string; + }): Promise { + const expired: string[] = []; + await this.store.updateLocked((records) => + records.map((record) => { + if ( + record.runId !== input.runId || + record.teamName !== input.teamName || + record.state !== 'pending' || + input.visibleProviderRequestIds.has(record.providerRequestId) + ) { + return record; + } + expired.push(record.appRequestId); + return { + ...record, + state: 'provider_missing' as const, + updatedAt: input.now, + lastError: 'Provider no longer lists this permission request', + }; + }) + ); + return expired; + } + + async listPendingForTeam(teamName: string): Promise { + const records = await this.readRequired(); + return records.filter((record) => record.teamName === teamName && record.state === 'pending'); + } + + async get(appRequestId: string): Promise { + const records = await this.readRequired(); + return records.find((record) => record.appRequestId === appRequestId) ?? null; + } + + async list(): Promise { + return this.readRequired(); + } + + private async updateExisting( + appRequestId: string, + updater: (record: RuntimePermissionRequestRecord) => RuntimePermissionRequestRecord + ): Promise { + let found = false; + await this.store.updateLocked((records) => + records.map((record) => { + if (record.appRequestId !== appRequestId) { + return record; + } + found = true; + return updater(record); + }) + ); + + if (!found) { + throw new Error(`Runtime permission request not found: ${appRequestId}`); + } + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export class RuntimePermissionAnswerService { + constructor( + private readonly store: RuntimePermissionRequestStore, + private readonly launchStateStore: RuntimePermissionLaunchStateStore, + private readonly openCodeClient: OpenCodePermissionClientPort, + private readonly diagnostics: RuntimePermissionDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async answer(input: { + appRequestId: string; + runId: string; + decision: OpenCodePermissionDecision; + message?: string; + }): Promise { + const now = this.clock().toISOString(); + const begin = await this.store.beginAnswer({ + appRequestId: input.appRequestId, + runId: input.runId, + now, + }); + + if (begin.state === 'missing') { + return { + ok: false, + requestId: input.appRequestId, + diagnostics: ['Permission request not found'], + }; + } + if (begin.state === 'stale_run') { + await this.diagnostics.append({ + type: 'opencode_permission_stale_answer_rejected', + providerId: 'opencode', + teamName: begin.record.teamName, + runId: input.runId, + severity: 'warning', + message: 'OpenCode permission answer rejected because request belongs to another run', + data: { appRequestId: input.appRequestId, requestRunId: begin.record.runId }, + createdAt: now, + }); + return { ok: false, requestId: input.appRequestId, diagnostics: ['Stale runId rejected'] }; + } + if (begin.state === 'already_answered') { + return { + ok: true, + requestId: input.appRequestId, + diagnostics: ['Permission already answered'], + }; + } + if (begin.state === 'already_answering') { + return { + ok: false, + requestId: input.appRequestId, + diagnostics: ['Permission answer already in progress'], + }; + } + + const record = begin.record; + const launchState = await this.launchStateStore.read(record.teamName); + if (launchState?.runId !== record.runId) { + await this.store.markFailed({ + appRequestId: record.appRequestId, + state: 'failed_terminal', + error: 'Launch state moved to another run before permission answer', + updatedAt: now, + }); + return { + ok: false, + requestId: record.appRequestId, + diagnostics: ['Launch state moved to another run'], + }; + } + + try { + await this.openCodeClient.answerPermission({ + requestId: record.providerRequestId, + sessionId: record.runtimeSessionId, + decision: input.decision, + message: input.message, + }); + const answeredAt = this.clock().toISOString(); + await this.store.markAnsweredWithSideEffects({ + appRequestId: record.appRequestId, + decision: input.decision, + answeredAt, + }); + const remainingMemberPendingIds = (await this.store.listPendingForTeam(record.teamName)) + .filter( + (pendingRecord) => + pendingRecord.runId === record.runId && pendingRecord.memberName === record.memberName + ) + .map((pendingRecord) => pendingRecord.appRequestId); + await this.launchStateStore.updateMember(record.teamName, record.memberName, (member) => ({ + ...member, + pendingPermissionRequestIds: remainingMemberPendingIds, + lastRuntimeEventAt: answeredAt, + })); + return { ok: true, requestId: record.appRequestId, diagnostics: [] }; + } catch (error) { + await this.store.markFailed({ + appRequestId: record.appRequestId, + state: 'failed_retryable', + error: stringifyError(error), + updatedAt: this.clock().toISOString(), + }); + await this.diagnostics.append({ + type: 'opencode_permission_answer_failed', + providerId: 'opencode', + teamName: record.teamName, + runId: record.runId, + severity: 'warning', + message: 'OpenCode permission answer failed and remains retryable', + data: { appRequestId: record.appRequestId, error: stringifyError(error) }, + createdAt: this.clock().toISOString(), + }); + throw error; + } + } +} + +export class RuntimePermissionReconciler { + constructor( + private readonly client: OpenCodePermissionClientPort, + private readonly store: RuntimePermissionRequestStore, + private readonly launchStateStore: RuntimePermissionLaunchStateStore, + private readonly diagnostics: RuntimePermissionDiagnosticsSink, + private readonly clock: () => Date = () => new Date() + ) {} + + async reconcile(input: { + runId: string; + teamName: string; + sessionsByOpenCodeId: Map; + }): Promise { + const now = this.clock().toISOString(); + const pending = await this.client.listPendingPermissions(); + const visibleProviderRequestIds = new Set(); + const pendingByMember = new Map(); + + for (const permission of pending) { + visibleProviderRequestIds.add(permission.requestId); + const session = input.sessionsByOpenCodeId.get(permission.sessionId); + if (!session || session.runId !== input.runId) { + await this.diagnostics.append({ + type: 'opencode_permission_unmatched_session', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId, + severity: 'warning', + message: 'OpenCode permission request did not match a current runtime session', + data: { providerRequestId: permission.requestId, sessionId: permission.sessionId }, + createdAt: now, + }); + continue; + } + + const appRequestId = createOpenCodePermissionAppRequestId(input.runId, permission.requestId); + await this.store.upsertPending({ + appRequestId, + providerRequestId: permission.requestId, + runId: input.runId, + teamName: input.teamName, + memberName: session.memberName, + providerId: 'opencode', + runtimeSessionId: permission.sessionId, + permission: permission.permission, + patterns: permission.patterns, + alwaysPatterns: permission.alwaysPatterns, + toolName: permission.toolName, + title: permission.title, + description: permission.description, + state: 'pending', + rawShape: permission.rawShape, + requestedAt: now, + updatedAt: now, + expiresAt: new Date(Date.parse(now) + 15 * 60_000).toISOString(), + answeredAt: null, + decision: null, + answerOrigin: null, + lastError: null, + }); + pendingByMember.set(session.memberName, [ + ...(pendingByMember.get(session.memberName) ?? []), + appRequestId, + ]); + } + + const expired = await this.store.expireMissingProviderRequests({ + runId: input.runId, + teamName: input.teamName, + visibleProviderRequestIds, + now, + }); + if (expired.length > 0) { + await this.diagnostics.append({ + type: 'opencode_permission_requests_expired', + providerId: 'opencode', + teamName: input.teamName, + runId: input.runId, + severity: 'info', + message: 'OpenCode permission requests disappeared from provider and were expired locally', + data: { expiredCount: expired.length }, + createdAt: now, + }); + } + + for (const [memberName, requestIds] of pendingByMember) { + await this.launchStateStore.updateMember(input.teamName, memberName, (member) => ({ + ...member, + launchState: + member.launchState === 'confirmed_alive' + ? member.launchState + : 'runtime_pending_permission', + pendingPermissionRequestIds: [...new Set(requestIds)], + lastRuntimeEventAt: now, + })); + } + } +} + +export function createRuntimePermissionRequestStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimePermissionRequestStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimePermissionRequestStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimePermissionRequestRecords, + clock, + }) + ); +} + +export function normalizeOpenCodePermissionRequest( + raw: OpenCodeRawPermissionRequest +): OpenCodeNormalizedPermissionRequest | null { + const requestId = asString(raw.id) ?? asString(raw.requestID); + const sessionId = asString(raw.sessionID); + if (!requestId || !sessionId) { + return null; + } + + const toolObject = isRecord(raw.tool) ? raw.tool : null; + const legacyToolName = asString(raw.tool); + const permission = asString(raw.permission) ?? asString(raw.kind) ?? legacyToolName ?? 'unknown'; + const patterns = asStringArray(raw.patterns); + const alwaysPatterns = asStringArray(raw.always); + const metadata = asRecord(raw.metadata); + const toolName = + legacyToolName ?? asString(toolObject?.name) ?? asString(metadata.toolName) ?? permission; + const messageId = asString(toolObject?.messageID) ?? asString(metadata.messageID); + const toolCallId = asString(toolObject?.callID) ?? asString(metadata.callID); + + return { + requestId, + sessionId, + permission, + patterns, + alwaysPatterns, + toolName, + toolCallId, + messageId, + title: asString(raw.title) ?? buildOpenCodePermissionTitle({ permission, toolName, patterns }), + description: + asString(raw.kind) ?? + buildOpenCodePermissionDescription({ patterns, alwaysPatterns, metadata }), + metadata, + rawShape: detectPermissionRawShape(raw), + raw, + }; +} + +export function createOpenCodePermissionAppRequestId( + runId: string, + providerRequestId: string +): string { + return `opencode:${runId}:${providerRequestId}`; +} + +export function validateRuntimePermissionRequestRecords( + value: unknown +): RuntimePermissionRequestRecord[] { + if (!Array.isArray(value)) { + throw new Error('Runtime permission requests must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimePermissionRequestRecord(record)) { + throw new Error(`Invalid runtime permission request at index ${index}`); + } + const normalized = normalizeRuntimePermissionRequestRecord(record); + if (seen.has(normalized.appRequestId)) { + throw new Error(`Duplicate runtime permission request id: ${normalized.appRequestId}`); + } + seen.add(normalized.appRequestId); + return normalized; + }); +} + +function detectPermissionRawShape( + raw: OpenCodeRawPermissionRequest +): OpenCodeNormalizedPermissionRequest['rawShape'] { + const hasV114Fields = + typeof raw.id === 'string' || typeof raw.permission === 'string' || Array.isArray(raw.patterns); + const hasLegacyFields = + typeof raw.requestID === 'string' || + typeof raw.title === 'string' || + typeof raw.kind === 'string'; + if (hasV114Fields && hasLegacyFields) { + return 'mixed'; + } + if (hasV114Fields) { + return 'v1.14'; + } + return 'legacy'; +} + +function buildOpenCodePermissionTitle(input: { + permission: string; + toolName: string; + patterns: string[]; +}): string { + if (input.patterns.length > 0) { + return `OpenCode wants ${input.permission} permission for ${input.patterns[0]}`; + } + if (input.toolName !== 'unknown') { + return `OpenCode wants to use ${input.toolName}`; + } + return `OpenCode permission request: ${input.permission}`; +} + +function buildOpenCodePermissionDescription(input: { + patterns: string[]; + alwaysPatterns: string[]; + metadata: Record; +}): string | null { + const parts: string[] = []; + if (input.patterns.length > 0) { + parts.push(`Patterns: ${input.patterns.join(', ')}`); + } + if (input.alwaysPatterns.length > 0) { + parts.push(`Always candidates: ${input.alwaysPatterns.join(', ')}`); + } + const reason = asString(input.metadata.reason); + if (reason) { + parts.push(`Reason: ${reason}`); + } + return parts.length > 0 ? parts.join('\n') : null; +} + +function answerPermissionRecord(input: { + record: RuntimePermissionRequestRecord; + decision: OpenCodePermissionDecision; + answeredAt: string; + answerOrigin: RuntimePermissionAnswerOrigin; +}): RuntimePermissionRequestRecord { + return { + ...input.record, + state: 'answered', + answeredAt: input.answeredAt, + decision: input.decision, + answerOrigin: input.answerOrigin, + updatedAt: input.answeredAt, + lastError: null, + }; +} + +function isProjectableProviderSideEffectPeer( + clicked: RuntimePermissionRequestRecord, + candidate: RuntimePermissionRequestRecord +): boolean { + return ( + candidate.appRequestId !== clicked.appRequestId && + candidate.providerId === 'opencode' && + candidate.runId === clicked.runId && + candidate.teamName === clicked.teamName && + candidate.runtimeSessionId === clicked.runtimeSessionId && + candidate.state === 'pending' + ); +} + +function findAlwaysProjectionMatches( + clicked: RuntimePermissionRequestRecord, + candidate: RuntimePermissionRequestRecord +): string[] { + const allowedPatterns = new Set([...clicked.alwaysPatterns, ...clicked.patterns]); + if (allowedPatterns.size === 0) { + return []; + } + return [...new Set(candidate.patterns.filter((pattern) => allowedPatterns.has(pattern)))]; +} + +function normalizeRuntimePermissionRequestRecord( + record: RuntimePermissionRequestRecord +): RuntimePermissionRequestRecord { + return { + ...record, + permission: isNonEmptyString(record.permission) ? record.permission : record.toolName, + patterns: isStringArray(record.patterns) ? record.patterns : [], + alwaysPatterns: isStringArray(record.alwaysPatterns) ? record.alwaysPatterns : [], + answerOrigin: isRuntimePermissionAnswerOrigin(record.answerOrigin) ? record.answerOrigin : null, + }; +} + +function isRuntimePermissionRequestRecord(value: unknown): value is RuntimePermissionRequestRecord { + return ( + isRecord(value) && + isNonEmptyString(value.appRequestId) && + isNonEmptyString(value.providerRequestId) && + isNonEmptyString(value.runId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.memberName) && + value.providerId === 'opencode' && + isNonEmptyString(value.runtimeSessionId) && + (value.permission === undefined || isNonEmptyString(value.permission)) && + (value.patterns === undefined || isStringArray(value.patterns)) && + (value.alwaysPatterns === undefined || isStringArray(value.alwaysPatterns)) && + isNonEmptyString(value.toolName) && + isNonEmptyString(value.title) && + (value.description === null || typeof value.description === 'string') && + isRuntimePermissionState(value.state) && + (value.rawShape === 'v1.14' || value.rawShape === 'legacy' || value.rawShape === 'mixed') && + isNonEmptyString(value.requestedAt) && + isNonEmptyString(value.updatedAt) && + isNonEmptyString(value.expiresAt) && + (value.answeredAt === null || isNonEmptyString(value.answeredAt)) && + (value.decision === null || isOpenCodePermissionDecision(value.decision)) && + (value.answerOrigin === undefined || + value.answerOrigin === null || + isRuntimePermissionAnswerOrigin(value.answerOrigin)) && + (value.lastError === null || typeof value.lastError === 'string') + ); +} + +function isRuntimePermissionState(value: unknown): value is RuntimePermissionState { + return ( + value === 'pending' || + value === 'answering' || + value === 'answered' || + value === 'expired' || + value === 'stale_run' || + value === 'provider_missing' || + value === 'failed_retryable' || + value === 'failed_terminal' + ); +} + +function isOpenCodePermissionDecision(value: unknown): value is OpenCodePermissionDecision { + return value === 'once' || value === 'always' || value === 'reject'; +} + +function isRuntimePermissionAnswerOrigin(value: unknown): value is RuntimePermissionAnswerOrigin { + return value === 'user_click' || value === 'provider_side_effect_projection'; +} + +function stablePermissionRecordJson(value: RuntimePermissionRequestRecord): string { + return JSON.stringify(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts new file mode 100644 index 00000000..19da9a8c --- /dev/null +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -0,0 +1,378 @@ +import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; +import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; +import { + evaluateOpenCodeSupport, + OPENCODE_TEAM_LAUNCH_VERSION_POLICY, + type OpenCodeInstallMethod, + type OpenCodeProductionE2EEvidence, + type OpenCodeSupportLevel, + type OpenCodeSupportedVersionPolicy, +} from '../version/OpenCodeVersionPolicy'; +import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; + +export type OpenCodeTeamLaunchReadinessState = + | 'ready' + | 'not_installed' + | 'not_authenticated' + | 'unsupported_version' + | 'capabilities_missing' + | 'e2e_missing' + | 'runtime_store_blocked' + | 'mcp_unavailable' + | 'model_unavailable' + | 'adapter_disabled' + | 'unknown_error'; + +export interface OpenCodeRuntimeInventory { + detected: boolean; + binaryPath: string | null; + installMethod: OpenCodeInstallMethod; + version: string | null; + authenticated: boolean; + connectedProviders: string[]; + models: string[]; + diagnostics: string[]; +} + +export interface OpenCodeModelExecutionProbeResult { + outcome: 'available' | 'unavailable' | 'unknown'; + reason: string | null; + diagnostics: string[]; +} + +export interface OpenCodeTeamLaunchReadiness { + state: OpenCodeTeamLaunchReadinessState; + launchAllowed: boolean; + modelId: string | null; + opencodeVersion: string | null; + installMethod: OpenCodeInstallMethod | null; + binaryPath: string | null; + hostHealthy: boolean; + appMcpConnected: boolean; + requiredToolsPresent: boolean; + permissionBridgeReady: boolean; + runtimeStoresReady: boolean; + supportLevel: OpenCodeSupportLevel | null; + missing: string[]; + diagnostics: string[]; + evidence: { + capabilitiesReady: boolean; + mcpToolProofRoute: OpenCodeMcpToolProof['route']; + observedMcpTools: string[]; + runtimeStoreReadinessReason: RuntimeStoreReadinessCheck['reason'] | null; + }; +} + +export interface OpenCodeRuntimeInventoryPort { + probe(input: { projectPath: string }): Promise; +} + +export interface OpenCodeApiCapabilityPort { + detect(input: { + projectPath: string; + inventory: OpenCodeRuntimeInventory; + }): Promise; +} + +export interface OpenCodeMcpToolProofPort { + prove(input: { + projectPath: string; + modelId: string; + inventory: OpenCodeRuntimeInventory; + capabilities: OpenCodeApiCapabilities; + }): Promise; +} + +export interface OpenCodeRuntimeStoreReadinessPort { + check(input: { projectPath: string }): Promise; +} + +export interface OpenCodeModelExecutionProbePort { + verify(input: { + projectPath: string; + modelId: string; + inventory: OpenCodeRuntimeInventory; + }): Promise; +} + +export interface OpenCodeProductionE2EEvidencePort { + read(input: { + projectPath: string; + inventory: OpenCodeRuntimeInventory; + capabilities: OpenCodeApiCapabilities; + }): Promise; +} + +export interface OpenCodeTeamLaunchReadinessServiceOptions { + versionPolicy?: OpenCodeSupportedVersionPolicy; + launchMode?: OpenCodeTeamLaunchMode; + /** + * @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate. + */ + adapterEnabled?: boolean; +} + +export class OpenCodeTeamLaunchReadinessService { + constructor( + private readonly inventory: OpenCodeRuntimeInventoryPort, + private readonly capabilities: OpenCodeApiCapabilityPort, + private readonly mcpTools: OpenCodeMcpToolProofPort, + private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort, + private readonly modelExecution: OpenCodeModelExecutionProbePort, + private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort, + private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {} + ) {} + + async check(input: { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; + }): Promise { + const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options); + const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; + const dogfoodWarnings: string[] = []; + + if (launchMode === 'disabled') { + return readiness({ + state: 'adapter_disabled', + inventory: null, + modelId: input.selectedModel, + missing: ['OpenCode team launch adapter is disabled by feature gate'], + diagnostics: ['OpenCode team launch adapter is disabled by feature gate'], + }); + } + + try { + const inventory = await this.inventory.probe({ projectPath: input.projectPath }); + if (!inventory.detected) { + return readiness({ + state: 'not_installed', + inventory, + modelId: input.selectedModel, + diagnostics: appendDiagnostics(inventory.diagnostics, [ + 'OpenCode CLI not detected on PATH', + ]), + }); + } + + if (!inventory.authenticated || inventory.connectedProviders.length === 0) { + return readiness({ + state: 'not_authenticated', + inventory, + modelId: input.selectedModel, + diagnostics: appendDiagnostics(inventory.diagnostics, [ + 'No connected OpenCode providers found', + ]), + }); + } + + const modelId = input.selectedModel ?? inventory.models[0] ?? null; + if (!modelId) { + return readiness({ + state: 'model_unavailable', + inventory, + modelId: null, + diagnostics: appendDiagnostics(inventory.diagnostics, ['No OpenCode model is available']), + }); + } + + const capabilities = await this.capabilities.detect({ + projectPath: input.projectPath, + inventory, + }); + const evidence = await this.e2eEvidence.read({ + projectPath: input.projectPath, + inventory, + capabilities, + }); + const support = evaluateOpenCodeSupport({ + version: inventory.version ?? '0.0.0', + capabilities, + evidence, + policy, + }); + + if (!support.supported) { + if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') { + dogfoodWarnings.push( + 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.' + ); + } else { + return readiness({ + state: mapSupportLevelToReadinessState(support.supportLevel), + inventory, + modelId, + capabilities, + supportLevel: support.supportLevel, + missing: support.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), + }); + } + } + + const runtimeStoreReadiness = await this.runtimeStores.check({ + projectPath: input.projectPath, + }); + if (!runtimeStoreReadiness.ok) { + return readiness({ + state: 'runtime_store_blocked', + inventory, + modelId, + capabilities, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: runtimeStoreReadiness.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, runtimeStoreReadiness.diagnostics), + }); + } + + const toolProof = await this.mcpTools.prove({ + projectPath: input.projectPath, + modelId, + inventory, + capabilities, + }); + if (!toolProof.ok) { + return readiness({ + state: 'mcp_unavailable', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: toolProof.missingTools, + diagnostics: appendDiagnostics(inventory.diagnostics, toolProof.diagnostics), + }); + } + + if (input.requireExecutionProbe) { + const modelProbe = await this.modelExecution.verify({ + projectPath: input.projectPath, + modelId, + inventory, + }); + if (modelProbe.outcome !== 'available') { + return readiness({ + state: 'model_unavailable', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + missing: [modelProbe.reason ?? 'OpenCode selected model execution is unavailable'], + diagnostics: appendDiagnostics(inventory.diagnostics, modelProbe.diagnostics), + }); + } + } + + return readiness({ + state: 'ready', + inventory, + modelId, + capabilities, + toolProof, + runtimeStoreReadiness, + supportLevel: support.supportLevel, + launchAllowed: true, + diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings), + }); + } catch (error) { + return readiness({ + state: 'unknown_error', + inventory: null, + modelId: input.selectedModel, + diagnostics: [`OpenCode readiness check failed: ${stringifyError(error)}`], + }); + } + } +} + +function resolveReadinessLaunchMode( + requested: OpenCodeTeamLaunchMode | undefined, + options: OpenCodeTeamLaunchReadinessServiceOptions +): OpenCodeTeamLaunchMode { + if (requested) { + return requested; + } + if (options.launchMode) { + return options.launchMode; + } + if (options.adapterEnabled === true) { + return 'production'; + } + return 'disabled'; +} + +function readiness(input: { + state: OpenCodeTeamLaunchReadinessState; + inventory: OpenCodeRuntimeInventory | null; + modelId: string | null; + capabilities?: OpenCodeApiCapabilities; + toolProof?: OpenCodeMcpToolProof; + runtimeStoreReadiness?: RuntimeStoreReadinessCheck; + supportLevel?: OpenCodeSupportLevel | null; + launchAllowed?: boolean; + missing?: string[]; + diagnostics: string[]; +}): OpenCodeTeamLaunchReadiness { + const toolProof = input.toolProof ?? null; + const capabilitiesReady = input.capabilities?.requiredForTeamLaunch.ready === true; + + return { + state: input.state, + launchAllowed: input.launchAllowed === true, + modelId: input.modelId, + opencodeVersion: input.inventory?.version ?? null, + installMethod: input.inventory?.installMethod ?? null, + binaryPath: input.inventory?.binaryPath ?? null, + hostHealthy: input.inventory?.detected === true, + appMcpConnected: toolProof !== null, + requiredToolsPresent: toolProof?.ok === true, + permissionBridgeReady: + input.capabilities?.endpoints.permissionList === true && + (input.capabilities.endpoints.permissionReply === true || + input.capabilities.endpoints.permissionLegacySessionRespond === true), + runtimeStoresReady: input.runtimeStoreReadiness?.ok === true, + supportLevel: input.supportLevel ?? null, + missing: dedupe(input.missing ?? []), + diagnostics: dedupe(input.diagnostics), + evidence: { + capabilitiesReady, + mcpToolProofRoute: toolProof?.route ?? null, + observedMcpTools: toolProof?.observedTools ?? [], + runtimeStoreReadinessReason: input.runtimeStoreReadiness?.reason ?? null, + }, + }; +} + +function mapSupportLevelToReadinessState( + supportLevel: OpenCodeSupportLevel +): OpenCodeTeamLaunchReadinessState { + switch (supportLevel) { + case 'unsupported_too_old': + case 'unsupported_prerelease': + return 'unsupported_version'; + case 'supported_capabilities_pending': + return 'capabilities_missing'; + case 'supported_e2e_pending': + return 'e2e_missing'; + case 'production_supported': + return 'ready'; + } +} + +function appendDiagnostics(left: string[], right: string[]): string[] { + return dedupe([...left, ...right]); +} + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts new file mode 100644 index 00000000..44e7783e --- /dev/null +++ b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts @@ -0,0 +1,399 @@ +import { createHash } from 'crypto'; + +import { stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract'; +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION = 1; + +export type OpenCodeLaunchCheckpointName = + | 'run_created' + | 'host_ready' + | 'lead_session_recorded' + | 'member_session_recorded' + | 'mcp_connected' + | 'required_tools_proven' + | 'prompt_sent' + | 'bootstrap_confirmed' + | 'permission_blocked' + | 'delivery_ready' + | 'member_ready' + | 'run_ready' + | 'run_failed' + | 'run_cancelled'; + +export interface OpenCodeLaunchCheckpoint { + name: OpenCodeLaunchCheckpointName; + teamName: string; + runId: string; + memberName: string | null; + runtimeSessionId: string | null; + hostKey: string | null; + evidenceHash: string; + createdAt: string; + diagnostics: string[]; +} + +export interface OpenCodeLaunchTransaction { + teamName: string; + runId: string; + providerId: 'opencode'; + startedAt: string; + updatedAt: string; + status: 'active' | 'ready' | 'failed' | 'cancelled' | 'reconciled'; + checkpoints: OpenCodeLaunchCheckpoint[]; +} + +export interface OpenCodeRunReadyInput { + members: Array<{ name: string; launchState?: string }>; + transaction: OpenCodeLaunchTransaction; + toolProof: { ok: boolean }; + deliveryReady: boolean; +} + +export class OpenCodeLaunchTransactionStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async beginRun(input: { + teamName: string; + runId: string; + startedAt?: string; + }): Promise< + | { state: 'created'; transaction: OpenCodeLaunchTransaction } + | { state: 'already_active'; transaction: OpenCodeLaunchTransaction } + > { + let result: + | { state: 'created'; transaction: OpenCodeLaunchTransaction } + | { state: 'already_active'; transaction: OpenCodeLaunchTransaction } + | null = null; + const startedAt = input.startedAt ?? this.clock().toISOString(); + + await this.store.updateLocked((transactions) => { + const active = transactions.find( + (transaction) => transaction.teamName === input.teamName && transaction.status === 'active' + ); + if (active) { + result = { state: 'already_active', transaction: active }; + return transactions; + } + + const transaction: OpenCodeLaunchTransaction = { + teamName: input.teamName, + runId: input.runId, + providerId: 'opencode', + startedAt, + updatedAt: startedAt, + status: 'active', + checkpoints: [], + }; + result = { state: 'created', transaction }; + return [...transactions, transaction]; + }); + + if (!result) { + throw new Error('OpenCode launch transaction begin failed'); + } + return result; + } + + async addCheckpoint(input: OpenCodeLaunchCheckpoint): Promise<'created' | 'unchanged'> { + let outcome: 'created' | 'unchanged' = 'created'; + await this.store.updateLocked((transactions) => + transactions.map((transaction) => { + if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) { + return transaction; + } + + if (transaction.status !== 'active') { + throw new Error(`OpenCode launch transaction is not active: ${input.runId}`); + } + + const duplicate = transaction.checkpoints.some( + (checkpoint) => + checkpoint.name === input.name && + checkpoint.memberName === input.memberName && + checkpoint.evidenceHash === input.evidenceHash + ); + if (duplicate) { + outcome = 'unchanged'; + return transaction; + } + + return { + ...transaction, + updatedAt: input.createdAt, + checkpoints: [...transaction.checkpoints, normalizeCheckpoint(input)], + }; + }) + ); + + if (!(await this.hasTransaction(input.teamName, input.runId))) { + throw new Error(`OpenCode launch transaction not found: ${input.runId}`); + } + + return outcome; + } + + async hasCheckpoint(input: { + teamName: string; + runId: string; + memberName: string | null; + name: OpenCodeLaunchCheckpointName; + evidenceHash?: string; + }): Promise { + const transaction = await this.read(input.teamName, input.runId); + return ( + transaction?.checkpoints.some( + (checkpoint) => + checkpoint.name === input.name && + checkpoint.memberName === input.memberName && + (input.evidenceHash === undefined || checkpoint.evidenceHash === input.evidenceHash) + ) ?? false + ); + } + + async readActive(teamName: string): Promise { + const transactions = await this.readRequired(); + return ( + transactions.find( + (transaction) => transaction.teamName === teamName && transaction.status === 'active' + ) ?? null + ); + } + + async read(teamName: string, runId: string): Promise { + const transactions = await this.readRequired(); + return ( + transactions.find( + (transaction) => transaction.teamName === teamName && transaction.runId === runId + ) ?? null + ); + } + + async finish(input: { + teamName: string; + runId: string; + status: 'ready' | 'failed' | 'cancelled' | 'reconciled'; + updatedAt?: string; + }): Promise<'finished' | 'unchanged'> { + let found = false; + let outcome: 'finished' | 'unchanged' = 'finished'; + const updatedAt = input.updatedAt ?? this.clock().toISOString(); + + await this.store.updateLocked((transactions) => + transactions.map((transaction) => { + if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) { + return transaction; + } + found = true; + if (transaction.status !== 'active') { + outcome = 'unchanged'; + return transaction; + } + return { + ...transaction, + status: input.status, + updatedAt, + }; + }) + ); + + if (!found) { + const active = await this.readActive(input.teamName); + if (active) { + throw new Error( + `OpenCode launch transaction ${input.runId} is stale; active run is ${active.runId}` + ); + } + throw new Error(`OpenCode launch transaction not found: ${input.runId}`); + } + + return outcome; + } + + async list(): Promise { + return this.readRequired(); + } + + private async hasTransaction(teamName: string, runId: string): Promise { + return (await this.read(teamName, runId)) !== null; + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function canMarkOpenCodeRunReady(input: OpenCodeRunReadyInput): { + ok: boolean; + missing: string[]; +} { + const missing: string[] = []; + + for (const member of input.members) { + if (!hasMemberCheckpoint(input.transaction, member.name, 'member_session_recorded')) { + missing.push(`${member.name}:member_session_recorded`); + } + if (!hasMemberCheckpoint(input.transaction, member.name, 'required_tools_proven')) { + missing.push(`${member.name}:required_tools_proven`); + } + if (member.launchState !== 'confirmed_alive') { + missing.push(`${member.name}:bootstrap_confirmed`); + } + } + + if (!input.toolProof.ok) { + missing.push('required_runtime_tools'); + } + if (!input.deliveryReady) { + missing.push('runtime_delivery_service'); + } + + return { + ok: missing.length === 0, + missing, + }; +} + +export function hasMemberCheckpoint( + transaction: OpenCodeLaunchTransaction, + memberName: string, + name: OpenCodeLaunchCheckpointName +): boolean { + return transaction.checkpoints.some( + (checkpoint) => checkpoint.memberName === memberName && checkpoint.name === name + ); +} + +export function createOpenCodeLaunchEvidenceHash(evidence: unknown): string { + return `sha256:${createHash('sha256') + .update(stableJsonStringify(redactOpenCodeLaunchEvidence(evidence))) + .digest('hex')}`; +} + +export function redactOpenCodeLaunchEvidence(evidence: unknown): unknown { + if (evidence === null || typeof evidence !== 'object') { + return evidence; + } + + if (Array.isArray(evidence)) { + return evidence.map(redactOpenCodeLaunchEvidence); + } + + const output: Record = {}; + for (const [key, value] of Object.entries(evidence)) { + if (/token|secret|password|api[_-]?key|authorization/i.test(key)) { + output[key] = '[redacted]'; + } else { + output[key] = redactOpenCodeLaunchEvidence(value); + } + } + return output; +} + +export function createOpenCodeLaunchTransactionStore(options: { + filePath: string; + clock?: () => Date; +}): OpenCodeLaunchTransactionStore { + const clock = options.clock ?? (() => new Date()); + return new OpenCodeLaunchTransactionStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION, + defaultData: () => [], + validate: validateOpenCodeLaunchTransactions, + clock, + }), + clock + ); +} + +export function validateOpenCodeLaunchTransactions(value: unknown): OpenCodeLaunchTransaction[] { + if (!Array.isArray(value)) { + throw new Error('OpenCode launch transactions must be an array'); + } + return value.map((transaction, index) => { + if (!isLaunchTransaction(transaction)) { + throw new Error(`Invalid OpenCode launch transaction at index ${index}`); + } + return transaction; + }); +} + +function normalizeCheckpoint(input: OpenCodeLaunchCheckpoint): OpenCodeLaunchCheckpoint { + return { + ...input, + diagnostics: [...input.diagnostics], + }; +} + +function isLaunchTransaction(value: unknown): value is OpenCodeLaunchTransaction { + return ( + isRecord(value) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + value.providerId === 'opencode' && + isNonEmptyString(value.startedAt) && + isNonEmptyString(value.updatedAt) && + (value.status === 'active' || + value.status === 'ready' || + value.status === 'failed' || + value.status === 'cancelled' || + value.status === 'reconciled') && + Array.isArray(value.checkpoints) && + value.checkpoints.every(isLaunchCheckpoint) + ); +} + +function isLaunchCheckpoint(value: unknown): value is OpenCodeLaunchCheckpoint { + return ( + isRecord(value) && + isLaunchCheckpointName(value.name) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + isNullableString(value.memberName) && + isNullableString(value.runtimeSessionId) && + isNullableString(value.hostKey) && + isNonEmptyString(value.evidenceHash) && + isNonEmptyString(value.createdAt) && + Array.isArray(value.diagnostics) && + value.diagnostics.every((item) => typeof item === 'string') + ); +} + +function isLaunchCheckpointName(value: unknown): value is OpenCodeLaunchCheckpointName { + return ( + value === 'run_created' || + value === 'host_ready' || + value === 'lead_session_recorded' || + value === 'member_session_recorded' || + value === 'mcp_connected' || + value === 'required_tools_proven' || + value === 'prompt_sent' || + value === 'bootstrap_confirmed' || + value === 'permission_blocked' || + value === 'delivery_ready' || + value === 'member_ready' || + value === 'run_ready' || + value === 'run_failed' || + value === 'run_cancelled' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts new file mode 100644 index 00000000..ce11bb46 --- /dev/null +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; + +import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; +import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService'; +import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest'; + +export interface OpenCodeRuntimeManifestEvidenceReaderOptions { + teamsBasePath: string; + clock?: () => Date; +} + +const OPENCODE_TEAM_RUNTIME_DIR = '.opencode-runtime'; +const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json'; + +export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader { + private readonly teamsBasePath: string; + private readonly clock: () => Date; + + constructor(options: OpenCodeRuntimeManifestEvidenceReaderOptions) { + this.teamsBasePath = options.teamsBasePath; + this.clock = options.clock ?? (() => new Date()); + } + + async read(teamName: string): Promise { + const manifest = await createRuntimeStoreManifestStore({ + filePath: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName), + teamName, + clock: this.clock, + }).read(); + + return { + highWatermark: manifest.highWatermark, + activeRunId: manifest.activeRunId, + capabilitySnapshotId: manifest.activeCapabilitySnapshotId, + }; + } +} + +export function getOpenCodeTeamRuntimeDirectory(teamsBasePath: string, teamName: string): string { + return path.join(teamsBasePath, teamName, OPENCODE_TEAM_RUNTIME_DIR); +} + +export function getOpenCodeRuntimeManifestPath(teamsBasePath: string, teamName: string): string { + return path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_RUNTIME_MANIFEST_FILE + ); +} diff --git a/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts b/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts new file mode 100644 index 00000000..a31bd4fc --- /dev/null +++ b/src/main/services/team/opencode/store/RuntimeRunTombstoneStore.ts @@ -0,0 +1,313 @@ +import { randomUUID } from 'crypto'; + +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION = 1; + +export type RuntimeEvidenceKind = + | 'sse_event' + | 'permission_reply' + | 'delivery_call' + | 'prompt_error' + | 'bootstrap_checkin' + | 'launch_checkpoint' + | 'heartbeat' + | 'bridge_result' + | 'recovery_result'; + +export type RuntimeRunTombstoneReason = + | 'stop_requested' + | 'relaunch_started' + | 'run_replaced' + | 'provider_session_aborted' + | 'recovery_rejected'; + +export interface RuntimeRunTombstone { + tombstoneId: string; + teamName: string; + runId: string; + reason: RuntimeRunTombstoneReason; + evidenceKinds: RuntimeEvidenceKind[]; + createdAt: string; + expiresAt: string | null; + diagnostic: string | null; +} + +export interface RuntimeEvidenceAcceptanceInput { + teamName: string; + runId: string | null; + currentRunId: string | null; + evidenceKind: RuntimeEvidenceKind; +} + +export class RuntimeStaleEvidenceError extends Error { + constructor( + message: string, + readonly reason: 'missing_run_id' | 'current_run_missing' | 'run_mismatch' | 'run_tombstoned', + readonly evidenceKind: RuntimeEvidenceKind, + readonly runId: string | null + ) { + super(message); + this.name = 'RuntimeStaleEvidenceError'; + } +} + +export class RuntimeRunTombstoneStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly options: { + idFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async add(input: { + teamName: string; + runId: string; + reason: RuntimeRunTombstoneReason; + evidenceKinds?: RuntimeEvidenceKind[]; + ttlMs?: number; + diagnostic?: string | null; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const now = clock(); + let created: RuntimeRunTombstone | null = null; + + await this.store.updateLocked((records) => { + const compacted = compactRuntimeRunTombstones(records, now); + const existing = compacted.find( + (record) => + record.teamName === input.teamName && + record.runId === input.runId && + record.reason === input.reason + ); + if (existing) { + created = existing; + return compacted; + } + + created = { + tombstoneId: this.options.idFactory?.() ?? `opencode-run-tombstone-${randomUUID()}`, + teamName: input.teamName, + runId: input.runId, + reason: input.reason, + evidenceKinds: normalizeEvidenceKinds(input.evidenceKinds), + createdAt: now.toISOString(), + expiresAt: + typeof input.ttlMs === 'number' + ? new Date(now.getTime() + input.ttlMs).toISOString() + : null, + diagnostic: input.diagnostic ?? null, + }; + return [...compacted, created]; + }); + + if (!created) { + throw new Error('Runtime run tombstone was not created'); + } + return created; + } + + async list(teamName: string): Promise { + const records = await this.readRequired(); + const now = (this.options.clock ?? (() => new Date()))(); + return compactRuntimeRunTombstones(records, now).filter( + (record) => record.teamName === teamName + ); + } + + async find(input: { + teamName: string; + runId: string; + evidenceKind?: RuntimeEvidenceKind; + }): Promise { + const records = await this.list(input.teamName); + return ( + records.find( + (record) => + record.runId === input.runId && + (!input.evidenceKind || record.evidenceKinds.includes(input.evidenceKind)) + ) ?? null + ); + } + + async assertEvidenceAccepted(input: RuntimeEvidenceAcceptanceInput): Promise { + assertRuntimeEvidenceRunMatches(input); + const tombstone = input.runId + ? await this.find({ + teamName: input.teamName, + runId: input.runId, + evidenceKind: input.evidenceKind, + }) + : null; + + if (tombstone) { + throw new RuntimeStaleEvidenceError( + `Rejected stale runtime evidence: ${input.evidenceKind}`, + 'run_tombstoned', + input.evidenceKind, + input.runId + ); + } + } + + async compact(): Promise { + const now = (this.options.clock ?? (() => new Date()))(); + let removed = 0; + await this.store.updateLocked((records) => { + const compacted = compactRuntimeRunTombstones(records, now); + removed = records.length - compacted.length; + return compacted; + }); + return removed; + } + + private async readRequired(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } +} + +export function assertRuntimeEvidenceRunMatches(input: RuntimeEvidenceAcceptanceInput): void { + if (!input.runId) { + throw new RuntimeStaleEvidenceError( + `Rejected runtime evidence without run id: ${input.evidenceKind}`, + 'missing_run_id', + input.evidenceKind, + input.runId + ); + } + + if (!input.currentRunId) { + throw new RuntimeStaleEvidenceError( + `Rejected runtime evidence without current run: ${input.evidenceKind}`, + 'current_run_missing', + input.evidenceKind, + input.runId + ); + } + + if (input.runId !== input.currentRunId) { + throw new RuntimeStaleEvidenceError( + `Rejected stale runtime evidence: ${input.evidenceKind}`, + 'run_mismatch', + input.evidenceKind, + input.runId + ); + } +} + +export function createRuntimeRunTombstoneStore(options: { + filePath: string; + idFactory?: () => string; + clock?: () => Date; +}): RuntimeRunTombstoneStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeRunTombstoneStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeRunTombstones, + clock, + }), + { + idFactory: options.idFactory, + clock, + } + ); +} + +export function validateRuntimeRunTombstones(value: unknown): RuntimeRunTombstone[] { + if (!Array.isArray(value)) { + throw new Error('Runtime run tombstones must be an array'); + } + const seen = new Set(); + return value.map((record, index) => { + if (!isRuntimeRunTombstone(record)) { + throw new Error(`Invalid runtime run tombstone at index ${index}`); + } + if (seen.has(record.tombstoneId)) { + throw new Error(`Duplicate runtime run tombstone id: ${record.tombstoneId}`); + } + seen.add(record.tombstoneId); + return record; + }); +} + +export function compactRuntimeRunTombstones( + records: RuntimeRunTombstone[], + now: Date +): RuntimeRunTombstone[] { + const nowMs = now.getTime(); + return records.filter( + (record) => record.expiresAt === null || Date.parse(record.expiresAt) > nowMs + ); +} + +function normalizeEvidenceKinds(input: RuntimeEvidenceKind[] | undefined): RuntimeEvidenceKind[] { + const all: RuntimeEvidenceKind[] = [ + 'sse_event', + 'permission_reply', + 'delivery_call', + 'prompt_error', + 'bootstrap_checkin', + 'launch_checkpoint', + 'heartbeat', + 'bridge_result', + 'recovery_result', + ]; + const source = input && input.length > 0 ? input : all; + return [...new Set(source)].sort(); +} + +function isRuntimeRunTombstone(value: unknown): value is RuntimeRunTombstone { + return ( + isRecord(value) && + isNonEmptyString(value.tombstoneId) && + isNonEmptyString(value.teamName) && + isNonEmptyString(value.runId) && + isRuntimeRunTombstoneReason(value.reason) && + Array.isArray(value.evidenceKinds) && + value.evidenceKinds.length > 0 && + value.evidenceKinds.every(isRuntimeEvidenceKind) && + isNonEmptyString(value.createdAt) && + (value.expiresAt === null || isNonEmptyString(value.expiresAt)) && + (value.diagnostic === null || typeof value.diagnostic === 'string') + ); +} + +function isRuntimeRunTombstoneReason(value: unknown): value is RuntimeRunTombstoneReason { + return ( + value === 'stop_requested' || + value === 'relaunch_started' || + value === 'run_replaced' || + value === 'provider_session_aborted' || + value === 'recovery_rejected' + ); +} + +function isRuntimeEvidenceKind(value: unknown): value is RuntimeEvidenceKind { + return ( + value === 'sse_event' || + value === 'permission_reply' || + value === 'delivery_call' || + value === 'prompt_error' || + value === 'bootstrap_checkin' || + value === 'launch_checkpoint' || + value === 'heartbeat' || + value === 'bridge_result' || + value === 'recovery_result' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts new file mode 100644 index 00000000..339c2ba9 --- /dev/null +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -0,0 +1,1144 @@ +import { createHash, randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; + +export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1; +export const OPENCODE_RUNTIME_STORE_RECEIPT_SCHEMA_VERSION = 1; + +export type RuntimeStoreSchemaName = + | 'opencode.launchState' + | 'opencode.sessionStore' + | 'opencode.launchTransaction' + | 'opencode.deliveryJournal' + | 'opencode.permissionRequests' + | 'opencode.hostLeases' + | 'opencode.compatibilitySnapshot' + | 'opencode.runtimeRevision' + | 'opencode.runtimeDiagnostics' + | 'opencode.e2eEvidence'; + +export type RuntimeStoreCriticality = + | 'readiness_blocking' + | 'rebuildable_from_provider' + | 'rebuildable_from_canonical_destination' + | 'diagnostic_only'; + +export type RuntimeStoreOwner = + | 'launch' + | 'session' + | 'delivery' + | 'permission' + | 'host' + | 'compatibility' + | 'ui' + | 'diagnostics' + | 'e2e'; + +export type RuntimeStoreRebuildStrategy = + | 'none' + | 'poll_opencode_provider' + | 'verify_canonical_destinations' + | 'rerun_capability_discovery' + | 'rebuild_from_launch_state' + | 'drop_after_quarantine'; + +export interface RuntimeStoreDescriptor { + schemaName: RuntimeStoreSchemaName; + schemaVersion: number; + relativePath: string; + criticality: RuntimeStoreCriticality; + owner: RuntimeStoreOwner; + rebuildStrategy: RuntimeStoreRebuildStrategy; +} + +export type RuntimeStoreManifestEntryState = + | 'healthy' + | 'missing' + | 'quarantined' + | 'future_schema' + | 'hash_mismatch' + | 'stale_run' + | 'rebuild_required' + | 'uncommitted_write'; + +export interface RuntimeStoreManifestEntry { + schemaName: RuntimeStoreSchemaName; + schemaVersion: number; + relativePath: string; + contentHash: string | null; + fileSize: number | null; + mtimeMs: number | null; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + lastWriteReceiptId: string | null; + state: RuntimeStoreManifestEntryState; +} + +export interface RuntimeStoreManifest { + schemaVersion: typeof OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION; + teamName: string; + activeRunId: string | null; + activeCapabilitySnapshotId: string | null; + activeBehaviorFingerprint: string | null; + highWatermark: number; + lastCommittedBatchId: string | null; + lastPreparingBatchId: string | null; + entries: RuntimeStoreManifestEntry[]; + lastRecoveryPlanId: string | null; + updatedAt: string; +} + +export type RuntimeStoreWriteBatchReason = + | 'launch_checkpoint' + | 'permission_reconcile' + | 'delivery_commit' + | 'host_lease_update' + | 'compatibility_discovery' + | 'stop_tombstone' + | 'migration' + | 'recovery'; + +export interface RuntimeStoreWriteReceipt { + receiptId: string; + batchId: string; + schemaName: RuntimeStoreSchemaName; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + schemaVersion: number; + relativePath: string; + contentHash: string; + fileSize: number; + mtimeMs: number; + writtenAt: string; +} + +export interface RuntimeStoreWriteBatch { + batchId: string; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + reason: RuntimeStoreWriteBatchReason; + startedAt: string; + completedAt: string | null; + state: 'preparing' | 'committing' | 'committed' | 'failed'; + receipts: RuntimeStoreWriteReceipt[]; + lastError: string | null; +} + +export interface RuntimeStoreFileInspection { + descriptor: RuntimeStoreDescriptor; + state: RuntimeStoreManifestEntryState; + entry: RuntimeStoreManifestEntry | null; + manifestEntry: RuntimeStoreManifestEntry | null; + message: string | null; +} + +export type RuntimeStoreRecoveryAction = + | { kind: 'none'; schemaName: RuntimeStoreSchemaName } + | { kind: 'quarantine'; schemaName: RuntimeStoreSchemaName; reason: string } + | { kind: 'rebuild_from_provider'; schemaName: RuntimeStoreSchemaName; reason: string } + | { + kind: 'rebuild_from_canonical_destination'; + schemaName: RuntimeStoreSchemaName; + reason: string; + } + | { kind: 'rerun_capability_discovery'; schemaName: RuntimeStoreSchemaName; reason: string } + | { kind: 'block_readiness'; schemaName: RuntimeStoreSchemaName; reason: string }; + +export interface RuntimeStoreRecoveryPlan { + planId: string; + teamName: string; + runId: string | null; + createdAt: string; + manifestHealthy: boolean; + readinessImpact: 'none' | 'degraded' | 'blocked'; + actions: RuntimeStoreRecoveryAction[]; + diagnostics: string[]; +} + +export interface RuntimeStoreReadinessCheck { + ok: boolean; + reason: + | 'runtime_store_manifest_valid' + | 'runtime_store_recovery_required' + | 'runtime_store_rebuild_in_progress'; + diagnostics: string[]; +} + +export const OPENCODE_RUNTIME_STORE_DESCRIPTORS: RuntimeStoreDescriptor[] = [ + { + schemaName: 'opencode.launchState', + schemaVersion: 1, + relativePath: 'launch-state.json', + criticality: 'readiness_blocking', + owner: 'launch', + rebuildStrategy: 'none', + }, + { + schemaName: 'opencode.sessionStore', + schemaVersion: 1, + relativePath: 'opencode-sessions.json', + criticality: 'rebuildable_from_provider', + owner: 'session', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.launchTransaction', + schemaVersion: 1, + relativePath: 'opencode-launch-transaction.json', + criticality: 'readiness_blocking', + owner: 'launch', + rebuildStrategy: 'none', + }, + { + schemaName: 'opencode.deliveryJournal', + schemaVersion: 1, + relativePath: 'opencode-delivery-journal.json', + criticality: 'rebuildable_from_canonical_destination', + owner: 'delivery', + rebuildStrategy: 'verify_canonical_destinations', + }, + { + schemaName: 'opencode.permissionRequests', + schemaVersion: 1, + relativePath: 'opencode-permissions.json', + criticality: 'rebuildable_from_provider', + owner: 'permission', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.hostLeases', + schemaVersion: 1, + relativePath: 'opencode-host-leases.json', + criticality: 'rebuildable_from_provider', + owner: 'host', + rebuildStrategy: 'poll_opencode_provider', + }, + { + schemaName: 'opencode.compatibilitySnapshot', + schemaVersion: 1, + relativePath: 'opencode-compatibility.json', + criticality: 'readiness_blocking', + owner: 'compatibility', + rebuildStrategy: 'rerun_capability_discovery', + }, + { + schemaName: 'opencode.runtimeRevision', + schemaVersion: 1, + relativePath: 'opencode-runtime-revision.json', + criticality: 'readiness_blocking', + owner: 'ui', + rebuildStrategy: 'rebuild_from_launch_state', + }, + { + schemaName: 'opencode.runtimeDiagnostics', + schemaVersion: 1, + relativePath: 'opencode-diagnostics.json', + criticality: 'diagnostic_only', + owner: 'diagnostics', + rebuildStrategy: 'drop_after_quarantine', + }, +]; + +export class RuntimeStoreManifestStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async read(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } + + async markBatchPreparing(batch: RuntimeStoreWriteBatch): Promise { + await this.store.updateLocked((manifest) => ({ + ...manifest, + lastPreparingBatchId: batch.batchId, + updatedAt: this.clock().toISOString(), + })); + } + + async applyCommittedBatch(batch: RuntimeStoreWriteBatch): Promise { + const result = await this.store.updateLocked((manifest) => { + const entries = new Map(manifest.entries.map((entry) => [entry.schemaName, entry])); + for (const receipt of batch.receipts) { + entries.set(receipt.schemaName, { + schemaName: receipt.schemaName, + schemaVersion: receipt.schemaVersion, + relativePath: receipt.relativePath, + contentHash: receipt.contentHash, + fileSize: receipt.fileSize, + mtimeMs: receipt.mtimeMs, + runId: receipt.runId, + capabilitySnapshotId: receipt.capabilitySnapshotId, + behaviorFingerprint: receipt.behaviorFingerprint, + lastWriteReceiptId: receipt.receiptId, + state: 'healthy', + }); + } + + return { + ...manifest, + activeRunId: batch.runId, + activeCapabilitySnapshotId: batch.capabilitySnapshotId, + activeBehaviorFingerprint: batch.behaviorFingerprint, + highWatermark: manifest.highWatermark + 1, + lastCommittedBatchId: batch.batchId, + lastPreparingBatchId: + manifest.lastPreparingBatchId === batch.batchId ? null : manifest.lastPreparingBatchId, + entries: [...entries.values()].sort((a, b) => a.schemaName.localeCompare(b.schemaName)), + updatedAt: this.clock().toISOString(), + }; + }); + return result.data; + } + + async markRecoveryPlan(plan: RuntimeStoreRecoveryPlan): Promise { + await this.store.updateLocked((manifest) => ({ + ...manifest, + lastRecoveryPlanId: plan.planId, + updatedAt: this.clock().toISOString(), + })); + } +} + +export class RuntimeStoreReceiptStore { + constructor( + private readonly store: VersionedJsonStore, + private readonly clock: () => Date = () => new Date() + ) {} + + async begin(batch: RuntimeStoreWriteBatch): Promise { + await this.store.updateLocked((batches) => { + if (batches.some((item) => item.batchId === batch.batchId)) { + throw new Error(`Runtime store batch already exists: ${batch.batchId}`); + } + return [...batches, batch]; + }); + } + + async commit(batch: RuntimeStoreWriteBatch): Promise { + await this.replace(batch.batchId, { + ...batch, + state: 'committed', + completedAt: this.clock().toISOString(), + lastError: null, + }); + } + + async fail(batch: RuntimeStoreWriteBatch, error: unknown): Promise { + await this.replace(batch.batchId, { + ...batch, + state: 'failed', + completedAt: this.clock().toISOString(), + lastError: stringifyError(error), + }); + } + + async list(): Promise { + const result = await this.store.read(); + if (!result.ok) { + throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath); + } + return result.data; + } + + async listUncommitted(teamName: string): Promise { + const batches = await this.list(); + return batches.filter((batch) => batch.teamName === teamName && batch.state !== 'committed'); + } + + private async replace(batchId: string, batch: RuntimeStoreWriteBatch): Promise { + let found = false; + await this.store.updateLocked((batches) => + batches.map((item) => { + if (item.batchId !== batchId) { + return item; + } + found = true; + return batch; + }) + ); + if (!found) { + throw new Error(`Runtime store batch not found: ${batchId}`); + } + } +} + +export class RuntimeStoreBatchWriter { + constructor( + private readonly teamRuntimeDirectory: string, + private readonly manifestStore: RuntimeStoreManifestStore, + private readonly receiptStore: RuntimeStoreReceiptStore, + private readonly options: { + batchIdFactory?: () => string; + receiptIdFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async writeBatch(input: { + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + behaviorFingerprint: string | null; + reason: RuntimeStoreWriteBatchReason; + writes: Array<{ + descriptor: RuntimeStoreDescriptor; + data: unknown; + }>; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const batch: RuntimeStoreWriteBatch = { + batchId: this.options.batchIdFactory?.() ?? `opencode-store-batch-${randomUUID()}`, + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: input.capabilitySnapshotId, + behaviorFingerprint: input.behaviorFingerprint, + reason: input.reason, + startedAt: clock().toISOString(), + completedAt: null, + state: 'preparing', + receipts: [], + lastError: null, + }; + + await this.receiptStore.begin(batch); + await this.manifestStore.markBatchPreparing(batch); + + try { + const receipts: RuntimeStoreWriteReceipt[] = []; + for (const write of input.writes) { + const receipt = await this.writeStoreFile({ + batch, + descriptor: write.descriptor, + data: write.data, + now: clock().toISOString(), + }); + receipts.push(receipt); + } + + const committed: RuntimeStoreWriteBatch = { + ...batch, + state: 'committed', + completedAt: clock().toISOString(), + receipts, + }; + await this.receiptStore.commit(committed); + await this.manifestStore.applyCommittedBatch(committed); + return committed; + } catch (error) { + await this.receiptStore.fail(batch, error); + throw error; + } + } + + private async writeStoreFile(input: { + batch: RuntimeStoreWriteBatch; + descriptor: RuntimeStoreDescriptor; + data: unknown; + now: string; + }): Promise { + const filePath = path.join(this.teamRuntimeDirectory, input.descriptor.relativePath); + const raw = `${JSON.stringify( + { + schemaVersion: input.descriptor.schemaVersion, + updatedAt: input.now, + data: input.data, + }, + null, + 2 + )}\n`; + await atomicWriteAsync(filePath, raw); + const stat = await fs.stat(filePath); + return { + receiptId: this.options.receiptIdFactory?.() ?? `opencode-store-receipt-${randomUUID()}`, + batchId: input.batch.batchId, + schemaName: input.descriptor.schemaName, + teamName: input.batch.teamName, + runId: input.batch.runId, + capabilitySnapshotId: input.batch.capabilitySnapshotId, + behaviorFingerprint: input.batch.behaviorFingerprint, + schemaVersion: input.descriptor.schemaVersion, + relativePath: input.descriptor.relativePath, + contentHash: computeRuntimeStoreContentHash(raw), + fileSize: stat.size, + mtimeMs: stat.mtimeMs, + writtenAt: input.now, + }; + } +} + +export class RuntimeStoreFileInspector { + constructor(private readonly teamRuntimeDirectory: string) {} + + async inspect(input: { + descriptor: RuntimeStoreDescriptor; + manifest: RuntimeStoreManifest; + }): Promise { + const filePath = path.join(this.teamRuntimeDirectory, input.descriptor.relativePath); + const manifestEntry = + input.manifest.entries.find((entry) => entry.schemaName === input.descriptor.schemaName) ?? + null; + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + descriptor: input.descriptor, + state: 'missing', + entry: manifestEntry ? { ...manifestEntry, state: 'missing' } : null, + manifestEntry, + message: `Runtime store missing: ${input.descriptor.schemaName}`, + }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return this.failedInspection(input.descriptor, manifestEntry, 'quarantined', 'invalid_json'); + } + + if (!isRecord(parsed) || !Number.isInteger(parsed.schemaVersion)) { + return this.failedInspection( + input.descriptor, + manifestEntry, + 'quarantined', + 'invalid_envelope' + ); + } + + const schemaVersion = parsed.schemaVersion as number; + if (schemaVersion > input.descriptor.schemaVersion) { + return this.failedInspection( + input.descriptor, + manifestEntry, + 'future_schema', + 'future_schema' + ); + } + + const stat = await fs.stat(filePath); + const contentHash = computeRuntimeStoreContentHash(raw); + const actualEntry: RuntimeStoreManifestEntry = { + schemaName: input.descriptor.schemaName, + schemaVersion, + relativePath: input.descriptor.relativePath, + contentHash, + fileSize: stat.size, + mtimeMs: stat.mtimeMs, + runId: manifestEntry?.runId ?? null, + capabilitySnapshotId: manifestEntry?.capabilitySnapshotId ?? null, + behaviorFingerprint: manifestEntry?.behaviorFingerprint ?? null, + lastWriteReceiptId: manifestEntry?.lastWriteReceiptId ?? null, + state: 'healthy', + }; + + if (!manifestEntry) { + return { + descriptor: input.descriptor, + state: 'uncommitted_write', + entry: { ...actualEntry, state: 'uncommitted_write' }, + manifestEntry, + message: `Runtime store has no manifest entry: ${input.descriptor.schemaName}`, + }; + } + + if (manifestEntry.contentHash !== contentHash) { + return { + descriptor: input.descriptor, + state: 'hash_mismatch', + entry: { ...actualEntry, state: 'hash_mismatch' }, + manifestEntry, + message: `Runtime store hash mismatch: ${input.descriptor.schemaName}`, + }; + } + + return { + descriptor: input.descriptor, + state: 'healthy', + entry: actualEntry, + manifestEntry, + message: null, + }; + } + + private failedInspection( + descriptor: RuntimeStoreDescriptor, + manifestEntry: RuntimeStoreManifestEntry | null, + state: RuntimeStoreManifestEntryState, + message: string + ): RuntimeStoreFileInspection { + return { + descriptor, + state, + entry: manifestEntry ? { ...manifestEntry, state } : null, + manifestEntry, + message: `Runtime store ${descriptor.schemaName} failed inspection: ${message}`, + }; + } +} + +export class RuntimeStoreRecoveryPlanner { + constructor( + private readonly descriptors: RuntimeStoreDescriptor[], + private readonly manifestStore: RuntimeStoreManifestStore, + private readonly receiptStore: RuntimeStoreReceiptStore, + private readonly inspector: RuntimeStoreFileInspector, + private readonly options: { + planIdFactory?: () => string; + clock?: () => Date; + } = {} + ) {} + + async buildPlan(input: { + teamName: string; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; + }): Promise { + const clock = this.options.clock ?? (() => new Date()); + const manifest = await this.manifestStore.read(); + const diagnostics: string[] = []; + const actions: RuntimeStoreRecoveryAction[] = []; + const descriptorBySchemaName = new Map( + this.descriptors.map((descriptor) => [descriptor.schemaName, descriptor]) + ); + + if (manifest.teamName !== input.teamName) { + diagnostics.push(`Runtime store manifest team mismatch: ${manifest.teamName}`); + actions.push({ + kind: 'block_readiness', + schemaName: 'opencode.launchState', + reason: 'manifest_team_mismatch', + }); + } + + for (const descriptor of this.descriptors) { + const inspected = await this.inspector.inspect({ descriptor, manifest }); + if (inspected.message) { + diagnostics.push(inspected.message); + } + + if (inspected.state === 'healthy' && inspected.entry) { + const identityFailure = validateManifestEntryIdentity({ + descriptor, + entry: inspected.entry, + expectedRunId: input.expectedRunId, + expectedCapabilitySnapshotId: input.expectedCapabilitySnapshotId, + expectedBehaviorFingerprint: input.expectedBehaviorFingerprint, + }); + if (!identityFailure) { + actions.push({ kind: 'none', schemaName: descriptor.schemaName }); + continue; + } + diagnostics.push(identityFailure); + actions.push({ + kind: 'block_readiness', + schemaName: descriptor.schemaName, + reason: identityFailure, + }); + continue; + } + + actions.push(buildRecoveryAction({ descriptor, inspected })); + } + + for (const batch of await this.receiptStore.listUncommitted(input.teamName)) { + diagnostics.push(`Uncommitted runtime store batch detected: ${batch.batchId}`); + actions.push({ + kind: 'block_readiness', + schemaName: 'opencode.launchTransaction', + reason: `uncommitted_batch:${batch.batchId}`, + }); + } + + const readinessImpact = computeRecoveryReadinessImpact(actions, descriptorBySchemaName); + return { + planId: this.options.planIdFactory?.() ?? `opencode-recovery-plan-${randomUUID()}`, + teamName: input.teamName, + runId: input.expectedRunId, + createdAt: clock().toISOString(), + manifestHealthy: actions.every((action) => action.kind === 'none'), + readinessImpact, + actions, + diagnostics, + }; + } +} + +export function buildRecoveryAction(input: { + descriptor: RuntimeStoreDescriptor; + inspected: RuntimeStoreFileInspection; +}): RuntimeStoreRecoveryAction { + if (input.inspected.state === 'future_schema' || input.inspected.state === 'hash_mismatch') { + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + + if (input.inspected.state === 'quarantined') { + if (input.descriptor.rebuildStrategy === 'poll_opencode_provider') { + return { + kind: 'rebuild_from_provider', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + if (input.descriptor.rebuildStrategy === 'verify_canonical_destinations') { + return { + kind: 'rebuild_from_canonical_destination', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } + + switch (input.descriptor.rebuildStrategy) { + case 'poll_opencode_provider': + return { + kind: 'rebuild_from_provider', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'verify_canonical_destinations': + return { + kind: 'rebuild_from_canonical_destination', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'rerun_capability_discovery': + return { + kind: 'rerun_capability_discovery', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'drop_after_quarantine': + return { + kind: 'quarantine', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + case 'rebuild_from_launch_state': + case 'none': + default: + return { + kind: 'block_readiness', + schemaName: input.descriptor.schemaName, + reason: input.inspected.state, + }; + } +} + +export function buildRuntimeStoreReadiness(input: { + recoveryPlan: RuntimeStoreRecoveryPlan; + invariantFailures: string[]; +}): RuntimeStoreReadinessCheck { + const diagnostics = [...input.recoveryPlan.diagnostics, ...input.invariantFailures]; + if (input.recoveryPlan.readinessImpact === 'blocked' || input.invariantFailures.length > 0) { + return { + ok: false, + reason: 'runtime_store_recovery_required', + diagnostics, + }; + } + + if (input.recoveryPlan.readinessImpact === 'degraded') { + return { + ok: false, + reason: 'runtime_store_rebuild_in_progress', + diagnostics, + }; + } + + return { + ok: true, + reason: 'runtime_store_manifest_valid', + diagnostics: [], + }; +} + +export interface RuntimeStoreCrossStoreInvariantInput { + launchState: { + providerId?: string; + teamName: string; + runId: string | null; + capabilitySnapshotId: string | null; + aggregateState?: string; + members?: Array<{ name: string; launchState?: string }>; + }; + sessionStore: { + sessions?: Array<{ teamName: string; memberName: string; runId: string | null }>; + }; + transaction: { status?: string } | null; + deliveryJournal: { + records?: Array<{ idempotencyKey: string; runId: string | null; status: string }>; + }; + permissionStore: { + requests?: Array<{ appRequestId: string; runId: string | null; status: string }>; + }; + compatibilitySnapshot: { snapshotId: string | null }; + manifest: RuntimeStoreManifest; +} + +export function validateOpenCodeRuntimeStoreInvariants( + input: RuntimeStoreCrossStoreInvariantInput +): string[] { + const failures: string[] = []; + if (input.launchState.providerId !== 'opencode') { + return failures; + } + + if (input.launchState.runId !== input.manifest.activeRunId) { + failures.push('launch-state runId does not match runtime store manifest activeRunId'); + } + + if (input.launchState.capabilitySnapshotId !== input.compatibilitySnapshot.snapshotId) { + failures.push('launch-state capability snapshot does not match compatibility snapshot'); + } + + for (const member of input.launchState.members ?? []) { + const session = (input.sessionStore.sessions ?? []).find( + (item) => + item.teamName === input.launchState.teamName && + item.memberName === member.name && + item.runId === input.launchState.runId + ); + + if (!session && member.launchState === 'confirmed_alive') { + failures.push(`confirmed member ${member.name} has no matching OpenCode session record`); + } + } + + for (const record of input.deliveryJournal.records ?? []) { + if (record.runId !== input.launchState.runId && record.status !== 'committed') { + failures.push( + `non-committed delivery journal record belongs to stale run: ${record.idempotencyKey}` + ); + } + } + + for (const request of input.permissionStore.requests ?? []) { + if (request.runId !== input.launchState.runId && request.status === 'pending') { + failures.push(`pending permission belongs to stale run: ${request.appRequestId}`); + } + } + + if (input.transaction?.status === 'active' && input.launchState.aggregateState === 'ready') { + failures.push('active launch transaction conflicts with ready launch-state'); + } + + return failures; +} + +export function createRuntimeStoreManifestStore(options: { + filePath: string; + teamName: string; + clock?: () => Date; +}): RuntimeStoreManifestStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeStoreManifestStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()), + validate: validateRuntimeStoreManifest, + clock, + }), + clock + ); +} + +export function createRuntimeStoreReceiptStore(options: { + filePath: string; + clock?: () => Date; +}): RuntimeStoreReceiptStore { + const clock = options.clock ?? (() => new Date()); + return new RuntimeStoreReceiptStore( + new VersionedJsonStore({ + filePath: options.filePath, + schemaVersion: OPENCODE_RUNTIME_STORE_RECEIPT_SCHEMA_VERSION, + defaultData: () => [], + validate: validateRuntimeStoreWriteBatches, + clock, + }), + clock + ); +} + +export function createDefaultRuntimeStoreManifest( + teamName: string, + updatedAt: string +): RuntimeStoreManifest { + return { + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + teamName, + activeRunId: null, + activeCapabilitySnapshotId: null, + activeBehaviorFingerprint: null, + highWatermark: 0, + lastCommittedBatchId: null, + lastPreparingBatchId: null, + entries: [], + lastRecoveryPlanId: null, + updatedAt, + }; +} + +export function computeRuntimeStoreContentHash(raw: string): string { + return `sha256:${createHash('sha256').update(raw).digest('hex')}`; +} + +export function validateRuntimeStoreManifest(value: unknown): RuntimeStoreManifest { + if (!isRecord(value)) { + throw new Error('Runtime store manifest must be an object'); + } + if (value.schemaVersion !== OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION) { + throw new Error('Runtime store manifest has unsupported schemaVersion'); + } + if ( + !isNonEmptyString(value.teamName) || + !isNullableString(value.activeRunId) || + !isNullableString(value.activeCapabilitySnapshotId) || + !isNullableString(value.activeBehaviorFingerprint) || + !Number.isInteger(value.highWatermark) || + (value.highWatermark as number) < 0 || + !isNullableString(value.lastCommittedBatchId) || + !isNullableString(value.lastPreparingBatchId) || + !Array.isArray(value.entries) || + !isNullableString(value.lastRecoveryPlanId) || + !isNonEmptyString(value.updatedAt) + ) { + throw new Error('Runtime store manifest envelope is invalid'); + } + + const seen = new Set(); + const entries = value.entries.map((entry, index) => { + if (!isManifestEntry(entry)) { + throw new Error(`Invalid runtime store manifest entry at index ${index}`); + } + if (seen.has(entry.schemaName)) { + throw new Error(`Duplicate runtime store manifest entry: ${entry.schemaName}`); + } + seen.add(entry.schemaName); + return entry; + }); + + return { + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + teamName: value.teamName, + activeRunId: value.activeRunId, + activeCapabilitySnapshotId: value.activeCapabilitySnapshotId, + activeBehaviorFingerprint: value.activeBehaviorFingerprint, + highWatermark: value.highWatermark as number, + lastCommittedBatchId: value.lastCommittedBatchId, + lastPreparingBatchId: value.lastPreparingBatchId, + entries, + lastRecoveryPlanId: value.lastRecoveryPlanId, + updatedAt: value.updatedAt, + }; +} + +export function validateRuntimeStoreWriteBatches(value: unknown): RuntimeStoreWriteBatch[] { + if (!Array.isArray(value)) { + throw new Error('Runtime store write batches must be an array'); + } + const seen = new Set(); + return value.map((batch, index) => { + if (!isWriteBatch(batch)) { + throw new Error(`Invalid runtime store write batch at index ${index}`); + } + if (seen.has(batch.batchId)) { + throw new Error(`Duplicate runtime store write batch: ${batch.batchId}`); + } + seen.add(batch.batchId); + return batch; + }); +} + +function validateManifestEntryIdentity(input: { + descriptor: RuntimeStoreDescriptor; + entry: RuntimeStoreManifestEntry; + expectedRunId: string | null; + expectedCapabilitySnapshotId: string | null; + expectedBehaviorFingerprint: string | null; +}): string | null { + if (input.descriptor.criticality === 'diagnostic_only') { + return null; + } + if (input.entry.runId !== input.expectedRunId) { + return `Runtime store ${input.descriptor.schemaName} has stale run id`; + } + if (input.entry.capabilitySnapshotId !== input.expectedCapabilitySnapshotId) { + return `Runtime store ${input.descriptor.schemaName} has stale capability snapshot`; + } + if (input.entry.behaviorFingerprint !== input.expectedBehaviorFingerprint) { + return `Runtime store ${input.descriptor.schemaName} has stale behavior fingerprint`; + } + return null; +} + +function computeRecoveryReadinessImpact( + actions: RuntimeStoreRecoveryAction[], + descriptors: Map +): RuntimeStoreRecoveryPlan['readinessImpact'] { + let degraded = false; + for (const action of actions) { + if (action.kind === 'none') { + continue; + } + const descriptor = descriptors.get(action.schemaName); + if (action.kind === 'block_readiness' || descriptor?.criticality === 'readiness_blocking') { + return 'blocked'; + } + if (descriptor?.criticality !== 'diagnostic_only') { + degraded = true; + } + } + return degraded ? 'degraded' : 'none'; +} + +function isManifestEntry(value: unknown): value is RuntimeStoreManifestEntry { + return ( + isRecord(value) && + isRuntimeStoreSchemaName(value.schemaName) && + Number.isInteger(value.schemaVersion) && + (value.schemaVersion as number) >= 1 && + isNonEmptyString(value.relativePath) && + isNullableString(value.contentHash) && + isNullableNumber(value.fileSize) && + isNullableNumber(value.mtimeMs) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + isNullableString(value.lastWriteReceiptId) && + isManifestEntryState(value.state) + ); +} + +function isWriteBatch(value: unknown): value is RuntimeStoreWriteBatch { + return ( + isRecord(value) && + isNonEmptyString(value.batchId) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + isWriteBatchReason(value.reason) && + isNonEmptyString(value.startedAt) && + isNullableString(value.completedAt) && + (value.state === 'preparing' || + value.state === 'committing' || + value.state === 'committed' || + value.state === 'failed') && + Array.isArray(value.receipts) && + value.receipts.every(isWriteReceipt) && + isNullableString(value.lastError) + ); +} + +function isWriteReceipt(value: unknown): value is RuntimeStoreWriteReceipt { + return ( + isRecord(value) && + isNonEmptyString(value.receiptId) && + isNonEmptyString(value.batchId) && + isRuntimeStoreSchemaName(value.schemaName) && + isNonEmptyString(value.teamName) && + isNullableString(value.runId) && + isNullableString(value.capabilitySnapshotId) && + isNullableString(value.behaviorFingerprint) && + Number.isInteger(value.schemaVersion) && + (value.schemaVersion as number) >= 1 && + isNonEmptyString(value.relativePath) && + isNonEmptyString(value.contentHash) && + typeof value.fileSize === 'number' && + Number.isFinite(value.fileSize) && + typeof value.mtimeMs === 'number' && + Number.isFinite(value.mtimeMs) && + isNonEmptyString(value.writtenAt) + ); +} + +function isRuntimeStoreSchemaName(value: unknown): value is RuntimeStoreSchemaName { + return ( + value === 'opencode.launchState' || + value === 'opencode.sessionStore' || + value === 'opencode.launchTransaction' || + value === 'opencode.deliveryJournal' || + value === 'opencode.permissionRequests' || + value === 'opencode.hostLeases' || + value === 'opencode.compatibilitySnapshot' || + value === 'opencode.runtimeRevision' || + value === 'opencode.runtimeDiagnostics' || + value === 'opencode.e2eEvidence' + ); +} + +function isManifestEntryState(value: unknown): value is RuntimeStoreManifestEntryState { + return ( + value === 'healthy' || + value === 'missing' || + value === 'quarantined' || + value === 'future_schema' || + value === 'hash_mismatch' || + value === 'stale_run' || + value === 'rebuild_required' || + value === 'uncommitted_write' + ); +} + +function isWriteBatchReason(value: unknown): value is RuntimeStoreWriteBatchReason { + return ( + value === 'launch_checkpoint' || + value === 'permission_reconcile' || + value === 'delivery_commit' || + value === 'host_lease_update' || + value === 'compatibility_discovery' || + value === 'stop_tombstone' || + value === 'migration' || + value === 'recovery' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || (typeof value === 'number' && Number.isFinite(value)); +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/store/VersionedJsonStore.ts b/src/main/services/team/opencode/store/VersionedJsonStore.ts new file mode 100644 index 00000000..425d1bf4 --- /dev/null +++ b/src/main/services/team/opencode/store/VersionedJsonStore.ts @@ -0,0 +1,292 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import { withFileLock } from '../../fileLock'; + +export interface VersionedJsonStoreEnvelope { + schemaVersion: number; + updatedAt: string; + data: TData; +} + +export type VersionedJsonStoreReadStatus = 'missing' | 'loaded'; + +export type VersionedJsonStoreFailureReason = + | 'invalid_json' + | 'invalid_envelope' + | 'invalid_data' + | 'future_schema'; + +export type VersionedJsonStoreReadResult = + | { + ok: true; + status: VersionedJsonStoreReadStatus; + data: TData; + envelope: VersionedJsonStoreEnvelope | null; + } + | { + ok: false; + reason: VersionedJsonStoreFailureReason; + message: string; + quarantinePath: string | null; + }; + +export interface VersionedJsonStoreUpdateResult { + changed: boolean; + data: TData; + envelope: VersionedJsonStoreEnvelope; +} + +export interface VersionedJsonStoreOptions { + filePath: string; + schemaVersion: number; + defaultData: () => TData; + validate: (value: unknown) => TData; + clock?: () => Date; + quarantineDir?: string; +} + +export class VersionedJsonStoreError extends Error { + constructor( + message: string, + readonly reason: VersionedJsonStoreFailureReason, + readonly quarantinePath: string | null + ) { + super(message); + this.name = 'VersionedJsonStoreError'; + } +} + +export class VersionedJsonStore { + private readonly filePath: string; + private readonly schemaVersion: number; + private readonly defaultData: () => TData; + private readonly validate: (value: unknown) => TData; + private readonly clock: () => Date; + private readonly quarantineDir: string | null; + + constructor(options: VersionedJsonStoreOptions) { + this.filePath = options.filePath; + this.schemaVersion = options.schemaVersion; + this.defaultData = options.defaultData; + this.validate = options.validate; + this.clock = options.clock ?? (() => new Date()); + this.quarantineDir = options.quarantineDir ?? null; + } + + async read(): Promise> { + return this.readUnlocked(); + } + + async updateLocked( + updater: (current: TData) => TData | Promise + ): Promise> { + return withFileLock(this.filePath, async () => { + const current = await this.readUnlocked(); + if (!current.ok) { + throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath); + } + + const nextData = await updater(cloneJson(current.data)); + const validatedNextData = this.validate(nextData); + const currentJson = stableJsonStringify(current.data); + const nextJson = stableJsonStringify(validatedNextData); + const changed = current.status === 'missing' || currentJson !== nextJson; + const envelope: VersionedJsonStoreEnvelope = { + schemaVersion: this.schemaVersion, + updatedAt: changed + ? this.clock().toISOString() + : (current.envelope?.updatedAt ?? this.clock().toISOString()), + data: changed ? validatedNextData : current.data, + }; + + if (changed) { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); + } + + return { + changed, + data: envelope.data, + envelope, + }; + }); + } + + private async readUnlocked(): Promise> { + let raw: string; + try { + raw = await fs.readFile(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + const data = this.validate(this.defaultData()); + return { + ok: true, + status: 'missing', + data, + envelope: null, + }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const quarantinePath = await this.quarantine(raw, 'invalid_json'); + return { + ok: false, + reason: 'invalid_json', + message: `Invalid JSON in versioned store ${this.filePath}: ${stringifyError(error)}`, + quarantinePath, + }; + } + + const envelopeResult = this.normalizeEnvelope(parsed); + if (!envelopeResult.ok) { + const quarantinePath = await this.quarantine(raw, envelopeResult.reason); + return { + ok: false, + reason: envelopeResult.reason, + message: envelopeResult.message, + quarantinePath, + }; + } + + if (envelopeResult.envelope.schemaVersion > this.schemaVersion) { + const quarantinePath = await this.quarantine(raw, 'future_schema'); + return { + ok: false, + reason: 'future_schema', + message: `Future schema ${envelopeResult.envelope.schemaVersion} in ${this.filePath}; supported ${this.schemaVersion}`, + quarantinePath, + }; + } + + try { + const data = this.validate(envelopeResult.envelope.data); + return { + ok: true, + status: 'loaded', + data, + envelope: { + schemaVersion: envelopeResult.envelope.schemaVersion, + updatedAt: envelopeResult.envelope.updatedAt, + data, + }, + }; + } catch (error) { + const quarantinePath = await this.quarantine(raw, 'invalid_data'); + return { + ok: false, + reason: 'invalid_data', + message: `Invalid data in versioned store ${this.filePath}: ${stringifyError(error)}`, + quarantinePath, + }; + } + } + + private normalizeEnvelope( + value: unknown + ): + | { ok: true; envelope: VersionedJsonStoreEnvelope } + | { ok: false; reason: VersionedJsonStoreFailureReason; message: string } { + if (!isRecord(value)) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} must contain a JSON object`, + }; + } + + const schemaVersion = value.schemaVersion; + if (!Number.isInteger(schemaVersion) || (schemaVersion as number) < 1) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} has invalid schemaVersion`, + }; + } + + if (typeof value.updatedAt !== 'string' || !value.updatedAt.trim()) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} has invalid updatedAt`, + }; + } + + if (!Object.prototype.hasOwnProperty.call(value, 'data')) { + return { + ok: false, + reason: 'invalid_envelope', + message: `Versioned store ${this.filePath} is missing data`, + }; + } + + return { + ok: true, + envelope: { + schemaVersion: schemaVersion as number, + updatedAt: value.updatedAt, + data: value.data, + }, + }; + } + + private async quarantine( + raw: string, + reason: VersionedJsonStoreFailureReason + ): Promise { + const dir = this.quarantineDir ?? path.dirname(this.filePath); + const baseName = path.basename(this.filePath); + const stamp = this.clock().toISOString().replace(/[:.]/g, '-'); + const quarantinePath = path.join(dir, `${baseName}.${reason}.${stamp}.quarantine`); + + try { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(quarantinePath, raw, 'utf8'); + return quarantinePath; + } catch { + return null; + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function stableJsonStringify(value: unknown): string { + return JSON.stringify(normalizeStableJson(value)); +} + +function normalizeStableJson(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map(normalizeStableJson); + } + + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + const nested = (value as Record)[key]; + if (nested !== undefined) { + output[key] = normalizeStableJson(nested); + } + } + return output; +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts new file mode 100644 index 00000000..2f7545a0 --- /dev/null +++ b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts @@ -0,0 +1,284 @@ +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; + +import type { + OpenCodeApiCapabilities, + OpenCodeApiEndpointKey, + OpenCodeEndpointEvidence, +} from '../capabilities/OpenCodeApiCapabilities'; +import { + assertOpenCodeProductionE2EEvidenceBasics, + type OpenCodeProductionE2EEvidence, +} from '../e2e/OpenCodeProductionE2EEvidence'; + +export interface OpenCodeSupportedVersionPolicy { + minimumVersion: string; + testedVersion: string; + allowedPrerelease: boolean; + requireCapabilities: boolean; + requireE2EArtifactsForTestedVersion: boolean; +} + +export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = { + minimumVersion: '1.14.19', + testedVersion: '1.14.19', + allowedPrerelease: false, + requireCapabilities: true, + requireE2EArtifactsForTestedVersion: true, +}; + +export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown'; + +export interface OpenCodeSemver { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +export type OpenCodeSupportLevel = + | 'unsupported_too_old' + | 'unsupported_prerelease' + | 'supported_capabilities_pending' + | 'supported_e2e_pending' + | 'production_supported'; + +export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence'; + +export interface OpenCodeCompatibilitySnapshot { + schemaVersion: 1; + createdAt: string; + binaryPath: string; + binaryFingerprint: string; + installMethod: OpenCodeInstallMethod; + version: string; + semver: OpenCodeSemver; + supported: boolean; + supportLevel: OpenCodeSupportLevel; + apiCapabilities: OpenCodeApiCapabilities; + testedEvidencePath: string | null; + diagnostics: string[]; +} + +export interface OpenCodeSupportDecision { + supported: boolean; + supportLevel: OpenCodeSupportLevel; + semver: OpenCodeSemver | null; + diagnostics: string[]; +} + +export interface OpenCodeRouteCompatibilityCache { + binaryFingerprint: string; + version: string; + routes: Record< + OpenCodeApiEndpointKey, + { + available: boolean; + evidence: OpenCodeEndpointEvidence; + lastVerifiedAt: string; + } + >; +} + +export type OpenCodePermissionReplyRoute = + | { + kind: 'primary_permission_reply'; + method: 'POST'; + pathTemplate: '/permission/:requestID/reply'; + bodyShape: { reply: 'once' }; + } + | { + kind: 'deprecated_session_permission'; + method: 'POST'; + pathTemplate: '/session/:sessionID/permissions/:permissionID'; + bodyShape: { response: 'once' }; + }; + +export async function buildOpenCodeBinaryFingerprint(binaryPath: string): Promise { + const stat = await fs.stat(binaryPath); + return stableHash({ + binaryPath, + realPath: await fs.realpath(binaryPath), + size: stat.size, + mtimeMs: stat.mtimeMs, + }); +} + +export function shouldReuseCompatibilitySnapshot(input: { + cached: OpenCodeCompatibilitySnapshot | null; + binaryPath: string; + binaryFingerprint: string; + version: string; +}): boolean { + return Boolean( + input.cached && + input.cached.binaryPath === input.binaryPath && + input.cached.binaryFingerprint === input.binaryFingerprint && + input.cached.version === input.version + ); +} + +export function evaluateOpenCodeSupport(input: { + version: string; + capabilities: OpenCodeApiCapabilities; + evidence: OpenCodeProductionE2EEvidence | null; + policy?: OpenCodeSupportedVersionPolicy; +}): OpenCodeSupportDecision { + const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; + const parsed = parseOpenCodeSemver(input.version); + if (!parsed || semverCoreLt(parsed, policy.minimumVersion)) { + return { + supported: false, + supportLevel: 'unsupported_too_old', + semver: parsed, + diagnostics: [ + `OpenCode ${input.version} is below supported minimum ${policy.minimumVersion}`, + ], + }; + } + + if (parsed.prerelease.length > 0 && !policy.allowedPrerelease) { + return { + supported: false, + supportLevel: 'unsupported_prerelease', + semver: parsed, + diagnostics: [ + `OpenCode prerelease ${input.version} is not enabled for production team launch`, + ], + }; + } + + if (policy.requireCapabilities && !input.capabilities.requiredForTeamLaunch.ready) { + return { + supported: false, + supportLevel: 'supported_capabilities_pending', + semver: parsed, + diagnostics: input.capabilities.requiredForTeamLaunch.missing, + }; + } + + if (policy.requireE2EArtifactsForTestedVersion) { + const evidenceDecision = assertOpenCodeProductionE2EGate({ + evidence: input.evidence, + testedVersion: policy.testedVersion, + }); + if (!evidenceDecision.ok) { + return { + supported: false, + supportLevel: 'supported_e2e_pending', + semver: parsed, + diagnostics: evidenceDecision.diagnostics, + }; + } + } + + return { + supported: true, + supportLevel: 'production_supported', + semver: parsed, + diagnostics: [], + }; +} + +export function assertOpenCodeProductionE2EGate(input: { + evidence: OpenCodeProductionE2EEvidence | null; + testedVersion: string; + now?: Date; +}): { ok: boolean; diagnostics: string[] } { + return assertOpenCodeProductionE2EEvidenceBasics(input); +} + +export function selectPermissionReplyRouteFromCache( + cache: OpenCodeRouteCompatibilityCache +): OpenCodePermissionReplyRoute | null { + if (cache.routes.permissionReply?.available) { + return { + kind: 'primary_permission_reply', + method: 'POST', + pathTemplate: '/permission/:requestID/reply', + bodyShape: { reply: 'once' }, + }; + } + + if (cache.routes.permissionLegacySessionRespond?.available) { + return { + kind: 'deprecated_session_permission', + method: 'POST', + pathTemplate: '/session/:sessionID/permissions/:permissionID', + bodyShape: { response: 'once' }, + }; + } + + return null; +} + +export function parseOpenCodeSemver(version: string): OpenCodeSemver | null { + const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4]?.split('.').filter(Boolean) ?? [], + }; +} + +export function semverLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean { + const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right; + if (!parsedRight) { + return true; + } + + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] < parsedRight[key]) { + return true; + } + if (left[key] > parsedRight[key]) { + return false; + } + } + + if (left.prerelease.length > 0 && parsedRight.prerelease.length === 0) { + return true; + } + + return false; +} + +function semverCoreLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean { + const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right; + if (!parsedRight) { + return true; + } + + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] < parsedRight[key]) { + return true; + } + if (left[key] > parsedRight[key]) { + return false; + } + } + + return false; +} + +function stableHash(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex'); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(',')}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`) + .join(',')}}`; +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts new file mode 100644 index 00000000..6bf9d983 --- /dev/null +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -0,0 +1,499 @@ +import { randomUUID } from 'crypto'; + +import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; +import type { + OpenCodeLaunchTeamCommandBody, + OpenCodeLaunchTeamCommandData, + OpenCodeBridgeRuntimeSnapshot, + OpenCodeReconcileTeamCommandBody, + OpenCodeStopTeamCommandBody, + OpenCodeStopTeamCommandData, + OpenCodeTeamLaunchMode, + OpenCodeTeamMemberLaunchBridgeState, +} from '../opencode/bridge/OpenCodeBridgeCommandContract'; +import type { + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareResult, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopResult, +} from './TeamRuntimeAdapter'; + +export interface OpenCodeTeamRuntimeBridgePort { + checkOpenCodeTeamLaunchReadiness(input: { + projectPath: string; + selectedModel: string | null; + requireExecutionProbe: boolean; + launchMode?: OpenCodeTeamLaunchMode; + }): Promise; + getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null; + launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise; + reconcileOpenCodeTeam?( + input: OpenCodeReconcileTeamCommandBody + ): Promise; + stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise; +} + +export interface OpenCodeTeamRuntimeAdapterOptions { + launchMode?: OpenCodeTeamLaunchMode; + /** + * @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired. + */ + launchEnabled?: boolean; +} + +export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract'; + +const REQUIRED_READY_CHECKPOINTS = new Set([ + 'required_tools_proven', + 'delivery_ready', + 'member_ready', + 'run_ready', +]); + +export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { + readonly providerId = 'opencode' as const; + private readonly lastProjectPathByTeamName = new Map(); + + constructor( + private readonly bridge: OpenCodeTeamRuntimeBridgePort, + private readonly options: OpenCodeTeamRuntimeAdapterOptions = {} + ) {} + + async prepare(input: TeamRuntimeLaunchInput): Promise { + const launchMode = resolveOpenCodeTeamLaunchMode(this.options); + if (launchMode === 'disabled') { + return { + ok: false, + providerId: this.providerId, + reason: 'opencode_team_launch_disabled', + retryable: false, + diagnostics: [ + 'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.', + ], + warnings: [], + }; + } + + const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ + projectPath: input.cwd, + selectedModel: input.model ?? null, + requireExecutionProbe: true, + launchMode, + }); + + if (!readiness.launchAllowed) { + return { + ok: false, + providerId: this.providerId, + reason: readiness.state, + retryable: isRetryableReadinessState(readiness.state), + diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing), + warnings: [], + }; + } + + const warnings = + launchMode === 'dogfood' + ? [ + 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', + ] + : []; + + if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') { + return { + ok: false, + providerId: this.providerId, + reason: 'opencode_production_e2e_evidence_missing', + retryable: false, + diagnostics: [ + 'OpenCode production launch requires strict production E2E evidence before enabling team launch.', + ], + warnings, + }; + } + + return { + ok: true, + providerId: this.providerId, + modelId: readiness.modelId, + diagnostics: readiness.diagnostics, + warnings, + }; + } + + async launch(input: TeamRuntimeLaunchInput): Promise { + const prepared = await this.prepare(input); + if (!prepared.ok) { + return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); + } + + if (!this.bridge.launchOpenCodeTeam) { + return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [ + 'OpenCode readiness passed, but the state-changing launch bridge is not registered.', + ]); + } + + const selectedModel = prepared.modelId ?? input.model?.trim() ?? ''; + if (!selectedModel) { + return blockedLaunchResult(input, 'opencode_model_unavailable', [ + 'OpenCode launch requires a selected raw model id.', + ]); + } + + const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; + this.lastProjectPathByTeamName.set(input.teamName, input.cwd); + const data = await this.bridge.launchOpenCodeTeam({ + mode: resolveOpenCodeTeamLaunchMode(this.options), + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath: input.cwd, + selectedModel, + members: input.expectedMembers.map((member) => ({ + name: member.name, + role: member.role?.trim() || member.workflow?.trim() || 'teammate', + prompt: buildMemberBootstrapPrompt(input, member.name), + })), + leadPrompt: input.prompt?.trim() ?? '', + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + }); + + return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings); + } + + async reconcile(input: TeamRuntimeReconcileInput): Promise { + if (this.bridge.reconcileOpenCodeTeam) { + const projectPath = + input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); + const runtimeSnapshot = projectPath + ? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null) + : null; + const data = await this.bridge.reconcileOpenCodeTeam({ + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath, + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + reconcileAttemptId: `opencode-reconcile-${randomUUID()}`, + expectedMembers: input.expectedMembers.map((member) => ({ + name: member.name, + model: member.model ?? null, + })), + reason: input.reason, + }); + const mapped = mapOpenCodeLaunchDataToRuntimeResult( + { + runId: input.runId, + teamName: input.teamName, + cwd: input.expectedMembers[0]?.cwd ?? '', + providerId: this.providerId, + skipPermissions: false, + expectedMembers: input.expectedMembers, + previousLaunchState: input.previousLaunchState, + }, + data, + [] + ); + return { + ...mapped, + snapshot: input.previousLaunchState, + }; + } + + const snapshot = input.previousLaunchState; + if (!snapshot) { + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'reconciled', + teamLaunchState: 'partial_pending', + members: {}, + snapshot: null, + warnings: [], + diagnostics: ['No previous OpenCode launch snapshot was available for reconciliation.'], + }; + } + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: snapshot.launchPhase, + teamLaunchState: snapshot.teamLaunchState, + members: Object.fromEntries( + Object.entries(snapshot.members).map(([memberName, member]) => [ + memberName, + { + memberName, + providerId: this.providerId, + launchState: member.launchState, + agentToolAccepted: member.agentToolAccepted, + runtimeAlive: member.runtimeAlive, + bootstrapConfirmed: member.bootstrapConfirmed, + hardFailure: member.hardFailure, + hardFailureReason: member.hardFailureReason, + diagnostics: member.diagnostics ?? [], + } satisfies TeamRuntimeMemberLaunchEvidence, + ]) + ), + snapshot, + warnings: [], + diagnostics: [`OpenCode launch snapshot reconciled from ${input.reason}.`], + }; + } + + async stop(input: TeamRuntimeStopInput): Promise { + if (this.bridge.stopOpenCodeTeam) { + const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); + const runtimeSnapshot = projectPath + ? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null) + : null; + const data = await this.bridge.stopOpenCodeTeam({ + runId: input.runId, + teamId: input.teamName, + teamName: input.teamName, + projectPath, + expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, + manifestHighWatermark: null, + reason: input.reason, + force: input.force, + }); + if (data.stopped) { + this.lastProjectPathByTeamName.delete(input.teamName); + } + return { + runId: input.runId, + teamName: input.teamName, + stopped: data.stopped, + members: Object.fromEntries( + Object.entries(data.members).map(([memberName, member]) => [ + memberName, + { + memberName, + providerId: this.providerId, + stopped: member.stopped, + sessionId: member.sessionId, + diagnostics: member.diagnostics, + } satisfies TeamRuntimeMemberStopEvidence, + ]) + ), + warnings: data.warnings.map((warning) => warning.message), + diagnostics: data.diagnostics.map(formatOpenCodeBridgeDiagnostic), + }; + } + + const members = input.previousLaunchState + ? Object.fromEntries( + Object.keys(input.previousLaunchState.members).map((memberName) => [ + memberName, + { + memberName, + providerId: this.providerId, + stopped: true, + diagnostics: [ + 'No live OpenCode session stop command is wired in this adapter shell.', + ], + } satisfies TeamRuntimeMemberStopEvidence, + ]) + ) + : {}; + + return { + runId: input.runId, + teamName: input.teamName, + stopped: true, + members, + warnings: [], + diagnostics: input.previousLaunchState + ? ['OpenCode stop was acknowledged without live session ownership changes.'] + : ['No previous OpenCode launch snapshot was available to stop.'], + }; + } +} + +export function resolveOpenCodeTeamLaunchMode( + options: OpenCodeTeamRuntimeAdapterOptions = {} +): OpenCodeTeamLaunchMode { + if (options.launchMode) { + return options.launchMode; + } + if (options.launchEnabled === true) { + return 'production'; + } + return 'disabled'; +} + +function mapOpenCodeLaunchDataToRuntimeResult( + input: TeamRuntimeLaunchInput, + data: OpenCodeLaunchTeamCommandData, + prepareWarnings: string[] +): TeamRuntimeLaunchResult { + const checkpointNames = extractCheckpointNames(data); + const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) => + checkpointNames.has(name) + ); + const bridgeReady = data.teamLaunchState === 'ready'; + const success = bridgeReady && readyCheckpointsPresent; + const checkpointDiagnostic = success + ? [] + : bridgeReady + ? [ + `OpenCode bridge reported ready without all required durable checkpoints: missing ${[ + ...REQUIRED_READY_CHECKPOINTS, + ] + .filter((name) => !checkpointNames.has(name)) + .join(', ')}`, + ] + : []; + + const members = Object.fromEntries( + input.expectedMembers.map((member) => { + const bridgeMember = data.members[member.name]; + return [ + member.name, + mapBridgeMemberToRuntimeEvidence( + member.name, + bridgeMember?.launchState ?? 'failed', + bridgeMember?.sessionId, + [ + ...(bridgeMember?.evidence ?? []).map( + (evidence) => `${evidence.kind} at ${evidence.observedAt}` + ), + ...checkpointDiagnostic, + ] + ), + ]; + }) + ); + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: success + ? 'finished' + : data.teamLaunchState === 'launching' + ? 'active' + : 'finished', + teamLaunchState: success + ? 'clean_success' + : data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked' + ? 'partial_pending' + : 'partial_failure', + members, + warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)], + diagnostics: [...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), ...checkpointDiagnostic], + }; +} + +function mapBridgeMemberToRuntimeEvidence( + memberName: string, + launchState: OpenCodeTeamMemberLaunchBridgeState, + sessionId: string | undefined, + diagnostics: string[] +): TeamRuntimeMemberLaunchEvidence { + const confirmed = launchState === 'confirmed_alive'; + const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked'; + const failed = launchState === 'failed'; + return { + memberName, + providerId: 'opencode', + launchState: failed + ? 'failed_to_start' + : confirmed + ? 'confirmed_alive' + : 'runtime_pending_bootstrap', + agentToolAccepted: confirmed || createdOrBlocked, + runtimeAlive: confirmed || createdOrBlocked, + bootstrapConfirmed: confirmed, + hardFailure: failed, + hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, + sessionId, + diagnostics, + }; +} + +function extractCheckpointNames(data: OpenCodeLaunchTeamCommandData): Set { + const names = new Set(); + for (const checkpoint of data.durableCheckpoints ?? []) { + if (checkpoint.name.trim()) names.add(checkpoint.name); + } + for (const member of Object.values(data.members)) { + for (const evidence of member.evidence) { + if (evidence.kind.trim()) names.add(evidence.kind); + } + } + return names; +} + +function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: string): string { + const shared = input.prompt?.trim(); + if (shared) { + return shared; + } + return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`; +} + +function formatOpenCodeBridgeDiagnostic(diagnostic: { + code: string; + severity: 'info' | 'warning' | 'error'; + message: string; +}): string { + return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`; +} + +function blockedLaunchResult( + input: TeamRuntimeLaunchInput, + reason: string, + diagnostics: string[], + warnings: string[] = [] +): TeamRuntimeLaunchResult { + const members = Object.fromEntries( + input.expectedMembers.map((member) => [ + member.name, + { + memberName: member.name, + providerId: 'opencode' as const, + launchState: 'failed_to_start' as const, + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + diagnostics, + }, + ]) + ); + + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members, + warnings, + diagnostics, + }; +} + +function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']): boolean { + return ( + state === 'not_installed' || + state === 'not_authenticated' || + state === 'e2e_missing' || + state === 'runtime_store_blocked' || + state === 'mcp_unavailable' || + state === 'model_unavailable' || + state === 'unknown_error' + ); +} + +function mergeDiagnostics(left: string[], right: string[]): string[] { + return [...new Set([...left, ...right].filter((value) => value.trim().length > 0))]; +} diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts new file mode 100644 index 00000000..e2a13b4c --- /dev/null +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -0,0 +1,184 @@ +import type { + EffortLevel, + MemberLaunchState, + PersistedTeamLaunchPhase, + PersistedTeamLaunchSnapshot, + TeamAgentRuntimeBackendType, + TeamLaunchAggregateState, +} from '@shared/types'; + +export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; + +export type TeamRuntimeProviderId = (typeof TEAM_RUNTIME_PROVIDER_IDS)[number]; + +export interface TeamRuntimeMemberSpec { + name: string; + role?: string; + workflow?: string; + providerId: TeamRuntimeProviderId; + model?: string; + effort?: EffortLevel; + cwd: string; +} + +export interface TeamRuntimeLaunchInput { + runId: string; + teamName: string; + cwd: string; + prompt?: string; + providerId: TeamRuntimeProviderId; + model?: string; + effort?: EffortLevel; + skipPermissions: boolean; + expectedMembers: TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; +} + +export interface TeamRuntimePrepareSuccess { + ok: true; + providerId: TeamRuntimeProviderId; + modelId: string | null; + diagnostics: string[]; + warnings: string[]; +} + +export interface TeamRuntimePrepareFailure { + ok: false; + providerId: TeamRuntimeProviderId; + reason: string; + diagnostics: string[]; + warnings: string[]; + retryable: boolean; +} + +export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure; + +export interface TeamRuntimeMemberLaunchEvidence { + memberName: string; + providerId: TeamRuntimeProviderId; + launchState: MemberLaunchState; + agentToolAccepted: boolean; + runtimeAlive: boolean; + bootstrapConfirmed: boolean; + hardFailure: boolean; + hardFailureReason?: string; + sessionId?: string; + backendType?: TeamAgentRuntimeBackendType; + diagnostics: string[]; +} + +export interface TeamRuntimeLaunchResult { + runId: string; + teamName: string; + leadSessionId?: string; + launchPhase: PersistedTeamLaunchPhase; + teamLaunchState: TeamLaunchAggregateState; + members: Record; + warnings: string[]; + diagnostics: string[]; +} + +export type TeamRuntimeReconcileReason = + | 'startup_recovery' + | 'manual_refresh' + | 'launch_progress' + | 'provider_event' + | 'watcher_event' + | 'stop'; + +export interface TeamRuntimeReconcileInput { + runId: string; + teamName: string; + providerId: TeamRuntimeProviderId; + expectedMembers: TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + reason: TeamRuntimeReconcileReason; +} + +export interface TeamRuntimeReconcileResult { + runId: string; + teamName: string; + launchPhase: PersistedTeamLaunchPhase; + teamLaunchState: TeamLaunchAggregateState; + members: Record; + snapshot: PersistedTeamLaunchSnapshot | null; + warnings: string[]; + diagnostics: string[]; +} + +export type TeamRuntimeStopReason = 'user_requested' | 'relaunch' | 'cleanup' | 'app_shutdown'; + +export interface TeamRuntimeStopInput { + runId: string; + teamName: string; + cwd?: string; + providerId: TeamRuntimeProviderId; + reason: TeamRuntimeStopReason; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + force?: boolean; +} + +export interface TeamRuntimeMemberStopEvidence { + memberName: string; + providerId: TeamRuntimeProviderId; + stopped: boolean; + sessionId?: string; + diagnostics: string[]; +} + +export interface TeamRuntimeStopResult { + runId: string; + teamName: string; + stopped: boolean; + members: Record; + warnings: string[]; + diagnostics: string[]; +} + +export interface TeamLaunchRuntimeAdapter { + readonly providerId: TeamRuntimeProviderId; + prepare(input: TeamRuntimeLaunchInput): Promise; + launch(input: TeamRuntimeLaunchInput): Promise; + reconcile(input: TeamRuntimeReconcileInput): Promise; + stop(input: TeamRuntimeStopInput): Promise; +} + +export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { + return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode'; +} + +export class TeamRuntimeAdapterRegistry { + private readonly adapters = new Map(); + + constructor(adapters: readonly TeamLaunchRuntimeAdapter[] = []) { + for (const adapter of adapters) { + this.register(adapter); + } + } + + register(adapter: TeamLaunchRuntimeAdapter): void { + if (!isTeamRuntimeProviderId(adapter.providerId)) { + throw new Error(`Invalid runtime adapter provider: ${String(adapter.providerId)}`); + } + if (this.adapters.has(adapter.providerId)) { + throw new Error(`Runtime adapter already registered: ${adapter.providerId}`); + } + this.adapters.set(adapter.providerId, adapter); + } + + get(providerId: TeamRuntimeProviderId): TeamLaunchRuntimeAdapter { + const adapter = this.adapters.get(providerId); + if (!adapter) { + throw new Error(`Runtime adapter is not available for provider ${providerId}`); + } + return adapter; + } + + has(providerId: TeamRuntimeProviderId): boolean { + return this.adapters.has(providerId); + } + + providers(): TeamRuntimeProviderId[] { + return Array.from(this.adapters.keys()); + } +} diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts new file mode 100644 index 00000000..fa3ed5fe --- /dev/null +++ b/src/main/services/team/runtime/index.ts @@ -0,0 +1,29 @@ +export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; +export type { + OpenCodeTeamLaunchMode, + OpenCodeTeamRuntimeAdapterOptions, + OpenCodeTeamRuntimeBridgePort, +} from './OpenCodeTeamRuntimeAdapter'; +export { + isTeamRuntimeProviderId, + TeamRuntimeAdapterRegistry, + TEAM_RUNTIME_PROVIDER_IDS, +} from './TeamRuntimeAdapter'; +export type { + TeamLaunchRuntimeAdapter, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, + TeamRuntimeMemberStopEvidence, + TeamRuntimePrepareFailure, + TeamRuntimePrepareResult, + TeamRuntimePrepareSuccess, + TeamRuntimeProviderId, + TeamRuntimeReconcileInput, + TeamRuntimeReconcileReason, + TeamRuntimeReconcileResult, + TeamRuntimeStopInput, + TeamRuntimeStopReason, + TeamRuntimeStopResult, +} from './TeamRuntimeAdapter'; diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 3e0a3cec..bb47fbe4 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -10,6 +10,7 @@ import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictP import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; +import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; @@ -1078,7 +1079,8 @@ export class BoardTaskLogStreamService { private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), - private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource() ) {} private async buildInferredExecutionSlices( @@ -1294,6 +1296,10 @@ export class BoardTaskLogStreamService { teamName: string, taskId: string ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptySummary(); + } + const layout = await this.buildStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { return emptySummary(); @@ -1305,9 +1311,15 @@ export class BoardTaskLogStreamService { } async getTaskLogStream(teamName: string, taskId: string): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptyResponse(); + } + const layout = await this.buildStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - return emptyResponse(); + return ( + (await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId)) ?? emptyResponse() + ); } const segments: BoardTaskLogSegment[] = []; @@ -1360,6 +1372,7 @@ export class BoardTaskLogStreamService { participants: layout.participants, defaultFilter, segments, + source: 'transcript', }; } } diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts new file mode 100644 index 00000000..e51c1fa8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts @@ -0,0 +1,293 @@ +import { + OpenCodeTaskLogAttributionStore, + OpenCodeTaskLogAttributionRecord, + OpenCodeTaskLogAttributionScope, + OpenCodeTaskLogAttributionSource, + OpenCodeTaskLogAttributionWriteResult, +} from './OpenCodeTaskLogAttributionStore'; + +export interface OpenCodeTaskLogAttributionWriter { + upsertTaskRecord( + teamName: string, + record: OpenCodeTaskLogAttributionRecord, + options?: { now?: Date } + ): Promise; + replaceTaskRecords( + teamName: string, + taskId: string, + records: OpenCodeTaskLogAttributionRecord[], + options?: { now?: Date } + ): Promise; + clearTaskRecords( + teamName: string, + taskId: string + ): Promise; +} + +export interface OpenCodeTaskLogAttributionRecordDraft { + memberName: string; + scope?: OpenCodeTaskLogAttributionScope; + sessionId?: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionTaskSessionInput { + teamName: string; + taskId: string; + memberName: string; + sessionId: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionMemberWindowInput { + teamName: string; + taskId: string; + memberName: string; + sessionId?: string; + since?: string | Date; + until?: string | Date; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionReplaceInput { + teamName: string; + taskId: string; + records: OpenCodeTaskLogAttributionRecordDraft[]; + source?: OpenCodeTaskLogAttributionSource; +} + +export interface OpenCodeTaskLogAttributionTaskInput { + teamName: string; + taskId: string; +} + +export interface OpenCodeTaskLogAttributionRecordWriteOutcome { + result: OpenCodeTaskLogAttributionWriteResult; + record: OpenCodeTaskLogAttributionRecord; +} + +export interface OpenCodeTaskLogAttributionBulkWriteOutcome { + result: OpenCodeTaskLogAttributionWriteResult; + recordCount: number; +} + +const VALID_SOURCES = new Set([ + 'manual', + 'launch_runtime', + 'reconcile', +]); +const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/; +const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const MAX_RUNTIME_ID_LENGTH = 256; + +function trimOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function requireString(field: string, value: unknown): string { + const trimmed = trimOptionalString(value); + if (!trimmed) { + throw new Error(`OpenCode task-log attribution ${field} is required`); + } + return trimmed; +} + +function requirePatternString(field: string, value: unknown, pattern: RegExp): string { + const trimmed = requireString(field, value); + if (!pattern.test(trimmed)) { + throw new Error(`OpenCode task-log attribution ${field} contains invalid characters`); + } + return trimmed; +} + +function trimRuntimeId(field: string, value: unknown): string | undefined { + const trimmed = trimOptionalString(value); + if (!trimmed) { + return undefined; + } + if (trimmed.length > MAX_RUNTIME_ID_LENGTH) { + throw new Error( + `OpenCode task-log attribution ${field} exceeds max length (${MAX_RUNTIME_ID_LENGTH})` + ); + } + return trimmed; +} + +function normalizeIso(field: string, value: string | Date | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + const timestamp = + value instanceof Date ? value.getTime() : Date.parse(requireString(field, value)); + if (!Number.isFinite(timestamp)) { + throw new Error(`OpenCode task-log attribution ${field} must be a valid timestamp`); + } + + return new Date(timestamp).toISOString(); +} + +function normalizeScope( + value: OpenCodeTaskLogAttributionScope | undefined +): OpenCodeTaskLogAttributionScope { + if (value === undefined) { + return 'member_session_window'; + } + if (value === 'task_session' || value === 'member_session_window') { + return value; + } + throw new Error('OpenCode task-log attribution scope is invalid'); +} + +function normalizeSource( + value: OpenCodeTaskLogAttributionSource | undefined, + fallback: OpenCodeTaskLogAttributionSource +): OpenCodeTaskLogAttributionSource { + const source = value ?? fallback; + if (!VALID_SOURCES.has(source)) { + throw new Error('OpenCode task-log attribution source is invalid'); + } + return source; +} + +function assertRecordPolicy(record: OpenCodeTaskLogAttributionRecord): void { + if (record.since && record.until && Date.parse(record.since) > Date.parse(record.until)) { + throw new Error('OpenCode task-log attribution since must be before or equal to until'); + } + + if (record.scope === 'task_session') { + if (!record.sessionId) { + throw new Error('OpenCode task-log attribution task_session requires sessionId'); + } + return; + } + + if (!record.since && !record.startMessageUuid) { + throw new Error( + 'OpenCode task-log attribution member_session_window requires since or startMessageUuid' + ); + } +} + +function buildRecord( + taskId: string, + draft: OpenCodeTaskLogAttributionRecordDraft, + fallbackSource: OpenCodeTaskLogAttributionSource +): OpenCodeTaskLogAttributionRecord { + const sessionId = trimRuntimeId('sessionId', draft.sessionId); + const since = normalizeIso('since', draft.since); + const until = normalizeIso('until', draft.until); + const startMessageUuid = trimRuntimeId('startMessageUuid', draft.startMessageUuid); + const endMessageUuid = trimRuntimeId('endMessageUuid', draft.endMessageUuid); + const record: OpenCodeTaskLogAttributionRecord = { + taskId: requirePatternString('taskId', taskId, TASK_ID_PATTERN), + memberName: requirePatternString('memberName', draft.memberName, MEMBER_NAME_PATTERN), + scope: normalizeScope(draft.scope), + ...(sessionId ? { sessionId } : {}), + ...(since ? { since } : {}), + ...(until ? { until } : {}), + ...(startMessageUuid ? { startMessageUuid } : {}), + ...(endMessageUuid ? { endMessageUuid } : {}), + source: normalizeSource(draft.source, fallbackSource), + }; + assertRecordPolicy(record); + return record; +} + +export class OpenCodeTaskLogAttributionService { + constructor( + private readonly writer: OpenCodeTaskLogAttributionWriter = new OpenCodeTaskLogAttributionStore(), + private readonly now: () => Date = () => new Date() + ) {} + + async recordTaskSession( + input: OpenCodeTaskLogAttributionTaskSessionInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const record = buildRecord( + requireString('taskId', input.taskId), + { + memberName: input.memberName, + scope: 'task_session', + sessionId: input.sessionId, + since: input.since, + until: input.until, + startMessageUuid: input.startMessageUuid, + endMessageUuid: input.endMessageUuid, + source: input.source, + }, + 'launch_runtime' + ); + + return { + result: await this.writer.upsertTaskRecord(teamName, record, { now: this.now() }), + record, + }; + } + + async recordMemberSessionWindow( + input: OpenCodeTaskLogAttributionMemberWindowInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const record = buildRecord( + requireString('taskId', input.taskId), + { + memberName: input.memberName, + scope: 'member_session_window', + sessionId: input.sessionId, + since: input.since, + until: input.until, + startMessageUuid: input.startMessageUuid, + endMessageUuid: input.endMessageUuid, + source: input.source, + }, + 'reconcile' + ); + + return { + result: await this.writer.upsertTaskRecord(teamName, record, { now: this.now() }), + record, + }; + } + + async replaceTaskAttribution( + input: OpenCodeTaskLogAttributionReplaceInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const taskId = requirePatternString('taskId', input.taskId, TASK_ID_PATTERN); + const fallbackSource = normalizeSource(input.source, 'reconcile'); + const records = input.records.map((record) => buildRecord(taskId, record, fallbackSource)); + + return { + result: await this.writer.replaceTaskRecords(teamName, taskId, records, { now: this.now() }), + recordCount: records.length, + }; + } + + async clearTaskAttribution( + input: OpenCodeTaskLogAttributionTaskInput + ): Promise { + const teamName = requirePatternString('teamName', input.teamName, TEAM_NAME_PATTERN); + const taskId = requirePatternString('taskId', input.taskId, TASK_ID_PATTERN); + + return { + result: await this.writer.clearTaskRecords(teamName, taskId), + recordCount: 0, + }; + } +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts new file mode 100644 index 00000000..0240999e --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts @@ -0,0 +1,481 @@ +import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '../../atomicWrite'; +import { withFileLock } from '../../fileLock'; + +const logger = createLogger('OpenCodeTaskLogAttributionStore'); + +const MAX_ATTRIBUTION_FILE_BYTES = 512 * 1024; + +export type OpenCodeTaskLogAttributionScope = 'task_session' | 'member_session_window'; +export type OpenCodeTaskLogAttributionSource = 'manual' | 'launch_runtime' | 'reconcile'; + +export interface OpenCodeTaskLogAttributionRecord { + taskId: string; + memberName: string; + scope: OpenCodeTaskLogAttributionScope; + sessionId?: string; + since?: string; + until?: string; + startMessageUuid?: string; + endMessageUuid?: string; + source?: OpenCodeTaskLogAttributionSource; + createdAt?: string; + updatedAt?: string; +} + +interface RawAttributionRecord extends Record { + taskId?: unknown; +} + +interface OpenCodeTaskLogAttributionFile { + schemaVersion: 1; + tasks: Record; +} + +export type OpenCodeTaskLogAttributionWriteResult = 'created' | 'updated' | 'unchanged' | 'deleted'; + +export interface OpenCodeTaskLogAttributionReader { + readTaskRecords(teamName: string, taskId: string): Promise; +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeIso(value: unknown): string | undefined { + const trimmed = trimString(value); + if (!trimmed) { + return undefined; + } + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; +} + +function normalizeScope(value: unknown): OpenCodeTaskLogAttributionScope { + return value === 'task_session' ? 'task_session' : 'member_session_window'; +} + +function normalizeSource(value: unknown): OpenCodeTaskLogAttributionSource | undefined { + return value === 'manual' || value === 'launch_runtime' || value === 'reconcile' + ? value + : undefined; +} + +function normalizeRecord( + taskId: string, + raw: RawAttributionRecord +): OpenCodeTaskLogAttributionRecord | null { + const memberName = trimString(raw.memberName); + if (!memberName) { + return null; + } + + const since = normalizeIso(raw.since); + const until = normalizeIso(raw.until); + if (since && until && Date.parse(since) > Date.parse(until)) { + return null; + } + const sessionId = trimString(raw.sessionId); + const startMessageUuid = trimString(raw.startMessageUuid); + const endMessageUuid = trimString(raw.endMessageUuid); + const source = normalizeSource(raw.source); + const createdAt = normalizeIso(raw.createdAt); + const updatedAt = normalizeIso(raw.updatedAt); + + return { + taskId, + memberName, + scope: normalizeScope(raw.scope), + ...(sessionId ? { sessionId } : {}), + ...(since ? { since } : {}), + ...(until ? { until } : {}), + ...(startMessageUuid ? { startMessageUuid } : {}), + ...(endMessageUuid ? { endMessageUuid } : {}), + ...(source ? { source } : {}), + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +function extractRawRecords(parsed: unknown, taskId: string): RawAttributionRecord[] { + if (!parsed || typeof parsed !== 'object') { + return []; + } + + const file = parsed as Record; + if (file.schemaVersion !== 1) { + return []; + } + + const rawRecords: RawAttributionRecord[] = []; + if (file.tasks && typeof file.tasks === 'object' && !Array.isArray(file.tasks)) { + const taskRecords = (file.tasks as Record)[taskId]; + if (Array.isArray(taskRecords)) { + for (const record of taskRecords) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push(record as RawAttributionRecord); + } + } + } + } + + if (Array.isArray(file.records)) { + for (const record of file.records) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + continue; + } + const raw = record as RawAttributionRecord; + if (trimString(raw.taskId) === taskId) { + rawRecords.push(raw); + } + } + } + + return rawRecords; +} + +function extractAllRawRecords(parsed: unknown): RawAttributionRecord[] { + if (!parsed || typeof parsed !== 'object') { + return []; + } + + const file = parsed as Record; + if (file.schemaVersion !== 1) { + return []; + } + + const rawRecords: RawAttributionRecord[] = []; + if (file.tasks && typeof file.tasks === 'object' && !Array.isArray(file.tasks)) { + for (const [taskId, taskRecords] of Object.entries(file.tasks as Record)) { + if (!Array.isArray(taskRecords)) { + continue; + } + for (const record of taskRecords) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push({ + ...(record as RawAttributionRecord), + taskId, + }); + } + } + } + } + + if (Array.isArray(file.records)) { + for (const record of file.records) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + rawRecords.push(record as RawAttributionRecord); + } + } + } + + return rawRecords; +} + +function dedupeRecords( + records: OpenCodeTaskLogAttributionRecord[] +): OpenCodeTaskLogAttributionRecord[] { + const deduped = new Map(); + for (const record of records) { + deduped.set( + [ + record.taskId, + record.memberName.trim().toLowerCase(), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.until ?? '', + record.startMessageUuid ?? '', + record.endMessageUuid ?? '', + ].join('\0'), + record + ); + } + return Array.from(deduped.values()).sort((left, right) => { + const leftStart = left.since ?? left.createdAt ?? ''; + const rightStart = right.since ?? right.createdAt ?? ''; + if (leftStart !== rightStart) { + return leftStart.localeCompare(rightStart); + } + return left.memberName.localeCompare(right.memberName); + }); +} + +function buildUpsertKey(record: OpenCodeTaskLogAttributionRecord): string { + return JSON.stringify([ + record.taskId, + record.memberName.trim().toLowerCase(), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.startMessageUuid ?? '', + ]); +} + +function canonicalizeFile( + records: OpenCodeTaskLogAttributionRecord[] +): OpenCodeTaskLogAttributionFile { + const byTask = new Map(); + for (const record of records) { + const existing = byTask.get(record.taskId) ?? []; + existing.push(record); + byTask.set(record.taskId, existing); + } + + const tasks: Record = {}; + for (const [taskId, taskRecords] of [...byTask.entries()].sort(([left], [right]) => + left.localeCompare(right) + )) { + const normalized = dedupeRecords(taskRecords); + if (normalized.length > 0) { + tasks[taskId] = normalized; + } + } + + return { + schemaVersion: 1, + tasks, + }; +} + +function normalizeRecordForWrite( + record: OpenCodeTaskLogAttributionRecord +): OpenCodeTaskLogAttributionRecord | null { + return normalizeRecord(record.taskId, record as unknown as RawAttributionRecord); +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function stripAuditFields( + record: OpenCodeTaskLogAttributionRecord +): Omit { + const { createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = record; + return rest; +} + +export function getOpenCodeTaskLogAttributionPath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, OPENCODE_TASK_LOG_ATTRIBUTION_FILE); +} + +export class OpenCodeTaskLogAttributionStore implements OpenCodeTaskLogAttributionReader { + private async readFileForWrite(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + throw new Error(`OpenCode task-log attribution path is not a file: ${filePath}`); + } + if (stat.size > MAX_ATTRIBUTION_FILE_BYTES) { + throw new Error(`OpenCode task-log attribution file is too large: ${filePath}`); + } + + const raw = await readFileUtf8WithTimeout(filePath, 5_000); + const parsed = JSON.parse(raw) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as { schemaVersion?: unknown }).schemaVersion !== 1 + ) { + throw new Error(`Unsupported OpenCode task-log attribution schema: ${filePath}`); + } + + return canonicalizeFile( + extractAllRawRecords(parsed) + .map((record) => { + const taskId = trimString(record.taskId); + return taskId ? normalizeRecord(taskId, record) : null; + }) + .filter((record): record is OpenCodeTaskLogAttributionRecord => record !== null) + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { schemaVersion: 1, tasks: {} }; + } + if (error instanceof SyntaxError) { + throw new Error(`Invalid OpenCode task-log attribution JSON: ${filePath}`); + } + throw error; + } + } + + private async writeFileIfChanged( + filePath: string, + previous: OpenCodeTaskLogAttributionFile, + next: OpenCodeTaskLogAttributionFile + ): Promise { + if (sameJson(previous, next)) { + return false; + } + + await atomicWriteAsync(filePath, `${JSON.stringify(next, null, 2)}\n`); + return true; + } + + async readTaskRecords( + teamName: string, + taskId: string + ): Promise { + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size > MAX_ATTRIBUTION_FILE_BYTES) { + return []; + } + + const raw = await readFileUtf8WithTimeout(filePath, 5_000); + const parsed = JSON.parse(raw) as unknown; + return dedupeRecords( + extractRawRecords(parsed, taskId) + .map((record) => normalizeRecord(taskId, record)) + .filter((record): record is OpenCodeTaskLogAttributionRecord => record !== null) + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + if (error instanceof SyntaxError) { + logger.warn(`[${teamName}/${taskId}] invalid OpenCode task-log attribution JSON`); + return []; + } + if (error instanceof FileReadTimeoutError) { + logger.warn(`[${teamName}/${taskId}] OpenCode task-log attribution read timed out`); + return []; + } + logger.warn( + `[${teamName}/${taskId}] failed to read OpenCode task-log attribution: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return []; + } + } + + async upsertTaskRecord( + teamName: string, + record: OpenCodeTaskLogAttributionRecord, + options?: { now?: Date } + ): Promise { + const normalized = normalizeRecordForWrite(record); + if (!normalized) { + throw new Error('Invalid OpenCode task-log attribution record'); + } + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + const now = (options?.now ?? new Date()).toISOString(); + const taskRecords = previous.tasks[normalized.taskId] ?? []; + const targetKey = buildUpsertKey(normalized); + const existingIndex = taskRecords.findIndex( + (candidate) => buildUpsertKey(candidate) === targetKey + ); + const existingRecord = existingIndex >= 0 ? taskRecords[existingIndex] : undefined; + if ( + existingRecord && + sameJson(stripAuditFields(existingRecord), stripAuditFields(normalized)) + ) { + return 'unchanged'; + } + + const nextRecord: OpenCodeTaskLogAttributionRecord = { + ...normalized, + createdAt: existingRecord?.createdAt ?? normalized.createdAt ?? now, + updatedAt: now, + }; + + const nextTaskRecords = + existingIndex >= 0 + ? taskRecords.map((candidate, index) => + index === existingIndex ? nextRecord : candidate + ) + : [...taskRecords, nextRecord]; + const next = canonicalizeFile([ + ...Object.entries(previous.tasks).flatMap(([taskId, records]) => + taskId === normalized.taskId ? [] : records + ), + ...nextTaskRecords, + ]); + const changed = await this.writeFileIfChanged(filePath, previous, next); + if (!changed) { + return 'unchanged'; + } + return existingIndex >= 0 ? 'updated' : 'created'; + }); + } + + async replaceTaskRecords( + teamName: string, + taskId: string, + records: OpenCodeTaskLogAttributionRecord[], + options?: { now?: Date } + ): Promise { + const normalizedTaskId = trimString(taskId); + if (!normalizedTaskId) { + throw new Error('Invalid OpenCode task-log attribution task id'); + } + + const now = (options?.now ?? new Date()).toISOString(); + const normalizedRecords = records.map((record) => + normalizeRecordForWrite({ + ...record, + taskId: normalizedTaskId, + createdAt: record.createdAt ?? now, + updatedAt: record.updatedAt ?? now, + }) + ); + if (normalizedRecords.some((record) => record === null)) { + throw new Error('Invalid OpenCode task-log attribution record'); + } + const validRecords = normalizedRecords as OpenCodeTaskLogAttributionRecord[]; + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + const next = canonicalizeFile([ + ...Object.entries(previous.tasks).flatMap(([candidateTaskId, taskRecords]) => + candidateTaskId === normalizedTaskId ? [] : taskRecords + ), + ...validRecords, + ]); + const changed = await this.writeFileIfChanged(filePath, previous, next); + return changed ? 'updated' : 'unchanged'; + }); + } + + async clearTaskRecords( + teamName: string, + taskId: string + ): Promise { + const normalizedTaskId = trimString(taskId); + if (!normalizedTaskId) { + throw new Error('Invalid OpenCode task-log attribution task id'); + } + + const filePath = getOpenCodeTaskLogAttributionPath(teamName); + return withFileLock(filePath, async () => { + const previous = await this.readFileForWrite(filePath); + if (!previous.tasks[normalizedTaskId]) { + return 'unchanged'; + } + + const next = canonicalizeFile( + Object.entries(previous.tasks).flatMap(([candidateTaskId, taskRecords]) => + candidateTaskId === normalizedTaskId ? [] : taskRecords + ) + ); + await this.writeFileIfChanged(filePath, previous, next); + return 'deleted'; + }); + } +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts new file mode 100644 index 00000000..b69a9109 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -0,0 +1,1122 @@ +import { createLogger } from '@shared/utils/logger'; + +import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; +import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; +import { TeamTaskReader } from '../../TeamTaskReader'; +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; + +import type { + OpenCodeRuntimeTranscriptLogContentBlock, + OpenCodeRuntimeTranscriptLogMessage, +} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { + OpenCodeTaskLogAttributionReader, + OpenCodeTaskLogAttributionRecord, +} from './OpenCodeTaskLogAttributionStore'; +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, + TeamTask, +} from '@shared/types'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; + +const logger = createLogger('OpenCodeTaskLogStreamSource'); + +const CACHE_TTL_MS = 1_500; +const HEURISTIC_TRANSCRIPT_LIMIT = 200; +const ATTRIBUTED_TRANSCRIPT_LIMIT = 500; +const WINDOW_GRACE_BEFORE_MS = 30_000; +const WINDOW_GRACE_AFTER_MS = 15_000; +const ATTRIBUTION_WINDOW_GRACE_MS = 1_000; +const TASK_MARKER_CONTEXT_BEFORE_MESSAGES = 1; +const TASK_MARKER_CONTEXT_MAX_MS = 5 * 60_000; + +const TASK_LOG_MARKER_TOOL_NAMES = new Set([ + 'task_start', + 'task_complete', + 'task_set_status', + 'task_set_owner', + 'task_add_comment', + 'task_attach_file', + 'task_attach_comment_file', + 'task_set_clarification', + 'review_start', + 'review_request', + 'review_approve', + 'review_request_changes', +]); + +const TERMINAL_TASK_MARKER_TOOL_NAMES = new Set([ + 'task_complete', + 'review_approve', + 'review_request_changes', +]); + +const TERMINAL_TASK_SET_STATUS_VALUES = new Set(['completed', 'pending', 'deleted']); + +const TASK_REFERENCE_KEYS = new Set([ + 'taskid', + 'task_id', + 'targetid', + 'targettaskid', + 'target_task_id', + 'canonicalid', + 'canonical_id', + 'displayid', + 'display_id', +]); + +const TEAM_REFERENCE_KEYS = new Set(['team', 'teamid', 'team_id', 'teamname', 'team_name']); + +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +interface TaskMarkerCall { + toolName: string; + input: unknown; +} + +interface TaskMarkerMatch { + index: number; + markerCalls: TaskMarkerCall[]; + windowIndex: number | null; +} + +interface BinaryResolverLike { + resolve(): Promise; +} + +interface MemberProjectedMessages { + memberName: string; + sessionId?: string; + messages: ParsedMessage[]; +} + +interface TaskMarkerProjection { + messages: ParsedMessage[]; + markerMatchCount: number; + markerSpanCount: number; +} + +type HeuristicFallbackReason = + | 'no_attribution_records' + | 'attribution_no_projected_messages' + | 'task_tool_markers'; + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function buildParticipantKey(memberName: string): string { + return `member:${normalizeMemberName(memberName)}`; +} + +function buildParticipant(memberName: string): BoardTaskLogParticipant { + return { + key: buildParticipantKey(memberName), + label: memberName, + role: 'member', + isLead: false, + isSidechain: true, + }; +} + +function buildActor(memberName: string, sessionId: string | undefined): BoardTaskLogActor { + return { + memberName, + role: 'member', + sessionId: sessionId?.trim() || `opencode:${normalizeMemberName(memberName)}`, + isSidechain: true, + }; +} + +function stableTaskWindowKey(task: TeamTask): string { + const intervals = (task.workIntervals ?? []) + .map((interval) => `${interval.startedAt}:${interval.completedAt ?? ''}`) + .join('|'); + return [task.id, task.owner ?? '', task.createdAt ?? '', task.updatedAt ?? '', intervals].join( + '::' + ); +} + +function stableAttributionKey(records: OpenCodeTaskLogAttributionRecord[]): string { + if (records.length === 0) { + return 'no-attribution'; + } + + return records + .map((record) => + JSON.stringify([ + normalizeMemberName(record.memberName), + record.scope, + record.sessionId ?? '', + record.since ?? '', + record.until ?? '', + record.startMessageUuid ?? '', + record.endMessageUuid ?? '', + ]) + ) + .sort() + .join('|'); +} + +function normalizeTaskRef(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') { + return null; + } + + const normalized = String(value).trim().replace(/^#/, '').toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function buildTaskRefSet(task: TeamTask): Set { + return new Set( + [task.id, task.displayId] + .map(normalizeTaskRef) + .filter((value): value is string => value !== null) + ); +} + +function valueReferencesTask(value: unknown, taskRefs: Set, depth = 0): boolean { + if (depth > 4 || value === null || value === undefined || taskRefs.size === 0) { + return false; + } + + const normalized = normalizeTaskRef(value); + if (normalized && taskRefs.has(normalized)) { + return true; + } + + if (Array.isArray(value)) { + return value.some((item) => valueReferencesTask(item, taskRefs, depth + 1)); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).some(([key, nestedValue]) => { + const normalizedKey = key.toLowerCase(); + if (TASK_REFERENCE_KEYS.has(normalizedKey)) { + return valueReferencesTask(nestedValue, taskRefs, depth + 1); + } + return depth < 2 && valueReferencesTask(nestedValue, taskRefs, depth + 1); + }); + } + + return false; +} + +function collectNormalizedRefs(value: unknown, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + const normalized = normalizeTaskRef(value); + if (normalized) { + refs.add(normalized); + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectNormalizedRefs(item, depth + 1)) { + refs.add(ref); + } + } + } else if (typeof value === 'object') { + for (const nestedValue of Object.values(value as Record)) { + for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) { + refs.add(ref); + } + } + } + + return refs; +} + +function collectExplicitRefsForKeys(value: unknown, keys: Set, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectExplicitRefsForKeys(item, keys, depth + 1)) { + refs.add(ref); + } + } + return refs; + } + + if (typeof value !== 'object') { + return refs; + } + + for (const [key, nestedValue] of Object.entries(value as Record)) { + if (keys.has(key.toLowerCase())) { + for (const ref of collectNormalizedRefs(nestedValue)) { + refs.add(ref); + } + continue; + } + + for (const ref of collectExplicitRefsForKeys(nestedValue, keys, depth + 1)) { + refs.add(ref); + } + } + + return refs; +} + +function refsIntersect(left: Set, right: Set): boolean { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +function markerInputReferencesTaskInTeam( + input: unknown, + teamName: string, + taskRefs: Set +): boolean { + const normalizedTeamName = normalizeTaskRef(teamName); + const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS); + if ( + normalizedTeamName && + explicitTeamRefs.size > 0 && + !explicitTeamRefs.has(normalizedTeamName) + ) { + return false; + } + + const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS); + if (explicitTaskRefs.size > 0) { + return refsIntersect(explicitTaskRefs, taskRefs); + } + + return valueReferencesTask(input, taskRefs); +} + +function collectTaskMarkerCalls( + message: ParsedMessage, + teamName: string, + taskRefs: Set +): TaskMarkerCall[] { + if (taskRefs.size === 0) { + return []; + } + + return message.toolCalls.flatMap((toolCall) => { + const toolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '').toLowerCase(); + return TASK_LOG_MARKER_TOOL_NAMES.has(toolName) && + markerInputReferencesTaskInTeam(toolCall.input, teamName, taskRefs) + ? [{ toolName, input: toolCall.input }] + : []; + }); +} + +function isTerminalTaskMarkerCall(markerCall: TaskMarkerCall): boolean { + if (TERMINAL_TASK_MARKER_TOOL_NAMES.has(markerCall.toolName)) { + return true; + } + + if ( + markerCall.toolName === 'task_set_status' && + markerCall.input && + typeof markerCall.input === 'object' && + !Array.isArray(markerCall.input) + ) { + const status = (markerCall.input as Record).status; + return ( + typeof status === 'string' && TERMINAL_TASK_SET_STATUS_VALUES.has(status.trim().toLowerCase()) + ); + } + + return false; +} + +function isTerminalTaskMarkerMatch(match: TaskMarkerMatch): boolean { + return match.markerCalls.some(isTerminalTaskMarkerCall); +} + +function sortParsedMessagesByTime(messages: ParsedMessage[]): ParsedMessage[] { + return messages + .map((message, index) => ({ message, index })) + .sort((left, right) => { + const timeDiff = left.message.timestamp.getTime() - right.message.timestamp.getTime(); + return timeDiff !== 0 ? timeDiff : left.index - right.index; + }) + .map(({ message }) => message); +} + +function isWithinSingleTimeWindow(timestamp: Date, window: TimeWindow): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + + const endMs = window.endMs ?? Date.now(); + return messageTime >= window.startMs && messageTime <= endMs; +} + +function findContainingWindowIndex(timestamp: Date, windows: TimeWindow[]): number | null { + if (windows.length === 0) { + return null; + } + + const index = windows.findIndex((window) => isWithinSingleTimeWindow(timestamp, window)); + return index >= 0 ? index : null; +} + +function groupMarkerMatchesByWindow(matches: TaskMarkerMatch[]): TaskMarkerMatch[][] { + const groups = new Map(); + for (const match of matches) { + if (match.windowIndex === null) { + continue; + } + const existing = groups.get(match.windowIndex) ?? []; + existing.push(match); + groups.set(match.windowIndex, existing); + } + + return [...groups.entries()].sort(([left], [right]) => left - right).map(([, group]) => group); +} + +function groupMarkerMatchesByLifecycle(matches: TaskMarkerMatch[]): TaskMarkerMatch[][] { + const groups: TaskMarkerMatch[][] = []; + let currentGroup: TaskMarkerMatch[] = []; + + for (const match of matches) { + currentGroup.push(match); + if (isTerminalTaskMarkerMatch(match)) { + groups.push(currentGroup); + currentGroup = []; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +function groupMarkerMatches( + matches: TaskMarkerMatch[], + windows: TimeWindow[] +): TaskMarkerMatch[][] { + return windows.length > 0 + ? groupMarkerMatchesByWindow(matches) + : groupMarkerMatchesByLifecycle(matches); +} + +function shouldIncludeMarkerContext( + previousMessage: ParsedMessage | undefined, + markerMessage: ParsedMessage +): boolean { + if (!previousMessage || previousMessage.isMeta) { + return false; + } + + if (markerMessage.parentUuid && previousMessage.uuid === markerMessage.parentUuid) { + return true; + } + + const diffMs = markerMessage.timestamp.getTime() - previousMessage.timestamp.getTime(); + return ( + previousMessage.type === 'user' && + Number.isFinite(diffMs) && + diffMs >= 0 && + diffMs <= TASK_MARKER_CONTEXT_MAX_MS + ); +} + +function resolveMarkerSpanStart(messages: ParsedMessage[], markerIndex: number): number { + const contextIndex = markerIndex - TASK_MARKER_CONTEXT_BEFORE_MESSAGES; + if ( + contextIndex >= 0 && + shouldIncludeMarkerContext(messages[contextIndex], messages[markerIndex]) + ) { + return contextIndex; + } + return markerIndex; +} + +function findLastMessageIndexInWindow( + messages: ParsedMessage[], + startIndex: number, + window: TimeWindow +): number { + let endIndex = startIndex; + for (let index = startIndex + 1; index < messages.length; index += 1) { + if (!isWithinSingleTimeWindow(messages[index].timestamp, window)) { + break; + } + endIndex = index; + } + return endIndex; +} + +function extendSpanEndForToolResults( + messages: ParsedMessage[], + startIndex: number, + endIndex: number +): number { + const includedAssistantUuids = new Set(); + for (let index = startIndex; index <= endIndex; index += 1) { + const message = messages[index]; + if (message?.type === 'assistant') { + includedAssistantUuids.add(message.uuid); + } + } + + let extendedEndIndex = endIndex; + while (extendedEndIndex + 1 < messages.length) { + const nextMessage = messages[extendedEndIndex + 1]; + if ( + !nextMessage?.isMeta || + !nextMessage.sourceToolAssistantUUID || + !includedAssistantUuids.has(nextMessage.sourceToolAssistantUUID) + ) { + break; + } + extendedEndIndex += 1; + } + + return extendedEndIndex; +} + +function buildMarkerSpan( + messages: ParsedMessage[], + markerGroup: TaskMarkerMatch[], + windows: TimeWindow[] +): { startIndex: number; endIndex: number } | null { + const firstMarker = markerGroup[0]; + const lastMarker = markerGroup[markerGroup.length - 1]; + if (!firstMarker || !lastMarker) { + return null; + } + + const startIndex = resolveMarkerSpanStart(messages, firstMarker.index); + let endIndex = lastMarker.index; + const window = + lastMarker.windowIndex === null ? undefined : (windows[lastMarker.windowIndex] ?? undefined); + + if (!isTerminalTaskMarkerMatch(lastMarker) && window) { + endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window); + } + + return { + startIndex, + endIndex: extendSpanEndForToolResults(messages, startIndex, endIndex), + }; +} + +function buildTaskMarkerProjection( + projectedMessages: OpenCodeRuntimeTranscriptLogMessage[], + teamName: string, + task: TeamTask +): TaskMarkerProjection | null { + const parsedMessages = sortParsedMessagesByTime( + projectedMessages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null) + ); + const taskRefs = buildTaskRefSet(task); + const taskWindows = buildTaskTimeWindows(task); + + const markerMatches = parsedMessages.flatMap((message, index) => { + const markerCalls = collectTaskMarkerCalls(message, teamName, taskRefs); + const windowIndex = findContainingWindowIndex(message.timestamp, taskWindows); + return markerCalls.length > 0 && (taskWindows.length === 0 || windowIndex !== null) + ? [{ index, markerCalls, windowIndex }] + : []; + }); + if (markerMatches.length === 0) { + return null; + } + + const spans = groupMarkerMatches(markerMatches, taskWindows) + .map((group) => buildMarkerSpan(parsedMessages, group, taskWindows)) + .filter((span): span is { startIndex: number; endIndex: number } => span !== null); + const includedIndexes = new Set(); + for (const span of spans) { + for (let index = span.startIndex; index <= span.endIndex; index += 1) { + includedIndexes.add(index); + } + } + + const messages = [...includedIndexes] + .sort((left, right) => left - right) + .map((index) => parsedMessages[index]) + .filter((message): message is ParsedMessage => message !== undefined); + const markerMatchCount = markerMatches.reduce( + (count, match) => count + match.markerCalls.length, + 0 + ); + + return messages.length > 0 + ? { + messages, + markerMatchCount, + markerSpanCount: spans.length, + } + : null; +} + +function buildTaskTimeWindows(task: TeamTask): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + return []; +} + +function buildAttributionTimeWindows(record: OpenCodeTaskLogAttributionRecord): TimeWindow[] { + const sinceMs = record.since ? Date.parse(record.since) : Number.NaN; + const untilMs = record.until ? Date.parse(record.until) : Number.NaN; + if (!Number.isFinite(sinceMs) && !Number.isFinite(untilMs)) { + return []; + } + + return [ + { + startMs: Number.isFinite(sinceMs) + ? sinceMs - ATTRIBUTION_WINDOW_GRACE_MS + : Number.NEGATIVE_INFINITY, + endMs: Number.isFinite(untilMs) ? untilMs + ATTRIBUTION_WINDOW_GRACE_MS : null, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function filterByMessageUuidRange( + messages: ParsedMessage[], + record: OpenCodeTaskLogAttributionRecord +): ParsedMessage[] { + const startIndex = record.startMessageUuid + ? messages.findIndex((message) => message.uuid === record.startMessageUuid) + : 0; + if (startIndex < 0) { + return []; + } + + const endIndex = record.endMessageUuid + ? messages.findIndex((message) => message.uuid === record.endMessageUuid) + : messages.length - 1; + if (endIndex < 0 || endIndex < startIndex) { + return []; + } + + return messages.slice(startIndex, endIndex + 1); +} + +function filterMessagesForAttribution( + messages: OpenCodeRuntimeTranscriptLogMessage[], + record: OpenCodeTaskLogAttributionRecord +): ParsedMessage[] { + const parsedMessages = messages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null); + + const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid); + const hasTimeBounds = Boolean(record.since || record.until); + const canUseTaskSessionScope = record.scope === 'task_session' && Boolean(record.sessionId); + if (!hasMessageBounds && !hasTimeBounds && !canUseTaskSessionScope) { + return []; + } + + const rangeFiltered = filterByMessageUuidRange(parsedMessages, record); + const windows = buildAttributionTimeWindows(record); + return rangeFiltered + .filter((message) => isWithinTimeWindows(message.timestamp, windows)) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); +} + +function mapOpenCodeContentBlock( + block: OpenCodeRuntimeTranscriptLogContentBlock +): ContentBlock | null { + switch (block.type) { + case 'text': + return { type: 'text', text: block.text }; + case 'thinking': + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + }; + case 'tool_use': + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }; + case 'tool_result': + return { + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: Array.isArray(block.content) + ? block.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null) + : block.content, + ...(block.is_error ? { is_error: true } : {}), + }; + default: + return null; + } +} + +function buildToolUseResultData( + message: OpenCodeRuntimeTranscriptLogMessage +): ToolUseResultData | undefined { + if (!message.sourceToolUseID || message.toolResults.length !== 1) { + return undefined; + } + + const toolResult = message.toolResults[0]; + if (!toolResult) { + return undefined; + } + + return { + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + }; +} + +function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null { + const timestamp = new Date(message.timestamp); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const normalizedContent: ContentBlock[] | string = + typeof message.content === 'string' + ? message.content + : message.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null); + + const toolCalls = message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + isTask: toolCall.isTask, + ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), + ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), + })); + + const toolResults = message.toolResults.map((toolResult) => ({ + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + })); + const toolUseResult = buildToolUseResultData(message); + + return { + uuid: message.uuid, + parentUuid: message.parentUuid, + type: message.type, + timestamp, + role: message.role, + content: normalizedContent, + model: message.model, + agentName: message.agentName, + isSidechain: true, + isMeta: message.isMeta, + sessionId: message.sessionId, + toolCalls, + toolResults, + ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + ...(message.subtype ? { subtype: message.subtype } : {}), + ...(message.level ? { level: message.level } : {}), + }; +} + +export class OpenCodeTaskLogStreamSource { + private readonly cache = new Map< + string, + { + expiresAt: number; + response: BoardTaskLogStreamResponse | null; + } + >(); + + private readonly inFlight = new Map>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService = new ClaudeMultimodelBridgeService(), + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver, + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly attributionStore: OpenCodeTaskLogAttributionReader = new OpenCodeTaskLogAttributionStore() + ) {} + + private async resolveTask(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + ]); + return [...activeTasks, ...deletedTasks].find((task) => task.id === taskId) ?? null; + } + + async getTaskLogStream( + teamName: string, + taskId: string + ): Promise { + const task = await this.resolveTask(teamName, taskId); + if (!task) { + return null; + } + + const attributionRecords = await this.attributionStore.readTaskRecords(teamName, taskId); + if (!task.owner?.trim() && attributionRecords.length === 0) { + return null; + } + + const cacheKey = `${teamName}::${stableTaskWindowKey(task)}::${stableAttributionKey(attributionRecords)}`; + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.response; + } + + const existingPromise = this.inFlight.get(cacheKey); + if (existingPromise) { + return await existingPromise; + } + + const promise = this.buildTaskLogStream(teamName, task, attributionRecords) + .catch((error) => { + logger.warn( + `[${teamName}/${task.id}] OpenCode task-log fallback failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + }) + .then((response) => { + this.cache.set(cacheKey, { + expiresAt: Date.now() + CACHE_TTL_MS, + response, + }); + return response; + }) + .finally(() => { + this.inFlight.delete(cacheKey); + }); + + this.inFlight.set(cacheKey, promise); + return await promise; + } + + private async buildTaskLogStream( + teamName: string, + task: TeamTask, + attributionRecords: OpenCodeTaskLogAttributionRecord[] + ): Promise { + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return null; + } + + let fallbackReason: HeuristicFallbackReason = 'no_attribution_records'; + if (attributionRecords.length > 0) { + const attributedResponse = await this.buildAttributedTaskLogStream( + binaryPath, + teamName, + task, + attributionRecords + ); + if (attributedResponse) { + return attributedResponse; + } + fallbackReason = 'attribution_no_projected_messages'; + } + + return await this.buildHeuristicTaskLogStream(binaryPath, teamName, task, { + attributionRecordCount: attributionRecords.length, + fallbackReason, + }); + } + + private async buildHeuristicTaskLogStream( + binaryPath: string, + teamName: string, + task: TeamTask, + projectionContext: { + attributionRecordCount: number; + fallbackReason: HeuristicFallbackReason; + } + ): Promise { + const ownerName = task.owner?.trim(); + if (!ownerName) { + return null; + } + + const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: teamName, + memberName: ownerName, + limit: HEURISTIC_TRANSCRIPT_LIMIT, + }); + + const projectedMessages = transcript?.logProjection?.messages ?? []; + if (projectedMessages.length === 0) { + return null; + } + + const markerProjection = buildTaskMarkerProjection(projectedMessages, teamName, task); + const timeWindows = markerProjection ? [] : buildTaskTimeWindows(task); + const projectionReason: HeuristicFallbackReason = markerProjection + ? 'task_tool_markers' + : projectionContext.fallbackReason; + const filteredMessages = + markerProjection?.messages ?? + projectedMessages + .map(toParsedMessage) + .filter((message): message is ParsedMessage => message !== null) + .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + + if (filteredMessages.length === 0) { + return null; + } + + const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages); + if (chunks.length === 0) { + return null; + } + + const firstMessage = filteredMessages[0]; + const lastMessage = filteredMessages[filteredMessages.length - 1]; + if (!firstMessage || !lastMessage) { + return null; + } + + const actor = buildActor(ownerName, transcript?.sessionId ?? firstMessage.sessionId); + const participant = buildParticipant(ownerName); + const segment: BoardTaskLogSegment = { + id: `opencode:${teamName}:${task.id}:${normalizeMemberName(ownerName)}`, + participantKey: participant.key, + actor, + startTimestamp: firstMessage.timestamp.toISOString(), + endTimestamp: lastMessage.timestamp.toISOString(), + chunks, + }; + + logger.debug( + `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName})` + ); + + return { + participants: [participant], + defaultFilter: participant.key, + segments: [segment], + source: 'opencode_runtime_fallback', + runtimeProjection: { + provider: 'opencode', + mode: 'heuristic', + attributionRecordCount: projectionContext.attributionRecordCount, + projectedMessageCount: filteredMessages.length, + fallbackReason: projectionReason, + ...(markerProjection + ? { + markerMatchCount: markerProjection.markerMatchCount, + markerSpanCount: markerProjection.markerSpanCount, + } + : {}), + }, + }; + } + + private async buildAttributedTaskLogStream( + binaryPath: string, + teamName: string, + task: TeamTask, + attributionRecords: OpenCodeTaskLogAttributionRecord[] + ): Promise { + const projectedByParticipant = new Map(); + const transcriptCache = new Map< + string, + Awaited> + >(); + + for (const record of attributionRecords) { + const memberName = record.memberName.trim(); + if (!memberName) { + continue; + } + + const memberKey = normalizeMemberName(memberName); + if (!transcriptCache.has(memberKey)) { + transcriptCache.set( + memberKey, + await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: teamName, + memberName, + limit: ATTRIBUTED_TRANSCRIPT_LIMIT, + }) + ); + } + + const transcript = transcriptCache.get(memberKey); + if (!transcript) { + continue; + } + if (record.sessionId && transcript.sessionId !== record.sessionId) { + continue; + } + + const filteredMessages = filterMessagesForAttribution( + transcript.logProjection?.messages ?? [], + record + ); + if (filteredMessages.length === 0) { + continue; + } + + const participantKey = buildParticipantKey(memberName); + const existing = projectedByParticipant.get(participantKey); + if (existing) { + const seen = new Set(existing.messages.map((message) => message.uuid)); + for (const message of filteredMessages) { + if (!seen.has(message.uuid)) { + existing.messages.push(message); + seen.add(message.uuid); + } + } + existing.messages.sort( + (left, right) => left.timestamp.getTime() - right.timestamp.getTime() + ); + } else { + projectedByParticipant.set(participantKey, { + memberName, + sessionId: transcript.sessionId ?? record.sessionId, + messages: filteredMessages, + }); + } + } + + const members = Array.from(projectedByParticipant.values()).filter( + (item) => item.messages.length > 0 + ); + if (members.length === 0) { + logger.debug( + `[${teamName}/${task.id}] OpenCode task-log attribution yielded no projected messages; falling back to owner/time-window heuristic` + ); + return null; + } + + const participants: BoardTaskLogParticipant[] = []; + const segments: BoardTaskLogSegment[] = []; + let projectedMessageCount = 0; + for (const member of members.sort((left, right) => { + const leftStart = left.messages[0]?.timestamp.getTime() ?? 0; + const rightStart = right.messages[0]?.timestamp.getTime() ?? 0; + if (leftStart !== rightStart) { + return leftStart - rightStart; + } + return left.memberName.localeCompare(right.memberName); + })) { + const chunks = this.chunkBuilder.buildBundleChunks(member.messages); + if (chunks.length === 0) { + continue; + } + + const firstMessage = member.messages[0]; + const lastMessage = member.messages[member.messages.length - 1]; + if (!firstMessage || !lastMessage) { + continue; + } + + const participant = buildParticipant(member.memberName); + projectedMessageCount += member.messages.length; + participants.push(participant); + segments.push({ + id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`, + participantKey: participant.key, + actor: buildActor(member.memberName, member.sessionId ?? firstMessage.sessionId), + startTimestamp: firstMessage.timestamp.toISOString(), + endTimestamp: lastMessage.timestamp.toISOString(), + chunks, + }); + } + + if (segments.length === 0) { + return null; + } + + logger.debug( + `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s))` + ); + + return { + participants, + defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all', + segments, + source: 'opencode_runtime_attribution', + runtimeProjection: { + provider: 'opencode', + mode: 'attribution', + attributionRecordCount: attributionRecords.length, + projectedMessageCount, + }, + }; + } +} diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 9b415bf0..da249f22 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -279,6 +279,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Codex'; case 'gemini': return 'Gemini'; + case 'opencode': + return 'OpenCode'; } } @@ -1132,7 +1134,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleProviderRefresh = useCallback( (providerId: CliProviderId) => { - void fetchCliProviderStatus(providerId); + void fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }); }, [fetchCliProviderStatus] ); @@ -1218,7 +1222,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { providerStatusLoading={cliProviderStatusLoading} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} onSelectBackend={handleProviderBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={(providerId) => + fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }) + } onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} /> {providerTerminal && renderCliStatus.binaryPath && ( diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ba19a617..874e6b08 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -61,7 +61,7 @@ const ProviderCapabilityCardSkeleton = ({ providerId, displayName, }: { - providerId: 'anthropic' | 'codex' | 'gemini'; + providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode'; displayName: string; }): React.JSX.Element => (

diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 970a0b4c..d48363fb 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -124,6 +124,8 @@ function getConnectionDescription(provider: CliProviderStatus): string { return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.'; case 'gemini': return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.'; + case 'opencode': + return 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.'; } } @@ -135,6 +137,8 @@ function getRuntimeDescription(provider: CliProviderStatus): string { return 'Codex now runs only through the native runtime path.'; case 'gemini': return 'Choose which Gemini runtime backend multimodel should use.'; + case 'opencode': + return 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.'; } } @@ -1093,6 +1097,26 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
+ {selectedProvider.detailMessage ? ( +
+ {selectedProvider.detailMessage} +
+ ) : null} + {selectedProvider.externalRuntimeDiagnostics && + selectedProvider.externalRuntimeDiagnostics.length > 0 ? ( +
+ {selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => ( +
+ {diagnostic.label}:{' '} + {diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')} + {diagnostic.detailMessage ? ` - ${diagnostic.detailMessage}` : ''} +
+ ))} +
+ ) : null}
) : null} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 9122dda6..a9e78dfb 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -122,6 +122,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Codex'; case 'gemini': return 'Gemini'; + case 'opencode': + return 'OpenCode'; } } @@ -697,7 +699,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { providerStatusLoading={cliProviderStatusLoading} disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading} onSelectBackend={handleRuntimeBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={(providerId) => + fetchCliProviderStatus(providerId, { + verifyModels: providerId === 'opencode', + }) + } onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' }) } diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 59c304f3..fe578e07 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -408,6 +408,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { anthropic: 0, codex: 0, gemini: 0, + opencode: 0, }; for (const session of searchedSessions) { diff --git a/src/renderer/components/sidebar/SessionFiltersPopover.tsx b/src/renderer/components/sidebar/SessionFiltersPopover.tsx index f0fe0290..e2855b1e 100644 --- a/src/renderer/components/sidebar/SessionFiltersPopover.tsx +++ b/src/renderer/components/sidebar/SessionFiltersPopover.tsx @@ -14,6 +14,7 @@ export const SESSION_PROVIDER_IDS = [ 'anthropic', 'codex', 'gemini', + 'opencode', ] as const satisfies readonly TeamProviderId[]; interface SessionFiltersPopoverProps { diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index b0eb827c..7c766e07 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -48,11 +48,16 @@ export function getProvisioningProviderBackendSummary( const optionById = new Map(options.map((option) => [option.id, option.label])); const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId; const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null; - const inferredProviderId = - provider.providerId ?? - (effectiveBackendId === 'codex-native' || options.some((option) => option.id === 'codex-native') - ? 'codex' - : undefined); + const inferredProviderId: TeamProviderId | undefined = + provider.providerId === 'anthropic' || + provider.providerId === 'codex' || + provider.providerId === 'gemini' || + provider.providerId === 'opencode' + ? provider.providerId + : effectiveBackendId === 'codex-native' || + options.some((option) => option.id === 'codex-native') + ? 'codex' + : undefined; const normalizedLabel = formatProviderBackendLabel(inferredProviderId, effectiveBackendId ?? undefined) ?? null; diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index e5b0fc67..f043c1fe 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -28,22 +28,24 @@ import { getProviderScopedTeamModelLabel, getRuntimeAwareProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, + getTeamModelSourceBadgeLabel, getTeamProviderLabel as getCatalogTeamProviderLabel, isAnthropicHaikuTeamModel, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; +import { isTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, Info } from 'lucide-react'; -import type { CliProviderStatus } from '@shared/types'; +import type { CliProviderStatus, TeamProviderId } from '@shared/types'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; // --- Provider definitions --- interface ProviderDef { - id: 'anthropic' | 'codex' | 'gemini' | 'opencode'; + id: TeamProviderId; label: string; comingSoon: boolean; } @@ -55,13 +57,13 @@ const PROVIDERS: ProviderDef[] = [ { id: 'opencode', label: 'OpenCode', comingSoon: false }, ]; -const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development'; +const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; export function getTeamModelLabel(model: string): string { return getCatalogTeamModelLabel(model) ?? model; } -export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { +export function getTeamProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } @@ -73,11 +75,15 @@ export function getTeamEffortLabel(effort: string): string { } export function formatTeamModelSummary( - providerId: 'anthropic' | 'codex' | 'gemini', + providerId: TeamProviderId, model: string, effort?: string ): string { const providerLabel = getTeamProviderLabel(providerId); + const routeLabel = + providerId === 'opencode' + ? (getTeamModelSourceBadgeLabel(providerId, model.trim()) ?? providerLabel) + : providerLabel; const rawModelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; const modelLabel = model.trim() ? getProviderScopedTeamModelLabel(providerId, model.trim()) @@ -93,7 +99,7 @@ export function formatTeamModelSummary( const parts = modelAlreadyCarriesProviderBrand ? [modelLabel, effortLabel] : providerActsAsBackendOnly - ? [modelLabel, `via ${providerLabel}`, effortLabel] + ? [modelLabel, `via ${routeLabel}`, effortLabel] : [providerLabel, modelLabel, effortLabel]; return parts.filter(Boolean).join(' · '); @@ -108,7 +114,7 @@ export function formatTeamModelSummary( export function computeEffectiveTeamModel( selectedModel: string, limitContext: boolean, - providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic', + providerId: TeamProviderId = 'anthropic', providerStatus?: Pick | null ): string | undefined { if (providerId !== 'anthropic') { @@ -129,8 +135,8 @@ export function computeEffectiveTeamModel( } export interface TeamModelSelectorProps { - providerId: 'anthropic' | 'codex' | 'gemini'; - onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void; + providerId: TeamProviderId; + onProviderChange: (providerId: TeamProviderId) => void; value: string; onValueChange: (value: string) => void; id?: string; @@ -158,6 +164,13 @@ export const TeamModelSelector: React.FC = ({ } = useEffectiveCliProviderStatus(effectiveProviderId); const multimodelAvailable = multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; + const runtimeProviderStatusById = useMemo( + () => + new Map( + (effectiveCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider]) + ), + [effectiveCliStatus?.providers] + ); const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { const defaultLongContextModel = @@ -179,7 +192,32 @@ export const TeamModelSelector: React.FC = ({ }, [effectiveProviderId, runtimeProviderStatus]); const getProviderDisabledReason = (candidateProviderId: string): string | null => { if (candidateProviderId === 'opencode') { - return OPENCODE_UI_DISABLED_REASON; + const providerStatus = runtimeProviderStatusById.get('opencode') ?? null; + if (!providerStatus) { + return 'OpenCode runtime status is still loading.'; + } + if (!providerStatus.supported) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + 'OpenCode CLI is not installed.' + ); + } + if (!providerStatus.authenticated) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + 'OpenCode has no connected provider.' + ); + } + if (!providerStatus.capabilities.teamLaunch) { + return ( + providerStatus.detailMessage ?? + providerStatus.statusMessage ?? + OPENCODE_UI_DISABLED_REASON + ); + } + return null; } if (disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini') { return GEMINI_UI_DISABLED_REASON; @@ -194,7 +232,7 @@ export const TeamModelSelector: React.FC = ({ const activeProviderSelectable = isProviderSelectable(effectiveProviderId); const getProviderStatusBadge = (candidateProviderId: string): string | null => { if (candidateProviderId === 'opencode') { - return 'In development'; + return getProviderDisabledReason(candidateProviderId) ? 'Gated' : null; } const providerDisabledReason = getProviderDisabledReason(candidateProviderId); @@ -209,8 +247,8 @@ export const TeamModelSelector: React.FC = ({ return null; }; const getProviderStatusBadgeLabel = (statusBadge: string | null): string | null => { - if (statusBadge === 'In development') { - return 'Dev'; + if (statusBadge === 'Gated') { + return 'Gate'; } if (statusBadge === 'Multimodel off') { @@ -250,10 +288,7 @@ export const TeamModelSelector: React.FC = ({ { - if ( - (nextValue === 'anthropic' || nextValue === 'codex' || nextValue === 'gemini') && - isProviderSelectable(nextValue) - ) { + if (isTeamProviderId(nextValue) && isProviderSelectable(nextValue)) { onProviderChange(nextValue); } }} @@ -353,6 +388,10 @@ export const TeamModelSelector: React.FC = ({ availabilityStatus === 'available'); const modelStatusMessage = modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null; + const sourceBadgeLabel = + effectiveProviderId === 'opencode' && opt.value !== '' + ? opt.badgeLabel?.trim() || null + : null; return (