diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index f36c7595..f07e523e 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -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'); } diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index a60807d0..969fac6c 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -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; + file?: Record; +} = {}): Promise { + 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 { return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-')); } diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index da924758..19b8f388 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -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({}); + }); });