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_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<string | null> {
private async readContentRef(
projectDir: string,
ref: LedgerContentRef | null
): Promise<string | null> {
if (!ref?.blobRef) {
return null;
}
@ -1116,7 +1142,10 @@ export class TaskChangeLedgerReader {
}
private buildFallbackFilesFromGroupedSnippets(
grouped: Map<string, { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }>,
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, '/');
}

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_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;
}

View file

@ -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: [

View file

@ -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');
});
});