feat(task-logs): show codex native trace fallback
This commit is contained in:
parent
ca21ab206e
commit
90aa2942f9
22 changed files with 2991 additions and 94 deletions
|
|
@ -11,13 +11,17 @@ const TASK_ACTIVITY_INDEX_CACHE_TTL_MS = 1_000;
|
||||||
|
|
||||||
interface TaskActivityIndex {
|
interface TaskActivityIndex {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
generation: number;
|
||||||
tasksById: Map<string, TeamTask>;
|
tasksById: Map<string, TeamTask>;
|
||||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BoardTaskActivityRecordSource {
|
export class BoardTaskActivityRecordSource {
|
||||||
private readonly indexCache = new Map<string, TaskActivityIndex>();
|
private readonly indexCache = new Map<string, TaskActivityIndex>();
|
||||||
private readonly indexInFlight = new Map<string, Promise<TaskActivityIndex>>();
|
private readonly indexInFlight = new Map<
|
||||||
|
string,
|
||||||
|
{ generation: number; promise: Promise<TaskActivityIndex> }
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||||
|
|
@ -38,33 +42,48 @@ export class BoardTaskActivityRecordSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
private async getTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||||
|
const generation = this.getTranscriptDiscoveryGeneration(teamName);
|
||||||
const cached = this.indexCache.get(teamName);
|
const cached = this.indexCache.get(teamName);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.generation === generation && cached.expiresAt > Date.now()) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingPromise = this.indexInFlight.get(teamName);
|
const existingInFlight = this.indexInFlight.get(teamName);
|
||||||
if (existingPromise) {
|
if (existingInFlight && existingInFlight.generation === generation) {
|
||||||
return await existingPromise;
|
return await existingInFlight.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = this.buildTaskActivityIndex(teamName)
|
const promise = this.buildTaskActivityIndex(teamName, generation)
|
||||||
.then((index) => {
|
.then((index) => {
|
||||||
this.indexCache.set(teamName, index);
|
if (this.getTranscriptDiscoveryGeneration(teamName) === generation) {
|
||||||
|
this.indexCache.set(teamName, index);
|
||||||
|
}
|
||||||
return index;
|
return index;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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;
|
return await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
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<TaskActivityIndex> {
|
||||||
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
||||||
this.taskReader.getTasks(teamName),
|
this.taskReader.getTasks(teamName),
|
||||||
this.taskReader.getDeletedTasks(teamName),
|
this.taskReader.getDeletedTasks(teamName),
|
||||||
this.transcriptSourceLocator.listTranscriptFiles(teamName),
|
this.listTranscriptFiles(teamName),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tasks = [...activeTasks, ...deletedTasks];
|
const tasks = [...activeTasks, ...deletedTasks];
|
||||||
|
|
@ -72,6 +91,7 @@ export class BoardTaskActivityRecordSource {
|
||||||
if (tasks.length === 0 || transcriptFiles.length === 0) {
|
if (tasks.length === 0 || transcriptFiles.length === 0) {
|
||||||
return {
|
return {
|
||||||
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||||
|
generation,
|
||||||
tasksById,
|
tasksById,
|
||||||
recordsByTaskId: new Map(),
|
recordsByTaskId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
@ -85,8 +105,21 @@ export class BoardTaskActivityRecordSource {
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||||
|
generation,
|
||||||
tasksById,
|
tasksById,
|
||||||
recordsByTaskId,
|
recordsByTaskId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async listTranscriptFiles(teamName: string): Promise<string[]> {
|
||||||
|
const locator = this.transcriptSourceLocator as {
|
||||||
|
getContext?: (teamName: string) => Promise<{ transcriptFiles: string[] } | null>;
|
||||||
|
listTranscriptFiles?: (teamName: string) => Promise<string[]>;
|
||||||
|
};
|
||||||
|
const context = await locator.getContext?.(teamName);
|
||||||
|
if (context) {
|
||||||
|
return context.transcriptFiles;
|
||||||
|
}
|
||||||
|
return (await locator.listTranscriptFiles?.(teamName)) ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityReco
|
||||||
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||||
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
||||||
import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService';
|
import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService';
|
||||||
|
import { HistoricalBoardMcpRawProbe } from '../stream/HistoricalBoardMcpRawProbe';
|
||||||
|
import { TaskLogTranscriptCandidateSelector } from '../stream/TaskLogTranscriptCandidateSelector';
|
||||||
|
|
||||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||||
import type { ParsedMessage } from '@main/types';
|
import type { ParsedMessage } from '@main/types';
|
||||||
|
|
@ -37,6 +39,13 @@ export interface BoardTaskLogDiagnosticsReport {
|
||||||
transcript: {
|
transcript: {
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
files: string[];
|
files: string[];
|
||||||
|
parsedFileCount: number;
|
||||||
|
candidateSelection?: {
|
||||||
|
mode: 'activity_records' | 'historical_raw_probe' | 'none';
|
||||||
|
candidateFileCount: number;
|
||||||
|
rawProbeScannedFileCount?: number;
|
||||||
|
rawProbeHitCount?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
explicitRecords: {
|
explicitRecords: {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -249,15 +258,96 @@ export class BoardTaskLogDiagnosticsService {
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||||
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
||||||
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
|
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<string[]>;
|
||||||
|
};
|
||||||
|
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<string, ParsedMessage[]>;
|
||||||
|
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<BoardTaskLogDiagnosticsReport> {
|
async diagnose(teamName: string, taskRef: string): Promise<BoardTaskLogDiagnosticsReport> {
|
||||||
const normalizedRef = normalizeRequestedTaskRef(taskRef);
|
const normalizedRef = normalizeRequestedTaskRef(taskRef);
|
||||||
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([
|
||||||
this.taskReader.getTasks(teamName),
|
this.taskReader.getTasks(teamName),
|
||||||
this.taskReader.getDeletedTasks(teamName),
|
this.taskReader.getDeletedTasks(teamName),
|
||||||
this.transcriptSourceLocator.listTranscriptFiles(teamName),
|
this.getTranscriptContext(teamName),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tasks = [...activeTasks, ...deletedTasks];
|
const tasks = [...activeTasks, ...deletedTasks];
|
||||||
|
|
@ -267,7 +357,12 @@ export class BoardTaskLogDiagnosticsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await this.recordSource.getTaskRecords(teamName, task.id);
|
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 stream = await this.streamService.getTaskLogStream(teamName, task.id);
|
||||||
|
|
||||||
const toolNameByUseId = buildToolNameMap(parsedMessagesByFile);
|
const toolNameByUseId = buildToolNameMap(parsedMessagesByFile);
|
||||||
|
|
@ -331,7 +426,7 @@ export class BoardTaskLogDiagnosticsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnosis: string[] = [];
|
const diagnosis: string[] = [];
|
||||||
if (transcriptFiles.length === 0) {
|
if (transcriptContext.transcriptFiles.length === 0) {
|
||||||
diagnosis.push('No transcript files were found for this team.');
|
diagnosis.push('No transcript files were found for this team.');
|
||||||
}
|
}
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
|
|
@ -373,8 +468,10 @@ export class BoardTaskLogDiagnosticsService {
|
||||||
workIntervals,
|
workIntervals,
|
||||||
},
|
},
|
||||||
transcript: {
|
transcript: {
|
||||||
fileCount: transcriptFiles.length,
|
fileCount: transcriptContext.transcriptFiles.length,
|
||||||
files: transcriptFiles,
|
files: transcriptContext.transcriptFiles,
|
||||||
|
parsedFileCount: parsedMessagesByFile.size,
|
||||||
|
candidateSelection,
|
||||||
},
|
},
|
||||||
explicitRecords: {
|
explicitRecords: {
|
||||||
total: records.length,
|
total: records.length,
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,7 @@ function readEnabledFlag(value: string | undefined, defaultValue: boolean): bool
|
||||||
export function isBoardTaskExactLogsReadEnabled(): boolean {
|
export function isBoardTaskExactLogsReadEnabled(): boolean {
|
||||||
return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
|
||||||
import { TeamConfigReader } from '../../TeamConfigReader';
|
import { TeamConfigReader } from '../../TeamConfigReader';
|
||||||
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
|
||||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
|
|
@ -17,6 +16,14 @@ import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates';
|
||||||
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
|
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
|
||||||
|
|
||||||
import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
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 { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||||
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
|
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_CACHE_TTL_MS = 1_000;
|
||||||
const STREAM_LAYOUT_BUILD_WARN_MS = 3_000;
|
const STREAM_LAYOUT_BUILD_WARN_MS = 3_000;
|
||||||
const RUNTIME_FALLBACK_WARN_MS = 3_000;
|
const RUNTIME_FALLBACK_WARN_MS = 3_000;
|
||||||
const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([
|
const INFERRED_CANDIDATE_SELECTION_WARN_COUNT = 100;
|
||||||
'task_complete',
|
const HISTORICAL_RAW_PROBE_WARN_MS = 3_000;
|
||||||
'task_set_status',
|
const HISTORICAL_RAW_PROBE_WARN_FILE_COUNT = 500;
|
||||||
'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 TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']);
|
const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']);
|
||||||
|
|
||||||
function emptyResponse(): BoardTaskLogStreamResponse {
|
function emptyResponse(): BoardTaskLogStreamResponse {
|
||||||
|
|
@ -112,21 +101,8 @@ function normalizeMemberName(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBoardMcpToolName(toolName: string | undefined): boolean {
|
const isBoardMcpToolName = isBoardTaskLogMcpToolName;
|
||||||
if (!toolName) return false;
|
const canonicalizeBoardToolName = canonicalizeBoardTaskLogToolName;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTaskReference(value: unknown): string | null {
|
function normalizeTaskReference(value: unknown): string | null {
|
||||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||||
|
|
@ -231,13 +207,28 @@ function normalizeRelationshipDetail(
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferHistoricalLinkKind(canonicalToolName: string): 'lifecycle' | 'board_action' | null {
|
function inferHistoricalLinkKind(canonicalToolName: string): 'lifecycle' | 'board_action' | null {
|
||||||
if (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonicalToolName)) {
|
switch (canonicalToolName) {
|
||||||
return 'lifecycle';
|
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 {
|
function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActivityCategory {
|
||||||
|
|
@ -1388,10 +1379,9 @@ function collectAllowedMemberNames(
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName);
|
const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName);
|
||||||
if (
|
const isReadOnlyTool =
|
||||||
record.action?.category === 'read' ||
|
canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment';
|
||||||
(canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName))
|
if (record.action?.category === 'read' || isReadOnlyTool) {
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1578,15 +1568,73 @@ function mergeRuntimeFallbackResponse(
|
||||||
fallback: BoardTaskLogStreamResponse
|
fallback: BoardTaskLogStreamResponse
|
||||||
): BoardTaskLogStreamResponse {
|
): BoardTaskLogStreamResponse {
|
||||||
const participants = mergeParticipants(primary.participants, fallback.participants);
|
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 {
|
return {
|
||||||
participants,
|
participants,
|
||||||
defaultFilter: chooseDefaultFilter(participants),
|
defaultFilter: chooseDefaultFilter(participants),
|
||||||
segments: mergeSegments(primary.segments, fallback.segments),
|
segments: mergeSegments(primary.segments, fallback.segments),
|
||||||
source: primary.source,
|
source,
|
||||||
runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection,
|
runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectNativeToolSignatures(response: BoardTaskLogStreamResponse): Set<string> {
|
||||||
|
const signatures = new Set<string>();
|
||||||
|
for (const segment of response.segments) {
|
||||||
|
for (const chunk of segment.chunks) {
|
||||||
|
const record = chunk as unknown as Record<string, unknown>;
|
||||||
|
const toolExecutions = Array.isArray(record.toolExecutions) ? record.toolExecutions : [];
|
||||||
|
for (const execution of toolExecutions) {
|
||||||
|
const toolCall = (execution as Record<string, unknown>)?.toolCall as
|
||||||
|
| { name?: string; input?: Record<string, unknown> }
|
||||||
|
| 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<string, unknown>)?.content as
|
||||||
|
| { toolName?: string; toolInput?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
const signature = buildCodexNativeToolSignature({
|
||||||
|
toolName: content?.toolName,
|
||||||
|
input: content?.toolInput,
|
||||||
|
});
|
||||||
|
if (signature) {
|
||||||
|
signatures.add(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNativeToolSignaturesFromSlices(slices: StreamSlice[]): Set<string> {
|
||||||
|
const signatures = new Set<string>();
|
||||||
|
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 {
|
export class BoardTaskLogStreamService {
|
||||||
private readonly layoutCache = new Map<
|
private readonly layoutCache = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -1613,9 +1661,12 @@ export class BoardTaskLogStreamService {
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
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 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 {
|
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||||
|
|
@ -1695,7 +1746,23 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcriptFiles = transcriptContext?.transcriptFiles ?? [];
|
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;
|
let mergedParsedMessagesByFile = parsedMessagesByFile;
|
||||||
if (missingFiles.length > 0) {
|
if (missingFiles.length > 0) {
|
||||||
const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles);
|
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 taskRefs = buildTaskReferenceSet(task);
|
||||||
const leadName =
|
const leadName =
|
||||||
transcriptContext?.config.members
|
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 =
|
const parsedMessagesByFileForCandidates =
|
||||||
parsedMessagesByFile &&
|
parsedMessagesByFile &&
|
||||||
candidateFilePaths.every((filePath) => parsedMessagesByFile?.has(filePath))
|
candidateFilePaths.every((filePath) => parsedMessagesByFile?.has(filePath))
|
||||||
|
|
@ -2128,12 +2217,12 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadRuntimeFallback(
|
private async loadOpenCodeRuntimeFallback(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string
|
taskId: string
|
||||||
): Promise<BoardTaskLogStreamResponse | null> {
|
): Promise<BoardTaskLogStreamResponse | null> {
|
||||||
const startedAt = Date.now();
|
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;
|
const elapsedMs = Date.now() - startedAt;
|
||||||
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -2145,6 +2234,26 @@ export class BoardTaskLogStreamService {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadCodexNativeTraceFallback(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
excludeNativeToolSignatures?: ReadonlySet<string>
|
||||||
|
): Promise<BoardTaskLogStreamResponse | null> {
|
||||||
|
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(
|
async getTaskLogStreamSummary(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string
|
taskId: string
|
||||||
|
|
@ -2155,11 +2264,22 @@ export class BoardTaskLogStreamService {
|
||||||
|
|
||||||
const layout = await this.getStreamLayout(teamName, taskId);
|
const layout = await this.getStreamLayout(teamName, taskId);
|
||||||
if (layout.visibleSlices.length === 0) {
|
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 {
|
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);
|
const layout = await this.getStreamLayout(teamName, taskId);
|
||||||
if (layout.visibleSlices.length === 0) {
|
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();
|
return fallback ?? emptyResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2219,18 +2343,29 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
flushSegment();
|
flushSegment();
|
||||||
|
|
||||||
const primaryResponse: BoardTaskLogStreamResponse = {
|
let primaryResponse: BoardTaskLogStreamResponse = {
|
||||||
participants: layout.participants,
|
participants: layout.participants,
|
||||||
defaultFilter: chooseDefaultFilter(layout.participants),
|
defaultFilter: chooseDefaultFilter(layout.participants),
|
||||||
segments,
|
segments,
|
||||||
source: 'transcript',
|
source: 'transcript',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!layout.shouldMergeRuntimeFallback) {
|
if (layout.shouldMergeRuntimeFallback) {
|
||||||
return primaryResponse;
|
const fallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId);
|
||||||
|
if (fallback) {
|
||||||
|
primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, fallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = await this.loadRuntimeFallback(teamName, taskId);
|
const codexFallback = await this.loadCodexNativeTraceFallback(
|
||||||
return fallback ? mergeRuntimeFallbackResponse(primaryResponse, fallback) : primaryResponse;
|
teamName,
|
||||||
|
taskId,
|
||||||
|
collectNativeToolSignatures(primaryResponse)
|
||||||
|
);
|
||||||
|
if (codexFallback) {
|
||||||
|
primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, codexFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return primaryResponse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string> } = {}
|
||||||
|
): Promise<BoardTaskLogStreamResponse | null> {
|
||||||
|
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<string>();
|
||||||
|
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<TeamTask | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, unknown>;
|
||||||
|
}): 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<string, unknown>).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<string, unknown>;
|
||||||
|
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<string, unknown>),
|
||||||
|
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 = '<synthetic>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> } = {}
|
||||||
|
): 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
377
src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts
Normal file
377
src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRawString(record: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, 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<CodexNativeTraceRun[]> {
|
||||||
|
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<string, TraceFileCandidate>();
|
||||||
|
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<string, CodexNativeTraceRun>();
|
||||||
|
|
||||||
|
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<TraceFileCandidate[]> {
|
||||||
|
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<CodexNativeTraceRun | null> {
|
||||||
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(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<string>();
|
||||||
|
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<boolean> {
|
||||||
|
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<HistoricalBoardMcpRawProbeResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptSessionIndex {
|
||||||
|
filesBySessionId: Map<string, string[]>;
|
||||||
|
sessionIdByFilePath: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string[]>();
|
||||||
|
const sessionIdByFilePath = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string, TaskLogTranscriptCandidateFile>,
|
||||||
|
filePath: string,
|
||||||
|
candidate: Omit<TaskLogTranscriptCandidateFile, 'filePath'>
|
||||||
|
): 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<string>();
|
||||||
|
const sessionIndex = buildTranscriptSessionIndex(input.transcriptFiles, input.projectDir);
|
||||||
|
const candidatesByFilePath = new Map<string, TaskLogTranscriptCandidateFile>();
|
||||||
|
const recordFiles = new Set<string>();
|
||||||
|
const nonReadSessionIds = new Set<string>();
|
||||||
|
const sameSessionFiles = new Set<string>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ interface MemberExecutionLogProps {
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
|
hideMemberHeading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExpandedItemIdsByGroup = Map<string, Set<string>>;
|
type ExpandedItemIdsByGroup = Map<string, Set<string>>;
|
||||||
|
|
@ -31,6 +32,7 @@ export const MemberExecutionLog = ({
|
||||||
memberName,
|
memberName,
|
||||||
memberColor,
|
memberColor,
|
||||||
teamName,
|
teamName,
|
||||||
|
hideMemberHeading,
|
||||||
}: MemberExecutionLogProps): React.JSX.Element => {
|
}: MemberExecutionLogProps): React.JSX.Element => {
|
||||||
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
|
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
|
||||||
|
|
||||||
|
|
@ -69,6 +71,7 @@ export const MemberExecutionLog = ({
|
||||||
memberName={memberName}
|
memberName={memberName}
|
||||||
memberColor={memberColor}
|
memberColor={memberColor}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
hideMemberHeading={hideMemberHeading}
|
||||||
expanded={!collapsedGroupIds.has(item.group.id)}
|
expanded={!collapsedGroupIds.has(item.group.id)}
|
||||||
expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()}
|
expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()}
|
||||||
onToggleExpanded={() => {
|
onToggleExpanded={() => {
|
||||||
|
|
@ -162,6 +165,7 @@ interface AIExecutionGroupProps {
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
|
hideMemberHeading?: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
expandedItemIds: Set<string>;
|
expandedItemIds: Set<string>;
|
||||||
onToggleExpanded: () => void;
|
onToggleExpanded: () => void;
|
||||||
|
|
@ -173,6 +177,7 @@ const AIExecutionGroup = ({
|
||||||
memberName,
|
memberName,
|
||||||
memberColor,
|
memberColor,
|
||||||
teamName,
|
teamName,
|
||||||
|
hideMemberHeading,
|
||||||
expanded,
|
expanded,
|
||||||
expandedItemIds,
|
expandedItemIds,
|
||||||
onToggleExpanded,
|
onToggleExpanded,
|
||||||
|
|
@ -210,7 +215,7 @@ const AIExecutionGroup = ({
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
{normalizedMemberName ? (
|
{normalizedMemberName && !hideMemberHeading ? (
|
||||||
<>
|
<>
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={normalizedMemberName}
|
name={normalizedMemberName}
|
||||||
|
|
@ -223,6 +228,10 @@ const AIExecutionGroup = ({
|
||||||
turn
|
turn
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
) : hideMemberHeading ? (
|
||||||
|
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||||
|
turn
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
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') {
|
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.';
|
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.';
|
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,9 +151,26 @@ function buildParticipantVisualMap(
|
||||||
return visuals;
|
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 (
|
return (
|
||||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{visual ? (
|
||||||
|
<MemberBadge
|
||||||
|
name={visual.name}
|
||||||
|
color={visual.color}
|
||||||
|
teamName={teamName}
|
||||||
|
size="xs"
|
||||||
|
disableHoverCard
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{formatRelativeTime(segment.endTimestamp)}
|
{formatRelativeTime(segment.endTimestamp)}
|
||||||
|
|
@ -166,12 +192,13 @@ const SegmentBlock = ({
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
{showHeader ? <SegmentMarker segment={segment} /> : null}
|
{showHeader ? <SegmentMarker segment={segment} visual={visual} teamName={teamName} /> : null}
|
||||||
<MemberExecutionLog
|
<MemberExecutionLog
|
||||||
chunks={segment.chunks}
|
chunks={segment.chunks}
|
||||||
memberName={segment.actor.memberName}
|
memberName={segment.actor.memberName}
|
||||||
memberColor={visual?.color}
|
memberColor={visual?.color}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -349,8 +349,8 @@ export interface BoardTaskLogSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardTaskLogStreamRuntimeProjection {
|
export interface BoardTaskLogStreamRuntimeProjection {
|
||||||
provider: 'opencode';
|
provider: 'opencode' | 'codex_native';
|
||||||
mode: 'attribution' | 'heuristic';
|
mode: 'attribution' | 'heuristic' | 'trace';
|
||||||
attributionRecordCount: number;
|
attributionRecordCount: number;
|
||||||
projectedMessageCount: number;
|
projectedMessageCount: number;
|
||||||
boardMcpToolCount?: number;
|
boardMcpToolCount?: number;
|
||||||
|
|
@ -358,16 +358,26 @@ export interface BoardTaskLogStreamRuntimeProjection {
|
||||||
fallbackReason?:
|
fallbackReason?:
|
||||||
| 'no_attribution_records'
|
| 'no_attribution_records'
|
||||||
| 'attribution_no_projected_messages'
|
| 'attribution_no_projected_messages'
|
||||||
| 'task_tool_markers';
|
| 'task_tool_markers'
|
||||||
|
| 'codex_native_trace';
|
||||||
markerMatchCount?: number;
|
markerMatchCount?: number;
|
||||||
markerSpanCount?: number;
|
markerSpanCount?: number;
|
||||||
|
traceFileCount?: number;
|
||||||
|
traceRunCount?: number;
|
||||||
|
dedupedNativeToolCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardTaskLogStreamResponse {
|
export interface BoardTaskLogStreamResponse {
|
||||||
participants: BoardTaskLogParticipant[];
|
participants: BoardTaskLogParticipant[];
|
||||||
defaultFilter: 'all' | string;
|
defaultFilter: 'all' | string;
|
||||||
segments: BoardTaskLogSegment[];
|
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;
|
runtimeProjection?: BoardTaskLogStreamRuntimeProjection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,4 +136,52 @@ describe('BoardTaskActivityRecordSource', () => {
|
||||||
expect(transcriptReader.readFiles).toHaveBeenCalledTimes(1);
|
expect(transcriptReader.readFiles).toHaveBeenCalledTimes(1);
|
||||||
expect(recordBuilder.buildForTasks).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import path from 'path';
|
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 { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
|
||||||
import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource';
|
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 { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
|
||||||
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
|
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';
|
import type { TeamTask } from '../../../../src/shared/types';
|
||||||
|
|
||||||
const TEAM_NAME = 'beacon-desk-2';
|
const TEAM_NAME = 'beacon-desk-2';
|
||||||
|
|
@ -465,4 +466,137 @@ describe('BoardTaskLogDiagnosticsService', () => {
|
||||||
expect(report.stream.emptyPayloadExamples).toEqual([]);
|
expect(report.stream.emptyPayloadExamples).toEqual([]);
|
||||||
expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
|
||||||
import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
|
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_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim();
|
||||||
const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.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);
|
const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId);
|
||||||
expect(stream.segments.length).toBeGreaterThan(0);
|
expect(stream.segments.length).toBeGreaterThan(0);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
|
||||||
if (EXPECT_MISSING_WORKER_LINKS) {
|
if (EXPECT_MISSING_WORKER_LINKS) {
|
||||||
expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0);
|
expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0);
|
||||||
|
|
|
||||||
|
|
@ -158,9 +158,9 @@ describe('BoardTaskLogStreamService', () => {
|
||||||
expect(response.source).toBe('opencode_runtime_fallback');
|
expect(response.source).toBe('opencode_runtime_fallback');
|
||||||
expect(response.segments).toHaveLength(1);
|
expect(response.segments).toHaveLength(1);
|
||||||
expect(await service.getTaskLogStreamSummary('demo', 'task-a')).toEqual({
|
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 () => {
|
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');
|
expect(response.segments[0]?.participantKey).toBe('member:alice');
|
||||||
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
|
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
|
||||||
expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']);
|
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<string, ParsedMessage[]>([[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 () => {
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
133
test/main/services/team/CodexNativeTaskLogStreamSource.test.ts
Normal file
133
test/main/services/team/CodexNativeTaskLogStreamSource.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
179
test/main/services/team/CodexNativeTraceProjector.test.ts
Normal file
179
test/main/services/team/CodexNativeTraceProjector.test.ts
Normal file
|
|
@ -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> = {}): 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>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
239
test/main/services/team/CodexNativeTraceReader.test.ts
Normal file
239
test/main/services/team/CodexNativeTraceReader.test.ts
Normal file
|
|
@ -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<Record<string, unknown>>;
|
||||||
|
suffix?: '.jsonl' | '.jsonl.tmp';
|
||||||
|
}): Promise<string> {
|
||||||
|
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<string, unknown>> = {}): Record<string, unknown> {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
test/main/services/team/HistoricalBoardMcpRawProbe.test.ts
Normal file
99
test/main/services/team/HistoricalBoardMcpRawProbe.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<BoardTaskActivityRecord['action']>['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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue