fix(task-change-ledger): read long hashed task artifacts
This commit is contained in:
parent
e944e2c937
commit
95b62d6013
4 changed files with 107 additions and 14 deletions
|
|
@ -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, '/');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue