From c69b7e421206e0b4f76f97c33b04673007e7e56f Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 17:21:29 +0300 Subject: [PATCH] feat(task-change-ledger): harden review flow --- .../renderer/adapters/TeamGraphAdapter.ts | 3 +- src/main/ipc/review.ts | 16 +- src/main/ipc/teams.ts | 3 +- .../services/team/ChangeExtractorService.ts | 10 +- src/main/services/team/FileContentResolver.ts | 26 +- .../services/team/ReviewApplierService.ts | 21 +- src/main/services/team/ReviewDecisionStore.ts | 207 ++- .../services/team/TaskChangeLedgerReader.ts | 1281 ++++++++++++++--- .../services/team/TeamLogSourceTracker.ts | 71 +- .../cache/JsonTaskChangePresenceRepository.ts | 7 +- .../cache/taskChangePresenceCacheSchema.ts | 10 +- .../cache/taskChangePresenceCacheTypes.ts | 3 +- .../TeamTaskLogFreshnessReader.ts | 66 +- src/preload/index.ts | 15 +- src/renderer/api/httpClient.ts | 1 + .../team/dialogs/TaskDetailDialog.tsx | 25 +- .../team/kanban/KanbanTaskCard.test.tsx | 37 + .../components/team/kanban/KanbanTaskCard.tsx | 3 +- .../team/review/ChangeReviewDialog.tsx | 59 +- .../team/review/CodeMirrorDiffUtils.ts | 9 +- .../team/review/ContinuousScrollView.tsx | 15 +- .../components/team/review/ReviewFileTree.tsx | 20 +- .../store/slices/changeReviewSlice.ts | 514 ++++--- src/renderer/utils/reviewDecisionScope.ts | 72 + src/renderer/utils/reviewKey.ts | 104 ++ src/renderer/utils/taskChangePresence.ts | 19 + src/shared/types/api.ts | 6 +- src/shared/types/review.ts | 71 + src/shared/types/team.ts | 6 +- src/shared/utils/taskChangePresence.ts | 15 + .../task-change-ledger/binary/manifest.json | 17 + .../fixture-binary.json | 1 + ...9294ff43def81c6cdcad6cbb1820cff48d3aa4355d | 1 + .../bundles/fixture-binary.json | 1 + .../events/fixture-binary.jsonl | 1 + .../binary/project/fixtures/blob.bin | 1 + .../task-change-ledger/copy/manifest.json | 17 + .../fixture-copy.json | 1 + ...9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 | 1 + .../bundles/fixture-copy.json | 1 + .../events/fixture-copy.jsonl | 1 + .../copy/project/src/base.ts | 1 + .../copy/project/src/copy.ts | 1 + .../generation-mismatch/manifest.json | 15 + .../fixture-generation-mismatch.json | 18 + ...2507ead5461b8dc5a491189747c5f87ff3c278e797 | 1 + .../bundles/fixture-generation-mismatch.json | 1 + .../events/fixture-generation-mismatch.jsonl | 1 + .../missing-blob/manifest.json | 15 + .../fixture-missing-blob.json | 1 + ...37dcfe10ef98f288997f79669e5374a60615277f74 | 1 + .../bundles/fixture-missing-blob.json | 1 + .../events/fixture-missing-blob.jsonl | 1 + .../missing-blob/project/src/missing.ts | 1 + .../notices-only/manifest.json | 15 + .../fixture-notices-only-other.json | 1 + .../fixture-notices-only.json | 1 + .../bundles/fixture-notices-only-other.json | 1 + .../bundles/fixture-notices-only.json | 1 + .../notices/fixture-notices-only-other.jsonl | 1 + .../notices/fixture-notices-only.jsonl | 1 + .../recovered-journal/manifest.json | 17 + .../fixture-recovered-journal.json | 1 + ...a13fbe07068662681d75f2c253244ec898a773c120 | 1 + ...a9d91424d938780b7475ff8025cd131a37997bc5c5 | 1 + .../bundles/fixture-recovered-journal.json | 1 + .../events/fixture-recovered-journal.jsonl | 3 + .../task-change-ledger/rename/manifest.json | 17 + .../fixture-rename.json | 1 + ...8044763f64919804d92341f50935df2d46eed748b9 | 1 + .../bundles/fixture-rename.json | 1 + .../events/fixture-rename.jsonl | 2 + .../rename/project/src/new.ts | 1 + .../v2-summary/manifest.json | 15 + .../fixture-v2-summary.json | 1 + ...2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf | 1 + .../bundles/fixture-v2-summary.json | 1 + .../events/fixture-v2-summary.jsonl | 1 + .../team/ChangeExtractorService.test.ts | 46 + .../services/team/FileContentResolver.test.ts | 39 + .../team/ReviewApplierService.test.ts | 71 + .../services/team/ReviewDecisionStore.test.ts | 135 ++ .../team/TaskChangeLedgerReader.test.ts | 166 +++ .../services/team/TeamDataService.test.ts | 76 + .../team/TeamLogSourceTracker.test.ts | 74 +- .../TeamTaskLogFreshnessReader.test.ts | 29 + .../team/taskChangeLedgerFixtureUtils.ts | 88 ++ ...skChangeLedgerFixtures.integration.test.ts | 421 ++++++ .../taskChangePresenceCacheSchema.test.ts | 91 ++ test/renderer/store/changeReviewSlice.test.ts | 250 +++- .../utils/reviewDecisionScope.test.ts | 49 + test/renderer/utils/reviewKey.test.ts | 29 + 92 files changed, 3890 insertions(+), 577 deletions(-) create mode 100644 src/renderer/utils/reviewDecisionScope.ts create mode 100644 src/renderer/utils/reviewKey.ts create mode 100644 src/renderer/utils/taskChangePresence.ts create mode 100644 src/shared/utils/taskChangePresence.ts create mode 100644 test/fixtures/team/task-change-ledger/binary/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json create mode 100644 test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl create mode 100644 test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin create mode 100644 test/fixtures/team/task-change-ledger/copy/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json create mode 100644 test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl create mode 100644 test/fixtures/team/task-change-ledger/copy/project/src/base.ts create mode 100644 test/fixtures/team/task-change-ledger/copy/project/src/copy.ts create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json create mode 100644 test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl create mode 100644 test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts create mode 100644 test/fixtures/team/task-change-ledger/notices-only/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl create mode 100644 test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json create mode 100644 test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl create mode 100644 test/fixtures/team/task-change-ledger/rename/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json create mode 100644 test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl create mode 100644 test/fixtures/team/task-change-ledger/rename/project/src/new.ts create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json create mode 100644 test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl create mode 100644 test/main/services/team/ReviewDecisionStore.test.ts create mode 100644 test/main/services/team/taskChangeLedgerFixtureUtils.ts create mode 100644 test/main/services/team/taskChangeLedgerFixtures.integration.test.ts create mode 100644 test/main/services/team/taskChangePresenceCacheSchema.test.ts create mode 100644 test/renderer/utils/reviewDecisionScope.test.ts create mode 100644 test/renderer/utils/reviewKey.test.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 179d4e1d..b1c8a051 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -642,7 +642,8 @@ export class TeamGraphAdapter { reviewerName: isReviewCycle ? reviewerName : null, reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined, reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined, - changePresence: task.changePresence, + changePresence: + task.changePresence === 'needs_attention' ? 'has_changes' : task.changePresence, displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index a2acf0f4..1f789fa1 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -454,7 +454,8 @@ async function handleGetGitFileLog( async function handleLoadDecisions( _event: IpcMainInvokeEvent, teamName: string, - scopeKey: string + scopeKey: string, + scopeToken: string | null = null ): Promise< IpcResult<{ hunkDecisions: Record; @@ -462,19 +463,23 @@ async function handleLoadDecisions( hunkContextHashesByFile?: Record>; } | null> > { - return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey)); + return wrapReviewHandler('loadDecisions', () => + reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined) + ); } async function handleSaveDecisions( _event: IpcMainInvokeEvent, teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile: Record> | null = null ): Promise> { return wrapReviewHandler('saveDecisions', () => reviewDecisionStore.save(teamName, scopeKey, { + scopeToken, hunkDecisions, fileDecisions, hunkContextHashesByFile: hunkContextHashesByFile ?? undefined, @@ -485,7 +490,10 @@ async function handleSaveDecisions( async function handleClearDecisions( _event: IpcMainInvokeEvent, teamName: string, - scopeKey: string + scopeKey: string, + scopeToken: string | null = null ): Promise> { - return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey)); + return wrapReviewHandler('clearDecisions', () => + reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined) + ); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 11f34910..fa91f4ab 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -177,6 +177,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TaskRef, TeamAgentRuntimeSnapshot, @@ -916,7 +917,7 @@ async function handleGetData( async function handleGetTaskChangePresence( _event: IpcMainInvokeEvent, teamName: unknown -): Promise>> { +): Promise>> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 58f783b3..9486b74f 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -31,6 +31,7 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; +import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; const logger = createLogger('Service:ChangeExtractorService'); @@ -759,11 +760,8 @@ export class ChangeExtractorService { return; } - if ( - result.files.length === 0 && - result.confidence !== 'high' && - result.confidence !== 'medium' - ) { + const resolvedPresence = resolveTaskChangePresenceFromResult(result); + if (!resolvedPresence) { return; } @@ -789,7 +787,7 @@ export class ChangeExtractorService { { taskId, taskSignature: descriptor.taskSignature, - presence: result.files.length > 0 ? 'has_changes' : 'no_changes', + presence: resolvedPresence, writtenAt: now, logSourceGeneration: snapshot.logSourceGeneration, } diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index bb80c25f..32732b7b 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -269,16 +269,7 @@ export class FileContentResolver { modified: string | null; source: FileChangeWithContent['contentSource']; } | null { - const ledgerSnippets = snippets - .filter((snippet) => snippet.ledger && !snippet.isError) - .sort((a, b) => { - const aTime = Date.parse(a.timestamp); - const bTime = Date.parse(b.timestamp); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) { - return aTime - bTime; - } - return a.toolUseId.localeCompare(b.toolUseId); - }); + const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); if (ledgerSnippets.length === 0) { return null; @@ -289,8 +280,19 @@ export class FileContentResolver { if (!first || !last) { return null; } - const original = first.originalFullContent ?? (first.operation === 'create' ? '' : null); - const modified = last.modifiedFullContent ?? (last.operation === 'delete' ? '' : null); + const canUseSyntheticOriginal = + first.originalFullContent === null && + first.operation === 'create' && + last.modifiedFullContent !== null && + !first.beforeState?.unavailableReason; + const canUseSyntheticModified = + last.modifiedFullContent === null && + last.operation === 'delete' && + first.originalFullContent !== null && + !last.afterState?.unavailableReason; + + const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null); + const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null); if (original === null && modified === null) { return null; } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 41af1ca3..95fe585c 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -454,9 +454,6 @@ export class ReviewApplierService { } const fullReject = fileRejected || allHunksRejected; - const hasSnapshot = ledgerSnippets.some( - (snippet) => snippet.type === 'shell-snapshot' || snippet.type === 'hook-snapshot' - ); const hasUnavailableState = ledgerSnippets.some( (snippet) => snippet.ledger?.beforeState?.unavailableReason || @@ -465,26 +462,26 @@ export class ReviewApplierService { const relation = this.resolveLedgerRelation(ledgerSnippets); if (!fullReject) { - if (relation?.kind === 'rename') { + if (relation?.kind === 'rename' || relation?.kind === 'copy') { return { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger rename partial reject requires manual review.', + error: `Ledger ${relation.kind} partial reject requires manual review.`, }; } - if (!hasSnapshot) { - return { handled: false }; - } if (original === null || modified === null) { return { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger snapshot content is unavailable; partial reject requires manual review.', + error: 'Ledger full text is unavailable; partial reject requires manual review.', }; } - const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + const guard = await this.checkLedgerCurrentHash( + filePath, + lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined + ); if (!guard.ok) { return guard.outcome; } @@ -494,7 +491,7 @@ export class ReviewApplierService { handled: true, status: 'error', code: 'manual-review-required', - error: 'Ledger snapshot partial reject could not be applied safely.', + error: 'Ledger partial reject could not be applied safely.', }; } try { @@ -598,7 +595,7 @@ export class ReviewApplierService { return { handled: true, status: 'error', - code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + code: 'manual-review-required', error: 'Ledger before content is unavailable; rejecting this change requires manual review.', }; diff --git a/src/main/services/team/ReviewDecisionStore.ts b/src/main/services/team/ReviewDecisionStore.ts index 83bb97c7..21f7998f 100644 --- a/src/main/services/team/ReviewDecisionStore.ts +++ b/src/main/services/team/ReviewDecisionStore.ts @@ -1,5 +1,6 @@ import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -10,6 +11,7 @@ import type { HunkDecision } from '@shared/types'; const logger = createLogger('ReviewDecisionStore'); export interface ReviewDecisionsData { + scopeToken?: string; hunkDecisions: Record; fileDecisions: Record; /** filePath -> (hunkIndex -> contextHash) */ @@ -17,50 +19,60 @@ export interface ReviewDecisionsData { updatedAt: string; } +interface ReviewDecisionsDataV2 extends ReviewDecisionsData { + version: 2; + scopeKey: string; + scopeToken: string; +} + export class ReviewDecisionStore { - private getDirPath(teamName: string): string { + private getLegacyDirPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'review-decisions'); } - private getFilePath(teamName: string, scopeKey: string): string { - return path.join(this.getDirPath(teamName), `${scopeKey}.json`); + private getLegacyFilePath(teamName: string, scopeKey: string): string { + return path.join(this.getLegacyDirPath(teamName), `${scopeKey}.json`); } - async load( - teamName: string, - scopeKey: string - ): Promise<{ - hunkDecisions: Record; - fileDecisions: Record; - hunkContextHashesByFile?: Record>; - } | null> { - const filePath = this.getFilePath(teamName, scopeKey); + private getV2DirPath(teamName: string, scopeKey: string): string { + return path.join( + this.getLegacyDirPath(teamName), + 'v2', + encodeURIComponent(scopeKey) + ); + } - let raw: string; - try { - raw = await fs.promises.readFile(filePath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - logger.error(`Failed to read review decisions for ${teamName}/${scopeKey}: ${String(error)}`); - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw) as unknown; - } catch { - logger.error(`Corrupted review decisions file for ${teamName}/${scopeKey}`); - return null; - } + private getV2FilePath(teamName: string, scopeKey: string, scopeToken: string): string { + const scopeHash = createHash('sha256').update(scopeToken).digest('hex'); + return path.join(this.getV2DirPath(teamName, scopeKey), `${scopeHash}.json`); + } + private parseStoredData(parsed: unknown): ReviewDecisionsData | ReviewDecisionsDataV2 | null { if (!parsed || typeof parsed !== 'object') { return null; } - const data = parsed as Partial; + const data = parsed as Partial; + const isV2 = + data.version === 2 && + typeof data.scopeKey === 'string' && + typeof data.scopeToken === 'string'; + if (data.version !== undefined && !isV2) { + return null; + } + + return data as ReviewDecisionsData | ReviewDecisionsDataV2; + } + + private extractDecisions( + data: ReviewDecisionsData | ReviewDecisionsDataV2, + scopeToken?: string + ): { + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null { const hunkDecisions: Record = data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {}; const fileDecisions: Record = @@ -70,37 +82,162 @@ export class ReviewDecisionStore { ? data.hunkContextHashesByFile : undefined; + if (scopeToken) { + if (typeof data.scopeToken !== 'string' || data.scopeToken !== scopeToken) { + return null; + } + } + return { hunkDecisions, fileDecisions, hunkContextHashesByFile }; } + private async loadFromPath( + filePath: string, + scopeToken?: string + ): Promise<{ + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null> { + let raw: string; + try { + raw = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to read review decisions at ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + logger.error(`Corrupted review decisions file at ${filePath}`); + return null; + } + + const data = this.parseStoredData(parsed); + return data ? this.extractDecisions(data, scopeToken) : null; + } + + private async pruneScopeDir(teamName: string, scopeKey: string): Promise { + const dirPath = this.getV2DirPath(teamName, scopeKey); + let entries: string[]; + try { + entries = await fs.promises.readdir(dirPath); + } catch { + return; + } + + if (entries.length <= 16) { + return; + } + + const files = await Promise.all( + entries + .filter((entry) => entry.endsWith('.json')) + .map(async (entry) => { + const filePath = path.join(dirPath, entry); + try { + const stats = await fs.promises.stat(filePath); + return { filePath, mtimeMs: stats.mtimeMs }; + } catch { + return null; + } + }) + ); + + const staleFiles = files + .filter((entry): entry is { filePath: string; mtimeMs: number } => !!entry) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(16); + + await Promise.all( + staleFiles.map((entry) => + fs.promises.unlink(entry.filePath).catch(() => undefined) + ) + ); + } + + async load( + teamName: string, + scopeKey: string, + scopeToken?: string + ): Promise<{ + hunkDecisions: Record; + fileDecisions: Record; + hunkContextHashesByFile?: Record>; + } | null> { + if (scopeToken) { + const exact = await this.loadFromPath( + this.getV2FilePath(teamName, scopeKey, scopeToken), + scopeToken + ); + if (exact) { + return exact; + } + } + + return this.loadFromPath(this.getLegacyFilePath(teamName, scopeKey), scopeToken); + } + async save( teamName: string, scopeKey: string, data: { + scopeToken: string; hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; } ): Promise { try { - const payload: ReviewDecisionsData = { + const payload: ReviewDecisionsDataV2 = { + version: 2, + scopeKey, + scopeToken: data.scopeToken, hunkDecisions: data.hunkDecisions, fileDecisions: data.fileDecisions, hunkContextHashesByFile: data.hunkContextHashesByFile, updatedAt: new Date().toISOString(), }; + const filePath = this.getV2FilePath(teamName, scopeKey, data.scopeToken); await atomicWriteAsync( - this.getFilePath(teamName, scopeKey), + filePath, JSON.stringify(payload, null, 2) ); + await this.pruneScopeDir(teamName, scopeKey); } catch (error) { logger.error(`Failed to save review decisions for ${teamName}/${scopeKey}: ${String(error)}`); } } - async clear(teamName: string, scopeKey: string): Promise { + async clear(teamName: string, scopeKey: string, scopeToken?: string): Promise { try { - await fs.promises.unlink(this.getFilePath(teamName, scopeKey)); + if (scopeToken) { + await fs.promises + .unlink(this.getV2FilePath(teamName, scopeKey, scopeToken)) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + const legacyPath = this.getLegacyFilePath(teamName, scopeKey); + const legacy = await this.loadFromPath(legacyPath, scopeToken); + if (legacy) { + await fs.promises.unlink(legacyPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + } + return; + } + await fs.promises.unlink(this.getLegacyFilePath(teamName, scopeKey)).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + }); + await fs.promises.rm(this.getV2DirPath(teamName, scopeKey), { + recursive: true, + force: true, + }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error( diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 01cd38b6..d3dde5ee 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -1,21 +1,68 @@ -import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import { diffLines } from 'diff'; -import { readFile } from 'fs/promises'; +import { open, readFile } from 'fs/promises'; import * as path from 'path'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; +import { createLogger } from '@shared/utils/logger'; + import type { FileChangeSummary, FileEditEvent, FileEditTimeline, + LedgerChangeRelation, + LedgerContentState, SnippetDiff, + TaskChangeJournalStamp, + TaskChangeProvenance, TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; const logger = createLogger('Service:TaskChangeLedgerReader'); -const TASK_CHANGE_LEDGER_SCHEMA_VERSION = 1; +const TASK_CHANGE_JOURNAL_SCHEMA_VERSION = 1; +const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2; +const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; +const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; + +function isWindowsReservedArtifactSegment(segment: string): boolean { + const stem = segment.split('.')[0]?.toUpperCase() ?? ''; + return ( + !segment || + stem === 'CON' || + stem === 'PRN' || + stem === 'AUX' || + stem === 'NUL' || + /^COM[1-9]$/.test(stem) || + /^LPT[1-9]$/.test(stem) + ); +} + +function encodeTaskId(taskId: string): string { + const encoded = encodeURIComponent(taskId); + return isWindowsReservedArtifactSegment(encoded) + ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` + : encoded; +} + +function taskIdArtifactSegments(taskId: string): string[] { + const safe = encodeTaskId(taskId); + const legacy = encodeURIComponent(taskId); + return safe === legacy ? [safe] : [safe, legacy]; +} + +function taskArtifactPathCandidates( + projectDir: string, + taskId: string, + dirName: string, + fileSuffix: string +): string[] { + return taskIdArtifactSegments(taskId).map((segment) => + path.join(projectDir, dirName, `${segment}${fileSuffix}`) + ); +} type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; @@ -26,21 +73,8 @@ interface LedgerContentRef { unavailableReason?: string; } -interface LedgerContentState { - exists?: boolean; - sha256?: string; - sizeBytes?: number; - unavailableReason?: string; -} - -interface LedgerChangeRelation { - kind: 'rename' | 'copy'; - oldPath: string; - newPath: string; -} - interface LedgerEvent { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; eventId: string; taskId: string; taskRef: string; @@ -49,6 +83,7 @@ interface LedgerEvent { executionSeq: number; sessionId: string; agentId?: string; + memberName?: string; toolUseId: string; source: | 'file_edit' @@ -79,7 +114,7 @@ interface LedgerEvent { } interface LedgerNotice { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; noticeId: string; taskId: string; taskRef: string; @@ -88,27 +123,31 @@ interface LedgerNotice { executionSeq: number; sessionId: string; agentId?: string; + memberName?: string; toolUseId: string; timestamp: string; severity: 'warning'; message: string; + code?: 'multi-scope-skipped' | 'journal-recovered' | 'writer-lock-stolen'; } -interface LedgerBundle { - schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; +interface LedgerBundleFileV1 { + filePath: string; + relativePath: string; + eventIds: string[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + latestAfterHash: string | null; +} + +interface LedgerBundleV1 { + schemaVersion: 1; source: 'task-change-ledger'; taskId: string; generatedAt: string; eventCount: number; - files: { - filePath: string; - relativePath: string; - eventIds: string[]; - linesAdded: number; - linesRemoved: number; - isNewFile: boolean; - latestAfterHash: string | null; - }[]; + files: LedgerBundleFileV1[]; totalLinesAdded: number; totalLinesRemoved: number; totalFiles: number; @@ -118,6 +157,123 @@ interface LedgerBundle { notices?: LedgerNotice[]; } +interface LedgerSummaryContributorV2 { + actorKey: string; + agentId?: string; + memberName?: string; + eventCount: number; + noticeCount: number; + touchedFileCount: number; + visibleFileCount: number; + toolUseCount: number; + cumulativeLinesAdded: number; + cumulativeLinesRemoved: number; + firstTimestamp: string; + lastTimestamp: string; +} + +interface LedgerSummaryScopeV2 { + confidence: TaskChangeScope['confidence']; + primaryActorKey?: string; + primaryAgentId?: string; + primaryMemberName?: string; + memberName: string; + agentIds: string[]; + memberNames?: string[]; + startTimestamp: string; + endTimestamp: string; + toolUseIds: string[]; + toolUseCount: number; + toolUseIdsTruncated?: boolean; + phaseSet: Array<'work' | 'review'>; + executionSeqRange?: { start: number; end: number }; + confidenceBreakdown?: TaskChangeScope['confidenceBreakdown']; + visibleFileCount: number; + contributors: LedgerSummaryContributorV2[]; +} + +interface LedgerSummaryFileV2 { + changeKey: string; + filePath: string; + relativePath: string; + displayPath?: string; + linesAdded: number; + linesRemoved: number; + diffStatKnown: boolean; + eventCount: number; + firstTimestamp: string; + lastTimestamp: string; + latestOperation: 'create' | 'modify' | 'delete'; + createdInTask: boolean; + deletedInTask: boolean; + baselineExists?: boolean; + finalExists?: boolean; + latestBeforeHash: string | null; + latestAfterHash: string | null; + latestBeforeState?: LedgerContentState; + latestAfterState?: LedgerContentState; + contentAvailability: 'full-text' | 'hash-only' | 'metadata-only'; + reviewability: 'full-text' | 'partial-text' | 'metadata-only'; + relation?: LedgerChangeRelation; + primaryActorKey?: string; + agentIds: string[]; + memberNames?: string[]; + executionSeqRange?: { start: number; end: number }; + warnings?: string[]; +} + +interface LedgerSummaryBundleV2 { + schemaVersion: typeof TASK_CHANGE_SUMMARY_SCHEMA_VERSION; + source: 'task-change-ledger'; + bundleKind: 'summary'; + taskId: string; + generatedAt: string; + journalStamp: TaskChangeJournalStamp; + integrity: 'ok' | 'recovered' | 'partial'; + eventCount: number; + noticeCount: number; + scope: LedgerSummaryScopeV2; + files: LedgerSummaryFileV2[]; + totalLinesAdded: number; + totalLinesRemoved: number; + diffStatCompleteness: 'complete' | 'partial'; + totalFiles: number; + confidence: 'high' | 'medium' | 'low'; + warningCount: number; + warnings: string[]; +} + +interface LedgerFreshnessV2 { + schemaVersion: typeof TASK_CHANGE_FRESHNESS_SCHEMA_VERSION; + source: 'task-change-ledger'; + taskId: string; + updatedAt: string; + journalStamp: TaskChangeJournalStamp; + eventCount: number; + noticeCount: number; + integrity: 'ok' | 'recovered' | 'partial'; + bundleSchemaVersion: 2; + bundleKind: 'summary'; +} + +type JournalReadResult = { + entries: T[]; + recovered: boolean; +}; + +type JournalData = { + events: LedgerEvent[]; + notices: LedgerNotice[]; + recovered: boolean; +}; + +type SummaryBundleRead = { + bundle: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + mode: 'validated' | 'degraded'; + degradedWarning?: string; +}; + export class TaskChangeLedgerReader { async readTaskChanges(params: { teamName: string; @@ -126,84 +282,716 @@ export class TaskChangeLedgerReader { projectPath?: string; includeDetails: boolean; }): Promise { - const bundle = await this.readBundle(params.projectDir, params.taskId); + const bundleRead = await this.tryReadSummaryBundleV2( + params.projectDir, + params.taskId, + params.projectPath + ); + + if (params.includeDetails) { + const journal = await this.readJournalData(params.projectDir, params.taskId); + if (journal) { + return this.buildDetailedResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + journal, + bundle: bundleRead?.bundle, + provenance: + bundleRead?.provenance ?? + this.buildLedgerProvenanceFromJournal( + (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, + undefined, + journal.recovered ? 'recovered' : 'ok' + ), + }); + } + + const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); + if (legacy) { + return this.buildLegacyResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + bundle: legacy, + includeDetails: true, + }); + } + + if (bundleRead) { + const result = this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, + }); + return { + ...result, + warnings: [ + ...result.warnings, + 'Ledger journal was unavailable; detailed snippets could not be loaded.', + ], + }; + } + + return null; + } + + if (bundleRead?.mode === 'validated') { + return this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + }); + } + + const journal = await this.readJournalData(params.projectDir, params.taskId); + if (journal) { + return this.buildJournalFallbackSummary({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + journal, + }); + } + + if (bundleRead) { + return this.buildSummaryResultFromBundle({ + teamName: params.teamName, + taskId: params.taskId, + projectPath: params.projectPath, + bundle: bundleRead.bundle, + provenance: bundleRead.provenance, + extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, + }); + } + + const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); + if (legacy) { + return this.buildLegacyResult({ + teamName: params.teamName, + taskId: params.taskId, + projectDir: params.projectDir, + projectPath: params.projectPath, + bundle: legacy, + includeDetails: false, + }); + } + + return null; + } + + private async tryReadSummaryBundleV2( + projectDir: string, + taskId: string, + _projectPath?: string + ): Promise { + const [bundle, freshness, journalStamp] = await Promise.all([ + this.readSummaryBundleV2(projectDir, taskId), + this.readFreshnessV2(projectDir, taskId), + this.readJournalStampFromDisk(projectDir, taskId), + ]); if (!bundle) { return null; } - const events = bundle.events - .filter((event) => event.taskId === params.taskId) - .sort((a, b) => { - const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); - return timeDiff === 0 ? a.eventId.localeCompare(b.eventId) : timeDiff; - }); - const notices = (bundle.notices ?? []) - .filter((notice) => notice.taskId === params.taskId) - .sort((a, b) => { - const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp); - return timeDiff === 0 ? a.noticeId.localeCompare(b.noticeId) : timeDiff; - }); - if (events.length === 0 && notices.length === 0) { + const provenance = this.buildLedgerProvenance( + bundle.journalStamp, + bundle.integrity, + bundle.schemaVersion + ); + + if ( + freshness && + this.bundleMatchesFreshness(bundle, freshness) && + freshness.integrity !== 'partial' + ) { + return { bundle, provenance, mode: 'validated' }; + } + + if ( + !freshness && + journalStamp && + JSON.stringify(journalStamp) === JSON.stringify(bundle.journalStamp) && + bundle.integrity !== 'partial' + ) { + return { + bundle, + provenance: this.buildLedgerProvenance(journalStamp, bundle.integrity, bundle.schemaVersion), + mode: 'validated', + }; + } + + if (!freshness && !journalStamp) { + return { + bundle, + provenance, + mode: 'degraded', + degradedWarning: + 'Task change summary used bundle v2 without live validation because freshness and journal files were unavailable.', + }; + } + + return { + bundle, + provenance, + mode: 'degraded', + degradedWarning: + 'Task change summary bypassed bundle v2 fast-path because bundle freshness did not match the current ledger generation.', + }; + } + + private async readSummaryBundleV2( + projectDir: string, + taskId: string + ): Promise { + const bundlePaths = taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), + '.json' + ); + for (const bundlePath of bundlePaths) { + try { + const raw = await readFile(bundlePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== TASK_CHANGE_SUMMARY_SCHEMA_VERSION || + parsed.source !== 'task-change-ledger' || + parsed.bundleKind !== 'summary' || + parsed.taskId !== taskId || + !Array.isArray(parsed.files) + ) { + return null; + } + return parsed as LedgerSummaryBundleV2; + } catch { + continue; + } + } + logger.debug(`No v2 task-change bundle for ${taskId}.`); + return null; + } + + private async readFreshnessV2( + projectDir: string, + taskId: string + ): Promise { + const freshnessPaths = taskArtifactPathCandidates( + projectDir, + taskId, + TASK_CHANGE_FRESHNESS_DIRNAME, + '.json' + ); + for (const freshnessPath of freshnessPaths) { + try { + const raw = await readFile(freshnessPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== TASK_CHANGE_FRESHNESS_SCHEMA_VERSION || + parsed.source !== 'task-change-ledger' || + parsed.taskId !== taskId || + parsed.bundleKind !== 'summary' + ) { + return null; + } + return parsed as LedgerFreshnessV2; + } catch { + continue; + } + } + return null; + } + + private async readLegacyBundleV1( + projectDir: string, + taskId: string + ): Promise { + const bundlePaths = taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), + '.json' + ); + for (const bundlePath of bundlePaths) { + try { + const raw = await readFile(bundlePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schemaVersion !== 1 || + parsed.source !== 'task-change-ledger' || + parsed.taskId !== taskId || + !Array.isArray(parsed.events) + ) { + return null; + } + return parsed as LedgerBundleV1; + } catch { + continue; + } + } + return null; + } + + private async readJournalData(projectDir: string, taskId: string): Promise { + const [events, notices] = await Promise.all([ + this.readJournalEntries({ + filePath: taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), + '.jsonl' + ), + taskId, + schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, + idField: 'eventId', + }), + this.readJournalEntries({ + filePath: taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), + '.jsonl' + ), + taskId, + schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, + idField: 'noticeId', + }), + ]); + + if (events.entries.length === 0 && notices.entries.length === 0) { return null; } - const snippets = params.includeDetails - ? await this.buildSnippets(params.projectDir, events) - : []; - const files = params.includeDetails - ? this.aggregateByFile(snippets, params.projectPath, true) - : this.buildSummaryFiles(bundle, params.projectPath); - const scope = this.buildScope(params.taskId, events, files, notices); - const warnings = new Set(bundle.warnings ?? []); - for (const notice of notices) warnings.add(notice.message); - for (const event of events) { - for (const warning of event.warnings ?? []) warnings.add(warning); - if (event.toolStatus === 'failed') { - warnings.add(`Tool ${event.toolUseId} failed after changing files.`); + return { + events: events.entries, + notices: notices.entries, + recovered: events.recovered || notices.recovered, + }; + } + + private async readJournalEntries(params: { + filePath: string | string[]; + taskId: string; + schemaVersion: number; + idField: 'eventId' | 'noticeId'; + }): Promise> { + let raw: string | null = null; + for (const filePath of Array.isArray(params.filePath) ? params.filePath : [params.filePath]) { + try { + raw = await readFile(filePath, 'utf8'); + break; + } catch { + continue; } - if (event.toolStatus === 'killed') { - warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + if (raw === null) { + return { entries: [], recovered: false }; + } + + const entries: T[] = []; + const seenIds = new Set(); + let recovered = false; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as T & Record; + const id = parsed?.[params.idField]; + if ( + parsed?.schemaVersion !== params.schemaVersion || + parsed.taskId !== params.taskId || + typeof id !== 'string' + ) { + recovered = true; + continue; + } + if (seenIds.has(id)) { + recovered = true; + continue; + } + seenIds.add(id); + entries.push(parsed); + } catch { + recovered = true; } } + return { entries, recovered }; + } + + private bundleMatchesFreshness(bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2): boolean { + return ( + JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && + bundle.eventCount === freshness.eventCount && + bundle.noticeCount === freshness.noticeCount && + freshness.bundleSchemaVersion === bundle.schemaVersion && + freshness.bundleKind === bundle.bundleKind + ); + } + + private buildLedgerProvenance( + journalStamp: TaskChangeJournalStamp, + integrity: 'ok' | 'recovered' | 'partial', + bundleSchemaVersion?: number + ): TaskChangeProvenance { + return { + sourceKind: 'ledger', + sourceFingerprint: this.hashFingerprintPayload({ + journalStamp, + integrity, + ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), + }), + journalStamp, + ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), + integrity, + }; + } + + private buildLedgerProvenanceFromJournal( + journalStamp: TaskChangeJournalStamp, + bundleSchemaVersion?: number, + integrity: 'ok' | 'recovered' | 'partial' = 'ok' + ): TaskChangeProvenance { + return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion); + } + + private hashFingerprintPayload(payload: unknown): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + } + + private async readJournalStampFromDisk( + projectDir: string, + taskId: string + ): Promise { + const readFileStamp = async (filePaths: string[]) => { + let handle: Awaited> | null = null; + for (const filePath of filePaths) { + try { + handle = await open(filePath, 'r'); + const fileStat = await handle.stat(); + if (!fileStat.isFile()) { + continue; + } + const tailLength = Math.min(fileStat.size, 4096); + const tail = Buffer.alloc(tailLength); + if (tailLength > 0) { + await handle.read(tail, 0, tailLength, fileStat.size - tailLength); + } + return { + bytes: fileStat.size, + mtimeMs: fileStat.mtimeMs, + tailSha256: tailLength > 0 ? createHash('sha256').update(tail).digest('hex') : null, + }; + } catch { + continue; + } finally { + await handle?.close().catch(() => undefined); + handle = null; + } + } + return undefined; + }; + + const [events, notices] = await Promise.all([ + readFileStamp( + taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), + '.jsonl' + ) + ), + readFileStamp( + taskArtifactPathCandidates( + projectDir, + taskId, + path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), + '.jsonl' + ) + ), + ]); + + if (!events && !notices) { + return null; + } + + return { + ...(events ? { events } : {}), + ...(notices ? { notices } : {}), + }; + } + + private buildSummaryResultFromBundle(params: { + teamName: string; + taskId: string; + projectPath?: string; + bundle: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + extraWarnings?: string[]; + }): TaskChangeSetV2 { + return { + teamName: params.teamName, + taskId: params.taskId, + files: params.bundle.files.map((file) => this.mapV2SummaryFile(file, params.projectPath)), + totalLinesAdded: params.bundle.totalLinesAdded, + totalLinesRemoved: params.bundle.totalLinesRemoved, + totalFiles: params.bundle.totalFiles, + confidence: params.bundle.confidence, + computedAt: params.bundle.generatedAt, + scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files), + warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])], + diffStatCompleteness: params.bundle.diffStatCompleteness, + provenance: params.provenance, + }; + } + + private async buildDetailedResult(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + journal: JournalData; + bundle?: LedgerSummaryBundleV2; + provenance: TaskChangeProvenance; + }): Promise { + const snippets = await this.buildSnippets(params.projectDir, params.journal.events); + const groupedSnippets = this.groupSnippets(snippets); + const warnings = this.collectWarnings(params.journal.events, params.journal.notices, { + recovered: params.journal.recovered, + }); + + let files: FileChangeSummary[]; + let totalLinesAdded: number; + let totalLinesRemoved: number; + let totalFiles: number; + let confidence: TaskChangeSetV2['confidence']; + let scope: TaskChangeScope; + let diffStatCompleteness: 'complete' | 'partial' | undefined; + + if (params.bundle) { + files = params.bundle.files.map((file) => { + const groupKey = this.groupKeyForFileSummary(file.filePath, file.relation); + const entry = groupedSnippets.get(groupKey); + return { + ...this.mapV2SummaryFile(file, params.projectPath), + snippets: entry?.snippets ?? [], + timeline: entry ? this.buildTimeline(file.filePath, entry.snippets) : undefined, + }; + }); + totalLinesAdded = params.bundle.totalLinesAdded; + totalLinesRemoved = params.bundle.totalLinesRemoved; + totalFiles = params.bundle.totalFiles; + confidence = params.bundle.confidence; + scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); + diffStatCompleteness = params.bundle.diffStatCompleteness; + } else { + const fallback = this.buildFallbackFilesFromGroupedSnippets(groupedSnippets, params.projectPath); + files = fallback.files; + totalLinesAdded = fallback.totalLinesAdded; + totalLinesRemoved = fallback.totalLinesRemoved; + totalFiles = fallback.files.length; + confidence = params.journal.events.some((event) => event.confidence === 'low') + ? 'low' + : params.journal.events.some((event) => event.confidence === 'medium') + ? 'medium' + : 'high'; + scope = this.buildFallbackScope(params.taskId, files, params.journal.events, params.journal.notices); + diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial'; + warnings.push('Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'); + } return { teamName: params.teamName, taskId: params.taskId, files, - totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), - totalFiles: files.length, - confidence: bundle.confidence, - computedAt: bundle.generatedAt, + totalLinesAdded, + totalLinesRemoved, + totalFiles, + confidence, + computedAt: params.bundle?.generatedAt ?? new Date().toISOString(), scope, - warnings: [...warnings], + warnings, + ...(diffStatCompleteness ? { diffStatCompleteness } : {}), + provenance: params.provenance, }; } - private async readBundle(projectDir: string, taskId: string): Promise { - const bundlePath = path.join( - projectDir, - TASK_CHANGE_LEDGER_DIRNAME, - 'bundles', - `${encodeURIComponent(taskId)}.json` + private async buildJournalFallbackSummary(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + journal: JournalData; + }): Promise { + const provenance = this.buildLedgerProvenanceFromJournal( + (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, + undefined, + params.journal.recovered ? 'recovered' : 'ok' ); + const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null)); + const grouped = this.groupSnippets(snippets); + const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); + return { + teamName: params.teamName, + taskId: params.taskId, + files: fallback.files.map((file) => ({ ...file, snippets: [] })), + totalLinesAdded: fallback.totalLinesAdded, + totalLinesRemoved: fallback.totalLinesRemoved, + totalFiles: fallback.files.length, + confidence: params.journal.events.some((event) => event.confidence === 'low') + ? 'low' + : params.journal.events.some((event) => event.confidence === 'medium') + ? 'medium' + : 'high', + computedAt: new Date().toISOString(), + scope: this.buildFallbackScope( + params.taskId, + fallback.files, + params.journal.events, + params.journal.notices + ), + warnings: [ + ...this.collectWarnings(params.journal.events, params.journal.notices, { + recovered: params.journal.recovered, + }), + 'Task change summary fell back to journal reconstruction.', + ], + diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial', + provenance, + }; + } - try { - const raw = await readFile(bundlePath, 'utf8'); - const parsed = JSON.parse(raw) as LedgerBundle; - if ( - parsed?.schemaVersion !== TASK_CHANGE_LEDGER_SCHEMA_VERSION || - parsed.source !== 'task-change-ledger' || - parsed.taskId !== taskId || - !Array.isArray(parsed.events) - ) { - return null; - } - return parsed; - } catch (error) { - logger.debug(`No task-change ledger bundle for ${taskId}: ${String(error)}`); - return null; + private async buildLegacyResult(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + bundle: LedgerBundleV1; + includeDetails: boolean; + }): Promise { + const snippets = params.includeDetails + ? await this.buildSnippets(params.projectDir, params.bundle.events) + : params.bundle.events.map((event) => this.eventToSnippet(event, null, null)); + const grouped = this.groupSnippets(snippets); + const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); + const warnings = new Set(params.bundle.warnings ?? []); + warnings.add( + 'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.' + ); + for (const notice of params.bundle.notices ?? []) warnings.add(notice.message); + + return { + teamName: params.teamName, + taskId: params.taskId, + files: params.includeDetails + ? fallback.files + : fallback.files.map((file) => ({ ...file, snippets: [], timeline: undefined })), + totalLinesAdded: fallback.totalLinesAdded, + totalLinesRemoved: fallback.totalLinesRemoved, + totalFiles: fallback.files.length, + confidence: params.bundle.confidence, + computedAt: params.bundle.generatedAt, + scope: this.buildFallbackScope( + params.taskId, + fallback.files, + params.bundle.events, + params.bundle.notices ?? [] + ), + warnings: [...warnings], + diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) + ? 'complete' + : 'partial', + provenance: { + sourceKind: 'ledger', + sourceFingerprint: this.hashFingerprintPayload({ + legacyTaskId: params.taskId, + generatedAt: params.bundle.generatedAt, + eventCount: params.bundle.eventCount, + }), + }, + }; + } + + private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { + const displayPath = file.displayPath ?? file.filePath; + return { + filePath: file.filePath, + relativePath: this.relativePath(displayPath, projectPath, file.relativePath), + snippets: [], + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + isNewFile: Boolean( + file.createdInTask && file.latestOperation !== 'delete' && file.relation?.kind !== 'rename' + ), + changeKey: this.normalizeSummaryChangeKey(file), + diffStatKnown: file.diffStatKnown, + ledgerSummary: { + latestOperation: file.latestOperation, + createdInTask: file.createdInTask, + deletedInTask: file.deletedInTask, + contentAvailability: file.contentAvailability, + reviewability: file.reviewability, + ...(file.relation ? { relation: file.relation } : {}), + ...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}), + ...(file.latestAfterState ? { afterState: file.latestAfterState } : {}), + ...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}), + ...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}), + ...(file.memberNames ? { memberNames: file.memberNames } : {}), + ...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}), + }, + }; + } + + private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string { + if (file.relation) { + return `${file.relation.kind}:${normalizePathForComparison(file.relation.oldPath)}->${normalizePathForComparison(file.relation.newPath)}`; } + const slashNormalized = file.changeKey.replace(/\\/g, '/'); + const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized); + if (pathKeyMatch) { + return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`; + } + return slashNormalized; + } + + private mapV2Scope( + taskId: string, + scope: LedgerSummaryScopeV2, + files: LedgerSummaryFileV2[] + ): TaskChangeScope { + return { + taskId, + memberName: + scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', + startLine: 0, + endLine: 0, + startTimestamp: scope.startTimestamp, + endTimestamp: scope.endTimestamp, + toolUseIds: scope.toolUseIds, + filePaths: files.map((file) => file.filePath), + confidence: scope.confidence, + ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), + ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), + ...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}), + ...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}), + ...(scope.memberNames ? { memberNames: scope.memberNames } : {}), + ...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}), + ...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}), + ...(scope.phaseSet ? { phaseSet: scope.phaseSet } : {}), + ...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}), + ...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}), + ...(scope.contributors ? { contributors: scope.contributors } : {}), + }; } private async buildSnippets(projectDir: string, events: LedgerEvent[]): Promise { @@ -216,10 +1004,7 @@ export class TaskChangeLedgerReader { ); } - private async readContentRef( - projectDir: string, - ref: LedgerContentRef | null - ): Promise { + private async readContentRef(projectDir: string, ref: LedgerContentRef | null): Promise { if (!ref?.blobRef) { return null; } @@ -238,14 +1023,11 @@ export class TaskChangeLedgerReader { beforeContent: string | null, afterContent: string | null ): SnippetDiff { - const toolName = this.mapToolName(event.source); - const type = this.mapSnippetType(event); - const source = event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot'; return { toolUseId: event.toolUseId, filePath: event.filePath, - toolName, - type, + toolName: this.mapToolName(event.source), + type: this.mapSnippetType(event), oldString: event.oldString ?? beforeContent ?? '', newString: event.newString ?? afterContent ?? '', replaceAll: event.replaceAll ?? false, @@ -253,7 +1035,7 @@ export class TaskChangeLedgerReader { isError: false, ledger: { eventId: event.eventId, - source, + source: event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot', confidence: event.confidence, originalFullContent: beforeContent, modifiedFullContent: afterContent, @@ -264,6 +1046,14 @@ export class TaskChangeLedgerReader { afterState: event.afterState, relation: event.relation, executionSeq: event.executionSeq, + linesAdded: event.linesAdded, + linesRemoved: event.linesRemoved, + textAvailability: + beforeContent !== null && afterContent !== null + ? 'full-text' + : event.oldString !== undefined || event.newString !== undefined + ? 'patch-text' + : 'unavailable', }, }; } @@ -302,151 +1092,150 @@ export class TaskChangeLedgerReader { return 'edit'; } - private aggregateByFile( - snippets: SnippetDiff[], - projectPath: string | undefined, - includeDetails: boolean - ): FileChangeSummary[] { - const fileMap = new Map< + private groupSnippets( + snippets: SnippetDiff[] + ): Map { + const grouped = new Map< string, - { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } >(); for (const snippet of snippets) { - const key = this.fileGroupKey(snippet); - const existing = fileMap.get(key); + const groupKey = this.groupKeyForSnippet(snippet); + const existing = grouped.get(groupKey); if (existing) { existing.snippets.push(snippet); - existing.isNewFile ||= - snippet.type === 'write-new' || snippet.ledger?.operation === 'create'; } else { - fileMap.set(key, { + grouped.set(groupKey, { filePath: snippet.filePath, + ...(snippet.ledger?.relation ? { relation: snippet.ledger.relation } : {}), snippets: [snippet], - isNewFile: snippet.type === 'write-new' || snippet.ledger?.operation === 'create', }); } } + return grouped; + } - return [...fileMap.values()].map((entry) => { + private buildFallbackFilesFromGroupedSnippets( + grouped: Map, + projectPath?: string + ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { + const files: FileChangeSummary[] = []; + for (const entry of grouped.values()) { + const relation = entry.relation ?? this.relationForSnippets(entry.snippets); let linesAdded = 0; let linesRemoved = 0; for (const snippet of entry.snippets) { + if ( + typeof snippet.ledger?.linesAdded === 'number' || + typeof snippet.ledger?.linesRemoved === 'number' + ) { + linesAdded += snippet.ledger?.linesAdded ?? 0; + linesRemoved += snippet.ledger?.linesRemoved ?? 0; + continue; + } const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); linesAdded += added; linesRemoved += removed; } - - const displayFilePath = this.displayFilePathForGroup(entry); - const relation = this.relationForSnippets(entry.snippets); - return { - filePath: displayFilePath, - relativePath: this.relativePath(displayFilePath, projectPath), - snippets: includeDetails ? entry.snippets : [], + const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets); + files.push({ + filePath: displayPath, + relativePath: this.relativePath(displayPath, projectPath), + snippets: entry.snippets, linesAdded, linesRemoved, - isNewFile: relation?.kind === 'rename' ? false : entry.isNewFile, - timeline: includeDetails ? this.buildTimeline(displayFilePath, entry.snippets) : undefined, - }; - }); - } - - private buildSummaryFiles( - bundle: LedgerBundle, - projectPath: string | undefined - ): FileChangeSummary[] { - const eventById = new Map(bundle.events.map((event) => [event.eventId, event])); - const fileMap = new Map< - string, - { - filePath: string; - filePaths: string[]; - linesAdded: number; - linesRemoved: number; - isNewFile: boolean; - relation?: LedgerChangeRelation; - } - >(); - - for (const file of bundle.files) { - const relation = file.eventIds - .map((eventId) => eventById.get(eventId)?.relation) - .find((value): value is LedgerChangeRelation => Boolean(value)); - const key = relation - ? `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}` - : this.normalizePathKey(file.filePath); - const displayFilePath = relation?.newPath ?? file.filePath; - const existing = fileMap.get(key); - if (existing) { - existing.filePaths.push(file.filePath); - existing.filePath = relation - ? this.displayFilePathForRelation(relation, existing.filePaths) - : existing.filePath; - existing.linesAdded += file.linesAdded; - existing.linesRemoved += file.linesRemoved; - existing.isNewFile ||= file.isNewFile; - existing.relation ??= relation; - } else { - fileMap.set(key, { - filePath: relation - ? this.displayFilePathForRelation(relation, [file.filePath]) - : displayFilePath, - filePaths: [file.filePath], - linesAdded: file.linesAdded, - linesRemoved: file.linesRemoved, - isNewFile: file.isNewFile, - relation, - }); - } + isNewFile: + relation?.kind !== 'rename' && + entry.snippets.some( + (snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create' + ), + changeKey: relation + ? `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}` + : `path:${normalizePathForComparison(displayPath)}`, + diffStatKnown: true, + ledgerSummary: { + ...(relation ? { relation } : {}), + latestOperation: + entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? + (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' + ? 'create' + : 'modify'), + }, + timeline: this.buildTimeline(displayPath, entry.snippets), + }); } - - return [...fileMap.values()].map((file) => ({ - filePath: file.filePath, - relativePath: this.relativePath(file.filePath, projectPath), - snippets: [], - linesAdded: file.linesAdded, - linesRemoved: file.linesRemoved, - isNewFile: file.relation?.kind === 'rename' ? false : file.isNewFile, - })); + const totalLinesAdded = files.reduce((sum, file) => sum + file.linesAdded, 0); + const totalLinesRemoved = files.reduce((sum, file) => sum + file.linesRemoved, 0); + return { files, totalLinesAdded, totalLinesRemoved }; } - private buildScope( + private buildFallbackScope( taskId: string, - events: LedgerEvent[], files: FileChangeSummary[], - notices: LedgerNotice[] = [] + events: LedgerEvent[], + notices: LedgerNotice[] ): TaskChangeScope { - const first = events[0]; - const last = events[events.length - 1]; - const firstNotice = notices[0]; - const lastNotice = notices[notices.length - 1]; - const worstConfidence = events.some((event) => event.confidence !== 'exact') ? 2 : 1; + const primaryMemberName = events.find((event) => event.memberName)?.memberName; + const primaryAgentId = events.find((event) => event.agentId)?.agentId; return { taskId, - memberName: first?.agentId ?? firstNotice?.agentId ?? '', + memberName: primaryMemberName ?? primaryAgentId ?? '', startLine: 0, endLine: 0, - startTimestamp: first?.timestamp ?? firstNotice?.timestamp ?? new Date().toISOString(), + startTimestamp: events[0]?.timestamp ?? notices[0]?.timestamp ?? '', endTimestamp: - last?.timestamp ?? - first?.timestamp ?? - lastNotice?.timestamp ?? - firstNotice?.timestamp ?? - new Date().toISOString(), + events[events.length - 1]?.timestamp ?? notices[notices.length - 1]?.timestamp ?? '', toolUseIds: [ - ...new Set([ - ...events.map((event) => event.toolUseId), - ...notices.map((notice) => notice.toolUseId), - ]), + ...new Set([...events.map((event) => event.toolUseId), ...notices.map((n) => n.toolUseId)]), ], filePaths: files.map((file) => file.filePath), confidence: { - tier: worstConfidence, - label: worstConfidence === 1 ? 'high' : 'medium', + tier: events.some((event) => event.confidence !== 'exact') ? 2 : 1, + label: events.some((event) => event.confidence !== 'exact') ? 'medium' : 'high', reason: 'Scoped by orchestrator task-change ledger', }, + ...(primaryMemberName ? { primaryMemberName } : {}), + ...(primaryAgentId ? { primaryAgentId } : {}), + ...(events.some((event) => !!event.memberName) + ? { + memberNames: [ + ...new Set(events.flatMap((event) => (event.memberName ? [event.memberName] : []))), + ].sort(), + } + : {}), + ...(events.length > 0 + ? { + executionSeqRange: { + start: Math.min(...events.map((event) => event.executionSeq)), + end: Math.max(...events.map((event) => event.executionSeq)), + }, + } + : {}), }; } + private collectWarnings( + events: LedgerEvent[], + notices: LedgerNotice[], + options: { recovered: boolean } + ): string[] { + const warnings = new Set(); + for (const notice of notices) warnings.add(notice.message); + for (const event of events) { + for (const warning of event.warnings ?? []) warnings.add(warning); + if (event.toolStatus === 'failed') { + warnings.add(`Tool ${event.toolUseId} failed after changing files.`); + } + if (event.toolStatus === 'killed') { + warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + } + if (options.recovered) { + warnings.add('Task change ledger recovered from malformed journal lines.'); + } + return [...warnings]; + } + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { const events: FileEditEvent[] = snippets.map((snippet, index) => { const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); @@ -491,46 +1280,88 @@ export class TaskChangeLedgerReader { return { added, removed }; } - private normalizePathKey(filePath: string): string { - return path.normalize(filePath).toLowerCase(); + private groupKeyForSnippet(snippet: SnippetDiff): string { + return this.groupKeyForFileSummary(snippet.filePath, snippet.ledger?.relation); } - private fileGroupKey(snippet: SnippetDiff): string { - const relation = snippet.ledger?.relation; + private groupKeyForFileSummary(filePath: string, relation?: LedgerChangeRelation): string { if (relation) { - return `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}`; + return `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`; } - return this.normalizePathKey(snippet.filePath); - } - - private displayFilePathForGroup(entry: { filePath: string; snippets: SnippetDiff[] }): string { - const relation = this.relationForSnippets(entry.snippets); - if (!relation) { - return entry.filePath; - } - return this.displayFilePathForRelation( - relation, - entry.snippets.map((snippet) => snippet.filePath) - ); + return `path:${normalizePathForComparison(filePath)}`; } private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; } - private displayFilePathForRelation(relation: LedgerChangeRelation, filePaths: string[]): string { - const expected = relation.newPath.replace(/\\/g, '/'); - const match = filePaths.find((filePath) => { - const normalized = filePath.replace(/\\/g, '/'); - return normalized === expected || normalized.endsWith(`/${expected}`); - }); - return match ?? relation.newPath; + private resolveGroupedDisplayPath( + fallbackPath: string, + relation: LedgerChangeRelation | undefined, + snippets: SnippetDiff[] + ): string { + if (!relation) { + return fallbackPath; + } + + const newPathSnippet = snippets.find((snippet) => + this.pathMatchesRelationPath(snippet.filePath, relation.newPath) + ); + if (newPathSnippet) { + return newPathSnippet.filePath; + } + + const createdSnippet = snippets.find( + (snippet) => snippet.ledger?.operation === 'create' || snippet.type === 'write-new' + ); + if (createdSnippet) { + return createdSnippet.filePath; + } + + return ( + this.resolveRelatedPathFromRelation(fallbackPath, relation.oldPath, relation.newPath) ?? + fallbackPath + ); } - private relativePath(filePath: string, projectPath?: string): string { + private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedRelationPath = relationPath.replace(/\\/g, '/'); + return ( + normalizedFilePath === normalizedRelationPath || + normalizedFilePath.endsWith(`/${normalizedRelationPath}`) + ); + } + + private resolveRelatedPathFromRelation( + anchorPath: string, + anchorRelationPath: string, + targetRelationPath: string + ): string | null { + const normalizedAnchor = anchorPath.replace(/\\/g, '/'); + const normalizedAnchorRelation = anchorRelationPath.replace(/\\/g, '/'); + if (!normalizedAnchor.endsWith(normalizedAnchorRelation)) { + return null; + } + + return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; + } + + private relativePath(filePath: string, projectPath?: string, explicitRelativePath?: string): string { + if (explicitRelativePath) { + return explicitRelativePath.replace(/\\/g, '/'); + } const normalizedFilePath = filePath.replace(/\\/g, '/'); const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); - if (normalizedProjectPath && normalizedFilePath.startsWith(normalizedProjectPath + '/')) { + const comparableFilePath = normalizePathForComparison(normalizedFilePath); + const comparableProjectPath = normalizedProjectPath + ? normalizePathForComparison(normalizedProjectPath) + : undefined; + if ( + normalizedProjectPath && + comparableProjectPath && + comparableFilePath.startsWith(`${comparableProjectPath}/`) + ) { return normalizedFilePath.slice(normalizedProjectPath.length + 1); } return normalizedFilePath.split('/').slice(-3).join('/'); diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index cb23ee30..85f248b0 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; +const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes'; const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { @@ -42,6 +43,28 @@ interface TrackingState { lifecycleVersion: number; } +type DecodedFreshnessTaskId = + | { kind: 'task-id'; taskId: string } + | { kind: 'opaque-safe-segment' } + | { kind: 'invalid' }; + +function isOpaqueSafeTaskIdSegment(segment: string): boolean { + return /^task-id-[0-9a-f]{32}$/.test(segment); +} + +export function shouldIgnoreLogSourceWatcherPath( + projectDir: string, + watchedPath: string +): boolean { + const relativePath = path.relative(projectDir, watchedPath); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return false; + } + + const parts = relativePath.split(path.sep).filter(Boolean); + return parts[0] === BOARD_TASK_CHANGES_DIRNAME; +} + export class TeamLogSourceTracker { private readonly stateByTeam = new Map(); private emitter: ((event: TeamChangeEvent) => void) | null = null; @@ -276,6 +299,7 @@ export class TeamLogSourceTracker { ignorePermissionErrors: true, followSymlinks: false, depth: 3, + ignored: (watchedPath) => shouldIgnoreLogSourceWatcherPath(projectDir, watchedPath), awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50, @@ -343,37 +367,68 @@ export class TeamLogSourceTracker { return true; } - const taskId = this.decodeTaskLogFreshnessTaskId(relativePath); - if (!taskId) { + const decoded = this.decodeTaskLogFreshnessTaskId(relativePath); + if (decoded.kind === 'invalid') { + return true; + } + if (decoded.kind === 'opaque-safe-segment') { + void this.emitTaskFreshnessSignalFromFile(teamName, changedPath); return true; } this.emitter?.({ type: 'task-log-change', teamName, - taskId, + taskId: decoded.taskId, }); return true; } - private decodeTaskLogFreshnessTaskId(fileName: string): string | null { + private decodeTaskLogFreshnessTaskId(fileName: string): DecodedFreshnessTaskId { if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) { - return null; + return { kind: 'invalid' }; } const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length); if (!encodedTaskId) { - return null; + return { kind: 'invalid' }; + } + if (isOpaqueSafeTaskIdSegment(encodedTaskId)) { + return { kind: 'opaque-safe-segment' }; } try { const taskId = decodeURIComponent(encodedTaskId); - return taskId.trim().length > 0 ? taskId : null; + return taskId.trim().length > 0 + ? { kind: 'task-id', taskId } + : { kind: 'invalid' }; } catch { - return null; + return { kind: 'invalid' }; } } + private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const taskId = + typeof parsed.taskId === 'string' && parsed.taskId.trim().length > 0 + ? parsed.taskId.trim() + : null; + if (taskId) { + this.emitter?.({ + type: 'task-log-change', + teamName, + taskId, + }); + return; + } + } catch { + // Deletions or partially unavailable files still need a team-level refresh. + } + this.emitLogSourceChange(teamName); + } + private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); if (this.getActiveConsumerCount(state) === 0) { diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts index 0e573e5e..ef271951 100644 --- a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -10,7 +10,10 @@ import { } from './taskChangePresenceCacheSchema'; import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; -import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; +import type { + PersistedTaskChangePresence, + PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; const logger = createLogger('Service:JsonTaskChangePresenceRepository'); @@ -87,7 +90,7 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos entry: { taskId: string; taskSignature: string; - presence: 'has_changes' | 'no_changes'; + presence: PersistedTaskChangePresence; writtenAt: string; logSourceGeneration: string; } diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts index b65af3e8..1ebcfed2 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -1,4 +1,5 @@ import { + LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, type PersistedTaskChangePresence, type PersistedTaskChangePresenceEntry, type PersistedTaskChangePresenceIndex, @@ -10,7 +11,9 @@ function isIsoString(value: unknown): value is string { } function normalizePresence(value: unknown): PersistedTaskChangePresence | null { - return value === 'has_changes' || value === 'no_changes' ? value : null; + return value === 'has_changes' || value === 'needs_attention' || value === 'no_changes' + ? value + : null; } function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null { @@ -47,8 +50,11 @@ export function normalizePersistedTaskChangePresenceIndex( } const raw = value as Record; + const rawVersion = + typeof raw.version === 'number' ? raw.version : Number.NaN; if ( - raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION || + (rawVersion !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION && + rawVersion !== LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION) || typeof raw.teamName !== 'string' || typeof raw.projectFingerprint !== 'string' || raw.projectFingerprint.length === 0 || diff --git a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts index f06f853f..0116564a 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts @@ -1,6 +1,7 @@ import type { TaskChangePresenceState } from '@shared/types/team'; -export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; +export const LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; +export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 2; export type PersistedTaskChangePresence = Exclude; diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts index 326af24e..db5bc230 100644 --- a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -14,8 +15,40 @@ interface ParsedFreshnessSignal { transcriptFileBasename?: string; } +function isWindowsReservedArtifactSegment(segment: string): boolean { + const stem = segment.split('.')[0]?.toUpperCase() ?? ''; + return ( + !segment || + stem === 'CON' || + stem === 'PRN' || + stem === 'AUX' || + stem === 'NUL' || + /^COM[1-9]$/.test(stem) || + /^LPT[1-9]$/.test(stem) + ); +} + function encodeTaskId(taskId: string): string { - return encodeURIComponent(taskId); + const encoded = encodeURIComponent(taskId); + return isWindowsReservedArtifactSegment(encoded) + ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` + : encoded; +} + +function taskIdArtifactSegments(taskId: string): string[] { + const safe = encodeTaskId(taskId); + const legacy = encodeURIComponent(taskId); + return safe === legacy ? [safe] : [safe, legacy]; +} + +function taskSignalPathCandidates(projectDir: string, taskId: string): string[] { + return taskIdArtifactSegments(taskId).map((segment) => + path.join( + projectDir, + BOARD_TASK_LOG_FRESHNESS_DIRNAME, + `${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}` + ) + ); } function isValidTimestamp(value: unknown): value is string { @@ -30,28 +63,25 @@ export class TeamTaskLogFreshnessReader { taskIds: string[] ): Promise> { const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort(); - const signalFilePaths = uniqueTaskIds.map((taskId) => - path.join( - projectDir, - BOARD_TASK_LOG_FRESHNESS_DIRNAME, - `${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}` - ) + const signalFilePathCandidates = uniqueTaskIds.map((taskId) => + taskSignalPathCandidates(projectDir, taskId) ); - this.cache.retainOnly(new Set(signalFilePaths)); + this.cache.retainOnly(new Set(signalFilePathCandidates.flat())); const rows = await Promise.all( uniqueTaskIds.map(async (taskId, index) => { - const filePath = signalFilePaths[index]; - const parsed = await this.readSignal(filePath); - if (!parsed || parsed.taskId !== taskId) { + const candidates = signalFilePathCandidates[index] ?? []; + const result = await this.readFirstSignal(candidates); + if (!result || result.parsed.taskId !== taskId) { return null; } + const parsed = result.parsed; return [ taskId, { taskId, updatedAt: parsed.updatedAt, - filePath, + filePath: result.filePath, ...(parsed.transcriptFileBasename ? { transcriptFileBasename: parsed.transcriptFileBasename } : {}), @@ -63,6 +93,18 @@ export class TeamTaskLogFreshnessReader { return new Map(rows.filter((row): row is NonNullable => row !== null)); } + private async readFirstSignal( + filePaths: string[] + ): Promise<{ filePath: string; parsed: ParsedFreshnessSignal } | null> { + for (const filePath of filePaths) { + const parsed = await this.readSignal(filePath); + if (parsed) { + return { filePath, parsed }; + } + } + return null; + } + private async readSignal(filePath: string): Promise { try { const stat = await fs.stat(filePath); diff --git a/src/preload/index.ts b/src/preload/index.ts index ccf85725..fb50e9cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1398,16 +1398,17 @@ const electronAPI: ElectronAPI = { }; }, // Decision persistence - loadDecisions: async (teamName: string, scopeKey: string) => { + loadDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => { return invokeIpcWithResult<{ hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; - } | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey); + } | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey, scopeToken ?? null); }, saveDecisions: async ( teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile?: Record> @@ -1416,13 +1417,19 @@ const electronAPI: ElectronAPI = { REVIEW_SAVE_DECISIONS, teamName, scopeKey, + scopeToken, hunkDecisions, fileDecisions, hunkContextHashesByFile ?? null ); }, - clearDecisions: async (teamName: string, scopeKey: string) => { - return invokeIpcWithResult(REVIEW_CLEAR_DECISIONS, teamName, scopeKey); + clearDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => { + return invokeIpcWithResult( + REVIEW_CLEAR_DECISIONS, + teamName, + scopeKey, + scopeToken ?? null + ); }, onCmdN: (callback: () => void): (() => void) => { const handler = (): void => callback(); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 58b5af8b..84b62d4c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1133,6 +1133,7 @@ export class HttpAPIClient implements ElectronAPI { saveDecisions: async ( _teamName: string, _scopeKey: string, + _scopeToken: string, _hunkDecisions: Record, _fileDecisions: Record, _hunkContextHashesByFile?: Record> diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 6eb67a21..ba760d20 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -51,6 +51,7 @@ import { buildTaskChangeSignature, deriveTaskSince, } from '@renderer/utils/taskChangeRequest'; +import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -103,16 +104,6 @@ import type { TeamTaskWithKanban, } from '@shared/types'; -function resolveTaskChangePresenceFromResult( - data: Pick -): 'has_changes' | 'no_changes' | null { - if (data.files.length > 0) { - return 'has_changes'; - } - - return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; -} - interface TaskDetailDialogProps { open: boolean; loading?: boolean; @@ -154,7 +145,7 @@ export const TaskDetailDialog = ({ const { isLight } = useTheme(); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); - const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges); + const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); const [logsRefreshing, setLogsRefreshing] = useState(false); @@ -391,22 +382,22 @@ export const TaskDetailDialog = ({ const syncTaskChangeSummaryResult = useCallback( (data: TaskChangeSetV2 | null) => { setTaskChangesFiles(data?.files ?? null); + const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && taskChangeRequestOptions) { - recordTaskHasChanges( + recordTaskChangePresence( teamName, currentTask.id, taskChangeRequestOptions, - !!data?.files.length + nextPresence ); } - const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; - if (currentTask && nextPresence) { - setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence); + if (currentTask) { + setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence ?? 'unknown'); } }, [ currentTask, - recordTaskHasChanges, + recordTaskChangePresence, setSelectedTeamTaskChangePresence, taskChangeRequestOptions, teamName, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 908c7c35..4f4b8da9 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -159,4 +159,41 @@ describe('KanbanTaskCard change badge', () => { await Promise.resolve(); }); }); + + it('still renders the Changes action when changePresence needs attention', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'needs_attention' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7c84488d..de058bdf 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -257,7 +257,8 @@ export const KanbanTaskCard = memo( const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; const metaActions = ( <> - {canDisplay && task.changePresence === 'has_changes' ? ( + {canDisplay && + (task.changePresence === 'has_changes' || task.changePresence === 'needs_attention') ? ( } diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 25daf6c3..68a00a78 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -14,7 +14,12 @@ import { buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo'; import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; import { displayMemberName } from '@renderer/utils/memberHelpers'; -import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; +import { buildReviewDecisionScopeToken } from '@renderer/utils/reviewDecisionScope'; +import { + buildTaskChangeSignature, + type TaskChangeRequestOptions, +} from '@renderer/utils/taskChangeRequest'; import { normalizePathForComparison } from '@shared/utils/platformPath'; import { ChevronDown, Clock, X } from 'lucide-react'; @@ -122,6 +127,25 @@ export const ChangeReviewDialog = ({ const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; // Filesystem-safe: use `-` instead of `:` for decision persistence key const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`; + const decisionScopeToken = useMemo(() => { + if (!activeChangeSet) return null; + if (mode === 'task') { + if (!('taskId' in activeChangeSet) || activeChangeSet.taskId !== taskId) { + return null; + } + } else if (!('memberName' in activeChangeSet) || activeChangeSet.memberName !== memberName) { + return null; + } + + return buildReviewDecisionScopeToken({ + mode, + taskId, + memberName, + requestSignature: + mode === 'task' ? buildTaskChangeSignature(taskChangeRequestOptions ?? {}) : undefined, + changeSet: activeChangeSet, + }); + }, [activeChangeSet, memberName, mode, taskChangeRequestOptions, taskId]); // Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll) const [activeFilePath, setActiveFilePath] = useState(null); @@ -813,8 +837,7 @@ export const ChangeReviewDialog = ({ useEffect(() => { if (!open) return; - // Load persisted decisions from disk - void loadDecisionsFromDisk(teamName, decisionScopeKey); + resetAllReviewState(); // Fetch changeSet if (mode === 'agent' && memberName) { @@ -836,9 +859,14 @@ export const ChangeReviewDialog = ({ fetchAgentChanges, fetchTaskChanges, clearChangeReviewCache, - loadDecisionsFromDisk, + resetAllReviewState, ]); + useEffect(() => { + if (!open || !decisionScopeToken) return; + void loadDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken); + }, [decisionScopeKey, decisionScopeToken, loadDecisionsFromDisk, open, teamName]); + // Persist decisions to disk on change (debounced via store action). // When decisions go from non-empty to empty (e.g. undo to clean state), // clear the persisted file so stale decisions don't reload on reopen. @@ -846,21 +874,27 @@ export const ChangeReviewDialog = ({ Object.keys(hunkDecisions).length > 0 || Object.keys(fileDecisions).length > 0; const hadDecisionsRef = useRef(false); useEffect(() => { - if (!open) return; + hadDecisionsRef.current = false; + }, [decisionScopeToken]); + useEffect(() => { + if (!open || !decisionScopeToken) return; if (hasDecisions) { hadDecisionsRef.current = true; - persistDecisions(teamName, decisionScopeKey); + persistDecisions(teamName, decisionScopeKey, decisionScopeToken); } else if (hadDecisionsRef.current) { hadDecisionsRef.current = false; - void clearDecisionsFromDisk(teamName, decisionScopeKey); + void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken); } }, [ open, hasDecisions, hunkDecisions, fileDecisions, + fileContents, + fileChunkCounts, teamName, decisionScopeKey, + decisionScopeToken, persistDecisions, clearDecisionsFromDisk, ]); @@ -1125,7 +1159,8 @@ export const ChangeReviewDialog = ({ for (const file of activeChangeSet.files) { // File-level decision takes priority (set by Accept All / Reject All) - const fileDec = fileDecisions[file.filePath]; + const reviewKey = getFileReviewKey(file); + const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath]; const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); if (fileDec === 'accepted') { @@ -1138,8 +1173,9 @@ export const ChangeReviewDialog = ({ } for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; - const decision: HunkDecision = hunkDecisions[key] ?? 'pending'; + const key = buildHunkDecisionKey(reviewKey, i); + const decision: HunkDecision = + hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending'; if (decision === 'pending') pending++; else if (decision === 'accepted') accepted++; else if (decision === 'rejected') rejected++; @@ -1163,7 +1199,7 @@ export const ChangeReviewDialog = ({ // Only cleanup if apply succeeded (no error in store) const state = useStore.getState(); if (!state.applyError) { - void clearDecisionsFromDisk(teamName, decisionScopeKey); + void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken ?? undefined); resetAllReviewState(); } }, [ @@ -1173,6 +1209,7 @@ export const ChangeReviewDialog = ({ memberName, clearDecisionsFromDisk, decisionScopeKey, + decisionScopeToken, resetAllReviewState, ]); diff --git a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts index 7eda04af..3014d8cb 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts +++ b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts @@ -9,6 +9,7 @@ import { } from '@codemirror/merge'; import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state'; import { type EditorView } from '@codemirror/view'; +import { buildHunkDecisionKey } from '@renderer/utils/reviewKey'; import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { structuredPatch } from 'diff'; @@ -93,7 +94,7 @@ export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) = */ export function replayHunkDecisions( view: EditorView, - filePath: string, + reviewKey: string, hunkDecisions: Record ): void { const result = getChunks(view.state); @@ -102,7 +103,7 @@ export function replayHunkDecisions( // Collect decisions that need replaying const toReplay: { index: number; decision: 'accepted' | 'rejected' }[] = []; for (let i = 0; i < result.chunks.length; i++) { - const key = `${filePath}:${i}`; + const key = buildHunkDecisionKey(reviewKey, i); const d = hunkDecisions[key]; if (d === 'accepted' || d === 'rejected') { toReplay.push({ index: i, decision: d }); @@ -134,7 +135,7 @@ export function replayHunkDecisions( */ export function replayHunkDecisionsSmart( view: EditorView, - filePath: string, + reviewKey: string, hunkDecisions: Record, hunkContextHashes?: Record ): void { @@ -171,7 +172,7 @@ export function replayHunkDecisionsSmart( } // Collect all decided indices from the decision map (don't assume contiguous 0..N) - const prefix = `${filePath}:`; + const prefix = `${reviewKey}:`; const decided: { mappedIndex: number; decision: 'accepted' | 'rejected' }[] = []; const usedMapped = new Set(); diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 5758d80a..d6fc2ec1 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; import { useStore } from '@renderer/store'; +import { getFileReviewKey } from '@renderer/utils/reviewKey'; import { acceptAllChunks, @@ -189,6 +190,8 @@ export const ContinuousScrollView = ({ const handleEditorViewReady = useCallback( (filePath: string, view: EditorView | null) => { if (view) { + const file = files.find((candidate) => candidate.filePath === filePath); + const reviewKey = file ? getFileReviewKey(file) : filePath; // Skip if this exact view instance was already processed if (editorViewMapRef.current.get(filePath) === view && replayedViewsRef.current.has(view)) { return; @@ -202,7 +205,8 @@ export const ContinuousScrollView = ({ setFileChunkCount(filePath, chunks.chunks.length); } - const fileDecision = fileDecisionsRef.current[filePath]; + const fileDecision = + fileDecisionsRef.current[reviewKey] ?? fileDecisionsRef.current[filePath]; if (fileDecision === 'accepted' || fileDecision === 'rejected') { // Sync file-level "Accept All" / "Reject All" decisions requestAnimationFrame(() => { @@ -217,9 +221,9 @@ export const ContinuousScrollView = ({ requestAnimationFrame(() => { replayHunkDecisionsSmart( view, - filePath, + reviewKey, hunkDecisionsRef.current, - hunkHashesRef.current[filePath] + hunkHashesRef.current[reviewKey] ?? hunkHashesRef.current[filePath] ); }); } @@ -229,7 +233,7 @@ export const ContinuousScrollView = ({ // is not needed since view instances are unique and old ones get GC'd) } }, - [editorViewMapRef, setFileChunkCount] + [editorViewMapRef, files, setFileChunkCount] ); if (files.length === 0) { @@ -253,11 +257,12 @@ export const ContinuousScrollView = ({ ) : null} {files.map((file) => { const filePath = file.filePath; + const reviewKey = getFileReviewKey(file); const content = fileContents[filePath] ?? null; const hasContent = filePath in fileContents; const hasEdits = filePath in editedContents; const isViewed = viewedSet.has(filePath); - const decision = fileDecisions[filePath]; + const decision = fileDecisions[reviewKey] ?? fileDecisions[filePath]; const isCollapsed = collapsedFiles.has(filePath); diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index c80c5747..94d3ce70 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -6,6 +6,7 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice'; import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; import { Check, ChevronRight, @@ -47,7 +48,8 @@ function getFileStatus( fileChunkCounts: Record ): FileStatus { // File-level decision takes priority (set by Accept All / Reject All) - const fileDec = fileDecisions[file.filePath]; + const reviewKey = getFileReviewKey(file); + const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath]; if (fileDec === 'accepted') return 'accepted'; if (fileDec === 'rejected') return 'rejected'; @@ -56,8 +58,8 @@ function getFileStatus( const decisions: HunkDecision[] = []; for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; - decisions.push(hunkDecisions[key] ?? 'pending'); + const key = buildHunkDecisionKey(reviewKey, i); + decisions.push(hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending'); } const allAccepted = decisions.every((d) => d === 'accepted'); @@ -300,10 +302,18 @@ export const ReviewFileTree = ({ }; const hasAnyRejected = (f: FileChangeSummary): boolean => { - if (fileDecisions[f.filePath] === 'rejected') return true; + const reviewKey = getFileReviewKey(f); + if (fileDecisions[reviewKey] === 'rejected' || fileDecisions[f.filePath] === 'rejected') { + return true; + } const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts); for (let i = 0; i < count; i++) { - if (hunkDecisions[`${f.filePath}:${i}`] === 'rejected') return true; + if ( + hunkDecisions[buildHunkDecisionKey(reviewKey, i)] === 'rejected' || + hunkDecisions[`${f.filePath}:${i}`] === 'rejected' + ) { + return true; + } } return false; }; diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 98044fb2..b3b1e153 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -4,6 +4,20 @@ import { isTaskSummaryCacheableForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; +import { + getReviewChangeSetIdentityToken, + type ReviewChangeSetLike, +} from '@renderer/utils/reviewDecisionScope'; +import { + buildHunkDecisionKey, + getFileReviewKey, + getReviewKeyForFilePath, + normalizePersistedReviewState, +} from '@renderer/utils/reviewKey'; +import { + resolveTaskChangePresenceFromResult, + shouldBackgroundRevalidateTaskPresence, +} from '@renderer/utils/taskChangePresence'; import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { createLogger } from '@shared/utils/logger'; import { normalizePathForComparison } from '@shared/utils/platformPath'; @@ -18,10 +32,12 @@ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; const TASK_CHANGE_WARM_CONCURRENCY = 4; const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); +let latestAgentChangesRequestToken = 0; let latestTaskChangesRequestToken = 0; +let latestDecisionLoadRequestToken = 0; /** Debounce timer for persisting decisions to disk */ -let persistDebounceTimer: ReturnType | null = null; +const persistDebounceTimers = new Map>(); const PERSIST_DEBOUNCE_MS = 500; import type { AppState } from '../types'; @@ -37,11 +53,23 @@ import type { SnippetDiff, TaskChangeSet, TaskChangeSetV2, + TaskChangePresenceState, } from '@shared/types'; import type { StateCreator } from 'zustand'; const logger = createLogger('changeReviewSlice'); +function reviewPathsEqual(left: string, right: string): boolean { + return normalizePathForComparison(left) === normalizePathForComparison(right); +} + +function findReviewFileByPath( + files: readonly FileChangeSummary[] | null | undefined, + filePath: string +): FileChangeSummary | undefined { + return files?.find((file) => reviewPathsEqual(file.filePath, filePath)); +} + /** Snapshot of review decisions for undo support */ interface DecisionSnapshot { hunkDecisions: Record; @@ -52,8 +80,6 @@ export interface ReviewExternalChange { type: 'change' | 'add' | 'unlink'; } -type ReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; - const MAX_REVIEW_UNDO_DEPTH = 10; /** @@ -70,22 +96,53 @@ function mapReviewError(error: unknown): string { return message || 'Failed to apply review changes'; } -function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { - const computedAtMs = Date.parse(data.computedAt); - if (!Number.isFinite(computedAtMs)) { - return true; - } - return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; +function clearPersistDecisionTimer(scopeStorageKey: string): void { + const timer = persistDebounceTimers.get(scopeStorageKey); + if (!timer) return; + clearTimeout(timer); + persistDebounceTimers.delete(scopeStorageKey); } -function resolveTaskChangePresenceFromResult( - data: Pick -): 'has_changes' | 'no_changes' | null { - if (data.files.length > 0) { - return 'has_changes'; - } +function buildPersistDecisionScopeKey( + teamName: string, + scopeKey: string, + scopeToken?: string +): string { + return scopeToken ? `${teamName}:${scopeKey}:${scopeToken}` : `${teamName}:${scopeKey}`; +} - return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +function clearAllPersistDecisionTimers(): void { + for (const timer of persistDebounceTimers.values()) { + clearTimeout(timer); + } + persistDebounceTimers.clear(); +} + +function applyTaskChangePresenceCacheUpdate( + taskChangePresenceByKey: Record>, + cacheKey: string, + presence: TaskChangePresenceState | null +): Record> { + const nextTaskChangePresenceByKey = { ...taskChangePresenceByKey }; + if (presence && presence !== 'unknown') { + nextTaskChangePresenceByKey[cacheKey] = presence; + } else { + delete nextTaskChangePresenceByKey[cacheKey]; + } + return nextTaskChangePresenceByKey; +} + +function syncTaskChangeNegativeCache( + cacheKey: string, + presence: TaskChangePresenceState | null +): void { + if (presence === 'has_changes' || presence === 'needs_attention') { + taskChangesNegativeCache.delete(cacheKey); + } else if (presence === 'no_changes') { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } else { + taskChangesNegativeCache.delete(cacheKey); + } } export interface ChangeReviewSlice { @@ -118,8 +175,8 @@ export interface ChangeReviewSlice { // Editable diff state editedContents: Record; - /** Cache: "teamName:taskId:signature" → true/false (has file changes) */ - taskHasChanges: Record; + /** Cache: "teamName:taskId:signature" → resolved task change presence */ + taskChangePresenceByKey: Record>; // Phase 1 actions fetchAgentChanges: (teamName: string, memberName: string) => Promise; @@ -128,11 +185,11 @@ export interface ChangeReviewSlice { taskId: string, options: TaskChangeRequestOptions ) => Promise; - recordTaskHasChanges: ( + recordTaskChangePresence: ( teamName: string, taskId: string, options: TaskChangeRequestOptions, - hasChanges: boolean + presence: TaskChangePresenceState | null ) => void; selectReviewFile: (filePath: string | null) => void; clearChangeReview: () => void; @@ -141,9 +198,13 @@ export interface ChangeReviewSlice { fetchChangeStats: (teamName: string, memberName: string) => Promise; // Decision persistence actions - loadDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise; - persistDecisions: (teamName: string, scopeKey: string) => void; - clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise; + loadDecisionsFromDisk: (teamName: string, scopeKey: string, scopeToken: string) => Promise; + persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => void; + clearDecisionsFromDisk: ( + teamName: string, + scopeKey: string, + scopeToken?: string + ) => Promise; // Phase 2 actions /** @@ -218,14 +279,14 @@ export interface ChangeReviewSlice { * This function reverses that shift so decisions are stored with stable indices. */ function mapCurrentToOriginalIndex( - filePath: string, + reviewKey: string, currentIdx: number, hunkDecisions: Record, totalChunks: number ): number { const decided = new Set(); for (let i = 0; i < totalChunks; i++) { - if (`${filePath}:${i}` in hunkDecisions) { + if (buildHunkDecisionKey(reviewKey, i) in hunkDecisions) { decided.add(i); } } @@ -251,11 +312,11 @@ export function getFileHunkCount( } function getMaxDecisionIndexForFile( - filePath: string, + reviewKey: string, hunkDecisions: Record ): number { let max = -1; - const prefix = `${filePath}:`; + const prefix = `${reviewKey}:`; for (const key of Object.keys(hunkDecisions)) { if (!key.startsWith(prefix)) continue; const raw = key.slice(prefix.length); @@ -296,38 +357,6 @@ function buildHunkContextHashesForFile( return out; } -function encodeFingerprintField(value: string): string { - return `${value.length}:${value}`; -} - -function fingerprintSnippet(snippet: SnippetDiff): string { - return [ - encodeFingerprintField(normalizePathForComparison(snippet.filePath)), - encodeFingerprintField(snippet.toolUseId), - encodeFingerprintField(snippet.timestamp), - encodeFingerprintField(snippet.type), - encodeFingerprintField(snippet.oldString), - encodeFingerprintField(snippet.newString), - encodeFingerprintField(snippet.replaceAll ? '1' : '0'), - encodeFingerprintField(snippet.isError ? '1' : '0'), - encodeFingerprintField(snippet.contextHash ?? ''), - ].join('|'); -} - -function fingerprintChangeSet(changeSet: ReviewChangeSet): string { - return [...changeSet.files] - .sort((a, b) => - normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath)) - ) - .map((file) => - [ - encodeFingerprintField(normalizePathForComparison(file.filePath)), - ...file.snippets.map(fingerprintSnippet), - ].join('|') - ) - .join('||'); -} - export const createChangeReviewSlice: StateCreator = ( set, get @@ -353,6 +382,8 @@ export const createChangeReviewSlice: StateCreator ): void => { set((s) => ({ activeChangeSet: data, changeSetLoading: false, selectedReviewFilePath: data.files[0]?.filePath ?? null, + hunkDecisions: {}, + fileDecisions: {}, fileContents: {}, fileContentsLoading: {}, fileChunkCounts: {}, + reviewUndoStack: [], hunkContextHashesByFile: {}, applyError: null, + editedContents: {}, changeSetEpoch: s.changeSetEpoch + 1, fileContentVersionByPath: {}, reviewExternalChangesByFile: {}, @@ -388,7 +423,7 @@ export const createChangeReviewSlice: StateCreator { set((s) => ({ @@ -430,14 +465,16 @@ export const createChangeReviewSlice: StateCreator ({ - taskHasChanges: { ...state.taskHasChanges, [cacheKey]: data.files.length > 0 }, + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + state.taskChangePresenceByKey, + cacheKey, + nextPresence + ), })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + syncTaskChangeNegativeCache(cacheKey, nextPresence); + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown'); } catch { // Best-effort background revalidation; keep optimistic state on transient failure. } finally { @@ -472,35 +509,40 @@ export const createChangeReviewSlice: StateCreator { + const requestToken = ++latestAgentChangesRequestToken; set({ changeSetLoading: true, changeSetError: null }); try { const data = await api.review.getAgentChanges(teamName, memberName); + if (requestToken !== latestAgentChangesRequestToken) return; installActiveChangeSetForLoad(data, { activeTaskChangeRequestOptions: null }); } catch (error) { + if (requestToken !== latestAgentChangesRequestToken) return; const message = error instanceof Error ? error.message : 'Failed to fetch agent changes'; logger.error('fetchAgentChanges error:', message); set({ changeSetError: message, changeSetLoading: false }); } }, - recordTaskHasChanges: ( + recordTaskChangePresence: ( teamName: string, taskId: string, options: TaskChangeRequestOptions, - hasChanges: boolean + presence: TaskChangePresenceState | null ) => { const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges }, - })); - if (hasChanges) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + set((s) => { + return { + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + s.taskChangePresenceByKey, + cacheKey, + presence + ), + }; + }); + syncTaskChangeNegativeCache(cacheKey, presence); }, fetchTaskChanges: async ( @@ -517,16 +559,14 @@ export const createChangeReviewSlice: StateCreator 0 }, + taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( + get().taskChangePresenceByKey, + cacheKey, + nextPresence + ), }); - if (nextPresence) { - get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); - } - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown'); + syncTaskChangeNegativeCache(cacheKey, nextPresence); } catch (error) { if (requestToken !== latestTaskChangesRequestToken) return; const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; @@ -540,7 +580,10 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, @@ -564,13 +607,18 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, changeSetError: null, selectedReviewFilePath: null, activeTaskChangeRequestOptions: null, + hunkDecisions: {}, + fileDecisions: {}, fileChunkCounts: {}, reviewUndoStack: [], hunkContextHashesByFile: {}, @@ -586,7 +634,10 @@ export const createChangeReviewSlice: StateCreator { + latestAgentChangesRequestToken++; latestTaskChangesRequestToken++; + latestDecisionLoadRequestToken++; + clearAllPersistDecisionTimers(); set((s) => ({ activeChangeSet: null, changeSetLoading: false, @@ -611,72 +662,93 @@ export const createChangeReviewSlice: StateCreator { + loadDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken: string) => { + const requestToken = ++latestDecisionLoadRequestToken; try { - const data = await api.review.loadDecisions(teamName, scopeKey); + const data = await api.review.loadDecisions(teamName, scopeKey, scopeToken); + if (requestToken !== latestDecisionLoadRequestToken) return; + const normalized = normalizePersistedReviewState(get().activeChangeSet?.files ?? [], { + hunkDecisions: data?.hunkDecisions, + fileDecisions: data?.fileDecisions, + hunkContextHashesByFile: data?.hunkContextHashesByFile, + }); // Always set decisions — even to empty if no saved file exists. // This prevents stale decisions from a previous scope leaking through. set({ - hunkDecisions: data?.hunkDecisions ?? {}, - fileDecisions: data?.fileDecisions ?? {}, - hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {}, + hunkDecisions: normalized.hunkDecisions, + fileDecisions: normalized.fileDecisions, + hunkContextHashesByFile: normalized.hunkContextHashesByFile, }); } catch (error) { + if (requestToken !== latestDecisionLoadRequestToken) return; logger.error('loadDecisionsFromDisk error:', error); + set({ + hunkDecisions: {}, + fileDecisions: {}, + hunkContextHashesByFile: {}, + }); } }, - persistDecisions: (teamName: string, scopeKey: string) => { - if (persistDebounceTimer) { - clearTimeout(persistDebounceTimer); + persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => { + const scopeStorageKey = buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken); + clearPersistDecisionTimer(scopeStorageKey); + + const { + hunkDecisions, + fileDecisions, + hunkContextHashesByFile, + activeChangeSet, + fileContents, + fileChunkCounts, + } = get(); + + const computed: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + const content = fileContents[fp]; + if (!content) continue; + const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); + const hashes = buildHunkContextHashesForFile( + content.originalFullContent, + content.modifiedFullContent, + expected + ); + if (hashes) computed[fp] = hashes; } - persistDebounceTimer = setTimeout(() => { - const { - hunkDecisions, - fileDecisions, - hunkContextHashesByFile, - activeChangeSet, - fileContents, - fileChunkCounts, - } = get(); - const computed: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - const content = fileContents[fp]; - if (!content) continue; - const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); - const hashes = buildHunkContextHashesForFile( - content.originalFullContent, - content.modifiedFullContent, - expected - ); - if (hashes) computed[fp] = hashes; - } + const mergedHashes: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + const reviewKey = getFileReviewKey(file); + mergedHashes[reviewKey] = + computed[fp] ?? hunkContextHashesByFile[reviewKey] ?? hunkContextHashesByFile[fp] ?? {}; + } + set({ hunkContextHashesByFile: mergedHashes }); - // Prune to only files in the current scope. This avoids persisting stale file paths - // (e.g. from older sessions) that could confuse future replays. - const mergedHashes: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {}; - } - // Keep store in sync so replay can use hashes without reload. - set({ hunkContextHashesByFile: mergedHashes }); + const persistedHunkDecisions = { ...hunkDecisions }; + const persistedFileDecisions = { ...fileDecisions }; + const persistedHashes = { ...mergedHashes }; + const timer = setTimeout(() => { + persistDebounceTimers.delete(scopeStorageKey); void api.review.saveDecisions( teamName, scopeKey, - hunkDecisions, - fileDecisions, - mergedHashes + scopeToken, + persistedHunkDecisions, + persistedFileDecisions, + persistedHashes ); }, PERSIST_DEBOUNCE_MS); + + persistDebounceTimers.set(scopeStorageKey, timer); }, - clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => { + clearDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken?: string) => { + clearPersistDecisionTimer(buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken)); try { - await api.review.clearDecisions(teamName, scopeKey); + await api.review.clearDecisions(teamName, scopeKey, scopeToken); } catch (error) { logger.error('clearDecisionsFromDisk error:', error); } @@ -699,13 +771,14 @@ export const createChangeReviewSlice: StateCreator { const state = get(); const totalChunks = state.fileChunkCounts[filePath] ?? 0; + const reviewKey = getReviewKeyForFilePath(state.activeChangeSet?.files, filePath); // Map current chunk index to original: after accept/reject, chunks shift in CM. // We need the original index to keep decisions stable across shifts. const originalIndex = totalChunks > 0 - ? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks) + ? mapCurrentToOriginalIndex(reviewKey, hunkIndex, state.hunkDecisions, totalChunks) : hunkIndex; - const key = `${filePath}:${originalIndex}`; + const key = buildHunkDecisionKey(reviewKey, originalIndex); set((s) => ({ hunkDecisions: { ...s.hunkDecisions, [key]: decision }, })); @@ -713,7 +786,10 @@ export const createChangeReviewSlice: StateCreator { - const key = `${filePath}:${originalIndex}`; + const key = buildHunkDecisionKey( + getReviewKeyForFilePath(get().activeChangeSet?.files, filePath), + originalIndex + ); set((s) => { if (!(key in s.hunkDecisions)) return s; const next = { ...s.hunkDecisions }; @@ -723,8 +799,9 @@ export const createChangeReviewSlice: StateCreator { + const reviewKey = getReviewKeyForFilePath(get().activeChangeSet?.files, filePath); set((state) => ({ - fileDecisions: { ...state.fileDecisions, [filePath]: decision }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: decision }, })); }, @@ -762,33 +839,35 @@ export const createChangeReviewSlice: StateCreator { const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + const file = findReviewFileByPath(state.activeChangeSet?.files, filePath); if (!file) return; - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; + const reviewKey = getFileReviewKey(file); for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'accepted'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted'; } set({ hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: 'accepted' }, }); }, rejectAllFile: (filePath: string) => { const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + const file = findReviewFileByPath(state.activeChangeSet?.files, filePath); if (!file) return; - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; + const reviewKey = getFileReviewKey(file); for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'rejected'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected'; } set({ hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' }, + fileDecisions: { ...state.fileDecisions, [reviewKey]: 'rejected' }, }); }, @@ -800,10 +879,11 @@ export const createChangeReviewSlice: StateCreator = {}; for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'accepted'; + const reviewKey = getFileReviewKey(file); + newFileDecisions[reviewKey] = 'accepted'; const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'accepted'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted'; } } set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); @@ -817,10 +897,11 @@ export const createChangeReviewSlice: StateCreator = {}; for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'rejected'; + const reviewKey = getFileReviewKey(file); + newFileDecisions[reviewKey] = 'rejected'; const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'rejected'; + newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected'; } } set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); @@ -848,10 +929,11 @@ export const createChangeReviewSlice: StateCreator f.filePath === filePath); + const fileEntry = findReviewFileByPath(activeChangeSet?.files, filePath); + const canonicalFilePath = fileEntry?.filePath ?? filePath; const snippets = fileEntry?.snippets ?? []; - const content = await api.review.getFileContent(teamName, memberName, filePath, snippets); + const content = await api.review.getFileContent(teamName, memberName, canonicalFilePath, snippets); const latest = get(); if (changeSetEpoch !== latest.changeSetEpoch) return; if ((latest.fileContentVersionByPath[filePath] ?? 0) !== fileVersion) return; @@ -868,7 +950,7 @@ export const createChangeReviewSlice: StateCreator - f.filePath === filePath + reviewPathsEqual(f.filePath, canonicalFilePath) ? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved } : f ); @@ -902,15 +984,13 @@ export const createChangeReviewSlice: StateCreator = {}; const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions); + const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions); const count = Math.max(baseCount, maxIdx + 1); for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; + const key = buildHunkDecisionKey(reviewKey, i); hunkDecs[i] = hunkDecisions[key] ?? 'pending'; } @@ -1009,16 +1090,17 @@ export const createChangeReviewSlice: StateCreator f.filePath === filePath); + const file = findReviewFileByPath(activeChangeSet.files, filePath); if (!file) return null; - const fileDecision = fileDecisions[filePath] ?? 'pending'; + const reviewKey = getFileReviewKey(file); + const fileDecision = fileDecisions[reviewKey] ?? 'pending'; const hunkDecs: Record = {}; - const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); + const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions); const count = Math.max(baseCount, maxIdx + 1); for (let i = 0; i < count; i++) { - hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending'; + hunkDecs[i] = hunkDecisions[buildHunkDecisionKey(reviewKey, i)] ?? 'pending'; } const hasRejected = @@ -1026,9 +1108,9 @@ export const createChangeReviewSlice: StateCreator { set((s) => { if (!s.activeChangeSet) return s; - const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath); + const existing = findReviewFileByPath(s.activeChangeSet.files, filePath); if (!existing) return s; - const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath); + const nextFiles = s.activeChangeSet.files.filter( + (f) => !reviewPathsEqual(f.filePath, existing.filePath) + ); const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; + const reviewKey = getReviewKeyForFilePath(s.activeChangeSet.files, filePath); + const prefix = `${reviewKey}:`; for (const key of Object.keys(nextHunkDecisions)) { if (key.startsWith(prefix)) delete nextHunkDecisions[key]; } const nextFileDecisions = { ...s.fileDecisions }; - delete nextFileDecisions[filePath]; + delete nextFileDecisions[reviewKey]; const nextFileChunkCounts = { ...s.fileChunkCounts }; delete nextFileChunkCounts[filePath]; + delete nextFileChunkCounts[existing.filePath]; const nextFileContents = { ...s.fileContents }; delete nextFileContents[filePath]; + delete nextFileContents[existing.filePath]; const nextFileContentsLoading = { ...s.fileContentsLoading }; delete nextFileContentsLoading[filePath]; + delete nextFileContentsLoading[existing.filePath]; const nextEditedContents = { ...s.editedContents }; delete nextEditedContents[filePath]; + delete nextEditedContents[existing.filePath]; const nextHashes = { ...s.hunkContextHashesByFile }; + delete nextHashes[reviewKey]; delete nextHashes[filePath]; const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile }; delete nextReviewExternalChangesByFile[filePath]; + delete nextReviewExternalChangesByFile[existing.filePath]; const nextFileContentVersionByPath = { ...s.fileContentVersionByPath, [filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1, + [existing.filePath]: (s.fileContentVersionByPath[existing.filePath] ?? 0) + 1, }; const nextSelected = - s.selectedReviewFilePath === filePath + s.selectedReviewFilePath && reviewPathsEqual(s.selectedReviewFilePath, existing.filePath) ? (nextFiles[0]?.filePath ?? null) : s.selectedReviewFilePath; @@ -1137,7 +1229,7 @@ export const createChangeReviewSlice: StateCreator { set((s) => { if (!s.activeChangeSet) return s; - if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s; + if (findReviewFileByPath(s.activeChangeSet.files, file.filePath)) return s; const idxRaw = options?.index; const idx = @@ -1186,7 +1278,8 @@ export const createChangeReviewSlice: StateCreator { set((s) => { const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; + const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath); + const prefix = `${reviewKey}:`; for (const key of Object.keys(nextHunkDecisions)) { if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') { delete nextHunkDecisions[key]; @@ -1194,8 +1287,8 @@ export const createChangeReviewSlice: StateCreator 0) { + if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, + taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: nextPresence }, })); taskChangesNegativeCache.delete(cacheKey); - get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); - if (wasRestoredBeforeCurrentSession(data)) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); + if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) { void revalidateTaskChangePresence(teamName, taskId, options); } - } else { + } else if (nextPresence === 'no_changes') { set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false }, + taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: 'no_changes' }, })); taskChangesNegativeCache.set(cacheKey, Date.now()); - if (nextPresence === 'no_changes') { - get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes'); - } else if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') { + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes'); + } else { + set((s) => { + const nextTaskChangePresenceByKey = { ...s.taskChangePresenceByKey }; + delete nextTaskChangePresenceByKey[cacheKey]; + return { taskChangePresenceByKey: nextTaskChangePresenceByKey }; + }); + taskChangesNegativeCache.delete(cacheKey); + if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') { get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown'); } } @@ -1394,7 +1499,12 @@ export const createChangeReviewSlice: StateCreator => { - if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) { + const cachedPresence = get().taskChangePresenceByKey[cacheKey]; + if ( + cachedPresence === 'has_changes' || + cachedPresence === 'needs_attention' || + taskChangesCheckInFlight.has(cacheKey) + ) { return; } @@ -1404,16 +1514,24 @@ export const createChangeReviewSlice: StateCreator ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { + const nextPresence = resolveTaskChangePresenceFromResult(data); + if (nextPresence) { + set((s) => ({ + taskChangePresenceByKey: { + ...s.taskChangePresenceByKey, + [cacheKey]: nextPresence, + }, + })); + } + if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { taskChangesNegativeCache.delete(cacheKey); - if (wasRestoredBeforeCurrentSession(data)) { + if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) { void revalidateTaskChangePresence(request.teamName, request.taskId, request.options); } - } else { + } else if (nextPresence === 'no_changes') { taskChangesNegativeCache.set(cacheKey, Date.now()); + } else { + taskChangesNegativeCache.delete(cacheKey); } } catch { // Best-effort warm path. @@ -1435,16 +1553,16 @@ export const createChangeReviewSlice: StateCreator { - const nextTaskHasChanges = { ...state.taskHasChanges }; + const nextTaskChangePresenceByKey = { ...state.taskChangePresenceByKey }; let changed = false; for (const key of keySet) { - if (key in nextTaskHasChanges) { - delete nextTaskHasChanges[key]; + if (key in nextTaskChangePresenceByKey) { + delete nextTaskChangePresenceByKey[key]; changed = true; } taskChangesNegativeCache.delete(key); } - return changed ? { taskHasChanges: nextTaskHasChanges } : {}; + return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {}; }); }, diff --git a/src/renderer/utils/reviewDecisionScope.ts b/src/renderer/utils/reviewDecisionScope.ts new file mode 100644 index 00000000..99b251eb --- /dev/null +++ b/src/renderer/utils/reviewDecisionScope.ts @@ -0,0 +1,72 @@ +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +import type { AgentChangeSet, SnippetDiff, TaskChangeSet, TaskChangeSetV2 } from '@shared/types'; + +export type ReviewChangeSetLike = AgentChangeSet | TaskChangeSet | TaskChangeSetV2; + +function encodeFingerprintField(value: string): string { + return `${value.length}:${value}`; +} + +function fingerprintSnippet(snippet: SnippetDiff): string { + return [ + encodeFingerprintField(normalizePathForComparison(snippet.filePath)), + encodeFingerprintField(snippet.toolUseId), + encodeFingerprintField(snippet.timestamp), + encodeFingerprintField(snippet.type), + encodeFingerprintField(snippet.oldString), + encodeFingerprintField(snippet.newString), + encodeFingerprintField(snippet.replaceAll ? '1' : '0'), + encodeFingerprintField(snippet.isError ? '1' : '0'), + encodeFingerprintField(snippet.contextHash ?? ''), + ].join('|'); +} + +export function fingerprintReviewChangeSet(changeSet: ReviewChangeSetLike): string { + return [...changeSet.files] + .sort((a, b) => + normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath)) + ) + .map((file) => + [ + encodeFingerprintField(normalizePathForComparison(file.filePath)), + ...(file.changeKey ? [encodeFingerprintField(file.changeKey)] : []), + ...file.snippets.map(fingerprintSnippet), + ].join('|') + ) + .join('||'); +} + +export function getReviewChangeSetIdentityToken( + changeSet: ReviewChangeSetLike | null | undefined +): string | null { + if (!changeSet) { + return null; + } + + const provenance = 'provenance' in changeSet ? changeSet.provenance : undefined; + if (provenance?.sourceFingerprint) { + return `provenance:${provenance.sourceKind}:${provenance.sourceFingerprint}`; + } + + return `content:${fingerprintReviewChangeSet(changeSet)}`; +} + +export function buildReviewDecisionScopeToken(params: { + mode: 'agent' | 'task'; + taskId?: string; + memberName?: string; + requestSignature?: string | null; + changeSet: ReviewChangeSetLike | null | undefined; +}): string | null { + const identity = getReviewChangeSetIdentityToken(params.changeSet); + if (!identity) { + return null; + } + + if (params.mode === 'task') { + return `task:${params.taskId ?? ''}:${params.requestSignature ?? ''}:${identity}`; + } + + return `agent:${params.memberName ?? ''}:${identity}`; +} diff --git a/src/renderer/utils/reviewKey.ts b/src/renderer/utils/reviewKey.ts new file mode 100644 index 00000000..62363273 --- /dev/null +++ b/src/renderer/utils/reviewKey.ts @@ -0,0 +1,104 @@ +import type { FileChangeSummary, HunkDecision } from '@shared/types'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; + +function normalizeReviewAlias(alias: string): string { + const slashNormalized = alias.replace(/\\/g, '/'); + const relationMatch = /^(rename|copy):(.+)->(.+)$/.exec(slashNormalized); + if (relationMatch) { + return `${relationMatch[1]}:${normalizePathForComparison(relationMatch[2] ?? '')}->${normalizePathForComparison(relationMatch[3] ?? '')}`; + } + const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized); + if (pathKeyMatch) { + return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`; + } + return normalizePathForComparison(alias); +} + +export function getFileReviewKey( + file: Pick +): string { + return file.changeKey ?? file.filePath; +} + +export function getReviewKeyForFilePath( + files: readonly Pick[] | null | undefined, + filePath: string +): string { + const normalizedFilePath = normalizePathForComparison(filePath); + const file = files?.find( + (candidate) => normalizePathForComparison(candidate.filePath) === normalizedFilePath + ); + return file ? getFileReviewKey(file) : filePath; +} + +export function buildHunkDecisionKey(reviewKey: string, index: number): string { + return `${reviewKey}:${index}`; +} + +export function parseHunkDecisionKey(key: string): { reviewKey: string; index: number } | null { + const match = /^(.*):(\d+)$/.exec(key); + if (!match) { + return null; + } + return { + reviewKey: match[1] ?? '', + index: Number.parseInt(match[2] ?? '', 10), + }; +} + +export function normalizePersistedReviewState( + files: readonly Pick[], + state: { + fileDecisions?: Record; + hunkDecisions?: Record; + hunkContextHashesByFile?: Record>; + } +): { + fileDecisions: Record; + hunkDecisions: Record; + hunkContextHashesByFile: Record>; +} { + const reviewKeyByAlias = new Map(); + const addAlias = (alias: string, reviewKey: string): void => { + reviewKeyByAlias.set(alias, reviewKey); + reviewKeyByAlias.set(normalizeReviewAlias(alias), reviewKey); + }; + const resolveReviewKey = (alias: string): string | undefined => { + return reviewKeyByAlias.get(alias) ?? reviewKeyByAlias.get(normalizeReviewAlias(alias)); + }; + for (const file of files) { + const reviewKey = getFileReviewKey(file); + addAlias(reviewKey, reviewKey); + addAlias(file.filePath, reviewKey); + } + + const fileDecisions: Record = {}; + for (const [key, decision] of Object.entries(state.fileDecisions ?? {})) { + const reviewKey = resolveReviewKey(key); + if (reviewKey) { + fileDecisions[reviewKey] = decision; + } + } + + const hunkDecisions: Record = {}; + for (const [key, decision] of Object.entries(state.hunkDecisions ?? {})) { + const parsed = parseHunkDecisionKey(key); + if (!parsed) { + continue; + } + const reviewKey = resolveReviewKey(parsed.reviewKey); + if (reviewKey) { + hunkDecisions[buildHunkDecisionKey(reviewKey, parsed.index)] = decision; + } + } + + const hunkContextHashesByFile: Record> = {}; + for (const [key, hashes] of Object.entries(state.hunkContextHashesByFile ?? {})) { + const reviewKey = resolveReviewKey(key); + if (reviewKey) { + hunkContextHashesByFile[reviewKey] = hashes; + } + } + + return { fileDecisions, hunkDecisions, hunkContextHashesByFile }; +} diff --git a/src/renderer/utils/taskChangePresence.ts b/src/renderer/utils/taskChangePresence.ts new file mode 100644 index 00000000..5cecee7f --- /dev/null +++ b/src/renderer/utils/taskChangePresence.ts @@ -0,0 +1,19 @@ +export { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; + +import type { TaskChangeSetV2 } from '@shared/types'; + +export function shouldBackgroundRevalidateTaskPresence( + data: TaskChangeSetV2, + sessionStartedAtMs: number +): boolean { + if (data.provenance?.sourceKind === 'ledger' && !!data.provenance.sourceFingerprint) { + return false; + } + + const computedAtMs = Date.parse(data.computedAt); + if (!Number.isFinite(computedAtMs)) { + return true; + } + + return computedAtMs < sessionStartedAtMs; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 30ff59bc..01bf5cb4 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -700,7 +700,8 @@ export interface ReviewAPI { // Decision persistence loadDecisions: ( teamName: string, - scopeKey: string + scopeKey: string, + scopeToken?: string ) => Promise<{ hunkDecisions: Record; fileDecisions: Record; @@ -713,11 +714,12 @@ export interface ReviewAPI { saveDecisions: ( teamName: string, scopeKey: string, + scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile?: Record> ) => Promise; - clearDecisions: (teamName: string, scopeKey: string) => Promise; + clearDecisions: (teamName: string, scopeKey: string, scopeToken?: string) => Promise; onCmdN?: (callback: () => void) => (() => void) | undefined; // Phase 4 getGitFileLog: ( diff --git a/src/shared/types/review.ts b/src/shared/types/review.ts index fe7e68e0..abbaac45 100644 --- a/src/shared/types/review.ts +++ b/src/shared/types/review.ts @@ -3,6 +3,9 @@ export interface LedgerContentState { exists?: boolean; sha256?: string; sizeBytes?: number; + contentKind?: 'text' | 'binary' | 'unknown'; + blobRef?: string; + unavailableCode?: 'binary' | 'too-large' | 'read-error' | 'not-captured' | 'blob-missing'; unavailableReason?: string; } @@ -45,9 +48,31 @@ export interface SnippetDiff { afterState?: LedgerContentState; relation?: LedgerChangeRelation; executionSeq?: number; + linesAdded?: number; + linesRemoved?: number; + textAvailability?: 'patch-text' | 'full-text' | 'unavailable'; }; } +export interface TaskChangeJournalFileStamp { + bytes: number; + mtimeMs: number; + tailSha256: string | null; +} + +export interface TaskChangeJournalStamp { + events?: TaskChangeJournalFileStamp; + notices?: TaskChangeJournalFileStamp; +} + +export interface TaskChangeProvenance { + sourceKind: 'ledger' | 'legacy'; + sourceFingerprint: string; + journalStamp?: TaskChangeJournalStamp; + bundleSchemaVersion?: number; + integrity?: 'ok' | 'recovered' | 'partial'; +} + /** Агрегированные изменения по файлу */ export interface FileChangeSummary { filePath: string; @@ -56,6 +81,22 @@ export interface FileChangeSummary { linesAdded: number; linesRemoved: number; isNewFile: boolean; + changeKey?: string; + diffStatKnown?: boolean; + ledgerSummary?: { + latestOperation?: 'create' | 'modify' | 'delete'; + createdInTask?: boolean; + deletedInTask?: boolean; + contentAvailability?: 'full-text' | 'hash-only' | 'metadata-only'; + reviewability?: 'full-text' | 'partial-text' | 'metadata-only'; + relation?: LedgerChangeRelation; + beforeState?: LedgerContentState; + afterState?: LedgerContentState; + primaryActorKey?: string; + agentIds?: string[]; + memberNames?: string[]; + executionSeqRange?: { start: number; end: number }; + }; /** Edit timeline for this file (Phase 4) */ timeline?: FileEditTimeline; } @@ -192,6 +233,34 @@ export interface TaskChangeScope { toolUseIds: string[]; filePaths: string[]; confidence: TaskScopeConfidence; + primaryActorKey?: string; + primaryAgentId?: string; + primaryMemberName?: string; + agentIds?: string[]; + memberNames?: string[]; + toolUseCount?: number; + toolUseIdsTruncated?: boolean; + phaseSet?: Array<'work' | 'review'>; + executionSeqRange?: { start: number; end: number }; + confidenceBreakdown?: { + capture: 'exact' | 'high' | 'medium' | 'low'; + attribution: 'high' | 'medium' | 'low' | 'ambiguous'; + reviewability: 'full-text' | 'mixed' | 'metadata-only'; + }; + contributors?: Array<{ + actorKey: string; + agentId?: string; + memberName?: string; + eventCount: number; + noticeCount: number; + touchedFileCount: number; + visibleFileCount: number; + toolUseCount: number; + cumulativeLinesAdded: number; + cumulativeLinesRemoved: number; + firstTimestamp: string; + lastTimestamp: string; + }>; } /** Результат парсинга всех границ задач из JSONL файла */ @@ -206,6 +275,8 @@ export interface TaskBoundariesResult { export interface TaskChangeSetV2 extends TaskChangeSet { scope: TaskChangeScope; warnings: string[]; + diffStatCompleteness?: 'complete' | 'partial'; + provenance?: TaskChangeProvenance; } // ── Phase 4: Enhanced Features types ── diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9eb2ee92..bbbe2ac1 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -450,7 +450,11 @@ export interface TeamTask { } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ -export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown'; +export type TaskChangePresenceState = + | 'has_changes' + | 'needs_attention' + | 'no_changes' + | 'unknown'; export interface TeamTaskWithKanban extends TeamTask { /** Set when task is in team kanban (review or approved column). */ diff --git a/src/shared/utils/taskChangePresence.ts b/src/shared/utils/taskChangePresence.ts new file mode 100644 index 00000000..e5fd79f0 --- /dev/null +++ b/src/shared/utils/taskChangePresence.ts @@ -0,0 +1,15 @@ +import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types'; + +export function resolveTaskChangePresenceFromResult( + data: Pick +): Exclude | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + if ((data.warnings?.length ?? 0) > 0) { + return 'needs_attention'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} diff --git a/test/fixtures/team/task-change-ledger/binary/manifest.json b/test/fixtures/team/task-change-ledger/binary/manifest.json new file mode 100644 index 00000000..acee5960 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "binary", + "taskId": "fixture-binary", + "description": "Metadata-only binary fixture generated from a shell snapshot mutation.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [ + "Before content unavailable for fixtures/blob.bin: binary file." + ], + "relativePaths": [ + "fixtures/blob.bin" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json b/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json new file mode 100644 index 00000000..26ff43d9 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-change-freshness/fixture-binary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-binary","updatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d new file mode 100644 index 00000000..5bd8bb89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/blobs/sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json new file mode 100644 index 00000000..075b5ded --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/bundles/fixture-binary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-binary","generatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.852Z","endTimestamp":"2026-04-21T13:29:00.852Z","toolUseIds":["tool-binary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"mixed"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/fixtures/blob.bin","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","linesAdded":0,"linesRemoved":0,"diffStatKnown":false,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","latestBeforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"latestAfterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"contentAvailability":"hash-only","reviewability":"partial-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1},"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"partial","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl new file mode 100644 index 00000000..63d0164f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/.board-task-changes/events/fixture-binary.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-binary","taskRef":"fixture-binary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-jtt7uons2f7","agentId":"alice@test","memberName":"alice","toolUseId":"tool-binary","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","timestamp":"2026-04-21T13:29:00.852Z","toolStatus":"succeeded","before":null,"after":{"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4,"blobRef":"sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d"},"beforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"afterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"linesAdded":1,"linesRemoved":0,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."],"eventId":"6395549e93913abdd0c26120b21a911902c2fa85f5aa84b50f91ca6a2949444a"} diff --git a/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin b/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin new file mode 100644 index 00000000..5bd8bb89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/manifest.json b/test/fixtures/team/task-change-ledger/copy/manifest.json new file mode 100644 index 00000000..d2ac02fb --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "copy", + "taskId": "fixture-copy", + "description": "Copy relation fixture generated from a committed copy detected via git diff.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/copy.ts" + ], + "relationKinds": [ + "copy" + ] + } +} diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json b/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json new file mode 100644 index 00000000..b88f2ffa --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-change-freshness/fixture-copy.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-copy","updatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/blobs/sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832 @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json new file mode 100644 index 00000000..d1c10e7c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/bundles/fixture-copy.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-copy","generatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.535Z","endTimestamp":"2026-04-21T13:29:00.535Z","toolUseIds":["tool-copy"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z"}]},"files":[{"changeKey":"copy:src/base.ts->src/copy.ts","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","displayPath":"src/copy.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl new file mode 100644 index 00000000..6797688d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/.board-task-changes/events/fixture-copy.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-copy","taskRef":"fixture-copy","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-o2uv73v5zw","agentId":"alice@test","memberName":"alice","toolUseId":"tool-copy","source":"shell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","timestamp":"2026-04-21T13:29:00.535Z","toolStatus":"succeeded","before":null,"after":{"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28,"blobRef":"sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"20f28b7e49e9019165c3e3b57d0104b6b2107447f44df8ba88c3033d6520cd93"} diff --git a/test/fixtures/team/task-change-ledger/copy/project/src/base.ts b/test/fixtures/team/task-change-ledger/copy/project/src/base.ts new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/src/base.ts @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts b/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts new file mode 100644 index 00000000..109b4abc --- /dev/null +++ b/test/fixtures/team/task-change-ledger/copy/project/src/copy.ts @@ -0,0 +1 @@ +export const copied = true; diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json b/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json new file mode 100644 index 00000000..f7e9e212 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "generation-mismatch", + "taskId": "fixture-generation-mismatch", + "description": "Fixture with intentionally mismatched freshness metadata to force journal fallback.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/mismatch.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json new file mode 100644 index 00000000..102846de --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-change-freshness/fixture-generation-mismatch.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 2, + "source": "task-change-ledger", + "taskId": "fixture-generation-mismatch", + "updatedAt": "2026-04-21T13:29:01.353Z", + "journalStamp": { + "events": { + "bytes": 1123, + "mtimeMs": 1776778141346.9556, + "tailSha256": "mismatched-tail-sha256" + } + }, + "eventCount": 1, + "noticeCount": 0, + "integrity": "ok", + "bundleSchemaVersion": 2, + "bundleKind": "summary" +} diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 new file mode 100644 index 00000000..e729cf1d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/blobs/sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797 @@ -0,0 +1 @@ +export const mismatch = 1; diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json new file mode 100644 index 00000000..9b656a4c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/bundles/fixture-generation-mismatch.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-generation-mismatch","generatedAt":"2026-04-21T13:29:01.353Z","journalStamp":{"events":{"bytes":1123,"mtimeMs":1776778141346.9556,"tailSha256":"80e3ed6cc6bfad47ed0862a64b2aeeb13d78959cc727bbcccc2f0b8d77c5b0d3"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.341Z","endTimestamp":"2026-04-21T13:29:01.341Z","toolUseIds":["tool-generation-mismatch"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/mismatch.ts","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl new file mode 100644 index 00000000..76edf49f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/generation-mismatch/project/.board-task-changes/events/fixture-generation-mismatch.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-generation-mismatch","taskRef":"fixture-generation-mismatch","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-blrpcg3sdoq","agentId":"alice@test","memberName":"alice","toolUseId":"tool-generation-mismatch","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","timestamp":"2026-04-21T13:29:01.341Z","toolStatus":"succeeded","before":null,"after":{"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27,"blobRef":"sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"linesAdded":1,"linesRemoved":0,"eventId":"7bd0e6e9290680d76da0498ff931f64285a2f3acfc293ccf0f5756693943c897"} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/manifest.json b/test/fixtures/team/task-change-ledger/missing-blob/manifest.json new file mode 100644 index 00000000..60c8b2b1 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "missing-blob", + "taskId": "fixture-missing-blob", + "description": "Fixture with a missing pre-change text blob to validate metadata-only downgrade paths.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/missing.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json new file mode 100644 index 00000000..1ac27afb --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-change-freshness/fixture-missing-blob.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-missing-blob","updatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 new file mode 100644 index 00000000..a8edd625 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/blobs/sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74 @@ -0,0 +1 @@ +export const missing = 2; diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json new file mode 100644 index 00000000..80361842 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/bundles/fixture-missing-blob.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-missing-blob","generatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.162Z","endTimestamp":"2026-04-21T13:29:01.162Z","toolUseIds":["tool-missing-blob"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/src/missing.ts","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","latestAfterHash":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","latestBeforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"latestAfterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl new file mode 100644 index 00000000..6df88a29 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/.board-task-changes/events/fixture-missing-blob.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-missing-blob","taskRef":"fixture-missing-blob","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-koi1xm1bwwb","agentId":"alice@test","memberName":"alice","toolUseId":"tool-missing-blob","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","timestamp":"2026-04-21T13:29:01.162Z","toolStatus":"succeeded","before":{"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26,"blobRef":"sha256/65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76"},"after":{"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26,"blobRef":"sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74"},"beforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"afterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"linesAdded":1,"linesRemoved":1,"eventId":"010b63907849617acbf3138427d82382e19dcdfd1cfe03f711d27a88ed6ccbab"} diff --git a/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts b/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts new file mode 100644 index 00000000..a8edd625 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts @@ -0,0 +1 @@ +export const missing = 2; diff --git a/test/fixtures/team/task-change-ledger/notices-only/manifest.json b/test/fixtures/team/task-change-ledger/notices-only/manifest.json new file mode 100644 index 00000000..8df7e37b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "notices-only", + "taskId": "fixture-notices-only", + "description": "Warning-only ledger fixture with ambiguous multi-scope attribution and no file events.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 0, + "warnings": [ + "Task change ledger skipped attribution because multiple task scopes were active." + ], + "relativePaths": [], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json new file mode 100644 index 00000000..e59ec5b9 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only-other.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only-other","updatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json new file mode 100644 index 00000000..e1a2a7f5 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-change-freshness/fixture-notices-only.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only","updatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json new file mode 100644 index 00000000..ac0c0d4b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only-other.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only-other","generatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json new file mode 100644 index 00000000..e75f2418 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/bundles/fixture-notices-only.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only","generatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl new file mode 100644 index 00000000..6cbdfe9c --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only-other.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-notices-only-other","taskRef":"fixture-notices-only-other","taskRefKind":"canonical","phase":"review","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"56b59b7d7ade431d01e079f65a23ebe31fa0e8a2472976edf7a85629a2640526"} diff --git a/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl new file mode 100644 index 00000000..693506b1 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/notices-only/project/.board-task-changes/notices/fixture-notices-only.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-notices-only","taskRef":"fixture-notices-only","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"8c1d4d4642d7a26f4e7d9959d6908c2d6fe14d1ad49df10dccc7f3b4ebdf95e5"} diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json b/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json new file mode 100644 index 00000000..259833a4 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "recovered-journal", + "taskId": "fixture-recovered-journal", + "description": "Recovered journal fixture generated after replaying malformed journal lines.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [ + "Task change ledger recovered from malformed journal lines." + ], + "relativePaths": [ + "src/ok.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json new file mode 100644 index 00000000..1bc15d60 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-change-freshness/fixture-recovered-journal.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-recovered-journal","updatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"eventCount":1,"noticeCount":0,"integrity":"recovered","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 new file mode 100644 index 00000000..3d6576ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120 @@ -0,0 +1 @@ +export const ok = true; diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 new file mode 100644 index 00000000..a646fb9d --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/blobs/sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5 @@ -0,0 +1 @@ +export const recovered = true; diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json new file mode 100644 index 00000000..e2daceda --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/bundles/fixture-recovered-journal.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-recovered-journal","generatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"integrity":"recovered","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger with recovery from malformed journal lines"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.263Z","endTimestamp":"2026-04-21T13:29:01.263Z","toolUseIds":["tool-recovered-initial"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/ok.ts","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Task change ledger recovered from malformed journal lines."]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl new file mode 100644 index 00000000..8e8f5706 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/recovered-journal/project/.board-task-changes/events/fixture-recovered-journal.jsonl @@ -0,0 +1,3 @@ +{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-initial","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","timestamp":"2026-04-21T13:29:01.263Z","toolStatus":"succeeded","before":null,"after":{"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24,"blobRef":"sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"linesAdded":1,"linesRemoved":0,"eventId":"e7b3eca1462d1e2496e50ce01273857230234ae89fbd4177b58ed7dbe7b8c3a5"} + +{"bad-json"{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-trigger","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/recovered.ts","relativePath":"src/recovered.ts","timestamp":"2026-04-21T13:29:01.273Z","toolStatus":"succeeded","before":null,"after":{"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31,"blobRef":"sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31},"linesAdded":1,"linesRemoved":0,"eventId":"e0410d756f74335ccfa9601554096d676c6f72f88e31cf038e2f73e046633cc6"} diff --git a/test/fixtures/team/task-change-ledger/rename/manifest.json b/test/fixtures/team/task-change-ledger/rename/manifest.json new file mode 100644 index 00000000..87275990 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "rename", + "taskId": "fixture-rename", + "description": "Rename relation fixture generated from a committed rename shell snapshot.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/new.ts" + ], + "relationKinds": [ + "rename" + ] + } +} diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json b/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json new file mode 100644 index 00000000..3a91f1af --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-change-freshness/fixture-rename.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-rename","updatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 new file mode 100644 index 00000000..4009f0ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/blobs/sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9 @@ -0,0 +1 @@ +export const renamed = true; diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json new file mode 100644 index 00000000..4a7317ae --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/bundles/fixture-rename.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-rename","generatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"integrity":"ok","eventCount":2,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.967Z","endTimestamp":"2026-04-21T13:28:59.967Z","toolUseIds":["tool-rename"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":2,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":2,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z"}]},"files":[{"changeKey":"rename:src/old.ts->src/new.ts","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","displayPath":"src/new.ts","linesAdded":0,"linesRemoved":0,"diffStatKnown":true,"eventCount":2,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z","latestOperation":"delete","createdInTask":false,"deletedInTask":false,"baselineExists":false,"finalExists":false,"latestBeforeHash":null,"latestAfterHash":null,"latestBeforeState":{"exists":false},"latestAfterState":{"exists":false},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl new file mode 100644 index 00000000..96c43826 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/.board-task-changes/events/fixture-rename.jsonl @@ -0,0 +1,2 @@ +{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":null,"after":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"83c57985d61c25bc69b72df159a8f89688dd05731c93f6df854310ab8fffa171"} +{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"delete","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/old.ts","relativePath":"src/old.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"after":null,"beforeState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"afterState":{"exists":false},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":0,"linesRemoved":1,"eventId":"417d115a2d185ba5c288253beb73d4697f59021e5bb8c5f6553b2c68c4d7c6e7"} diff --git a/test/fixtures/team/task-change-ledger/rename/project/src/new.ts b/test/fixtures/team/task-change-ledger/rename/project/src/new.ts new file mode 100644 index 00000000..4009f0ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/rename/project/src/new.ts @@ -0,0 +1 @@ +export const renamed = true; diff --git a/test/fixtures/team/task-change-ledger/v2-summary/manifest.json b/test/fixtures/team/task-change-ledger/v2-summary/manifest.json new file mode 100644 index 00000000..f3f9366b --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "v2-summary", + "taskId": "fixture-v2-summary", + "description": "Simple bundle v2 summary fixture generated from an exact file write.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/summary.ts" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json new file mode 100644 index 00000000..10ab20b5 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-change-freshness/fixture-v2-summary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-v2-summary","updatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf new file mode 100644 index 00000000..347ce2ea --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/blobs/sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf @@ -0,0 +1 @@ +export const summary = 1; diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json new file mode 100644 index 00000000..f1ada803 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/bundles/fixture-v2-summary.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-v2-summary","generatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.479Z","endTimestamp":"2026-04-21T13:28:59.479Z","toolUseIds":["tool-summary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/summary.ts","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} \ No newline at end of file diff --git a/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl new file mode 100644 index 00000000..c65e978f --- /dev/null +++ b/test/fixtures/team/task-change-ledger/v2-summary/project/.board-task-changes/events/fixture-v2-summary.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"taskId":"fixture-v2-summary","taskRef":"fixture-v2-summary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-yas25omycm8","agentId":"alice@test","memberName":"alice","toolUseId":"tool-summary","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","timestamp":"2026-04-21T13:28:59.479Z","toolStatus":"succeeded","before":null,"after":{"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26,"blobRef":"sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"linesAdded":1,"linesRemoved":0,"eventId":"2192b24e939f42bd7936f4fbe620d66c76c88208cdf64ef3c5a3f501939f3839"} diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index f7045c35..31a8ffe5 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -732,6 +732,52 @@ describe('ChangeExtractorService', () => { ); }); + it('writes needs_attention presence entries for warning-only task diff results', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => + makeTaskChangeResult(TASK_ID, { + content: '', + confidence: 'fallback', + warning: 'Ledger skipped attribution because multiple task scopes were active.', + }) + ), + }; + const { service } = createService({ + logPaths: [], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(0); + expect(result.warnings).toEqual([ + 'Ledger skipped attribution because multiple task scopes were active.', + ]); + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + }), + expect.objectContaining({ + taskId: TASK_ID, + presence: 'needs_attention', + }) + ); + }); + it('does not write no_changes presence entries for uncertain empty task diff results', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/FileContentResolver.test.ts b/test/main/services/team/FileContentResolver.test.ts index 78d2d580..57890697 100644 --- a/test/main/services/team/FileContentResolver.test.ts +++ b/test/main/services/team/FileContentResolver.test.ts @@ -126,6 +126,45 @@ describe('FileContentResolver', () => { expect(content.contentSource).toBe('ledger-snapshot'); }); + it('does not synthesize empty text for metadata-only ledger lifecycle states', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + readFile.mockRejectedValue(new Error('ENOENT')); + + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn().mockResolvedValue([]) } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/binary-create.bin', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/binary-create.bin', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: null, + beforeHash: null, + afterHash: 'hash', + operation: 'create', + beforeState: { exists: false, unavailableReason: 'binary file' }, + afterState: { exists: true, sha256: 'hash', unavailableReason: 'binary file' }, + }, + }, + ]); + + expect(content.originalFullContent).toBeNull(); + expect(content.modifiedFullContent).toBeNull(); + expect(content.contentSource).toBe('unavailable'); + }); + it('reuses cached content only when disk bytes and snippets are unchanged', async () => { const fsPromises = await import('fs/promises'); const readFile = fsPromises.readFile as unknown as ReturnType; diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index c94fae64..1b41d5f4 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -581,6 +581,77 @@ describe('ReviewApplierService', () => { expect(writeFile).not.toHaveBeenCalled(); expect(unlink).not.toHaveBeenCalled(); }); + + it('ledger exact partial reject stays in the strict ledger lane and applies inverse hunk patch', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + const filePath = '/tmp/exact-ledger.ts'; + const original = 'const value = 1;\nconst keep = true;\n'; + const modified = 'const value = 2;\nconst keep = true;\n'; + readFile.mockResolvedValue(modified); + writeFile.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'pending', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'exact-ledger.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Edit', + type: 'edit', + oldString: 'const value = 1;\n', + newString: 'const value = 2;\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-exact', + confidence: 'exact', + originalFullContent: original, + modifiedFullContent: modified, + beforeHash: sha(original), + afterHash: sha(modified), + operation: 'modify', + beforeState: { exists: true, sha256: sha(original) }, + afterState: { exists: true, sha256: sha(modified) }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: original, + modifiedFullContent: modified, + contentSource: 'ledger-exact', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8'); + }); }); function sha(content: string): string { diff --git a/test/main/services/team/ReviewDecisionStore.test.ts b/test/main/services/team/ReviewDecisionStore.test.ts new file mode 100644 index 00000000..4a21e50e --- /dev/null +++ b/test/main/services/team/ReviewDecisionStore.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let teamsBasePath: string; + +vi.mock('@main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => teamsBasePath, +})); + +describe('ReviewDecisionStore', () => { + beforeEach(async () => { + teamsBasePath = await mkdtemp(path.join(tmpdir(), 'review-decision-store-')); + }); + + afterEach(async () => { + await rm(teamsBasePath, { recursive: true, force: true }); + }); + + it('stores exact-scope decision variants without last-write-wins overwrite', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + }); + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:b:src:two', + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + }); + + await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toEqual({ + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + hunkContextHashesByFile: undefined, + }); + await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({ + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('clears only the exact v2 scope file and leaves sibling variants intact', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + }); + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:b:src:two', + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + }); + + await store.clear('demo', 'task-123', 'task:123:req:a:src:one'); + + await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toBeNull(); + await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({ + hunkDecisions: { 'file-b:0': 'accepted' }, + fileDecisions: { 'file-b': 'accepted' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('still dual-reads legacy coarse files for matching scope tokens', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + const legacyDir = path.join(teamsBasePath, 'demo', 'review-decisions'); + await mkdir(legacyDir, { recursive: true }); + await writeFile( + path.join(legacyDir, 'task-123.json'), + JSON.stringify({ + scopeToken: 'task:123:req:legacy:src:one', + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + updatedAt: '2026-04-21T10:00:00.000Z', + }), + 'utf8' + ); + + await expect( + store.load('demo', 'task-123', 'task:123:req:legacy:src:one') + ).resolves.toEqual({ + hunkDecisions: { 'file-a:0': 'rejected' }, + fileDecisions: { 'file-a': 'rejected' }, + hunkContextHashesByFile: undefined, + }); + }); + + it('writes versioned v2 payloads under the scoped directory', async () => { + const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore'); + const store = new ReviewDecisionStore(); + + await store.save('demo', 'task-123', { + scopeToken: 'task:123:req:a:src:one', + hunkDecisions: {}, + fileDecisions: {}, + }); + + const scopeDir = path.join( + teamsBasePath, + 'demo', + 'review-decisions', + 'v2', + encodeURIComponent('task-123') + ); + const entries = await fsEntries(scopeDir); + expect(entries).toHaveLength(1); + + const payload = JSON.parse(await readFile(path.join(scopeDir, entries[0]!), 'utf8')) as { + version?: number; + scopeKey?: string; + scopeToken?: string; + }; + expect(payload.version).toBe(2); + expect(payload.scopeKey).toBe('task-123'); + expect(payload.scopeToken).toBe('task:123:req:a:src:one'); + }); +}); + +async function fsEntries(dirPath: string): Promise { + try { + return await (await import('fs/promises')).readdir(dirPath); + } catch { + return []; + } +} diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 5879b345..4f5f4cb9 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -8,6 +9,10 @@ import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerRead const TASK_ID = 'task-1'; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + describe('TaskChangeLedgerReader', () => { let tmpDir: string | null = null; @@ -56,6 +61,41 @@ describe('TaskChangeLedgerReader', () => { expect(result?.scope.toolUseIds).toEqual(['tool-1']); }); + it('reads ledger artifacts stored under Windows-safe task id segments', async () => { + tmpDir = await fsTempDir(); + const taskId = 'CON'; + 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: ['reserved segment safe path'], + events: [], + }), + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId, + projectDir: tmpDir, + includeDetails: true, + }); + + expect(result?.warnings).toContain('reserved segment safe path'); + }); + it('maps ledger state and rename relation into snippets', async () => { tmpDir = await makeLedgerBundle({ events: [ @@ -178,6 +218,132 @@ describe('TaskChangeLedgerReader', () => { expect(result?.files[0]?.linesAdded).toBe(3); expect(result?.files[0]?.linesRemoved).toBe(2); }); + + it('falls back to journal summary when bundle and freshness describe different generations', async () => { + tmpDir = await fsTempDir(); + const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); + const eventsDir = path.join(tmpDir, '.board-task-changes', 'events'); + const freshnessDir = path.join(tmpDir, '.board-task-change-freshness'); + await mkdir(bundleDir, { recursive: true }); + await mkdir(eventsDir, { recursive: true }); + await mkdir(freshnessDir, { recursive: true }); + + 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: 'bundle' } }, + integrity: 'ok', + eventCount: 1, + noticeCount: 0, + scope: { + confidence: { tier: 1, label: 'high', reason: 'bundle' }, + memberName: 'bundle-agent', + agentIds: ['bundle-agent'], + startTimestamp: '2026-03-01T10:00:00.000Z', + endTimestamp: '2026-03-01T10:00:00.000Z', + toolUseIds: ['bundle-tool'], + toolUseCount: 1, + phaseSet: ['work'], + visibleFileCount: 1, + contributors: [], + }, + files: [ + { + changeKey: 'path:/repo/stale.ts', + filePath: '/repo/stale.ts', + relativePath: 'stale.ts', + linesAdded: 1, + linesRemoved: 0, + 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: null, + latestAfterHash: null, + contentAvailability: 'metadata-only', + reviewability: 'metadata-only', + agentIds: ['bundle-agent'], + }, + ], + totalLinesAdded: 1, + totalLinesRemoved: 0, + diffStatCompleteness: 'complete', + totalFiles: 1, + confidence: 'high', + warningCount: 0, + warnings: [], + }), + 'utf8' + ); + + await writeFile( + path.join(freshnessDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 2, + source: 'task-change-ledger', + taskId: TASK_ID, + updatedAt: '2026-03-01T10:00:01.000Z', + journalStamp: { events: { bytes: 20, mtimeMs: 2, tailSha256: 'freshness' } }, + eventCount: 1, + noticeCount: 0, + integrity: 'ok', + bundleSchemaVersion: 2, + bundleKind: 'summary', + }), + 'utf8' + ); + + await writeFile( + path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`), + `${JSON.stringify({ + schemaVersion: 1, + eventId: 'event-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'journal-tool', + source: 'file_edit', + operation: 'modify', + confidence: 'exact', + workspaceRoot: '/repo', + filePath: '/repo/journal.ts', + relativePath: 'journal.ts', + timestamp: '2026-03-01T10:00:02.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + oldString: 'const a = 1;\n', + newString: 'const a = 2;\n', + linesAdded: 1, + linesRemoved: 1, + })}\n`, + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.files[0]?.filePath).toBe('/repo/journal.ts'); + expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.'); + }); }); async function makeLedgerBundle(params: { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 79ae0f0a..f3e49ace 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2656,6 +2656,82 @@ describe('TeamDataService', () => { expect(getMessages).not.toHaveBeenCalled(); }); + it('propagates persisted needs_attention presence through lightweight presence reads', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 2, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTaskChangePresence('my-team'); + + expect(data).toEqual({ 'task-1': 'needs_attention' }); + }); + it('persists standalone slash metadata when sending directly to the live lead', async () => { const appendSentMessage = vi.fn((payload) => payload); const service = new TeamDataService( diff --git a/test/main/services/team/TeamLogSourceTracker.test.ts b/test/main/services/team/TeamLogSourceTracker.test.ts index 6008cf55..47d3e2db 100644 --- a/test/main/services/team/TeamLogSourceTracker.test.ts +++ b/test/main/services/team/TeamLogSourceTracker.test.ts @@ -1,13 +1,21 @@ +import { createHash } from 'crypto'; import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker'; +import { + shouldIgnoreLogSourceWatcherPath, + TeamLogSourceTracker, +} from '../../../../src/main/services/team/TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; import type { TeamChangeEvent } from '../../../../src/shared/types'; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + describe('TeamLogSourceTracker', () => { let tempDir: string | null = null; @@ -150,4 +158,68 @@ describe('TeamLogSourceTracker', () => { await tracker.disableTracking('demo', 'stall_monitor'); }); + + it('emits the task id from Windows-safe hashed task-change freshness files', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-')); + + const logsFinder = { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'change_presence'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const taskId = 'CON'; + const signalDir = path.join(tempDir, '.board-task-change-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + }); + }); + expect(emitter.mock.calls).not.toContainEqual([ + expect.objectContaining({ type: 'task-log-change', taskId: safeTaskIdSegment(taskId) }), + ]); + + await tracker.disableTracking('demo', 'change_presence'); + }); + + it('ignores internal ledger artifact paths but keeps freshness signals visible', () => { + const projectDir = '/tmp/demo-project'; + + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-changes', 'events', 'task.jsonl') + ) + ).toBe(true); + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-changes', 'locks', 'task.lock', 'owner.json') + ) + ).toBe(true); + expect( + shouldIgnoreLogSourceWatcherPath( + projectDir, + path.join(projectDir, '.board-task-change-freshness', 'task.json') + ) + ).toBe(false); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts index 66bf671d..3b1d3969 100644 --- a/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -7,6 +8,10 @@ import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/tea const tempDirs: string[] = []; +function safeTaskIdSegment(taskId: string): string { + return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`; +} + afterEach(async () => { await Promise.all( tempDirs.splice(0).map(async (dirPath) => { @@ -54,4 +59,28 @@ describe('TeamTaskLogFreshnessReader', () => { transcriptFileBasename: 'session-a.jsonl', }); }); + + it('reads Windows-safe hashed freshness files for reserved 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 }); + + await fs.writeFile( + path.join(signalDir, `${safeTaskIdSegment('CON')}.json`), + JSON.stringify({ + taskId: 'CON', + updatedAt: '2026-04-19T12:00:00.000Z', + transcriptFile: 'session-con.jsonl', + }), + 'utf8' + ); + + const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, ['CON']); + + expect(signals.get('CON')?.filePath).toBe( + path.join(signalDir, `${safeTaskIdSegment('CON')}.json`) + ); + expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl'); + }); }); diff --git a/test/main/services/team/taskChangeLedgerFixtureUtils.ts b/test/main/services/team/taskChangeLedgerFixtureUtils.ts new file mode 100644 index 00000000..ed87c88e --- /dev/null +++ b/test/main/services/team/taskChangeLedgerFixtureUtils.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +const DEFAULT_PROJECT_ROOT_TOKEN = '__PROJECT_ROOT__'; +const FIXTURE_ROOT = path.join(process.cwd(), 'test', 'fixtures', 'team', 'task-change-ledger'); + +export type TaskChangeLedgerFixtureManifest = { + schemaVersion: number; + name: string; + taskId: string; + description: string; + projectRootToken?: string; + expected?: { + totalFiles?: number; + warnings?: string[]; + relativePaths?: string[]; + relationKinds?: Array<'rename' | 'copy'>; + }; +}; + +export type MaterializedTaskChangeLedgerFixture = { + rootDir: string; + projectDir: string; + manifest: TaskChangeLedgerFixtureManifest; + cleanup: () => Promise; +}; + +function replaceTokenInValue(value: T, token: string, replacement: string): T { + if (typeof value === 'string') { + return value.split(token).join(replacement) as T; + } + if (Array.isArray(value)) { + return value.map((item) => replaceTokenInValue(item, token, replacement)) as T; + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + replaceTokenInValue(item, token, replacement), + ]) + ) as T; + } + return value; +} + +async function rewriteProjectRootTokens(rootDir: string, token: string, projectDir: string): Promise { + const jsonStringReplacement = JSON.stringify(projectDir).slice(1, -1); + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + await rewriteProjectRootTokens(entryPath, token, projectDir); + continue; + } + if (!['.json', '.jsonl'].includes(path.extname(entry.name))) { + continue; + } + const raw = await fs.readFile(entryPath, 'utf8'); + await fs.writeFile(entryPath, raw.split(token).join(jsonStringReplacement), 'utf8'); + } +} + +export async function materializeTaskChangeLedgerFixture( + fixtureName: string +): Promise { + const sourceDir = path.join(FIXTURE_ROOT, fixtureName); + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), `task-change-ledger-${fixtureName}-`)); + await fs.cp(sourceDir, rootDir, { recursive: true }); + + const manifestPath = path.join(rootDir, 'manifest.json'); + const manifest = JSON.parse( + await fs.readFile(manifestPath, 'utf8') + ) as TaskChangeLedgerFixtureManifest; + const projectDir = path.join(rootDir, 'project'); + const token = manifest.projectRootToken ?? DEFAULT_PROJECT_ROOT_TOKEN; + + await rewriteProjectRootTokens(rootDir, token, projectDir); + + return { + rootDir, + projectDir, + manifest: replaceTokenInValue(manifest, token, projectDir), + cleanup: async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }, + }; +} diff --git a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts new file mode 100644 index 00000000..0d24cf1e --- /dev/null +++ b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts @@ -0,0 +1,421 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; +import { FileContentResolver } from '@main/services/team/FileContentResolver'; +import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; +import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader'; +import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; + +import { materializeTaskChangeLedgerFixture } from './taskChangeLedgerFixtureUtils'; + +const TEAM_NAME = 'team-a'; +const SUMMARY_OPTIONS = { + owner: 'alice', + status: 'completed', + stateBucket: 'completed' as const, + summaryOnly: true, +}; + +async function writeTaskFile(baseDir: string, taskId: string, projectPath: string): Promise { + const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${taskId}.json`); + await fs.mkdir(path.dirname(taskPath), { recursive: true }); + await fs.writeFile( + taskPath, + JSON.stringify( + { + id: taskId, + owner: 'alice', + status: 'completed', + createdAt: '2026-03-01T09:55:00.000Z', + updatedAt: '2026-03-01T10:10:00.000Z', + projectPath, + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + historyEvents: [], + }, + null, + 2 + ), + 'utf8' + ); +} + +function createLedgerBackedChangeExtractorService(params: { + projectDir: string; + taskChangePresenceRepository?: { upsertEntry: ReturnType }; + teamLogSourceTracker?: { + ensureTracking: ReturnType< + typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> + >; + }; +}) { + const findLogFileRefsForTask = vi.fn(async () => { + throw new Error('fallback log reconstruction should not run for ledger fixtures'); + }); + const computeTaskChanges = vi.fn(async () => { + throw new Error('worker path should not run for ledger fixtures'); + }); + const service = new ChangeExtractorService( + { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: params.projectDir, + projectPath: params.projectDir, + })), + findLogFileRefsForTask, + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => { + throw new Error('inline parser should not run for ledger fixtures'); + }), + } as any, + { getConfig: vi.fn(async () => ({ projectPath: params.projectDir })) } as any, + undefined, + { + isAvailable: vi.fn(() => true), + computeTaskChanges, + } as any + ); + + if (params.taskChangePresenceRepository && params.teamLogSourceTracker) { + service.setTaskChangePresenceServices( + params.taskChangePresenceRepository as any, + params.teamLogSourceTracker as any + ); + } + + return { service, findLogFileRefsForTask, computeTaskChanges }; +} + +describe('task change ledger golden fixtures', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + setClaudeBasePathOverride(null); + vi.restoreAllMocks(); + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } + }); + + it('reads rename and copy fixtures as grouped ledger changes', async () => { + const renameFixture = await materializeTaskChangeLedgerFixture('rename'); + const copyFixture = await materializeTaskChangeLedgerFixture('copy'); + cleanups.push(renameFixture.cleanup, copyFixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const rename = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: renameFixture.manifest.taskId, + projectDir: renameFixture.projectDir, + projectPath: renameFixture.projectDir, + includeDetails: false, + }); + const copy = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: copyFixture.manifest.taskId, + projectDir: copyFixture.projectDir, + projectPath: copyFixture.projectDir, + includeDetails: false, + }); + + expect(rename?.files).toHaveLength(1); + expect(rename?.files[0]?.changeKey).toBe('rename:src/old.ts->src/new.ts'); + expect(rename?.files[0]?.filePath).toBe(path.join(renameFixture.projectDir, 'src', 'new.ts')); + expect(rename?.files[0]?.ledgerSummary?.relation).toEqual({ + kind: 'rename', + oldPath: 'src/old.ts', + newPath: 'src/new.ts', + }); + + expect(copy?.files).toHaveLength(1); + expect(copy?.files[0]?.changeKey).toBe('copy:src/base.ts->src/copy.ts'); + expect(copy?.files[0]?.isNewFile).toBe(true); + expect(copy?.files[0]?.filePath).toBe(path.join(copyFixture.projectDir, 'src', 'copy.ts')); + expect(copy?.files[0]?.ledgerSummary?.relation).toEqual({ + kind: 'copy', + oldPath: 'src/base.ts', + newPath: 'src/copy.ts', + }); + }); + + it('returns warning-only notice fixtures without synthesizing fake file changes', async () => { + const fixture = await materializeTaskChangeLedgerFixture('notices-only'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + + expect(result).not.toBeNull(); + expect(result?.files).toEqual([]); + expect(result?.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + }); + + it('falls back when bundle freshness is intentionally mismatched', async () => { + const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + }); + + it('uses journal tail hash, not only size and mtime, when freshness is missing', async () => { + const fixture = await materializeTaskChangeLedgerFixture('v2-summary'); + cleanups.push(fixture.cleanup); + const taskId = fixture.manifest.taskId; + const eventPath = path.join( + fixture.projectDir, + '.board-task-changes', + 'events', + `${encodeURIComponent(taskId)}.jsonl` + ); + const freshnessSignalPath = path.join( + fixture.projectDir, + '.board-task-change-freshness', + `${encodeURIComponent(taskId)}.json` + ); + const originalStat = await fs.stat(eventPath); + const raw = await fs.readFile(eventPath, 'utf8'); + const mutated = raw.replace( + /"eventId":"([0-9a-f])([0-9a-f]+)"/, + (_match, first: string, rest: string) => + `"eventId":"${first === 'a' ? 'b' : 'a'}${rest}"` + ); + expect(mutated).not.toBe(raw); + expect(mutated.length).toBe(raw.length); + await fs.writeFile(eventPath, mutated, 'utf8'); + await fs.utimes(eventPath, originalStat.atime, originalStat.mtime); + await fs.unlink(freshnessSignalPath); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + }); + + it('surfaces recovered-journal warnings from real recovered artifacts', async () => { + const fixture = await materializeTaskChangeLedgerFixture('recovered-journal'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + + const result = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: false, + }); + + expect(result?.files).toHaveLength(1); + expect(result?.warnings).toContain('Task change ledger recovered from malformed journal lines.'); + }); + + it('keeps missing-blob fixture unavailable instead of synthesizing empty text', async () => { + const fixture = await materializeTaskChangeLedgerFixture('missing-blob'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + expect(resolved.originalFullContent).toBeNull(); + expect(resolved.modifiedFullContent).toBe('export const missing = 2;\n'); + expect(resolved.contentSource).toBe('ledger-snapshot'); + }); + + it('rejects grouped copy fixtures by deleting only the copied path', async () => { + const fixture = await materializeTaskChangeLedgerFixture('copy'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + const service = new ReviewApplierService(); + const result = await service.applyReviewDecisions( + { + teamName: TEAM_NAME, + decisions: [ + { + filePath: file!.filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + file!.filePath, + { + ...file!, + ...resolved, + }, + ], + ]) + ); + + expect(result).toMatchObject({ applied: 1, conflicts: 0 }); + await expect(fs.stat(path.join(fixture.projectDir, 'src', 'copy.ts'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(fs.readFile(path.join(fixture.projectDir, 'src', 'base.ts'), 'utf8')).resolves.toBe( + 'export const copied = true;\n' + ); + }); + + it('requires manual review when a fixture is missing original ledger text', async () => { + const fixture = await materializeTaskChangeLedgerFixture('missing-blob'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + const file = changeSet?.files[0]; + expect(file).toBeDefined(); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets); + + const service = new ReviewApplierService(); + const result = await service.applyReviewDecisions( + { + teamName: TEAM_NAME, + decisions: [ + { + filePath: file!.filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + file!.filePath, + { + ...file!, + ...resolved, + }, + ], + ]) + ); + + expect(result.applied).toBe(0); + expect(result.errors[0]?.code).toBe('manual-review-required'); + }); + + it('uses ledger fixtures as the primary source in ChangeExtractorService', async () => { + const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch'); + cleanups.push(fixture.cleanup); + const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-ledger-')); + cleanups.push(async () => { + await fs.rm(claudeBaseDir, { recursive: true, force: true }); + }); + setClaudeBasePathOverride(claudeBaseDir); + await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir); + + const { service, findLogFileRefsForTask, computeTaskChanges } = + createLedgerBackedChangeExtractorService({ + projectDir: fixture.projectDir, + }); + + const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(1); + expect(result.warnings).toContain( + 'Task change summary fell back to journal reconstruction.' + ); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + expect(computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('records needs_attention presence from warning-only ledger fixtures', async () => { + const fixture = await materializeTaskChangeLedgerFixture('notices-only'); + cleanups.push(fixture.cleanup); + const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-')); + cleanups.push(async () => { + await fs.rm(claudeBaseDir, { recursive: true, force: true }); + }); + setClaudeBasePathOverride(claudeBaseDir); + await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'fixture-project-fingerprint', + logSourceGeneration: 'fixture-log-generation', + })); + const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({ + projectDir: fixture.projectDir, + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS); + + expect(result.files).toEqual([]); + expect(result.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'fixture-project-fingerprint', + logSourceGeneration: 'fixture-log-generation', + }), + expect.objectContaining({ + taskId: fixture.manifest.taskId, + presence: 'needs_attention', + }) + ); + }); +}); diff --git a/test/main/services/team/taskChangePresenceCacheSchema.test.ts b/test/main/services/team/taskChangePresenceCacheSchema.test.ts new file mode 100644 index 00000000..817e977c --- /dev/null +++ b/test/main/services/team/taskChangePresenceCacheSchema.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizePersistedTaskChangePresenceIndex, + toPersistedTaskChangePresenceIndex, +} from '../../../../src/main/services/team/cache/taskChangePresenceCacheSchema'; +import { + LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, +} from '../../../../src/main/services/team/cache/taskChangePresenceCacheTypes'; + +describe('taskChangePresenceCacheSchema', () => { + it('dual-reads legacy v1 payloads and normalizes them to the current schema version', () => { + const normalized = normalizePersistedTaskChangePresenceIndex({ + version: LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(normalized).toEqual({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + }); + + it('preserves needs_attention when normalizing the current schema payload', () => { + const normalized = normalizePersistedTaskChangePresenceIndex({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(normalized?.entries['task-1']?.presence).toBe('needs_attention'); + }); + + it('serializes all new writes as schema version 2', () => { + const serialized = toPersistedTaskChangePresenceIndex({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: 'sig-1', + presence: 'needs_attention', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + }); + + expect(serialized.version).toBe(TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION); + expect(serialized.entries['task-1']?.presence).toBe('needs_attention'); + }); +}); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 8ef2d282..47ad82b3 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -11,6 +11,9 @@ const hoisted = vi.hoisted(() => ({ getFileContent: vi.fn(), applyDecisions: vi.fn(), saveEditedFile: vi.fn(), + loadDecisions: vi.fn(), + saveDecisions: vi.fn(), + clearDecisions: vi.fn(), checkConflict: vi.fn(), rejectHunks: vi.fn(), rejectFile: vi.fn(), @@ -26,6 +29,9 @@ vi.mock('@renderer/api', () => ({ getFileContent: hoisted.getFileContent, applyDecisions: hoisted.applyDecisions, saveEditedFile: hoisted.saveEditedFile, + loadDecisions: hoisted.loadDecisions, + saveDecisions: hoisted.saveDecisions, + clearDecisions: hoisted.clearDecisions, checkConflict: hoisted.checkConflict, rejectHunks: hoisted.rejectHunks, rejectFile: hoisted.rejectFile, @@ -181,7 +187,7 @@ describe('changeReviewSlice task changes', () => { totalLinesRemoved: 0, teamName: 'team-a', taskId: '1', - confidence: 'fallback', + confidence: 'high', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', @@ -192,7 +198,7 @@ describe('changeReviewSlice task changes', () => { endTimestamp: '', toolUseIds: [], filePaths: [], - confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + confidence: { tier: 1, label: 'high', reason: 'Confirmed empty summary' }, }, warnings: [], }); @@ -202,6 +208,9 @@ describe('changeReviewSlice task changes', () => { await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_B); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + expect( + store.getState().taskChangePresenceByKey[buildTaskChangePresenceKey('team-a', '1', OPTIONS_A)] + ).toBe('no_changes'); }); it('updates selected team task changePresence after a positive summary check', async () => { @@ -285,6 +294,53 @@ describe('changeReviewSlice task changes', () => { ); }); + it('treats warning-only summaries as needs_attention and rechecks after invalidation', async () => { + const store = createSliceStore(); + const teamName = 'team-a'; + const taskId = 'presence-warning'; + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName, + taskId, + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'Ambiguous scope skipped' }, + }, + warnings: ['Ledger skipped attribution because multiple task scopes were active.'], + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'ledger-warning-only', + }, + }); + + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + teamName, + taskId, + 'needs_attention' + ); + expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention'); + + store.getState().invalidateTaskChangePresence([cacheKey]); + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + }); + it('downgrades stale known presence to unknown for fallback empty summaries', async () => { const store = createSliceStore(); store.setState({ @@ -554,8 +610,10 @@ describe('changeReviewSlice task changes', () => { expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3); expect( - store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)] - ).toBe(true); + store.getState().taskChangePresenceByKey[ + buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) + ] + ).toBe('has_changes'); }); it('warms task summaries with bounded concurrency', async () => { @@ -708,7 +766,16 @@ describe('changeReviewSlice task changes', () => { summaryOnly: true, forceFresh: true, }); - expect(store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]).toBe(false); + expect( + store.getState().taskChangePresenceByKey[ + buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) + ] + ).toBeUndefined(); + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + teamName, + taskId, + 'unknown' + ); }); it('clears resolved file content state when fetchAgentChanges installs a new change set', async () => { @@ -741,7 +808,7 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); - expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); + expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(5); expect(store.getState().fileContentVersionByPath).toEqual({}); }); @@ -777,7 +844,7 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); - expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'accepted' }); + expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(2); expect(store.getState().fileContentVersionByPath).toEqual({}); }); @@ -884,6 +951,93 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('normalizes persisted legacy file-path review decisions onto changeKey entries', async () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeTaskChangeSet('task-ledger', '/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + }); + + hoisted.loadDecisions.mockResolvedValueOnce({ + hunkDecisions: { '/repo/new.ts:0': 'rejected' }, + fileDecisions: { '/repo/new.ts': 'rejected' }, + hunkContextHashesByFile: { '/repo/new.ts': { 0: 'ctx-rename' } }, + }); + + await store.getState().loadDecisionsFromDisk('team-a', 'task-task-ledger', 'scope-token'); + + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + expect(store.getState().hunkContextHashesByFile).toEqual({ + [changeKey]: { 0: 'ctx-rename' }, + }); + }); + + it('stores fresh decisions under changeKey for grouped ledger files', () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileChunkCounts: { '/repo/new.ts': 1 }, + }); + + const originalIndex = store.getState().setHunkDecision('/repo/new.ts', 0, 'rejected'); + store.getState().setFileDecision('/repo/new.ts', 'rejected'); + + expect(originalIndex).toBe(0); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + }); + + it('stores grouped copy decisions under the copy changeKey', () => { + const store = createSliceStore(); + const changeKey = 'copy:/repo/base.ts->/repo/copy.ts'; + const ledgerFile = { + ...makeFile('/repo/copy.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/copy.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileChunkCounts: { '/repo/copy.ts': 1 }, + }); + + const originalIndex = store.getState().setHunkDecision('/repo/copy.ts', 0, 'accepted'); + store.getState().setFileDecision('/repo/copy.ts', 'accepted'); + + expect(originalIndex).toBe(0); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'accepted' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'accepted' }); + }); + it('invalidates resolved file content without clearing draft or review decisions', async () => { const store = createSliceStore(); @@ -922,6 +1076,49 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('invalidates review-key hunk hashes for grouped ledger files without clearing decisions', () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, + fileDecisions: { [changeKey]: 'rejected' }, + fileChunkCounts: { '/repo/new.ts': 2 }, + hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, + fileContents: { + '/repo/new.ts': { + ...ledgerFile, + originalFullContent: 'before', + modifiedFullContent: 'after', + contentSource: 'ledger-exact', + }, + }, + fileContentsLoading: { '/repo/new.ts': true }, + editedContents: { '/repo/new.ts': 'draft' }, + reviewExternalChangesByFile: { '/repo/new.ts': { type: 'change' } }, + fileContentVersionByPath: {}, + }); + + store.getState().invalidateResolvedFileContent('/repo/new.ts'); + + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); + expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); + expect(store.getState().editedContents).toEqual({ '/repo/new.ts': 'draft' }); + expect(store.getState().fileContentVersionByPath['/repo/new.ts']).toBe(1); + }); + it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => { const store = createSliceStore(); @@ -1074,6 +1271,45 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); + it('clears review-key hunk hashes after saveEditedFile for grouped ledger files', async () => { + const store = createSliceStore(); + const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; + const ledgerFile = { + ...makeFile('/repo/new.ts'), + changeKey, + }; + hoisted.saveEditedFile.mockResolvedValueOnce(undefined); + + store.setState({ + activeChangeSet: { + ...makeAgentChangeSet('/repo/new.ts'), + files: [ledgerFile], + totalFiles: 1, + totalLinesAdded: ledgerFile.linesAdded, + totalLinesRemoved: ledgerFile.linesRemoved, + }, + fileContents: { + '/repo/new.ts': { + ...ledgerFile, + originalFullContent: 'before', + modifiedFullContent: 'draft-before-save', + contentSource: 'ledger-exact', + }, + }, + fileChunkCounts: { '/repo/new.ts': 2 }, + hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, + editedContents: { '/repo/new.ts': 'saved-content' }, + fileContentVersionByPath: {}, + }); + + await store.getState().saveEditedFile('/repo/new.ts'); + + expect(hoisted.saveEditedFile).toHaveBeenCalledWith('/repo/new.ts', 'saved-content', undefined); + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().fileChunkCounts).toEqual({}); + expect(store.getState().fileContents['/repo/new.ts']?.modifiedFullContent).toBe('saved-content'); + }); + it('forces re-review when snippets change even if file paths stay the same', async () => { const store = createSliceStore(); const current = makeAgentChangeSet('/repo/file.ts', { newString: 'after' }); diff --git a/test/renderer/utils/reviewDecisionScope.test.ts b/test/renderer/utils/reviewDecisionScope.test.ts new file mode 100644 index 00000000..1cd9d4e1 --- /dev/null +++ b/test/renderer/utils/reviewDecisionScope.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { buildReviewDecisionScopeToken } from '../../../src/renderer/utils/reviewDecisionScope'; + +describe('buildReviewDecisionScopeToken', () => { + it('includes task request signature so filtered task variants do not collide', () => { + const baseChangeSet = { + teamName: 'demo', + taskId: 'task-1', + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'high' as const, + computedAt: '2026-04-21T10:00:00.000Z', + scope: { + taskId: 'task-1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1 as const, label: 'high' as const, reason: 'ok' }, + }, + warnings: [], + provenance: { + sourceKind: 'ledger' as const, + sourceFingerprint: 'fp-1', + }, + }; + + const tokenA = buildReviewDecisionScopeToken({ + mode: 'task', + taskId: 'task-1', + requestSignature: '{"status":"in_progress"}', + changeSet: baseChangeSet, + }); + const tokenB = buildReviewDecisionScopeToken({ + mode: 'task', + taskId: 'task-1', + requestSignature: '{"status":"completed"}', + changeSet: baseChangeSet, + }); + + expect(tokenA).not.toBe(tokenB); + }); +}); diff --git a/test/renderer/utils/reviewKey.test.ts b/test/renderer/utils/reviewKey.test.ts new file mode 100644 index 00000000..12e369ac --- /dev/null +++ b/test/renderer/utils/reviewKey.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + getReviewKeyForFilePath, + normalizePersistedReviewState, +} from '../../../src/renderer/utils/reviewKey'; + +describe('reviewKey path normalization', () => { + it('maps slash variants of Windows file paths to the same review key', () => { + const files = [{ filePath: 'C:\\Repo\\src\\file.ts', changeKey: 'path:c:/repo/src/file.ts' }]; + + expect(getReviewKeyForFilePath(files, 'c:/repo/src/file.ts')).toBe('path:c:/repo/src/file.ts'); + }); + + it('normalizes persisted legacy Windows path decisions onto changeKey entries', () => { + const files = [{ filePath: 'C:/Repo/src/file.ts', changeKey: 'path:c:/repo/src/file.ts' }]; + const state = normalizePersistedReviewState(files, { + fileDecisions: { 'c:\\repo\\src\\file.ts': 'rejected' }, + hunkDecisions: { 'c:\\repo\\src\\file.ts:2': 'accepted' }, + hunkContextHashesByFile: { 'c:\\repo\\src\\file.ts': { 2: 'ctx' } }, + }); + + expect(state.fileDecisions).toEqual({ 'path:c:/repo/src/file.ts': 'rejected' }); + expect(state.hunkDecisions).toEqual({ 'path:c:/repo/src/file.ts:2': 'accepted' }); + expect(state.hunkContextHashesByFile).toEqual({ + 'path:c:/repo/src/file.ts': { 2: 'ctx' }, + }); + }); +});