fix(task-change-ledger): read long hashed task artifacts

This commit is contained in:
777genius 2026-04-21 18:02:40 +03:00
parent e944e2c937
commit 95b62d6013
4 changed files with 107 additions and 14 deletions

View file

@ -26,6 +26,7 @@ const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2;
const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2;
const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes';
const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
function isWindowsReservedArtifactSegment(segment: string): boolean { function isWindowsReservedArtifactSegment(segment: string): boolean {
const stem = segment.split('.')[0]?.toUpperCase() ?? ''; const stem = segment.split('.')[0]?.toUpperCase() ?? '';
@ -42,7 +43,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean {
function encodeTaskId(taskId: string): string { function encodeTaskId(taskId: string): string {
const encoded = encodeURIComponent(taskId); 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)}` ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`
: encoded; : encoded;
} }
@ -424,7 +426,11 @@ export class TaskChangeLedgerReader {
) { ) {
return { return {
bundle, bundle,
provenance: this.buildLedgerProvenance(journalStamp, bundle.integrity, bundle.schemaVersion), provenance: this.buildLedgerProvenance(
journalStamp,
bundle.integrity,
bundle.schemaVersion
),
mode: 'validated', mode: 'validated',
}; };
} }
@ -625,7 +631,10 @@ export class TaskChangeLedgerReader {
return { entries, recovered }; return { entries, recovered };
} }
private bundleMatchesFreshness(bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2): boolean { private bundleMatchesFreshness(
bundle: LedgerSummaryBundleV2,
freshness: LedgerFreshnessV2
): boolean {
return ( return (
JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) &&
bundle.eventCount === freshness.eventCount && bundle.eventCount === freshness.eventCount &&
@ -791,7 +800,10 @@ export class TaskChangeLedgerReader {
scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files);
diffStatCompleteness = params.bundle.diffStatCompleteness; diffStatCompleteness = params.bundle.diffStatCompleteness;
} else { } else {
const fallback = this.buildFallbackFilesFromGroupedSnippets(groupedSnippets, params.projectPath); const fallback = this.buildFallbackFilesFromGroupedSnippets(
groupedSnippets,
params.projectPath
);
files = fallback.files; files = fallback.files;
totalLinesAdded = fallback.totalLinesAdded; totalLinesAdded = fallback.totalLinesAdded;
totalLinesRemoved = fallback.totalLinesRemoved; totalLinesRemoved = fallback.totalLinesRemoved;
@ -801,11 +813,18 @@ export class TaskChangeLedgerReader {
: params.journal.events.some((event) => event.confidence === 'medium') : params.journal.events.some((event) => event.confidence === 'medium')
? 'medium' ? 'medium'
: 'high'; : '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) diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete' ? 'complete'
: 'partial'; : '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 { return {
@ -972,7 +991,11 @@ export class TaskChangeLedgerReader {
return { return {
taskId, taskId,
memberName: memberName:
scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', scope.memberName ||
scope.primaryMemberName ||
scope.primaryAgentId ||
scope.primaryActorKey ||
'',
startLine: 0, startLine: 0,
endLine: 0, endLine: 0,
startTimestamp: scope.startTimestamp, startTimestamp: scope.startTimestamp,
@ -1004,7 +1027,10 @@ export class TaskChangeLedgerReader {
); );
} }
private async readContentRef(projectDir: string, ref: LedgerContentRef | null): Promise<string | null> { private async readContentRef(
projectDir: string,
ref: LedgerContentRef | null
): Promise<string | null> {
if (!ref?.blobRef) { if (!ref?.blobRef) {
return null; return null;
} }
@ -1116,7 +1142,10 @@ export class TaskChangeLedgerReader {
} }
private buildFallbackFilesFromGroupedSnippets( private buildFallbackFilesFromGroupedSnippets(
grouped: Map<string, { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }>, grouped: Map<
string,
{ filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }
>,
projectPath?: string projectPath?: string
): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } {
const files: FileChangeSummary[] = []; const files: FileChangeSummary[] = [];
@ -1157,9 +1186,7 @@ export class TaskChangeLedgerReader {
...(relation ? { relation } : {}), ...(relation ? { relation } : {}),
latestOperation: latestOperation:
entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? entry.snippets[entry.snippets.length - 1]?.ledger?.operation ??
(entry.snippets[entry.snippets.length - 1]?.type === 'write-new' (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'),
? 'create'
: 'modify'),
}, },
timeline: this.buildTimeline(displayPath, entry.snippets), timeline: this.buildTimeline(displayPath, entry.snippets),
}); });
@ -1347,7 +1374,11 @@ export class TaskChangeLedgerReader {
return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; 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) { if (explicitRelativePath) {
return explicitRelativePath.replace(/\\/g, '/'); return explicitRelativePath.replace(/\\/g, '/');
} }

View file

@ -8,6 +8,7 @@ import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
interface ParsedFreshnessSignal { interface ParsedFreshnessSignal {
taskId: string; taskId: string;
@ -30,7 +31,8 @@ function isWindowsReservedArtifactSegment(segment: string): boolean {
function encodeTaskId(taskId: string): string { function encodeTaskId(taskId: string): string {
const encoded = encodeURIComponent(taskId); 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)}` ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`
: encoded; : encoded;
} }

View file

@ -96,6 +96,41 @@ describe('TaskChangeLedgerReader', () => {
expect(result?.warnings).toContain('reserved segment safe path'); 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 () => { it('maps ledger state and rename relation into snippets', async () => {
tmpDir = await makeLedgerBundle({ tmpDir = await makeLedgerBundle({
events: [ events: [

View file

@ -83,4 +83,29 @@ describe('TeamTaskLogFreshnessReader', () => {
); );
expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl'); 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');
});
}); });