diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index d3dde5ee..b9b7ec49 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -26,6 +26,7 @@ const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2; const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; +const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120; function isWindowsReservedArtifactSegment(segment: string): boolean { const stem = segment.split('.')[0]?.toUpperCase() ?? ''; @@ -42,7 +43,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean { function encodeTaskId(taskId: string): string { const encoded = encodeURIComponent(taskId); - return isWindowsReservedArtifactSegment(encoded) + return isWindowsReservedArtifactSegment(encoded) || + encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` : encoded; } @@ -424,7 +426,11 @@ export class TaskChangeLedgerReader { ) { return { bundle, - provenance: this.buildLedgerProvenance(journalStamp, bundle.integrity, bundle.schemaVersion), + provenance: this.buildLedgerProvenance( + journalStamp, + bundle.integrity, + bundle.schemaVersion + ), mode: 'validated', }; } @@ -625,7 +631,10 @@ export class TaskChangeLedgerReader { return { entries, recovered }; } - private bundleMatchesFreshness(bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2): boolean { + private bundleMatchesFreshness( + bundle: LedgerSummaryBundleV2, + freshness: LedgerFreshnessV2 + ): boolean { return ( JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && bundle.eventCount === freshness.eventCount && @@ -791,7 +800,10 @@ export class TaskChangeLedgerReader { scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); diffStatCompleteness = params.bundle.diffStatCompleteness; } else { - const fallback = this.buildFallbackFilesFromGroupedSnippets(groupedSnippets, params.projectPath); + const fallback = this.buildFallbackFilesFromGroupedSnippets( + groupedSnippets, + params.projectPath + ); files = fallback.files; totalLinesAdded = fallback.totalLinesAdded; totalLinesRemoved = fallback.totalLinesRemoved; @@ -801,11 +813,18 @@ export class TaskChangeLedgerReader { : params.journal.events.some((event) => event.confidence === 'medium') ? 'medium' : 'high'; - scope = this.buildFallbackScope(params.taskId, files, params.journal.events, params.journal.notices); + scope = this.buildFallbackScope( + params.taskId, + files, + params.journal.events, + params.journal.notices + ); diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) ? 'complete' : 'partial'; - warnings.push('Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'); + warnings.push( + 'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.' + ); } return { @@ -972,7 +991,11 @@ export class TaskChangeLedgerReader { return { taskId, memberName: - scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', + scope.memberName || + scope.primaryMemberName || + scope.primaryAgentId || + scope.primaryActorKey || + '', startLine: 0, endLine: 0, startTimestamp: scope.startTimestamp, @@ -1004,7 +1027,10 @@ export class TaskChangeLedgerReader { ); } - private async readContentRef(projectDir: string, ref: LedgerContentRef | null): Promise { + private async readContentRef( + projectDir: string, + ref: LedgerContentRef | null + ): Promise { if (!ref?.blobRef) { return null; } @@ -1116,7 +1142,10 @@ export class TaskChangeLedgerReader { } private buildFallbackFilesFromGroupedSnippets( - grouped: Map, + grouped: Map< + string, + { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } + >, projectPath?: string ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { const files: FileChangeSummary[] = []; @@ -1157,9 +1186,7 @@ export class TaskChangeLedgerReader { ...(relation ? { relation } : {}), latestOperation: entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? - (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' - ? 'create' - : 'modify'), + (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'), }, timeline: this.buildTimeline(displayPath, entry.snippets), }); @@ -1347,7 +1374,11 @@ export class TaskChangeLedgerReader { return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; } - private relativePath(filePath: string, projectPath?: string, explicitRelativePath?: string): string { + private relativePath( + filePath: string, + projectPath?: string, + explicitRelativePath?: string + ): string { if (explicitRelativePath) { return explicitRelativePath.replace(/\\/g, '/'); } diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts index db5bc230..85555063 100644 --- a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -8,6 +8,7 @@ import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes'; const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; +const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120; interface ParsedFreshnessSignal { taskId: string; @@ -30,7 +31,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean { function encodeTaskId(taskId: string): string { const encoded = encodeURIComponent(taskId); - return isWindowsReservedArtifactSegment(encoded) + return isWindowsReservedArtifactSegment(encoded) || + encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` : encoded; } diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 4f5f4cb9..9bfe082d 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -96,6 +96,41 @@ describe('TaskChangeLedgerReader', () => { expect(result?.warnings).toContain('reserved segment safe path'); }); + it('reads ledger artifacts stored under hashed long task id segments', async () => { + tmpDir = await fsTempDir(); + const taskId = `task-${'x'.repeat(180)}`; + const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: 0, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'high', + warnings: ['long task id safe path'], + events: [], + }), + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId, + projectDir: tmpDir, + includeDetails: true, + }); + + expect(result?.warnings).toContain('long task id safe path'); + }); + it('maps ledger state and rename relation into snippets', async () => { tmpDir = await makeLedgerBundle({ events: [ diff --git a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts index 3b1d3969..4c29533e 100644 --- a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts @@ -83,4 +83,29 @@ describe('TeamTaskLogFreshnessReader', () => { ); expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl'); }); + + it('reads hashed freshness files for very long task ids', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-')); + tempDirs.push(projectDir); + const signalDir = path.join(projectDir, '.board-task-log-freshness'); + await fs.mkdir(signalDir, { recursive: true }); + const taskId = `task-${'x'.repeat(180)}`; + + await fs.writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ + taskId, + updatedAt: '2026-04-19T12:00:00.000Z', + transcriptFile: 'session-long.jsonl', + }), + 'utf8' + ); + + const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, [taskId]); + + expect(signals.get(taskId)?.filePath).toBe( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`) + ); + expect(signals.get(taskId)?.transcriptFileBasename).toBe('session-long.jsonl'); + }); });