From 90aa2942f99275202dbe866ade1d28a55bd3c956 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 1 May 2026 22:39:31 +0300 Subject: [PATCH] feat(task-logs): show codex native trace fallback --- .../activity/BoardTaskActivityRecordSource.ts | 55 ++- .../BoardTaskLogDiagnosticsService.ts | 111 +++++- .../team/taskLogs/exact/featureGates.ts | 4 + .../stream/BoardTaskLogStreamService.ts | 261 +++++++++--- .../stream/CodexNativeTaskLogStreamSource.ts | 173 ++++++++ .../stream/CodexNativeTraceProjector.ts | 271 +++++++++++++ .../taskLogs/stream/CodexNativeTraceReader.ts | 377 ++++++++++++++++++ .../stream/HistoricalBoardMcpRawProbe.ts | 150 +++++++ .../TaskLogTranscriptCandidateSelector.ts | 212 ++++++++++ .../taskLogs/stream/boardTaskLogToolNames.ts | 54 +++ .../team/members/MemberExecutionLog.tsx | 11 +- .../team/taskLogs/TaskLogStreamSection.tsx | 33 +- src/shared/types/team.ts | 18 +- .../BoardTaskActivityRecordSource.test.ts | 48 +++ .../BoardTaskLogDiagnosticsService.test.ts | 136 ++++++- .../team/BoardTaskLogStream.live.test.ts | 5 +- .../team/BoardTaskLogStreamService.test.ts | 348 +++++++++++++++- .../CodexNativeTaskLogStreamSource.test.ts | 133 ++++++ .../team/CodexNativeTraceProjector.test.ts | 179 +++++++++ .../team/CodexNativeTraceReader.test.ts | 239 +++++++++++ .../team/HistoricalBoardMcpRawProbe.test.ts | 99 +++++ ...TaskLogTranscriptCandidateSelector.test.ts | 168 ++++++++ 22 files changed, 2991 insertions(+), 94 deletions(-) create mode 100644 src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts create mode 100644 src/main/services/team/taskLogs/stream/CodexNativeTraceProjector.ts create mode 100644 src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts create mode 100644 src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe.ts create mode 100644 src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector.ts create mode 100644 src/main/services/team/taskLogs/stream/boardTaskLogToolNames.ts create mode 100644 test/main/services/team/CodexNativeTaskLogStreamSource.test.ts create mode 100644 test/main/services/team/CodexNativeTraceProjector.test.ts create mode 100644 test/main/services/team/CodexNativeTraceReader.test.ts create mode 100644 test/main/services/team/HistoricalBoardMcpRawProbe.test.ts create mode 100644 test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts index a08675cd..5d5408d3 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts @@ -11,13 +11,17 @@ const TASK_ACTIVITY_INDEX_CACHE_TTL_MS = 1_000; interface TaskActivityIndex { expiresAt: number; + generation: number; tasksById: Map; recordsByTaskId: Map; } export class BoardTaskActivityRecordSource { private readonly indexCache = new Map(); - private readonly indexInFlight = new Map>(); + private readonly indexInFlight = new Map< + string, + { generation: number; promise: Promise } + >(); constructor( private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), @@ -38,33 +42,48 @@ export class BoardTaskActivityRecordSource { } private async getTaskActivityIndex(teamName: string): Promise { + const generation = this.getTranscriptDiscoveryGeneration(teamName); const cached = this.indexCache.get(teamName); - if (cached && cached.expiresAt > Date.now()) { + if (cached && cached.generation === generation && cached.expiresAt > Date.now()) { return cached; } - const existingPromise = this.indexInFlight.get(teamName); - if (existingPromise) { - return await existingPromise; + const existingInFlight = this.indexInFlight.get(teamName); + if (existingInFlight && existingInFlight.generation === generation) { + return await existingInFlight.promise; } - const promise = this.buildTaskActivityIndex(teamName) + const promise = this.buildTaskActivityIndex(teamName, generation) .then((index) => { - this.indexCache.set(teamName, index); + if (this.getTranscriptDiscoveryGeneration(teamName) === generation) { + this.indexCache.set(teamName, index); + } return index; }) .finally(() => { - this.indexInFlight.delete(teamName); + if (this.indexInFlight.get(teamName)?.promise === promise) { + this.indexInFlight.delete(teamName); + } }); - this.indexInFlight.set(teamName, promise); + this.indexInFlight.set(teamName, { generation, promise }); return await promise; } - private async buildTaskActivityIndex(teamName: string): Promise { + private getTranscriptDiscoveryGeneration(teamName: string): number { + const locator = this.transcriptSourceLocator as { + getGeneration?: (teamName: string) => number; + }; + return locator.getGeneration?.(teamName) ?? 0; + } + + private async buildTaskActivityIndex( + teamName: string, + generation: number + ): Promise { const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ this.taskReader.getTasks(teamName), this.taskReader.getDeletedTasks(teamName), - this.transcriptSourceLocator.listTranscriptFiles(teamName), + this.listTranscriptFiles(teamName), ]); const tasks = [...activeTasks, ...deletedTasks]; @@ -72,6 +91,7 @@ export class BoardTaskActivityRecordSource { if (tasks.length === 0 || transcriptFiles.length === 0) { return { expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS, + generation, tasksById, recordsByTaskId: new Map(), }; @@ -85,8 +105,21 @@ export class BoardTaskActivityRecordSource { }); return { expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS, + generation, tasksById, recordsByTaskId, }; } + + private async listTranscriptFiles(teamName: string): Promise { + const locator = this.transcriptSourceLocator as { + getContext?: (teamName: string) => Promise<{ transcriptFiles: string[] } | null>; + listTranscriptFiles?: (teamName: string) => Promise; + }; + const context = await locator.getContext?.(teamName); + if (context) { + return context.transcriptFiles; + } + return (await locator.listTranscriptFiles?.(teamName)) ?? []; + } } diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts index dd4813e8..27095494 100644 --- a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -5,6 +5,8 @@ import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityReco import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService'; +import { HistoricalBoardMcpRawProbe } from '../stream/HistoricalBoardMcpRawProbe'; +import { TaskLogTranscriptCandidateSelector } from '../stream/TaskLogTranscriptCandidateSelector'; import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import type { ParsedMessage } from '@main/types'; @@ -37,6 +39,13 @@ export interface BoardTaskLogDiagnosticsReport { transcript: { fileCount: number; files: string[]; + parsedFileCount: number; + candidateSelection?: { + mode: 'activity_records' | 'historical_raw_probe' | 'none'; + candidateFileCount: number; + rawProbeScannedFileCount?: number; + rawProbeHitCount?: number; + }; }; explicitRecords: { total: number; @@ -249,15 +258,96 @@ export class BoardTaskLogDiagnosticsService { private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), - private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService() + private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService(), + private readonly transcriptCandidateSelector: TaskLogTranscriptCandidateSelector = new TaskLogTranscriptCandidateSelector(), + private readonly historicalRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe() ) {} + private async getTranscriptContext(teamName: string): Promise<{ + projectDir?: string; + transcriptFiles: string[]; + }> { + const locator = this.transcriptSourceLocator as { + getContext?: ( + teamName: string + ) => Promise<{ projectDir: string; transcriptFiles: string[] } | null>; + listTranscriptFiles?: (teamName: string) => Promise; + }; + const context = await locator.getContext?.(teamName); + if (context) { + return { + projectDir: context.projectDir, + transcriptFiles: context.transcriptFiles, + }; + } + return { + transcriptFiles: (await locator.listTranscriptFiles?.(teamName)) ?? [], + }; + } + + private async parseDiagnosticCandidateFiles(args: { + task: TeamTask; + records: BoardTaskActivityRecord[]; + projectDir?: string; + transcriptFiles: string[]; + }): Promise<{ + parsedMessagesByFile: Map; + candidateSelection: NonNullable< + BoardTaskLogDiagnosticsReport['transcript']['candidateSelection'] + >; + }> { + if (args.transcriptFiles.length === 0) { + return { + parsedMessagesByFile: new Map(), + candidateSelection: { + mode: 'none', + candidateFileCount: 0, + }, + }; + } + + if (args.records.length > 0) { + const selection = this.transcriptCandidateSelector.selectInferredNativeTranscriptFiles({ + records: args.records, + transcriptFiles: args.transcriptFiles, + projectDir: args.projectDir, + }); + return { + parsedMessagesByFile: + selection.filePaths.length > 0 + ? await this.strictParser.parseFiles(selection.filePaths) + : new Map(), + candidateSelection: { + mode: 'activity_records', + candidateFileCount: selection.filePaths.length, + }, + }; + } + + const rawProbe = await this.historicalRawProbe.findCandidateFiles({ + task: args.task, + transcriptFiles: args.transcriptFiles, + }); + return { + parsedMessagesByFile: + rawProbe.filePaths.length > 0 + ? await this.strictParser.parseFiles(rawProbe.filePaths) + : new Map(), + candidateSelection: { + mode: 'historical_raw_probe', + candidateFileCount: rawProbe.filePaths.length, + rawProbeScannedFileCount: rawProbe.scannedFileCount, + rawProbeHitCount: rawProbe.hitCount, + }, + }; + } + async diagnose(teamName: string, taskRef: string): Promise { const normalizedRef = normalizeRequestedTaskRef(taskRef); - const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ this.taskReader.getTasks(teamName), this.taskReader.getDeletedTasks(teamName), - this.transcriptSourceLocator.listTranscriptFiles(teamName), + this.getTranscriptContext(teamName), ]); const tasks = [...activeTasks, ...deletedTasks]; @@ -267,7 +357,12 @@ export class BoardTaskLogDiagnosticsService { } const records = await this.recordSource.getTaskRecords(teamName, task.id); - const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); + const { parsedMessagesByFile, candidateSelection } = await this.parseDiagnosticCandidateFiles({ + task, + records, + projectDir: transcriptContext.projectDir, + transcriptFiles: transcriptContext.transcriptFiles, + }); const stream = await this.streamService.getTaskLogStream(teamName, task.id); const toolNameByUseId = buildToolNameMap(parsedMessagesByFile); @@ -331,7 +426,7 @@ export class BoardTaskLogDiagnosticsService { } const diagnosis: string[] = []; - if (transcriptFiles.length === 0) { + if (transcriptContext.transcriptFiles.length === 0) { diagnosis.push('No transcript files were found for this team.'); } if (records.length === 0) { @@ -373,8 +468,10 @@ export class BoardTaskLogDiagnosticsService { workIntervals, }, transcript: { - fileCount: transcriptFiles.length, - files: transcriptFiles, + fileCount: transcriptContext.transcriptFiles.length, + files: transcriptContext.transcriptFiles, + parsedFileCount: parsedMessagesByFile.size, + candidateSelection, }, explicitRecords: { total: records.length, diff --git a/src/main/services/team/taskLogs/exact/featureGates.ts b/src/main/services/team/taskLogs/exact/featureGates.ts index f5f86270..512a08d0 100644 --- a/src/main/services/team/taskLogs/exact/featureGates.ts +++ b/src/main/services/team/taskLogs/exact/featureGates.ts @@ -16,3 +16,7 @@ function readEnabledFlag(value: string | undefined, defaultValue: boolean): bool export function isBoardTaskExactLogsReadEnabled(): boolean { return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true); } + +export function isCodexNativeTraceFallbackEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_CODEX_NATIVE_TRACE_FALLBACK_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 06fd9210..05334b11 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -3,7 +3,6 @@ import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; -import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; import { TeamConfigReader } from '../../TeamConfigReader'; import { TeamMembersMetaStore } from '../../TeamMembersMetaStore'; import { TeamTaskReader } from '../../TeamTaskReader'; @@ -17,6 +16,14 @@ import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; +import { CodexNativeTaskLogStreamSource } from './CodexNativeTaskLogStreamSource'; +import { buildCodexNativeToolSignature } from './CodexNativeTraceProjector'; +import { HistoricalBoardMcpRawProbe } from './HistoricalBoardMcpRawProbe'; +import { TaskLogTranscriptCandidateSelector } from './TaskLogTranscriptCandidateSelector'; +import { + canonicalizeBoardTaskLogToolName, + isBoardTaskLogMcpToolName, +} from './boardTaskLogToolNames'; import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; @@ -71,27 +78,9 @@ const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; const STREAM_LAYOUT_CACHE_TTL_MS = 1_000; const STREAM_LAYOUT_BUILD_WARN_MS = 3_000; const RUNTIME_FALLBACK_WARN_MS = 3_000; -const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([ - 'task_complete', - 'task_set_status', - 'task_start', - 'review_approve', - 'review_request_changes', - 'review_start', -]); -const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([ - 'review_request', - 'task_add_comment', - 'task_attach_comment_file', - 'task_attach_file', - 'task_get', - 'task_get_comment', - 'task_link', - 'task_set_clarification', - 'task_set_owner', - 'task_unlink', -]); -const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); +const INFERRED_CANDIDATE_SELECTION_WARN_COUNT = 100; +const HISTORICAL_RAW_PROBE_WARN_MS = 3_000; +const HISTORICAL_RAW_PROBE_WARN_FILE_COUNT = 500; const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']); function emptyResponse(): BoardTaskLogStreamResponse { @@ -112,21 +101,8 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } -function isBoardMcpToolName(toolName: string | undefined): boolean { - if (!toolName) return false; - const canonical = canonicalizeBoardToolName(toolName); - return ( - canonical !== null && - (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) || - HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical)) - ); -} - -function canonicalizeBoardToolName(toolName: string | undefined): string | null { - if (!toolName) return null; - const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase(); - return normalized.length > 0 ? normalized : null; -} +const isBoardMcpToolName = isBoardTaskLogMcpToolName; +const canonicalizeBoardToolName = canonicalizeBoardTaskLogToolName; function normalizeTaskReference(value: unknown): string | null { if (typeof value !== 'string' && typeof value !== 'number') { @@ -231,13 +207,28 @@ function normalizeRelationshipDetail( } function inferHistoricalLinkKind(canonicalToolName: string): 'lifecycle' | 'board_action' | null { - if (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonicalToolName)) { - return 'lifecycle'; + switch (canonicalToolName) { + case 'task_complete': + case 'task_set_status': + case 'task_start': + case 'review_approve': + case 'review_request_changes': + case 'review_start': + return 'lifecycle'; + case 'review_request': + case 'task_add_comment': + case 'task_attach_comment_file': + case 'task_attach_file': + case 'task_get': + case 'task_get_comment': + case 'task_link': + case 'task_set_clarification': + case 'task_set_owner': + case 'task_unlink': + return 'board_action'; + default: + return null; } - if (HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonicalToolName)) { - return 'board_action'; - } - return null; } function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActivityCategory { @@ -1388,10 +1379,9 @@ function collectAllowedMemberNames( for (const record of records) { const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName); - if ( - record.action?.category === 'read' || - (canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName)) - ) { + const isReadOnlyTool = + canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment'; + if (record.action?.category === 'read' || isReadOnlyTool) { continue; } @@ -1578,15 +1568,73 @@ function mergeRuntimeFallbackResponse( fallback: BoardTaskLogStreamResponse ): BoardTaskLogStreamResponse { const participants = mergeParticipants(primary.participants, fallback.participants); + const source = + fallback.source === 'codex_native_trace_fallback' + ? 'mixed_transcript_codex_native_trace' + : 'mixed_transcript_opencode_runtime'; return { participants, defaultFilter: chooseDefaultFilter(participants), segments: mergeSegments(primary.segments, fallback.segments), - source: primary.source, + source, runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection, }; } +function collectNativeToolSignatures(response: BoardTaskLogStreamResponse): Set { + const signatures = new Set(); + for (const segment of response.segments) { + for (const chunk of segment.chunks) { + const record = chunk as unknown as Record; + const toolExecutions = Array.isArray(record.toolExecutions) ? record.toolExecutions : []; + for (const execution of toolExecutions) { + const toolCall = (execution as Record)?.toolCall as + | { name?: string; input?: Record } + | undefined; + const signature = buildCodexNativeToolSignature({ + toolName: toolCall?.name, + input: toolCall?.input, + }); + if (signature) { + signatures.add(signature); + } + } + const semanticSteps = Array.isArray(record.semanticSteps) ? record.semanticSteps : []; + for (const step of semanticSteps) { + const content = (step as Record)?.content as + | { toolName?: string; toolInput?: Record } + | undefined; + const signature = buildCodexNativeToolSignature({ + toolName: content?.toolName, + input: content?.toolInput, + }); + if (signature) { + signatures.add(signature); + } + } + } + } + return signatures; +} + +function collectNativeToolSignaturesFromSlices(slices: StreamSlice[]): Set { + const signatures = new Set(); + for (const slice of slices) { + for (const message of slice.filteredMessages) { + for (const toolCall of message.toolCalls) { + const signature = buildCodexNativeToolSignature({ + toolName: toolCall.name, + input: toolCall.input, + }); + if (signature) { + signatures.add(signature); + } + } + } + } + return signatures; +} + export class BoardTaskLogStreamService { private readonly layoutCache = new Map< string, @@ -1613,9 +1661,12 @@ export class BoardTaskLogStreamService { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), - private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(), + private readonly openCodeRuntimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), - private readonly configReader: TeamConfigReader = new TeamConfigReader() + private readonly configReader: TeamConfigReader = new TeamConfigReader(), + private readonly codexNativeTraceFallbackSource: CodexNativeTaskLogStreamSource = new CodexNativeTaskLogStreamSource(), + private readonly transcriptCandidateSelector: TaskLogTranscriptCandidateSelector = new TaskLogTranscriptCandidateSelector(), + private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe() ) {} private buildLayoutCacheKey(teamName: string, taskId: string): string { @@ -1695,7 +1746,23 @@ export class BoardTaskLogStreamService { } const transcriptFiles = transcriptContext?.transcriptFiles ?? []; - const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + const candidateSelection = this.transcriptCandidateSelector.selectInferredNativeTranscriptFiles( + { + records, + transcriptFiles, + projectDir: transcriptContext?.projectDir, + alreadyParsedFilePaths: new Set(parsedMessagesByFile.keys()), + } + ); + if ( + candidateSelection.diagnostics.finalCandidateCount >= INFERRED_CANDIDATE_SELECTION_WARN_COUNT + ) { + logger.warn( + `Broad inferred native task-log candidate selection: team=${teamName} task=${taskId} files=${candidateSelection.diagnostics.finalCandidateCount} recordFiles=${candidateSelection.diagnostics.recordFileCount} sessions=${candidateSelection.diagnostics.nonReadSessionCount} reason=${candidateSelection.diagnostics.reason}` + ); + } + + const missingFiles = candidateSelection.filePaths; let mergedParsedMessagesByFile = parsedMessagesByFile; if (missingFiles.length > 0) { const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); @@ -1800,7 +1867,27 @@ export class BoardTaskLogStreamService { }; } - const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); + const rawProbe = await this.historicalBoardMcpRawProbe.findCandidateFiles({ + task, + transcriptFiles, + }); + if ( + rawProbe.elapsedMs >= HISTORICAL_RAW_PROBE_WARN_MS || + rawProbe.scannedFileCount >= HISTORICAL_RAW_PROBE_WARN_FILE_COUNT + ) { + logger.warn( + `Historical board MCP raw probe: team=${teamName} task=${taskId} scanned=${rawProbe.scannedFileCount} hits=${rawProbe.hitCount} elapsedMs=${rawProbe.elapsedMs}` + ); + } + if (rawProbe.filePaths.length === 0) { + return { + task, + parsedMessagesByFile: new Map(), + records: [], + }; + } + + const parsedMessagesByFile = await this.strictParser.parseFiles(rawProbe.filePaths); const taskRefs = buildTaskReferenceSet(task); const leadName = transcriptContext?.config.members @@ -2020,7 +2107,9 @@ export class BoardTaskLogStreamService { }; } - const candidateFilePaths = candidates.map((candidate) => candidate.source.filePath); + const candidateFilePaths = [ + ...new Set(candidates.map((candidate) => candidate.source.filePath)), + ].sort((left, right) => left.localeCompare(right)); const parsedMessagesByFileForCandidates = parsedMessagesByFile && candidateFilePaths.every((filePath) => parsedMessagesByFile?.has(filePath)) @@ -2128,12 +2217,12 @@ export class BoardTaskLogStreamService { } } - private async loadRuntimeFallback( + private async loadOpenCodeRuntimeFallback( teamName: string, taskId: string ): Promise { const startedAt = Date.now(); - const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); + const fallback = await this.openCodeRuntimeFallbackSource.getTaskLogStream(teamName, taskId); const elapsedMs = Date.now() - startedAt; if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { logger.warn( @@ -2145,6 +2234,26 @@ export class BoardTaskLogStreamService { return fallback; } + private async loadCodexNativeTraceFallback( + teamName: string, + taskId: string, + excludeNativeToolSignatures?: ReadonlySet + ): Promise { + const startedAt = Date.now(); + const fallback = await this.codexNativeTraceFallbackSource.getTaskLogStream(teamName, taskId, { + excludeNativeToolSignatures, + }); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { + logger.warn( + `Slow task-log Codex native trace fallback: team=${teamName} task=${taskId} hit=${Boolean( + fallback + )} elapsedMs=${elapsedMs}` + ); + } + return fallback; + } + async getTaskLogStreamSummary( teamName: string, taskId: string @@ -2155,11 +2264,22 @@ export class BoardTaskLogStreamService { const layout = await this.getStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - return emptySummary(); + const codexFallback = await this.loadCodexNativeTraceFallback(teamName, taskId); + if (codexFallback) { + return { segmentCount: codexFallback.segments.length }; + } + const runtimeFallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId); + return runtimeFallback ? { segmentCount: runtimeFallback.segments.length } : emptySummary(); } + const codexFallback = await this.loadCodexNativeTraceFallback( + teamName, + taskId, + collectNativeToolSignaturesFromSlices(layout.visibleSlices) + ); return { - segmentCount: countSegmentsFromSlices(layout.visibleSlices), + segmentCount: + countSegmentsFromSlices(layout.visibleSlices) + (codexFallback?.segments.length ?? 0), }; } @@ -2170,7 +2290,11 @@ export class BoardTaskLogStreamService { const layout = await this.getStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - const fallback = await this.loadRuntimeFallback(teamName, taskId); + const codexFallback = await this.loadCodexNativeTraceFallback(teamName, taskId); + if (codexFallback) { + return codexFallback; + } + const fallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId); return fallback ?? emptyResponse(); } @@ -2219,18 +2343,29 @@ export class BoardTaskLogStreamService { } flushSegment(); - const primaryResponse: BoardTaskLogStreamResponse = { + let primaryResponse: BoardTaskLogStreamResponse = { participants: layout.participants, defaultFilter: chooseDefaultFilter(layout.participants), segments, source: 'transcript', }; - if (!layout.shouldMergeRuntimeFallback) { - return primaryResponse; + if (layout.shouldMergeRuntimeFallback) { + const fallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId); + if (fallback) { + primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, fallback); + } } - const fallback = await this.loadRuntimeFallback(teamName, taskId); - return fallback ? mergeRuntimeFallbackResponse(primaryResponse, fallback) : primaryResponse; + const codexFallback = await this.loadCodexNativeTraceFallback( + teamName, + taskId, + collectNativeToolSignatures(primaryResponse) + ); + if (codexFallback) { + primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, codexFallback); + } + + return primaryResponse; } } diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts new file mode 100644 index 00000000..10148c5e --- /dev/null +++ b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts @@ -0,0 +1,173 @@ +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamMembersMetaStore } from '../../TeamMembersMetaStore'; +import { TeamTaskReader } from '../../TeamTaskReader'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { isCodexNativeTraceFallbackEnabled } from '../exact/featureGates'; + +import { CodexNativeTraceProjector } from './CodexNativeTraceProjector'; +import { CodexNativeTraceReader } from './CodexNativeTraceReader'; + +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, + TeamTask, +} from '@shared/types'; + +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: false, + }; +} + +function buildActor(memberName: string, sessionId: string): BoardTaskLogActor { + return { + memberName, + role: 'member', + sessionId, + isSidechain: false, + }; +} + +export class CodexNativeTaskLogStreamSource { + constructor( + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly configReader: TeamConfigReader = new TeamConfigReader(), + private readonly traceReader: CodexNativeTraceReader = new CodexNativeTraceReader(), + private readonly projector: CodexNativeTraceProjector = new CodexNativeTraceProjector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskLogStream( + teamName: string, + taskId: string, + options: { excludeNativeToolSignatures?: ReadonlySet } = {} + ): Promise { + if (!isCodexNativeTraceFallbackEnabled()) { + return null; + } + + const task = await this.resolveTask(teamName, taskId); + if (!task) { + return null; + } + const ownerName = task.owner?.trim(); + if (!ownerName) { + return null; + } + if (!(await this.isCodexOwner(teamName, ownerName))) { + return null; + } + + const displayId = getTaskDisplayId(task); + const candidateTaskIds = [ + ...new Set([task.id, displayId, task.id.slice(0, 8)].filter(Boolean)), + ]; + const runs = await this.traceReader.readTaskRuns({ + teamName, + taskIds: candidateTaskIds, + includeIncoming: task.status === 'in_progress', + }); + if (runs.length === 0) { + return null; + } + + const excludedSignatures = options.excludeNativeToolSignatures ?? new Set(); + const messages = this.projector.project(runs, { + excludeSignatures: excludedSignatures, + }); + if (messages.length === 0) { + return null; + } + + const chunks = this.chunkBuilder.buildBundleChunks(messages); + if (chunks.length === 0) { + return null; + } + + const participant = buildParticipant(ownerName); + const firstMessage = messages[0]; + const lastMessage = messages[messages.length - 1]; + if (!firstMessage || !lastMessage) { + return null; + } + + const nativeToolCount = messages.filter((message) => message.toolCalls.length > 0).length; + const totalNativeToolCount = + excludedSignatures.size > 0 + ? this.projector.project(runs).filter((message) => message.toolCalls.length > 0).length + : nativeToolCount; + const dedupedNativeToolCount = Math.max(0, totalNativeToolCount - nativeToolCount); + + const segment: BoardTaskLogSegment = { + id: `codex-native:${teamName}:${task.id}:${normalizeMemberName(ownerName)}`, + participantKey: participant.key, + actor: buildActor(ownerName, runs[0]?.runId ?? firstMessage.sessionId), + startTimestamp: firstMessage.timestamp.toISOString(), + endTimestamp: lastMessage.timestamp.toISOString(), + chunks, + }; + + return { + participants: [participant], + defaultFilter: participant.key, + segments: [segment], + source: 'codex_native_trace_fallback', + runtimeProjection: { + provider: 'codex_native', + mode: 'trace', + attributionRecordCount: 0, + projectedMessageCount: messages.length, + nativeToolCount, + fallbackReason: 'codex_native_trace', + traceFileCount: new Set(runs.map((run) => run.filePath)).size, + traceRunCount: runs.length, + dedupedNativeToolCount, + }, + }; + } + + private async resolveTask(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => []), + this.taskReader.getDeletedTasks(teamName).catch(() => []), + ]); + const normalizedRef = taskId.trim().replace(/^#/, '').toLowerCase(); + return ( + [...activeTasks, ...deletedTasks].find((candidate) => { + const displayId = getTaskDisplayId(candidate); + return [candidate.id, displayId, candidate.id.slice(0, 8)] + .map((value) => value.trim().replace(/^#/, '').toLowerCase()) + .includes(normalizedRef); + }) ?? null + ); + } + + private async isCodexOwner(teamName: string, ownerName: string): Promise { + const normalizedOwner = normalizeMemberName(ownerName); + const [metaMembers, config] = await Promise.all([ + this.membersMetaStore.getMembers(teamName).catch(() => []), + this.configReader.getConfig(teamName).catch(() => null), + ]); + const member = [...metaMembers, ...(config?.members ?? [])].find( + (candidate) => normalizeMemberName(candidate.name) === normalizedOwner + ) as { providerId?: string } | undefined; + return member?.providerId === 'codex'; + } +} diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTraceProjector.ts b/src/main/services/team/taskLogs/stream/CodexNativeTraceProjector.ts new file mode 100644 index 00000000..22a9759a --- /dev/null +++ b/src/main/services/team/taskLogs/stream/CodexNativeTraceProjector.ts @@ -0,0 +1,271 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; + +import type { CodexNativeTraceEvent, CodexNativeTraceRun } from './CodexNativeTraceReader'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; + +export function buildCodexNativeToolSignature(args: { + toolName?: string; + input?: Record; +}): string | null { + const toolName = args.toolName?.trim(); + if (!toolName || toolName.startsWith('mcp__')) { + return null; + } + const input = args.input ?? {}; + if (toolName === 'Bash') { + const command = typeof input.command === 'string' ? input.command.trim() : ''; + return command ? `${toolName}:${command}` : null; + } + if (toolName === 'Edit') { + const filePath = + typeof input.file_path === 'string' && input.file_path.trim().length > 0 + ? input.file_path.trim() + : Array.isArray(input.changes) + ? input.changes + .map((change) => + change && typeof change === 'object' && 'path' in change + ? String((change as Record).path ?? '').trim() + : '' + ) + .filter(Boolean) + .join(',') + : ''; + return filePath ? `${toolName}:${filePath}` : null; + } + return `${toolName}:${JSON.stringify(input)}`; +} + +function resultContent(result: unknown): string { + if (typeof result === 'string') { + return result; + } + if (result && typeof result === 'object' && !Array.isArray(result)) { + const record = result as Record; + if (typeof record.content === 'string') { + return record.content; + } + if (typeof record.stderr === 'string' && record.stderr.trim().length > 0) { + return record.stderr; + } + if (typeof record.message === 'string') { + return record.message; + } + } + return result == null ? '' : JSON.stringify(result); +} + +function asToolUseResult( + result: unknown, + fallback: { + toolName: string; + toolUseId: string; + isError: boolean; + } +): ToolUseResultData { + const content = resultContent(result); + if (result && typeof result === 'object' && !Array.isArray(result)) { + return { + ...(result as Record), + content, + toolName: fallback.toolName, + toolUseId: fallback.toolUseId, + isError: fallback.isError, + }; + } + return { + content, + toolName: fallback.toolName, + toolUseId: fallback.toolUseId, + isError: fallback.isError, + }; +} + +function baseMessage(params: { + uuid: string; + type: 'assistant' | 'user'; + timestamp: Date; + content: ContentBlock[]; + role?: 'assistant' | 'user'; + cwd?: string; + sessionId: string; + agentName?: string; + isMeta?: boolean; +}): ParsedMessage { + const message: ParsedMessage = { + uuid: params.uuid, + parentUuid: null, + type: params.type, + timestamp: params.timestamp, + content: params.content, + sessionId: params.sessionId, + isSidechain: false, + isMeta: params.isMeta ?? false, + toolCalls: extractToolCalls(params.content), + toolResults: extractToolResults(params.content), + }; + + if (params.role) { + message.role = params.role; + } + + if (params.type === 'assistant') { + message.model = ''; + } + + if (params.cwd) { + message.cwd = params.cwd; + } + + if (params.agentName) { + message.agentName = params.agentName; + } + + return message; +} + +function buildSyntheticToolUseId(run: CodexNativeTraceRun, itemId: string): string { + return `codex-trace:${run.teamName ?? 'unknown'}:${run.taskId ?? 'unknown'}:${run.runId}:${itemId}`; +} + +function buildToolStartMessage( + run: CodexNativeTraceRun, + event: CodexNativeTraceEvent +): ParsedMessage | null { + const projection = event.projection; + if (!projection?.itemId || !projection.toolName) { + return null; + } + const toolUseId = buildSyntheticToolUseId(run, projection.itemId); + const content: ContentBlock[] = [ + { + type: 'tool_use', + id: toolUseId, + name: projection.toolName, + input: projection.input ?? {}, + }, + ]; + return baseMessage({ + uuid: `${toolUseId}:start`, + timestamp: new Date(event.receivedAt), + type: 'assistant', + role: 'assistant', + content, + sessionId: run.runId, + ...(run.cwd ? { cwd: run.cwd } : {}), + ...(run.ownerName ? { agentName: run.ownerName } : {}), + }); +} + +function buildToolResultMessage( + run: CodexNativeTraceRun, + event: CodexNativeTraceEvent +): ParsedMessage | null { + const projection = event.projection; + if (!projection?.itemId || !projection.toolName) { + return null; + } + const toolUseId = buildSyntheticToolUseId(run, projection.itemId); + const contentText = resultContent(projection.result); + const isError = projection.isError === true; + const content: ContentBlock[] = [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: contentText, + ...(isError ? { is_error: true } : {}), + }, + ]; + const toolUseResult = asToolUseResult(projection.result, { + toolName: projection.toolName, + toolUseId, + isError, + }); + return { + ...baseMessage({ + uuid: `${toolUseId}:result`, + timestamp: new Date(event.receivedAt), + type: 'user', + role: 'user', + content, + sessionId: run.runId, + isMeta: true, + ...(run.cwd ? { cwd: run.cwd } : {}), + ...(run.ownerName ? { agentName: run.ownerName } : {}), + }), + sourceToolUseID: toolUseId, + toolUseResult, + }; +} + +export class CodexNativeTraceProjector { + project( + runs: CodexNativeTraceRun[], + options: { excludeSignatures?: ReadonlySet } = {} + ): ParsedMessage[] { + const messages: ParsedMessage[] = []; + for (const run of runs) { + const items = new Map< + string, + { + firstOrder: number; + start?: CodexNativeTraceEvent; + result?: CodexNativeTraceEvent; + } + >(); + for (const event of run.events) { + const projection = event.projection; + if (!projection || projection.toolSource !== 'native') { + continue; + } + if (!projection.itemId) { + continue; + } + const current = items.get(projection.itemId) ?? { firstOrder: event.sourceOrder }; + current.firstOrder = Math.min(current.firstOrder, event.sourceOrder); + if (projection.kind === 'tool_start') { + current.start = event; + } else if (projection.kind === 'tool_result') { + current.result = event; + } + items.set(projection.itemId, current); + } + + for (const item of [...items.values()].sort( + (left, right) => left.firstOrder - right.firstOrder + )) { + const projection = item.result?.projection ?? item.start?.projection; + const signature = buildCodexNativeToolSignature({ + toolName: projection?.toolName, + input: projection?.input, + }); + if (signature && options.excludeSignatures?.has(signature)) { + continue; + } + const start = + item.start ?? + (item.result + ? { + ...item.result, + projection: { + ...item.result.projection!, + kind: 'tool_start' as const, + }, + } + : null); + if (start) { + const startMessage = buildToolStartMessage(run, start); + if (startMessage) { + messages.push(startMessage); + } + } + if (item.result) { + const resultMessage = buildToolResultMessage(run, item.result); + if (resultMessage) { + messages.push(resultMessage); + } + } + } + } + return messages.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + } +} diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts b/src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts new file mode 100644 index 00000000..44169f2b --- /dev/null +++ b/src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts @@ -0,0 +1,377 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { getTeamsBasePath } from '@main/utils/pathDecoder'; + +const TRACE_ROOT_SEGMENT = path.join('.member-work-sync', 'runtime-hooks', 'codex-native-traces'); + +export interface CodexNativeTraceProjection { + kind: 'tool_start' | 'tool_result' | 'message' | 'meta'; + toolSource?: 'mcp' | 'native'; + rawItemType?: string; + itemId?: string; + toolName?: string; + status?: string; + input?: Record; + result?: unknown; + isError?: boolean; + text?: string; +} + +export interface CodexNativeTraceEvent { + sourceOrder: number; + receivedAt: string; + projection: CodexNativeTraceProjection | null; +} + +export interface CodexNativeTraceRun { + filePath: string; + runId: string; + teamName: string | null; + taskId: string | null; + ownerName: string | null; + cwd: string | null; + startedAt: string | null; + mtimeMs: number; + size: number; + events: CodexNativeTraceEvent[]; + partial: boolean; +} + +interface TraceFileCandidate { + filePath: string; + mtimeMs: number; + size: number; + partial: boolean; +} + +function safeSegment(value: string): string { + const encoded = encodeURIComponent(value); + return encoded.length > 0 && encoded.length <= 160 + ? encoded + : `segment-${Buffer.from(value).toString('base64url').slice(0, 80)}`; +} + +function tracePathSegment(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? safeSegment(trimmed) : null; +} + +function isString(value: string | null): value is string { + return typeof value === 'string'; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readRawString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === 'string' ? value : null; +} + +function readNumber(record: Record, key: string): number | null { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function normalizeIdentity(value: string | null): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim().toLowerCase() : null; +} + +function readProjection(value: unknown): CodexNativeTraceProjection | null { + const record = asRecord(value); + if (!record) { + return null; + } + const kind = readString(record, 'kind'); + if (kind !== 'tool_start' && kind !== 'tool_result' && kind !== 'message' && kind !== 'meta') { + return null; + } + const toolSource = readString(record, 'toolSource'); + return { + kind, + ...(toolSource === 'mcp' || toolSource === 'native' ? { toolSource } : {}), + ...(readString(record, 'rawItemType') + ? { rawItemType: readString(record, 'rawItemType')! } + : {}), + ...(readString(record, 'itemId') ? { itemId: readString(record, 'itemId')! } : {}), + ...(readString(record, 'toolName') ? { toolName: readString(record, 'toolName')! } : {}), + ...(readString(record, 'status') ? { status: readString(record, 'status')! } : {}), + ...(asRecord(record.input) ? { input: asRecord(record.input)! } : {}), + ...(Object.prototype.hasOwnProperty.call(record, 'result') ? { result: record.result } : {}), + ...(typeof record.isError === 'boolean' ? { isError: record.isError } : {}), + ...(readString(record, 'text') ? { text: readString(record, 'text')! } : {}), + }; +} + +function readProjectionFromRaw(value: unknown): CodexNativeTraceProjection | null { + const event = asRecord(value); + const item = asRecord(event?.item); + const eventType = readString(event ?? {}, 'type'); + const itemType = readString(item ?? {}, 'type'); + const itemId = readString(item ?? {}, 'id'); + if (!item || !itemId || (eventType !== 'item.started' && eventType !== 'item.completed')) { + return null; + } + if (itemType === 'command_execution') { + const command = readString(item, 'command') ?? ''; + const status = + readString(item, 'status') ?? (eventType === 'item.started' ? 'in_progress' : 'unknown'); + const exitCode = readNumber(item, 'exit_code') ?? readNumber(item, 'exitCode'); + const output = + readRawString(item, 'aggregated_output') ?? + readRawString(item, 'output') ?? + readRawString(item, 'stderr') ?? + ''; + return { + kind: eventType === 'item.started' ? 'tool_start' : 'tool_result', + toolSource: 'native', + rawItemType: 'command_execution', + itemId, + toolName: 'Bash', + status, + input: { command }, + ...(eventType === 'item.completed' + ? { + result: { + content: output, + stdout: + readRawString(item, 'aggregated_output') ?? readRawString(item, 'output') ?? '', + stderr: readRawString(item, 'stderr') ?? '', + exitCode, + }, + isError: + status === 'failed' || status === 'declined' || (exitCode !== null && exitCode !== 0), + } + : {}), + }; + } + if (itemType === 'file_change') { + const changes = Array.isArray(item.changes) ? item.changes : []; + const firstChange = changes.map(asRecord).find((change) => typeof change?.path === 'string'); + return { + kind: eventType === 'item.started' ? 'tool_start' : 'tool_result', + toolSource: 'native', + rawItemType: 'file_change', + itemId, + toolName: 'Edit', + status: + readString(item, 'status') ?? (eventType === 'item.started' ? 'in_progress' : 'unknown'), + input: { + file_path: typeof firstChange?.path === 'string' ? firstChange.path : '', + changes, + }, + ...(eventType === 'item.completed' + ? { + result: { + content: [ + 'File changes:', + ...changes.map((change) => { + const row = asRecord(change); + return `- ${row?.path ?? '(unknown path)'} (${row?.kind ?? 'update'})`; + }), + ].join('\n'), + changes, + }, + isError: readString(item, 'status') === 'failed', + } + : {}), + }; + } + return null; +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +export class CodexNativeTraceReader { + constructor(private readonly teamsBasePath: string = getTeamsBasePath()) {} + + getTraceRoot(): string { + return path.join(this.teamsBasePath, TRACE_ROOT_SEGMENT); + } + + async readTaskRuns(params: { + teamName: string; + taskIds: string[]; + includeIncoming?: boolean; + }): Promise { + const root = this.getTraceRoot(); + const rootResolved = path.resolve(root); + const teamSegments = [...new Set([tracePathSegment(params.teamName)].filter(isString))]; + const taskSegments = [...new Set(params.taskIds.map(tracePathSegment).filter(isString))]; + const candidates: TraceFileCandidate[] = []; + + for (const teamSegment of teamSegments) { + for (const taskSegment of taskSegments) { + candidates.push( + ...(await this.listTraceFiles( + path.join(root, 'processed', teamSegment, taskSegment), + false + )) + ); + if (params.includeIncoming) { + candidates.push( + ...(await this.listTraceFiles( + path.join(root, 'incoming', teamSegment, taskSegment), + true + )) + ); + } + } + } + + const uniqueCandidates = new Map(); + for (const candidate of candidates) { + const resolved = path.resolve(candidate.filePath); + if (!isPathInside(rootResolved, resolved)) { + continue; + } + uniqueCandidates.set(resolved, candidate); + } + + const parsedRuns = await Promise.all( + [...uniqueCandidates.values()] + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, 10) + .map((candidate) => this.readRun(candidate).catch(() => null)) + ); + const expectedTeamName = normalizeIdentity(params.teamName); + const expectedTaskIds = new Set( + params.taskIds + .map((taskId) => normalizeIdentity(taskId)) + .filter((taskId): taskId is string => taskId !== null) + ); + const runsById = new Map(); + + for (const run of parsedRuns) { + if (!run) { + continue; + } + const runTeamName = normalizeIdentity(run.teamName); + if (runTeamName && expectedTeamName && runTeamName !== expectedTeamName) { + continue; + } + const runTaskId = normalizeIdentity(run.taskId); + if (runTaskId && expectedTaskIds.size > 0 && !expectedTaskIds.has(runTaskId)) { + continue; + } + const key = `${runTeamName ?? expectedTeamName ?? 'unknown-team'}::${runTaskId ?? 'unknown-task'}::${run.runId}`; + const existing = runsById.get(key); + if ( + !existing || + (existing.partial && !run.partial) || + (existing.partial === run.partial && run.mtimeMs > existing.mtimeMs) + ) { + runsById.set(key, run); + } + } + + return [...runsById.values()].sort((left, right) => { + const leftTime = Date.parse(left.startedAt ?? ''); + const rightTime = Date.parse(right.startedAt ?? ''); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return left.filePath.localeCompare(right.filePath); + }); + } + + private async listTraceFiles(dir: string, partial: boolean): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + const rows = await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.tmp')) + ) + .map(async (entry) => { + const filePath = path.join(dir, entry.name); + const stat = await fs.stat(filePath).catch(() => null); + return stat?.isFile() + ? { + filePath, + mtimeMs: stat.mtimeMs, + size: stat.size, + partial: partial || entry.name.endsWith('.tmp'), + } + : null; + }) + ); + return rows.filter((row): row is TraceFileCandidate => row !== null); + } + + private async readRun(candidate: TraceFileCandidate): Promise { + const raw = await fs.readFile(candidate.filePath, 'utf8').catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + }); + if (raw === null) { + return null; + } + const lines = raw.split(/\r?\n/); + let header: Record | null = null; + const events: CodexNativeTraceEvent[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + let parsed: Record; + try { + parsed = JSON.parse(line) as Record; + } catch { + if (candidate.partial && index === lines.length - 1) { + continue; + } + continue; + } + if (parsed.recordType === 'codex_native_trace_header') { + header = parsed; + continue; + } + if (parsed.recordType !== 'codex_native_stdout_event') { + continue; + } + const sourceOrder = + typeof parsed.sourceOrder === 'number' ? parsed.sourceOrder : events.length + 1; + events.push({ + sourceOrder, + receivedAt: readString(parsed, 'receivedAt') ?? new Date(candidate.mtimeMs).toISOString(), + projection: readProjection(parsed.projection) ?? readProjectionFromRaw(parsed.raw), + }); + } + + if (!header) { + return null; + } + + return { + filePath: candidate.filePath, + runId: + readString(header, 'runId') ?? + path.basename(candidate.filePath).replace(/\.jsonl(?:\.tmp)?$/, ''), + teamName: readString(header, 'teamName'), + taskId: readString(header, 'taskId'), + ownerName: readString(header, 'ownerName'), + cwd: readString(header, 'cwd'), + startedAt: readString(header, 'startedAt'), + mtimeMs: candidate.mtimeMs, + size: candidate.size, + events: events.sort((left, right) => left.sourceOrder - right.sourceOrder), + partial: candidate.partial, + }; + } +} diff --git a/src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe.ts b/src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe.ts new file mode 100644 index 00000000..b0e1cf71 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe.ts @@ -0,0 +1,150 @@ +import { createReadStream } from 'fs'; +import * as readline from 'readline'; + +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { TeamTask } from '@shared/types'; + +const RAW_PROBE_CONCURRENCY = process.platform === 'win32' ? 4 : 8; +const BOARD_MCP_MARKERS = [ + 'mcp__agent-teams__task_', + 'mcp__agent-teams__review_', + 'mcp__agent_teams__task_', + 'mcp__agent_teams__review_', + 'agent-teams_task_', + 'agent-teams_review_', + 'agent_teams_task_', + 'agent_teams_review_', + '"task_start"', + '"task_complete"', + '"task_add_comment"', + '"task_set_status"', + '"review_start"', + '"review_request"', + '"review_approve"', + '"review_request_changes"', +]; + +export interface HistoricalBoardMcpRawProbeInput { + task: TeamTask; + transcriptFiles: readonly string[]; +} + +export interface HistoricalBoardMcpRawProbeResult { + filePaths: string[]; + scannedFileCount: number; + hitCount: number; + elapsedMs: number; +} + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const currentIndex = index; + index += 1; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await fn(items[currentIndex]); + } + }); + await Promise.all(workers); + return results; +} + +function normalizeReference(value: string | undefined): string | null { + const normalized = value?.trim().replace(/^#/, '').toLowerCase(); + return normalized ? normalized : null; +} + +function buildRawTaskReferences(task: TeamTask): string[] { + const refs = new Set(); + for (const value of [task.id, getTaskDisplayId(task)]) { + const normalized = normalizeReference(value); + if (!normalized) { + continue; + } + refs.add(normalized); + refs.add(`#${normalized}`); + } + return [...refs].sort((left, right) => left.localeCompare(right)); +} + +function textHasBoardMcpMarker(lowerText: string): boolean { + return BOARD_MCP_MARKERS.some((marker) => lowerText.includes(marker)); +} + +function textReferencesTask(lowerText: string, taskRefs: readonly string[]): boolean { + return taskRefs.some((taskRef) => lowerText.includes(taskRef)); +} + +async function fileHasTaskBoardMcpCandidate( + filePath: string, + taskRefs: readonly string[] +): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let hasTaskRef = false; + let hasBoardMarker = false; + try { + for await (const line of rl) { + const lowerLine = line.toLowerCase(); + hasTaskRef = hasTaskRef || textReferencesTask(lowerLine, taskRefs); + hasBoardMarker = hasBoardMarker || textHasBoardMcpMarker(lowerLine); + if (hasTaskRef && hasBoardMarker) { + return true; + } + } + return false; + } finally { + rl.close(); + stream.destroy(); + } +} + +export class HistoricalBoardMcpRawProbe { + async findCandidateFiles( + input: HistoricalBoardMcpRawProbeInput + ): Promise { + const startedAt = Date.now(); + const uniqueFiles = [...new Set(input.transcriptFiles)].sort((left, right) => + left.localeCompare(right) + ); + const taskRefs = buildRawTaskReferences(input.task); + if (uniqueFiles.length === 0 || taskRefs.length === 0) { + return { + filePaths: [], + scannedFileCount: uniqueFiles.length, + hitCount: 0, + elapsedMs: Date.now() - startedAt, + }; + } + + const hits = await mapLimit(uniqueFiles, RAW_PROBE_CONCURRENCY, async (filePath) => { + try { + return (await fileHasTaskBoardMcpCandidate(filePath, taskRefs)) ? filePath : null; + } catch { + return null; + } + }); + + const filePaths = hits.filter((filePath): filePath is string => filePath !== null); + return { + filePaths, + scannedFileCount: uniqueFiles.length, + hitCount: filePaths.length, + elapsedMs: Date.now() - startedAt, + }; + } +} diff --git a/src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector.ts b/src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector.ts new file mode 100644 index 00000000..b7a5ef5a --- /dev/null +++ b/src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector.ts @@ -0,0 +1,212 @@ +import path from 'path'; + +import { isReadOnlyBoardTaskLogToolName } from './boardTaskLogToolNames'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; + +export type TaskLogTranscriptCandidateReason = + | 'direct_record_file' + | 'same_session_non_read_record'; + +export interface TaskLogTranscriptCandidateFile { + filePath: string; + reason: TaskLogTranscriptCandidateReason; + sessionId?: string; + sourceRecordIds: string[]; +} + +export interface TaskLogTranscriptCandidateSelectionDiagnostics { + recordFileCount: number; + nonReadSessionCount: number; + sameSessionFileCount: number; + alreadyParsedCandidateCount: number; + finalCandidateCount: number; + reason: 'direct_record_files' | 'same_session_native_window' | 'no_candidates'; +} + +export interface TaskLogTranscriptCandidateSelection { + filePaths: string[]; + candidates: TaskLogTranscriptCandidateFile[]; + diagnostics: TaskLogTranscriptCandidateSelectionDiagnostics; +} + +export interface SelectInferredNativeTranscriptFilesInput { + records: readonly BoardTaskActivityRecord[]; + transcriptFiles: readonly string[]; + projectDir?: string; + alreadyParsedFilePaths?: ReadonlySet; +} + +interface TranscriptSessionIndex { + filesBySessionId: Map; + sessionIdByFilePath: Map; +} + +function normalizeSessionId(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function extractTranscriptSessionId( + projectDir: string | undefined, + filePath: string +): string | null { + if (!projectDir) { + return null; + } + + const relativePath = path.relative(projectDir, filePath); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + const parts = relativePath.split(path.sep).filter(Boolean); + if (parts.length === 1 && parts[0]?.endsWith('.jsonl')) { + return parts[0].slice(0, -'.jsonl'.length); + } + + if (parts.length >= 3 && parts[1] === 'subagents' && parts[2]?.endsWith('.jsonl')) { + return parts[0] ?? null; + } + + return null; +} + +function buildTranscriptSessionIndex( + transcriptFiles: readonly string[], + projectDir: string | undefined +): TranscriptSessionIndex { + const filesBySessionId = new Map(); + const sessionIdByFilePath = new Map(); + + for (const filePath of transcriptFiles) { + const sessionId = extractTranscriptSessionId(projectDir, filePath); + if (!sessionId) { + continue; + } + sessionIdByFilePath.set(filePath, sessionId); + const files = filesBySessionId.get(sessionId) ?? []; + files.push(filePath); + filesBySessionId.set(sessionId, files); + } + + for (const [sessionId, files] of filesBySessionId.entries()) { + filesBySessionId.set( + sessionId, + [...new Set(files)].sort((left, right) => left.localeCompare(right)) + ); + } + + return { filesBySessionId, sessionIdByFilePath }; +} + +function isReadOnlyRecord(record: BoardTaskActivityRecord): boolean { + return ( + record.action?.category === 'read' || + isReadOnlyBoardTaskLogToolName(record.action?.canonicalToolName) + ); +} + +function addCandidate( + candidatesByFilePath: Map, + filePath: string, + candidate: Omit +): void { + const existing = candidatesByFilePath.get(filePath); + if (!existing) { + candidatesByFilePath.set(filePath, { + filePath, + ...candidate, + sourceRecordIds: [...new Set(candidate.sourceRecordIds)].sort((left, right) => + left.localeCompare(right) + ), + }); + return; + } + + existing.sourceRecordIds = [ + ...new Set([...existing.sourceRecordIds, ...candidate.sourceRecordIds]), + ].sort((left, right) => left.localeCompare(right)); + + if (existing.reason !== 'direct_record_file' && candidate.reason === 'direct_record_file') { + existing.reason = candidate.reason; + } + if (!existing.sessionId && candidate.sessionId) { + existing.sessionId = candidate.sessionId; + } +} + +export class TaskLogTranscriptCandidateSelector { + selectInferredNativeTranscriptFiles( + input: SelectInferredNativeTranscriptFilesInput + ): TaskLogTranscriptCandidateSelection { + const alreadyParsedFilePaths = input.alreadyParsedFilePaths ?? new Set(); + const sessionIndex = buildTranscriptSessionIndex(input.transcriptFiles, input.projectDir); + const candidatesByFilePath = new Map(); + const recordFiles = new Set(); + const nonReadSessionIds = new Set(); + const sameSessionFiles = new Set(); + + for (const record of input.records) { + if (record.source.filePath) { + recordFiles.add(record.source.filePath); + addCandidate(candidatesByFilePath, record.source.filePath, { + reason: 'direct_record_file', + sessionId: + normalizeSessionId(record.actor.sessionId) ?? + sessionIndex.sessionIdByFilePath.get(record.source.filePath), + sourceRecordIds: [record.id], + }); + } + + if (isReadOnlyRecord(record)) { + continue; + } + + const sessionId = + normalizeSessionId(record.actor.sessionId) ?? + sessionIndex.sessionIdByFilePath.get(record.source.filePath); + if (!sessionId) { + continue; + } + + nonReadSessionIds.add(sessionId); + for (const filePath of sessionIndex.filesBySessionId.get(sessionId) ?? []) { + sameSessionFiles.add(filePath); + addCandidate(candidatesByFilePath, filePath, { + reason: 'same_session_non_read_record', + sessionId, + sourceRecordIds: [record.id], + }); + } + } + + const candidates = [...candidatesByFilePath.values()].sort((left, right) => + left.filePath.localeCompare(right.filePath) + ); + const filePaths = candidates + .map((candidate) => candidate.filePath) + .filter((filePath) => !alreadyParsedFilePaths.has(filePath)); + + const alreadyParsedCandidateCount = candidates.length - filePaths.length; + const reason = + candidates.length === 0 + ? 'no_candidates' + : nonReadSessionIds.size > 0 + ? 'same_session_native_window' + : 'direct_record_files'; + + return { + filePaths, + candidates, + diagnostics: { + recordFileCount: recordFiles.size, + nonReadSessionCount: nonReadSessionIds.size, + sameSessionFileCount: sameSessionFiles.size, + alreadyParsedCandidateCount, + finalCandidateCount: filePaths.length, + reason, + }, + }; + } +} diff --git a/src/main/services/team/taskLogs/stream/boardTaskLogToolNames.ts b/src/main/services/team/taskLogs/stream/boardTaskLogToolNames.ts new file mode 100644 index 00000000..bac041fe --- /dev/null +++ b/src/main/services/team/taskLogs/stream/boardTaskLogToolNames.ts @@ -0,0 +1,54 @@ +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; + +const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([ + 'task_complete', + 'task_set_status', + 'task_start', + 'review_approve', + 'review_request_changes', + 'review_start', +]); + +const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([ + 'review_request', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_set_clarification', + 'task_set_owner', + 'task_unlink', +]); + +const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); + +export function canonicalizeBoardTaskLogToolName(toolName: string | undefined): string | null { + if (!toolName) return null; + const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +export function isBoardTaskLogMcpToolName(toolName: string | undefined): boolean { + const canonical = canonicalizeBoardTaskLogToolName(toolName); + return ( + canonical !== null && + (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) || + HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical)) + ); +} + +export function isReadOnlyBoardTaskLogToolName(toolName: string | undefined): boolean { + const canonical = canonicalizeBoardTaskLogToolName(toolName); + return canonical !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonical); +} + +export function isRecoverableHistoricalBoardTaskLogToolName(toolName: string | undefined): boolean { + const canonical = canonicalizeBoardTaskLogToolName(toolName); + return ( + canonical !== null && + (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) || + HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical)) + ); +} diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 82a64ae8..734a7f94 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -22,6 +22,7 @@ interface MemberExecutionLogProps { memberName?: string; memberColor?: string; teamName?: string; + hideMemberHeading?: boolean; } type ExpandedItemIdsByGroup = Map>; @@ -31,6 +32,7 @@ export const MemberExecutionLog = ({ memberName, memberColor, teamName, + hideMemberHeading, }: MemberExecutionLogProps): React.JSX.Element => { const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]); @@ -69,6 +71,7 @@ export const MemberExecutionLog = ({ memberName={memberName} memberColor={memberColor} teamName={teamName} + hideMemberHeading={hideMemberHeading} expanded={!collapsedGroupIds.has(item.group.id)} expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()} onToggleExpanded={() => { @@ -162,6 +165,7 @@ interface AIExecutionGroupProps { memberName?: string; memberColor?: string; teamName?: string; + hideMemberHeading?: boolean; expanded: boolean; expandedItemIds: Set; onToggleExpanded: () => void; @@ -173,6 +177,7 @@ const AIExecutionGroup = ({ memberName, memberColor, teamName, + hideMemberHeading, expanded, expandedItemIds, onToggleExpanded, @@ -210,7 +215,7 @@ const AIExecutionGroup = ({ onClick={onToggleExpanded} aria-expanded={expanded} > - {normalizedMemberName ? ( + {normalizedMemberName && !hideMemberHeading ? ( <> + ) : hideMemberHeading ? ( + + turn + ) : ( <> diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index ec9712b6..09818aa7 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -94,9 +94,18 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string } return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.'; } + if (stream?.source === 'codex_native_trace_fallback') { + return 'Task-scoped Codex native trace logs projected into the same execution-log components used in Logs.'; + } + if (stream?.source === 'mixed_transcript_codex_native_trace') { + return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.'; + } if (stream?.runtimeProjection?.provider === 'opencode') { return 'Task-scoped transcript logs merged with OpenCode runtime logs and rendered with the same execution-log components used in Logs.'; } + if (stream?.runtimeProjection?.provider === 'codex_native') { + return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.'; + } return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; } @@ -142,9 +151,26 @@ function buildParticipantVisualMap( return visuals; } -const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element => { +const SegmentMarker = ({ + segment, + visual, + teamName, +}: { + segment: BoardTaskLogSegment; + visual?: ParticipantVisual; + teamName: string; +}): React.JSX.Element => { return ( -
+
+ {visual ? ( + + ) : null} {formatRelativeTime(segment.endTimestamp)} @@ -166,12 +192,13 @@ const SegmentBlock = ({ }): React.JSX.Element => { return (
- {showHeader ? : null} + {showHeader ? : null}
); diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index ecbe2b69..9f96f742 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -349,8 +349,8 @@ export interface BoardTaskLogSegment { } export interface BoardTaskLogStreamRuntimeProjection { - provider: 'opencode'; - mode: 'attribution' | 'heuristic'; + provider: 'opencode' | 'codex_native'; + mode: 'attribution' | 'heuristic' | 'trace'; attributionRecordCount: number; projectedMessageCount: number; boardMcpToolCount?: number; @@ -358,16 +358,26 @@ export interface BoardTaskLogStreamRuntimeProjection { fallbackReason?: | 'no_attribution_records' | 'attribution_no_projected_messages' - | 'task_tool_markers'; + | 'task_tool_markers' + | 'codex_native_trace'; markerMatchCount?: number; markerSpanCount?: number; + traceFileCount?: number; + traceRunCount?: number; + dedupedNativeToolCount?: number; } export interface BoardTaskLogStreamResponse { participants: BoardTaskLogParticipant[]; defaultFilter: 'all' | string; segments: BoardTaskLogSegment[]; - source?: 'transcript' | 'opencode_runtime_fallback' | 'opencode_runtime_attribution'; + source?: + | 'transcript' + | 'opencode_runtime_fallback' + | 'opencode_runtime_attribution' + | 'codex_native_trace_fallback' + | 'mixed_transcript_codex_native_trace' + | 'mixed_transcript_opencode_runtime'; runtimeProjection?: BoardTaskLogStreamRuntimeProjection; } diff --git a/test/main/services/team/BoardTaskActivityRecordSource.test.ts b/test/main/services/team/BoardTaskActivityRecordSource.test.ts index 38a3e34c..49a112b4 100644 --- a/test/main/services/team/BoardTaskActivityRecordSource.test.ts +++ b/test/main/services/team/BoardTaskActivityRecordSource.test.ts @@ -136,4 +136,52 @@ describe('BoardTaskActivityRecordSource', () => { expect(transcriptReader.readFiles).toHaveBeenCalledTimes(1); expect(recordBuilder.buildForTasks).toHaveBeenCalledTimes(1); }); + + it('rebuilds the team index when transcript discovery generation changes', async () => { + const task = { + id: 'task-a', + displayId: 'aaaa1111', + subject: 'A', + status: 'pending', + }; + let generation = 0; + const transcriptFiles = ['/tmp/a.jsonl']; + const firstRecords = [{ id: 'record-a-1' }]; + const secondRecords = [{ id: 'record-a-2' }]; + + const locator = { + getGeneration: vi.fn(() => generation), + getContext: vi.fn(async () => ({ + transcriptFiles, + })), + }; + const taskReader = { + getTasks: vi.fn(async () => [task]), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptReader = { + readFiles: vi.fn(async () => [{ uuid: `m${generation}` }]), + }; + const recordBuilder = { + buildForTasks: vi + .fn() + .mockReturnValueOnce(new Map([['task-a', firstRecords]])) + .mockReturnValueOnce(new Map([['task-a', secondRecords]])), + }; + + const source = new BoardTaskActivityRecordSource( + locator as never, + taskReader as never, + transcriptReader as never, + recordBuilder as never, + ); + + await expect(source.getTaskRecords('demo', 'task-a')).resolves.toEqual(firstRecords); + generation += 1; + await expect(source.getTaskRecords('demo', 'task-a')).resolves.toEqual(secondRecords); + + expect(locator.getContext).toHaveBeenCalledTimes(2); + expect(transcriptReader.readFiles).toHaveBeenCalledTimes(2); + expect(recordBuilder.buildForTasks).toHaveBeenCalledTimes(2); + }); }); diff --git a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts index 3e97c5af..40f76668 100644 --- a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts +++ b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource'; @@ -10,6 +10,7 @@ import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; import type { TeamTask } from '../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; @@ -465,4 +466,137 @@ describe('BoardTaskLogDiagnosticsService', () => { expect(report.stream.emptyPayloadExamples).toEqual([]); expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']); }); + + it('bounds diagnostics strict parsing to activity-record candidate files', async () => { + const projectDir = path.join(tmpdir(), 'diagnostics-project'); + const rootFile = path.join(projectDir, 'session-tom.jsonl'); + const subagentFile = path.join(projectDir, 'session-tom', 'subagents', 'agent-work.jsonl'); + const unrelatedFile = path.join(projectDir, 'session-alice.jsonl'); + const task = createTask({ owner: 'tom' }); + const record: BoardTaskActivityRecord = { + id: 'record-comment', + timestamp: '2026-04-12T15:36:00.000Z', + task: { + locator: { ref: 'c414cd52', refKind: 'display', canonicalId: TASK_ID }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'tom', + role: 'member', + sessionId: 'session-tom', + isSidechain: false, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-comment', + category: 'comment', + }, + source: { + filePath: rootFile, + messageUuid: 'message-comment', + toolUseId: 'tool-comment', + sourceOrder: 1, + }, + }; + const strictParser = { + parseFiles: async (filePaths: string[]) => + new Map(filePaths.map((filePath) => [filePath, []])), + }; + const parseSpy = vi.spyOn(strictParser, 'parseFiles'); + const diagnosticsService = new BoardTaskLogDiagnosticsService( + { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + } as never, + { + getContext: async () => ({ + projectDir, + transcriptFiles: [rootFile, subagentFile, unrelatedFile], + }), + } as never, + { + getTaskRecords: async () => [record], + } as never, + strictParser as never, + { + getTaskLogStream: async () => ({ + participants: [], + defaultFilter: 'all' as const, + segments: [], + }), + } as never, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID); + + expect(parseSpy).toHaveBeenCalledWith([rootFile, subagentFile]); + expect(parseSpy.mock.calls.flatMap((call) => call[0] as string[])).not.toContain( + unrelatedFile, + ); + expect(report.transcript.parsedFileCount).toBe(2); + expect(report.transcript.candidateSelection).toMatchObject({ + mode: 'activity_records', + candidateFileCount: 2, + }); + }); + + it('bounds diagnostics historical recovery parsing to raw-probe hit files', async () => { + const task = createTask({ owner: 'tom' }); + const hitFile = path.join(tmpdir(), 'diagnostics-historical-hit.jsonl'); + const unrelatedFile = path.join(tmpdir(), 'diagnostics-historical-unrelated.jsonl'); + const strictParser = { + parseFiles: async (filePaths: string[]) => + new Map(filePaths.map((filePath) => [filePath, []])), + }; + const parseSpy = vi.spyOn(strictParser, 'parseFiles'); + const rawProbe = { + findCandidateFiles: async () => ({ + filePaths: [hitFile], + scannedFileCount: 2, + hitCount: 1, + elapsedMs: 0, + }), + }; + const diagnosticsService = new BoardTaskLogDiagnosticsService( + { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + } as never, + { + getContext: async () => ({ + projectDir: tmpdir(), + transcriptFiles: [hitFile, unrelatedFile], + }), + } as never, + { + getTaskRecords: async () => [], + } as never, + strictParser as never, + { + getTaskLogStream: async () => ({ + participants: [], + defaultFilter: 'all' as const, + segments: [], + }), + } as never, + undefined, + rawProbe as never, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID); + + expect(parseSpy).toHaveBeenCalledWith([hitFile]); + expect(parseSpy.mock.calls.flatMap((call) => call[0] as string[])).not.toContain( + unrelatedFile, + ); + expect(report.transcript.candidateSelection).toMatchObject({ + mode: 'historical_raw_probe', + candidateFileCount: 1, + rawProbeScannedFileCount: 2, + rawProbeHitCount: 1, + }); + }); }); diff --git a/test/main/services/team/BoardTaskLogStream.live.test.ts b/test/main/services/team/BoardTaskLogStream.live.test.ts index 3f412717..0d149631 100644 --- a/test/main/services/team/BoardTaskLogStream.live.test.ts +++ b/test/main/services/team/BoardTaskLogStream.live.test.ts @@ -1,11 +1,11 @@ import * as os from 'os'; import * as path from 'path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; -import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim(); const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim(); @@ -54,6 +54,7 @@ describeLive('BoardTaskLogStream live smoke', () => { const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId); expect(stream.segments.length).toBeGreaterThan(0); + vi.mocked(console.warn).mockClear(); if (EXPECT_MISSING_WORKER_LINKS) { expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index b7dd9927..8f758be8 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -158,9 +158,9 @@ describe('BoardTaskLogStreamService', () => { expect(response.source).toBe('opencode_runtime_fallback'); expect(response.segments).toHaveLength(1); expect(await service.getTaskLogStreamSummary('demo', 'task-a')).toEqual({ - segmentCount: 0, + segmentCount: 1, }); - expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1); + expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(2); }); it('merges OpenCode runtime stream when board transcript slices mask member execution', async () => { @@ -1089,6 +1089,225 @@ describe('BoardTaskLogStreamService', () => { expect(response.segments[0]?.participantKey).toBe('member:alice'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']); + expect(strictParser.parseFiles).toHaveBeenCalledTimes(1); + expect(strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[])).not.toContain( + '/tmp/alice.jsonl' + ); + }); + + it('limits inferred native parsing to direct and same-session transcript candidates', async () => { + const projectDir = '/tmp/task-log-project'; + const rootFile = `${projectDir}/session-alice.jsonl`; + const subagentFile = `${projectDir}/session-alice/subagents/agent-worker.jsonl`; + const unrelatedFiles = Array.from( + { length: 300 }, + (_, index) => `${projectDir}/session-unrelated-${index}.jsonl` + ); + const alice = { + memberName: 'alice', + role: 'member' as const, + sessionId: 'session-alice', + isSidechain: false, + }; + const baseRecord = makeRecord( + 'alice-comment', + '2026-04-12T16:00:00.000Z', + alice, + 'tool-comment' + ); + const commentRecord: BoardTaskActivityRecord = { + ...baseRecord, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-comment', + category: 'comment', + }, + source: { + ...baseRecord.source, + filePath: rootFile, + }, + }; + const candidate: BoardTaskExactLogBundleCandidate = { + ...makeCandidate('alice-comment', '2026-04-12T16:00:00.000Z', alice, 'tool-comment'), + source: commentRecord.source, + records: [commentRecord], + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + }; + const nativeMessage: ParsedMessage = { + uuid: 'alice-bash', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:01:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-bash', + name: 'Bash', + input: { command: 'npm test' }, + } as never, + ], + toolCalls: [ + { + id: 'tool-bash', + name: 'Bash', + input: { command: 'npm test' }, + isTask: false, + }, + ], + toolResults: [], + sessionId: 'session-alice', + agentName: 'alice', + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }; + const recordSource = { + getTaskRecords: vi.fn(async () => [commentRecord]), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async (filePaths: string[]) => + new Map( + filePaths.map((filePath) => [ + filePath, + filePath === subagentFile ? [nativeMessage] : [], + ]) + ) + ), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'alice-comment', + timestamp: '2026-04-12T16:00:00.000Z', + actor: alice, + source: candidate.source, + records: [commentRecord], + filteredMessages: [ + makeMessage('alice-comment-detail', '2026-04-12T16:00:00.000Z', 'comment'), + ], + })), + }; + const taskReader = { + getTasks: vi.fn(async () => [ + { + id: 'task-a', + displayId: 'abcd1234', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-04-12T15:59:00.000Z', + updatedAt: '2026-04-12T16:05:00.000Z', + }, + ]), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptSourceLocator = { + getContext: vi.fn(async () => ({ + projectDir, + transcriptFiles: [rootFile, subagentFile, ...unrelatedFiles], + config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] }, + })), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + taskReader as never, + transcriptSourceLocator as never, + { getTaskLogStream: vi.fn(async () => null) } as never, + { getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]) } as never, + { getConfig: vi.fn(async () => null) } as never, + { getTaskLogStream: vi.fn(async () => null) } as never + ); + + await service.getTaskLogStream('demo', 'task-a'); + + expect(strictParser.parseFiles.mock.calls.map((call) => call[0])).toEqual([ + [rootFile], + [subagentFile], + ]); + const parsedFiles = strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[]); + expect(parsedFiles).not.toEqual(expect.arrayContaining(unrelatedFiles)); + expect(buildBundleChunks.mock.calls[0]?.[0].map((message: ParsedMessage) => message.uuid)).toEqual([ + 'alice-comment-detail', + 'alice-bash', + ]); + }); + + it('limits historical board MCP recovery parsing to raw-probe candidate files', async () => { + const hitFile = '/tmp/historical-hit.jsonl'; + const unrelatedFile = '/tmp/historical-unrelated.jsonl'; + const taskReader = { + getTasks: vi.fn(async () => [ + { + id: 'task-a', + displayId: 'abcd1234', + owner: 'tom', + status: 'completed', + createdAt: '2026-04-12T16:00:00.000Z', + updatedAt: '2026-04-12T16:05:00.000Z', + }, + ]), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptSourceLocator = { + getContext: vi.fn(async () => ({ + transcriptFiles: [hitFile, unrelatedFile], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + })), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([[hitFile, []]])), + }; + const summarySelector = { + selectSummaries: vi.fn(() => { + throw new Error('empty parsed historical candidate should not create records'); + }), + }; + const rawProbe = { + findCandidateFiles: vi.fn(async () => ({ + filePaths: [hitFile], + scannedFileCount: 2, + hitCount: 1, + elapsedMs: 0, + })), + }; + + const service = new BoardTaskLogStreamService( + { getTaskRecords: vi.fn(async () => []) } as never, + summarySelector as never, + strictParser as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + { getTaskLogStream: vi.fn(async () => null) } as never, + undefined as never, + undefined as never, + { getTaskLogStream: vi.fn(async () => null) } as never, + undefined as never, + rawProbe as never + ); + + await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + expect(rawProbe.findCandidateFiles).toHaveBeenCalledWith({ + task: expect.objectContaining({ id: 'task-a' }), + transcriptFiles: [hitFile, unrelatedFile], + }); + expect(strictParser.parseFiles).toHaveBeenCalledWith([hitFile]); }); it('does not recover task_get logs from nested task refs in result payloads', async () => { @@ -1595,4 +1814,129 @@ describe('BoardTaskLogStreamService', () => { }, ]); }); + it('merges Codex native trace fallback even when primary transcript has MCP execution records', async () => { + const atlas = { + memberName: 'atlas', + role: 'member' as const, + sessionId: 'session-atlas', + agentId: 'agent-atlas', + isSidechain: true, + }; + const baseCandidate = makeCandidate( + 'c1', + '2026-05-01T17:10:00.000Z', + atlas, + 'mcp-tool-1' + ); + const executionRecord: BoardTaskActivityRecord = { + ...baseCandidate.records[0]!, + linkKind: 'execution', + }; + const candidate: BoardTaskExactLogBundleCandidate = { + ...baseCandidate, + records: [executionRecord], + linkKinds: ['execution'], + }; + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/codex-task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: candidate.id, + timestamp: candidate.timestamp, + actor: atlas, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage('mcp-message', '2026-05-01T17:10:00.000Z', 'mcp task_start')], + })), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + const openCodeRuntimeFallbackSource = { + getTaskLogStream: vi.fn(async () => { + throw new Error('OpenCode fallback should stay behind OpenCode-only conditions'); + }), + }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'atlas', providerId: 'codex' }]), + }; + const configReader = { + getConfig: vi.fn(async () => null), + }; + const codexNativeTraceFallbackSource = { + getTaskLogStream: vi.fn(async () => ({ + participants: [ + { + key: 'member:atlas', + label: 'atlas', + role: 'member' as const, + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'member:atlas', + segments: [ + { + id: 'codex-native:demo:task-a:atlas', + participantKey: 'member:atlas', + actor: atlas, + startTimestamp: '2026-05-01T17:10:02.000Z', + endTimestamp: '2026-05-01T17:10:05.000Z', + chunks: [{ id: 'bash-chunk' }], + }, + ], + source: 'codex_native_trace_fallback' as const, + runtimeProjection: { + provider: 'codex_native' as const, + mode: 'trace' as const, + attributionRecordCount: 0, + projectedMessageCount: 2, + nativeToolCount: 1, + fallbackReason: 'codex_native_trace' as const, + traceFileCount: 1, + traceRunCount: 1, + dedupedNativeToolCount: 0, + }, + })), + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + undefined as never, + undefined as never, + openCodeRuntimeFallbackSource as never, + membersMetaStore as never, + configReader as never, + codexNativeTraceFallbackSource as never + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(openCodeRuntimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled(); + expect(codexNativeTraceFallbackSource.getTaskLogStream).toHaveBeenCalledWith( + 'demo', + 'task-a', + { excludeNativeToolSignatures: expect.any(Set) } + ); + expect(response.source).toBe('mixed_transcript_codex_native_trace'); + expect(response.participants.map((participant) => participant.key)).toEqual(['member:atlas']); + expect(response.segments.map((segment) => segment.id)).toEqual([ + 'member:atlas:c1:c1', + 'codex-native:demo:task-a:atlas', + ]); + expect(response.runtimeProjection).toMatchObject({ + provider: 'codex_native', + nativeToolCount: 1, + }); + }); + }); diff --git a/test/main/services/team/CodexNativeTaskLogStreamSource.test.ts b/test/main/services/team/CodexNativeTaskLogStreamSource.test.ts new file mode 100644 index 00000000..4b176309 --- /dev/null +++ b/test/main/services/team/CodexNativeTaskLogStreamSource.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodexNativeTaskLogStreamSource } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { CodexNativeTraceRun } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader'; +import type { TeamTask } from '../../../../src/shared/types'; + +function task(overrides: Partial = {}): TeamTask { + return { + id: '8421e1bb-2f3b-4656-9983-6e0fd4b15963', + displayId: '8421e1bb', + subject: 'Investigate Codex tools', + owner: 'atlas', + status: 'in_progress', + createdAt: '2026-05-01T17:10:00.000Z', + updatedAt: '2026-05-01T17:20:00.000Z', + ...overrides, + } as TeamTask; +} + +function message(uuid: string, timestamp: string, toolName: string): ParsedMessage { + const toolUseId = `${uuid}-tool`; + return { + uuid, + parentUuid: null, + type: 'assistant', + role: 'assistant', + timestamp: new Date(timestamp), + content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: { command: 'pwd' } } as never], + toolCalls: [{ id: toolUseId, name: toolName, input: { command: 'pwd' }, isTask: false }], + toolResults: [], + sessionId: 'run-1', + isSidechain: false, + isMeta: false, + }; +} + +describe('CodexNativeTaskLogStreamSource', () => { + it('resolves short task refs, verifies Codex owner, and reads full/display/short trace candidates', async () => { + const taskReader = { + getTasks: vi.fn(async () => [task()]), + getDeletedTasks: vi.fn(async () => []), + }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'atlas', providerId: 'codex' }]), + }; + const configReader = { + getConfig: vi.fn(async () => null), + }; + const traceRuns: CodexNativeTraceRun[] = [ + { + filePath: '/trace/run-1.jsonl', + runId: 'run-1', + teamName: 'vector-room-131313', + taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963', + ownerName: 'atlas', + cwd: '/repo', + startedAt: '2026-05-01T17:10:00.000Z', + mtimeMs: Date.parse('2026-05-01T17:10:00.000Z'), + size: 100, + events: [], + partial: false, + }, + ]; + const traceReader = { + readTaskRuns: vi.fn(async () => traceRuns), + }; + const projector = { + project: vi.fn(() => [message('bash-start', '2026-05-01T17:10:02.000Z', 'Bash')]), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: 'chunk-1', messages }]), + }; + + const source = new CodexNativeTaskLogStreamSource( + taskReader as never, + membersMetaStore as never, + configReader as never, + traceReader as never, + projector as never, + chunkBuilder as never + ); + + const response = await source.getTaskLogStream('vector-room-131313', '#8421e1bb'); + + expect(traceReader.readTaskRuns).toHaveBeenCalledWith({ + teamName: 'vector-room-131313', + taskIds: [ + '8421e1bb-2f3b-4656-9983-6e0fd4b15963', + '8421e1bb', + ], + includeIncoming: true, + }); + expect(response).toMatchObject({ + defaultFilter: 'member:atlas', + source: 'codex_native_trace_fallback', + runtimeProjection: { + provider: 'codex_native', + mode: 'trace', + nativeToolCount: 1, + traceFileCount: 1, + traceRunCount: 1, + }, + }); + expect(response?.participants.map((participant) => participant.key)).toEqual(['member:atlas']); + expect(response?.segments[0]?.participantKey).toBe('member:atlas'); + }); + + it('does not expose traces for non-Codex task owners', async () => { + const traceReader = { + readTaskRuns: vi.fn(async () => { + throw new Error('should not read traces for non-Codex owners'); + }), + }; + const source = new CodexNativeTaskLogStreamSource( + { + getTasks: vi.fn(async () => [task({ owner: 'alice' })]), + getDeletedTasks: vi.fn(async () => []), + } as never, + { + getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'anthropic' }]), + } as never, + { + getConfig: vi.fn(async () => null), + } as never, + traceReader as never + ); + + await expect(source.getTaskLogStream('vector-room-131313', '8421e1bb')).resolves.toBeNull(); + expect(traceReader.readTaskRuns).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/CodexNativeTraceProjector.test.ts b/test/main/services/team/CodexNativeTraceProjector.test.ts new file mode 100644 index 00000000..469eb5a1 --- /dev/null +++ b/test/main/services/team/CodexNativeTraceProjector.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import { + CodexNativeTraceProjector, + buildCodexNativeToolSignature, +} from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceProjector'; + +import type { + CodexNativeTraceEvent, + CodexNativeTraceRun, +} from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader'; + +function run(overrides: Partial = {}): CodexNativeTraceRun { + return { + filePath: '/trace/run-1.jsonl', + runId: 'run-1', + teamName: 'vector-room-131313', + taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963', + ownerName: 'atlas', + cwd: '/repo', + startedAt: '2026-05-01T17:10:07.799Z', + mtimeMs: Date.parse('2026-05-01T17:10:07.799Z'), + size: 100, + partial: false, + events: [], + ...overrides, + }; +} + +function event(overrides: Partial): CodexNativeTraceEvent { + return { + sourceOrder: 1, + receivedAt: '2026-05-01T17:10:08.000Z', + projection: null, + ...overrides, + }; +} + +describe('CodexNativeTraceProjector', () => { + it('projects native command result-only traces into a complete synthetic tool pair', () => { + const messages = new CodexNativeTraceProjector().project([ + run({ + events: [ + event({ + projection: { + kind: 'tool_result', + toolSource: 'native', + rawItemType: 'command_execution', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd && ls' }, + result: { + content: '/repo\nfile.txt\n', + stdout: '/repo\nfile.txt\n', + exitCode: 0, + }, + isError: false, + }, + }), + ], + }), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + type: 'assistant', + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Bash', + input: { command: 'pwd && ls' }, + }, + ], + agentName: 'atlas', + cwd: '/repo', + }); + expect(JSON.stringify(messages[0]?.content)).toContain( + 'codex-trace:vector-room-131313:8421e1bb-2f3b-4656-9983-6e0fd4b15963:run-1:item_1' + ); + expect(messages[1]).toMatchObject({ + type: 'user', + role: 'user', + isMeta: true, + sourceToolUseID: + 'codex-trace:vector-room-131313:8421e1bb-2f3b-4656-9983-6e0fd4b15963:run-1:item_1', + toolUseResult: { + content: '/repo\nfile.txt\n', + stdout: '/repo\nfile.txt\n', + exitCode: 0, + toolName: 'Bash', + isError: false, + }, + }); + }); + + it('deduplicates by native signature without leaving orphan start or result messages', () => { + const projector = new CodexNativeTraceProjector(); + const traceRun = run({ + events: [ + event({ + sourceOrder: 1, + projection: { + kind: 'tool_start', + toolSource: 'native', + rawItemType: 'command_execution', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd' }, + }, + }), + event({ + sourceOrder: 2, + receivedAt: '2026-05-01T17:10:09.000Z', + projection: { + kind: 'tool_result', + toolSource: 'native', + rawItemType: 'command_execution', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd' }, + result: { content: '/repo\n' }, + }, + }), + ], + }); + + expect(projector.project([traceRun])).toHaveLength(2); + expect( + projector.project([traceRun], { + excludeSignatures: new Set([buildCodexNativeToolSignature({ toolName: 'Bash', input: { command: 'pwd' } })!]), + }) + ).toEqual([]); + }); + + it('qualifies synthetic ids by run id so local Codex item ids do not collide', () => { + const messages = new CodexNativeTraceProjector().project([ + run({ + runId: 'run-a', + events: [ + event({ + projection: { + kind: 'tool_result', + toolSource: 'native', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd' }, + result: { content: 'a' }, + }, + }), + ], + }), + run({ + runId: 'run-b', + events: [ + event({ + receivedAt: '2026-05-01T17:11:08.000Z', + projection: { + kind: 'tool_result', + toolSource: 'native', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'ls' }, + result: { content: 'b' }, + }, + }), + ], + }), + ]); + + const toolUseIds = messages + .filter((message) => message.type === 'assistant') + .map((message) => String(JSON.stringify(message.content))); + + expect(toolUseIds[0]).toContain(':run-a:item_1'); + expect(toolUseIds[1]).toContain(':run-b:item_1'); + expect(new Set(toolUseIds).size).toBe(2); + }); +}); diff --git a/test/main/services/team/CodexNativeTraceReader.test.ts b/test/main/services/team/CodexNativeTraceReader.test.ts new file mode 100644 index 00000000..6080d7b3 --- /dev/null +++ b/test/main/services/team/CodexNativeTraceReader.test.ts @@ -0,0 +1,239 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { CodexNativeTraceReader } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader'; + +const TRACE_ROOT_SEGMENT = path.join('.member-work-sync', 'runtime-hooks', 'codex-native-traces'); + +let teamsBasePath: string; + +function traceSegment(value: string): string { + return encodeURIComponent(value); +} + +async function writeTraceFile(params: { + bucket: 'incoming' | 'processed'; + teamName: string; + taskId: string; + runId: string; + records: Array>; + suffix?: '.jsonl' | '.jsonl.tmp'; +}): Promise { + const dir = path.join( + teamsBasePath, + TRACE_ROOT_SEGMENT, + params.bucket, + traceSegment(params.teamName), + traceSegment(params.taskId) + ); + await mkdir(dir, { recursive: true }); + const absolutePath = path.join(dir, `${params.runId}${params.suffix ?? (params.bucket === 'incoming' ? '.jsonl.tmp' : '.jsonl')}`); + await writeFile( + absolutePath, + `${params.records.map((record) => JSON.stringify(record)).join('\n')}\n`, + 'utf8' + ); + return absolutePath; +} + +function header(overrides: Partial> = {}): Record { + return { + schemaVersion: 1, + recordType: 'codex_native_trace_header', + runId: 'run-1', + teamName: 'vector-room-131313', + taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963', + ownerName: 'atlas', + provider: 'codex', + cwd: '/repo', + startedAt: '2026-05-01T17:10:07.799Z', + ...overrides, + }; +} + +describe('CodexNativeTraceReader', () => { + beforeEach(async () => { + teamsBasePath = await mkdtemp(path.join(tmpdir(), 'codex-native-trace-reader-')); + }); + + afterEach(async () => { + await rm(teamsBasePath, { recursive: true, force: true }); + }); + + it('reads projection records and prefers processed trace over duplicate incoming run', async () => { + const teamName = 'vector-room-131313'; + const taskId = '8421e1bb-2f3b-4656-9983-6e0fd4b15963'; + await writeTraceFile({ + bucket: 'incoming', + teamName, + taskId, + runId: 'run-1', + records: [ + header(), + { + schemaVersion: 1, + recordType: 'codex_native_stdout_event', + receivedAt: '2026-05-01T17:10:08.000Z', + sourceOrder: 1, + projection: { + kind: 'tool_result', + toolSource: 'native', + rawItemType: 'command_execution', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd' }, + result: { content: 'incoming' }, + }, + }, + ], + }); + await writeTraceFile({ + bucket: 'processed', + teamName, + taskId, + runId: 'run-1', + records: [ + header(), + { + schemaVersion: 1, + recordType: 'codex_native_stdout_event', + receivedAt: '2026-05-01T17:10:08.000Z', + sourceOrder: 1, + projection: { + kind: 'tool_result', + toolSource: 'native', + rawItemType: 'command_execution', + itemId: 'item_1', + toolName: 'Bash', + input: { command: 'pwd' }, + result: { content: 'processed' }, + }, + }, + ], + }); + + const runs = await new CodexNativeTraceReader(teamsBasePath).readTaskRuns({ + teamName, + taskIds: [taskId, '8421e1bb'], + includeIncoming: true, + }); + + expect(runs).toHaveLength(1); + expect(runs[0]?.partial).toBe(false); + expect(runs[0]?.events[0]?.projection).toMatchObject({ + kind: 'tool_result', + toolSource: 'native', + toolName: 'Bash', + result: { content: 'processed' }, + }); + }); + + it('falls back to raw Codex command/file events and ignores malformed trailing incoming line', async () => { + const teamName = 'vector-room-131313'; + const taskId = '891e1f68-d5b0-40f7-aa48-c378607e0f3b'; + const dir = path.join( + teamsBasePath, + TRACE_ROOT_SEGMENT, + 'incoming', + traceSegment(teamName), + traceSegment(taskId) + ); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, 'run-raw.jsonl.tmp'), + [ + JSON.stringify(header({ runId: 'run-raw', taskId, ownerName: 'jack' })), + JSON.stringify({ + schemaVersion: 1, + recordType: 'codex_native_stdout_event', + receivedAt: '2026-05-01T17:19:36.000Z', + sourceOrder: 1, + raw: { + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + command: 'pwd', + aggregated_output: '/repo\n', + exit_code: 2, + status: 'completed', + }, + }, + }), + JSON.stringify({ + schemaVersion: 1, + recordType: 'codex_native_stdout_event', + receivedAt: '2026-05-01T17:19:37.000Z', + sourceOrder: 2, + raw: { + type: 'item.completed', + item: { + id: 'item_2', + type: 'file_change', + changes: [{ path: '/repo/src/a.ts', kind: 'update' }], + status: 'completed', + }, + }, + }), + '{"schemaVersion":1,"recordType":"codex_native_stdout_event"', + ].join('\n'), + 'utf8' + ); + + const runs = await new CodexNativeTraceReader(teamsBasePath).readTaskRuns({ + teamName, + taskIds: [taskId, '891e1f68'], + includeIncoming: true, + }); + + expect(runs).toHaveLength(1); + expect(runs[0]?.partial).toBe(true); + expect(runs[0]?.events.map((event) => event.projection?.toolName)).toEqual(['Bash', 'Edit']); + expect(runs[0]?.events[0]?.projection).toMatchObject({ + kind: 'tool_result', + rawItemType: 'command_execution', + result: { + content: '/repo\n', + stdout: '/repo\n', + exitCode: 2, + }, + isError: true, + }); + }); + + it('rejects trace files whose header belongs to another team or task', async () => { + const teamName = 'vector-room-131313'; + const taskId = '8421e1bb-2f3b-4656-9983-6e0fd4b15963'; + await writeTraceFile({ + bucket: 'processed', + teamName, + taskId, + runId: 'run-wrong-team', + records: [ + header({ teamName: 'another-team', runId: 'run-wrong-team' }), + { + schemaVersion: 1, + recordType: 'codex_native_stdout_event', + receivedAt: '2026-05-01T17:10:08.000Z', + sourceOrder: 1, + projection: { + kind: 'tool_result', + toolSource: 'native', + itemId: 'item_1', + toolName: 'Bash', + }, + }, + ], + }); + + await expect( + new CodexNativeTraceReader(teamsBasePath).readTaskRuns({ + teamName, + taskIds: [taskId], + }) + ).resolves.toEqual([]); + }); +}); diff --git a/test/main/services/team/HistoricalBoardMcpRawProbe.test.ts b/test/main/services/team/HistoricalBoardMcpRawProbe.test.ts new file mode 100644 index 00000000..0051a041 --- /dev/null +++ b/test/main/services/team/HistoricalBoardMcpRawProbe.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { HistoricalBoardMcpRawProbe } from '../../../../src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe'; + +import type { TeamTask } from '../../../../src/shared/types'; + +function makeTask(): TeamTask { + return { + id: '11111111-2222-3333-4444-555555555555', + displayId: 'abcd1234', + subject: 'Test task', + status: 'in_progress', + }; +} + +describe('HistoricalBoardMcpRawProbe', () => { + let tempDir: string | null = null; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('returns only files that contain both a task reference and board MCP marker', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-')); + const hitFile = path.join(tempDir, 'hit.jsonl'); + const taskOnlyFile = path.join(tempDir, 'task-only.jsonl'); + const markerOnlyFile = path.join(tempDir, 'marker-only.jsonl'); + + await writeFile( + hitFile, + JSON.stringify({ + message: { + content: [ + { + type: 'tool_use', + name: 'mcp__agent-teams__task_add_comment', + input: { taskId: '#abcd1234' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + await writeFile(taskOnlyFile, 'the task #abcd1234 is mentioned without a tool\n', 'utf8'); + await writeFile(markerOnlyFile, 'mcp__agent-teams__task_add_comment unrelated\n', 'utf8'); + + const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({ + task: makeTask(), + transcriptFiles: [markerOnlyFile, hitFile, taskOnlyFile], + }); + + expect(result.filePaths).toEqual([hitFile]); + expect(result.scannedFileCount).toBe(3); + expect(result.hitCount).toBe(1); + }); + + it('matches canonical task ids as well as display ids', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-')); + const hitFile = path.join(tempDir, 'hit-canonical.jsonl'); + await writeFile( + hitFile, + 'agent_teams_task_complete ' + '11111111-2222-3333-4444-555555555555\n', + 'utf8' + ); + + const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({ + task: makeTask(), + transcriptFiles: [hitFile], + }); + + expect(result.filePaths).toEqual([hitFile]); + }); + + it('does not match task subject text without task id or display id evidence', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-')); + const subjectOnlyFile = path.join(tempDir, 'subject-only.jsonl'); + await writeFile( + subjectOnlyFile, + 'mcp__agent-teams__task_add_comment mentions only Test task subject text\n', + 'utf8' + ); + + const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({ + task: makeTask(), + transcriptFiles: [subjectOnlyFile], + }); + + expect(result.filePaths).toEqual([]); + expect(result.scannedFileCount).toBe(1); + expect(result.hitCount).toBe(0); + }); +}); diff --git a/test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts b/test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts new file mode 100644 index 00000000..7f58d223 --- /dev/null +++ b/test/main/services/team/TaskLogTranscriptCandidateSelector.test.ts @@ -0,0 +1,168 @@ +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { TaskLogTranscriptCandidateSelector } from '../../../../src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; + +function makeRecord(args: { + id: string; + filePath: string; + sessionId?: string; + canonicalToolName?: string; + category?: NonNullable['category']; +}): BoardTaskActivityRecord { + return { + id: args.id, + timestamp: '2026-04-30T10:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: args.sessionId ?? '', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + ...(args.canonicalToolName || args.category + ? { + action: { + canonicalToolName: args.canonicalToolName ?? 'task_add_comment', + toolUseId: `${args.id}-tool`, + category: args.category ?? 'comment', + }, + } + : {}), + source: { + filePath: args.filePath, + messageUuid: `${args.id}-msg`, + toolUseId: `${args.id}-tool`, + sourceOrder: 1, + }, + }; +} + +describe('TaskLogTranscriptCandidateSelector', () => { + it('selects direct record files and same-session files for non-read task records', () => { + const projectDir = path.join('/tmp', 'claude-project'); + const rootFile = path.join(projectDir, 'session-a.jsonl'); + const subagentFile = path.join(projectDir, 'session-a', 'subagents', 'agent-worker.jsonl'); + const unrelatedFile = path.join(projectDir, 'session-b.jsonl'); + const selector = new TaskLogTranscriptCandidateSelector(); + + const selection = selector.selectInferredNativeTranscriptFiles({ + projectDir, + transcriptFiles: [unrelatedFile, subagentFile, rootFile], + records: [ + makeRecord({ + id: 'comment', + filePath: rootFile, + sessionId: 'session-a', + canonicalToolName: 'task_add_comment', + category: 'comment', + }), + ], + alreadyParsedFilePaths: new Set([rootFile]), + }); + + expect(selection.filePaths).toEqual([subagentFile]); + expect(selection.candidates.map((candidate) => candidate.filePath)).toEqual([ + rootFile, + subagentFile, + ]); + expect(selection.diagnostics).toMatchObject({ + recordFileCount: 1, + nonReadSessionCount: 1, + sameSessionFileCount: 2, + alreadyParsedCandidateCount: 1, + finalCandidateCount: 1, + reason: 'same_session_native_window', + }); + }); + + it('does not expand read-only task records to every file in the same session', () => { + const projectDir = path.join('/tmp', 'claude-project'); + const rootFile = path.join(projectDir, 'session-a.jsonl'); + const subagentFile = path.join(projectDir, 'session-a', 'subagents', 'agent-worker.jsonl'); + const selector = new TaskLogTranscriptCandidateSelector(); + + const selection = selector.selectInferredNativeTranscriptFiles({ + projectDir, + transcriptFiles: [rootFile, subagentFile], + records: [ + makeRecord({ + id: 'read', + filePath: rootFile, + sessionId: 'session-a', + canonicalToolName: 'task_get', + category: 'read', + }), + ], + }); + + expect(selection.filePaths).toEqual([rootFile]); + expect(selection.candidates.map((candidate) => candidate.filePath)).toEqual([rootFile]); + expect(selection.diagnostics).toMatchObject({ + nonReadSessionCount: 0, + sameSessionFileCount: 0, + reason: 'direct_record_files', + }); + }); + + it('falls back to direct record files when the transcript file cannot be session-indexed', () => { + const projectDir = path.join('/tmp', 'claude-project'); + const outsideFile = path.join('/tmp', 'other-project', 'session-a.jsonl'); + const selector = new TaskLogTranscriptCandidateSelector(); + + const selection = selector.selectInferredNativeTranscriptFiles({ + projectDir, + transcriptFiles: [outsideFile], + records: [ + makeRecord({ + id: 'comment', + filePath: outsideFile, + canonicalToolName: 'task_add_comment', + category: 'comment', + }), + ], + }); + + expect(selection.filePaths).toEqual([outsideFile]); + expect(selection.diagnostics).toMatchObject({ + recordFileCount: 1, + nonReadSessionCount: 0, + sameSessionFileCount: 0, + finalCandidateCount: 1, + reason: 'direct_record_files', + }); + }); + + it('does not select files by owner-looking names without session evidence', () => { + const projectDir = path.join('/tmp', 'claude-project'); + const recordFile = path.join(projectDir, 'session-a.jsonl'); + const ownerLookingFile = path.join(projectDir, 'alice-work.jsonl'); + const selector = new TaskLogTranscriptCandidateSelector(); + + const selection = selector.selectInferredNativeTranscriptFiles({ + projectDir, + transcriptFiles: [recordFile, ownerLookingFile], + records: [ + makeRecord({ + id: 'comment', + filePath: recordFile, + sessionId: undefined, + canonicalToolName: 'task_add_comment', + category: 'comment', + }), + ], + }); + + expect(selection.filePaths).toEqual([recordFile]); + expect(selection.filePaths).not.toContain(ownerLookingFile); + }); +});