fix(changes): fingerprint projected ledger summaries
This commit is contained in:
parent
50b2c715e7
commit
ff506d0d96
3 changed files with 315 additions and 10 deletions
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue