fix(changes): fingerprint projected ledger summaries

This commit is contained in:
777genius 2026-04-28 21:08:10 +03:00
parent 50b2c715e7
commit ff506d0d96
3 changed files with 315 additions and 10 deletions

View file

@ -428,11 +428,7 @@ export class TaskChangeLedgerReader {
return null;
}
const provenance = this.buildLedgerProvenance(
bundle.journalStamp,
bundle.integrity,
bundle.schemaVersion
);
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
if (
freshness &&
@ -450,11 +446,7 @@ export class TaskChangeLedgerReader {
) {
return {
bundle,
provenance: this.buildLedgerProvenance(
journalStamp,
bundle.integrity,
bundle.schemaVersion
),
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
mode: 'validated',
};
}
@ -694,6 +686,86 @@ export class TaskChangeLedgerReader {
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
}
private buildLedgerProvenanceFromSummaryBundle(
bundle: LedgerSummaryBundleV2,
journalStamp: TaskChangeJournalStamp = bundle.journalStamp
): TaskChangeProvenance {
return {
sourceKind: 'ledger',
sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)),
journalStamp,
bundleSchemaVersion: bundle.schemaVersion,
integrity: bundle.integrity,
};
}
private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown {
return {
kind: 'ledger-summary-v2-projected-identity',
schemaVersion: bundle.schemaVersion,
bundleKind: bundle.bundleKind,
taskId: bundle.taskId,
integrity: bundle.integrity,
totalFiles: bundle.totalFiles,
totalLinesAdded: bundle.totalLinesAdded,
totalLinesRemoved: bundle.totalLinesRemoved,
diffStatCompleteness: bundle.diffStatCompleteness,
confidence: bundle.confidence,
files: [...bundle.files]
.map((file) => ({
changeKey: this.normalizeSummaryChangeKey(file),
filePath: normalizePathForComparison(file.filePath),
relativePath: normalizePathForComparison(file.relativePath),
displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined,
linesAdded: file.linesAdded,
linesRemoved: file.linesRemoved,
diffStatKnown: file.diffStatKnown,
latestOperation: file.latestOperation,
createdInTask: file.createdInTask,
deletedInTask: file.deletedInTask,
baselineExists: file.baselineExists,
finalExists: file.finalExists,
latestBeforeHash: file.latestBeforeHash,
latestAfterHash: file.latestAfterHash,
latestBeforeState: this.contentStateFingerprint(file.latestBeforeState),
latestAfterState: this.contentStateFingerprint(file.latestAfterState),
contentAvailability: file.contentAvailability,
reviewability: file.reviewability,
relation: file.relation
? {
kind: file.relation.kind,
oldPath: normalizePathForComparison(file.relation.oldPath),
newPath: normalizePathForComparison(file.relation.newPath),
}
: undefined,
worktreePath: file.worktreePath
? normalizePathForComparison(file.worktreePath)
: undefined,
worktreeBranch: file.worktreeBranch,
baseWorkspaceRoot: file.baseWorkspaceRoot
? normalizePathForComparison(file.baseWorkspaceRoot)
: undefined,
}))
.sort(
(left, right) =>
left.changeKey.localeCompare(right.changeKey) ||
left.filePath.localeCompare(right.filePath)
),
};
}
private contentStateFingerprint(state: LedgerContentState | undefined): unknown {
if (!state) {
return undefined;
}
return {
exists: state.exists,
sha256: state.sha256,
sizeBytes: state.sizeBytes,
unavailableReason: state.unavailableReason,
};
}
private hashFingerprintPayload(payload: unknown): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}

View file

@ -614,6 +614,91 @@ describe('TaskChangeLedgerReader', () => {
);
});
it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } },
eventCount: 1,
noticeCount: 0,
warningCount: 0,
warnings: [],
},
file: {
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
agentIds: ['alice@team'],
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
generatedAt: '2026-03-01T11:00:00.000Z',
journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } },
eventCount: 7,
noticeCount: 3,
warningCount: 1,
warnings: ['raw journal had a recovered warning'],
},
file: {
eventCount: 7,
firstTimestamp: '2026-03-01T09:00:00.000Z',
lastTimestamp: '2026-03-01T11:00:00.000Z',
agentIds: ['alice@team', 'bob@team'],
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint);
});
it('changes v2 provenance fingerprint when projected file evidence changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v1'),
latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 },
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v2'),
latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 },
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint);
});
it('keeps identical relative rename relations isolated by worktree path', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
@ -969,6 +1054,74 @@ async function makeLedgerBundle(params: {
return dir;
}
async function makeSummaryLedgerBundleV2(params: {
bundle?: Record<string, unknown>;
file?: Record<string, unknown>;
} = {}): Promise<string> {
const dir = await fsTempDir();
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
const file = {
changeKey: 'path:/repo/src/file.ts',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
linesAdded: 1,
linesRemoved: 1,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: sha('before'),
latestAfterHash: sha('after'),
latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 },
latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 },
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
...params.file,
};
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [file],
totalLinesAdded: 1,
totalLinesRemoved: 1,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
...params.bundle,
}),
'utf8'
);
return dir;
}
async function fsTempDir(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
}

View file

@ -1651,4 +1651,84 @@ describe('changeReviewSlice task changes', () => {
});
expect(store.getState().activeChangeSet).toEqual(current);
});
it('does not force re-review when ledger provenance stays stable despite warning changes', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-stable',
},
warnings: [],
};
const fresh = {
...current,
computedAt: '2026-03-01T13:00:00.000Z',
warnings: ['raw journal warning changed'],
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
hoisted.applyDecisions.mockResolvedValueOnce({
applied: 1,
skipped: 0,
conflicts: 0,
errors: [],
});
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
changeSetEpoch: 0,
fileContentVersionByPath: {},
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(store.getState().applyError).toBeNull();
expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1);
expect(store.getState().activeChangeSet).toEqual(current);
});
it('forces re-review when ledger projected provenance changes with the same file paths', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v1',
},
};
const fresh = {
...current,
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v2',
},
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }],
changeSetEpoch: 2,
fileContentVersionByPath: { '/repo/file.ts': 3 },
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().applyError).toBe(
'Changes have been updated since you started reviewing. Please re-review.'
);
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().fileDecisions).toEqual({});
expect(store.getState().reviewUndoStack).toEqual([]);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
});