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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provenance = this.buildLedgerProvenance(
|
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
|
||||||
bundle.journalStamp,
|
|
||||||
bundle.integrity,
|
|
||||||
bundle.schemaVersion
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
freshness &&
|
freshness &&
|
||||||
|
|
@ -450,11 +446,7 @@ export class TaskChangeLedgerReader {
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
bundle,
|
bundle,
|
||||||
provenance: this.buildLedgerProvenance(
|
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
|
||||||
journalStamp,
|
|
||||||
bundle.integrity,
|
|
||||||
bundle.schemaVersion
|
|
||||||
),
|
|
||||||
mode: 'validated',
|
mode: 'validated',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -694,6 +686,86 @@ export class TaskChangeLedgerReader {
|
||||||
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
|
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 {
|
private hashFingerprintPayload(payload: unknown): string {
|
||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
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 () => {
|
it('keeps identical relative rename relations isolated by worktree path', async () => {
|
||||||
tmpDir = await fsTempDir();
|
tmpDir = await fsTempDir();
|
||||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||||
|
|
@ -969,6 +1054,74 @@ async function makeLedgerBundle(params: {
|
||||||
return dir;
|
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> {
|
async function fsTempDir(): Promise<string> {
|
||||||
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
|
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1651,4 +1651,84 @@ describe('changeReviewSlice task changes', () => {
|
||||||
});
|
});
|
||||||
expect(store.getState().activeChangeSet).toEqual(current);
|
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