From 99102565f36a0f4f169b3e210ea797f8a3cd9837 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 12:25:42 +0300 Subject: [PATCH] feat(ledger): integrate task change ledger functionality into file content resolution and review application processes --- .../services/team/ChangeExtractorService.ts | 40 ++ src/main/services/team/FileContentResolver.ts | 57 +- src/main/services/team/HunkSnippetMatcher.ts | 10 +- .../services/team/ReviewApplierService.ts | 455 ++++++++++++++- .../services/team/TaskChangeLedgerReader.ts | 538 ++++++++++++++++++ .../services/team/TeamLogSourceTracker.ts | 21 +- .../team/review/FileSectionDiff.tsx | 13 +- .../team/review/FileSectionHeader.tsx | 24 +- .../team/review/ReviewDiffContent.tsx | 34 +- src/shared/types/review.ts | 49 +- .../services/team/FileContentResolver.test.ts | 84 ++- .../team/ReviewApplierService.test.ts | 478 +++++++++++++++- .../team/TaskChangeLedgerReader.test.ts | 222 ++++++++ 13 files changed, 2000 insertions(+), 25 deletions(-) create mode 100644 src/main/services/team/TaskChangeLedgerReader.ts create mode 100644 test/main/services/team/TaskChangeLedgerReader.test.ts diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 8201aa23..58f783b3 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -22,6 +22,7 @@ import { type TaskChangeEffectiveOptions, type TaskChangeTaskMeta, } from './taskChangeWorkerTypes'; +import { TaskChangeLedgerReader } from './TaskChangeLedgerReader'; import { TeamConfigReader } from './TeamConfigReader'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -66,6 +67,7 @@ export class ChangeExtractorService { private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null; private readonly taskChangeComputer: TaskChangeComputer; + private readonly taskChangeLedgerReader = new TaskChangeLedgerReader(); constructor( private readonly logsFinder: TeamMemberLogsFinder, @@ -164,6 +166,18 @@ export class ChangeExtractorService { includeDetails, }; + const ledgerResult = await this.readLedgerTaskChanges(resolvedInput); + if (ledgerResult) { + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + ledgerResult + ); + return ledgerResult; + } + if (!shouldUseSummaryCache) { const result = await this.computeTaskChangesPreferred(resolvedInput); await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); @@ -293,6 +307,32 @@ export class ChangeExtractorService { return this.taskChangeComputer.computeTaskChanges(input); } + private async readLedgerTaskChanges( + input: ResolvedTaskChangeComputeInput + ): Promise { + try { + if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') { + return null; + } + const context = await this.logsFinder.getLogSourceWatchContext(input.teamName); + if (!context?.projectDir) { + return null; + } + return await this.taskChangeLedgerReader.readTaskChanges({ + teamName: input.teamName, + taskId: input.taskId, + projectDir: context.projectDir, + projectPath: input.projectPath ?? context.projectPath, + includeDetails: input.includeDetails, + }); + } catch (error) { + logger.warn( + `Task change ledger read failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + private isValidWorkerTaskChangeResult( result: TaskChangeSetV2, input: ResolvedTaskChangeComputeInput diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 5cbdf65b..bb80c25f 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -63,6 +63,11 @@ export class FileContentResolver { modified: string | null; source: FileChangeWithContent['contentSource']; }> { + const ledgerResult = this.tryLedgerContent(snippets); + if (ledgerResult) { + return ledgerResult; + } + // Read current file from disk (= modified state after agent's changes) let currentContent: string | null = null; try { @@ -183,7 +188,9 @@ export class FileContentResolver { } } - const isNewFile = snippets.some((s) => s.type === 'write-new'); + const isNewFile = snippets.some( + (s) => s.type === 'write-new' || s.ledger?.operation === 'create' + ); return { filePath, @@ -257,6 +264,47 @@ export class FileContentResolver { // ── Private: Resolution strategies ── + private tryLedgerContent(snippets: SnippetDiff[]): { + original: string | null; + 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); + }); + + if (ledgerSnippets.length === 0) { + return null; + } + + const first = ledgerSnippets[0]?.ledger; + const last = ledgerSnippets[ledgerSnippets.length - 1]?.ledger; + if (!first || !last) { + return null; + } + const original = first.originalFullContent ?? (first.operation === 'create' ? '' : null); + const modified = last.modifiedFullContent ?? (last.operation === 'delete' ? '' : null); + if (original === null && modified === null) { + return null; + } + + const hasSnapshot = ledgerSnippets.some( + (snippet) => snippet.ledger?.source === 'ledger-snapshot' + ); + return { + original, + modified, + source: hasSnapshot ? 'ledger-snapshot' : 'ledger-exact', + }; + } + /** * Strategy 1: Read original content from Claude's file-history backup. * @@ -429,6 +477,13 @@ export class FileContentResolver { return null; } + case 'notebook-edit': + case 'shell-snapshot': + case 'hook-snapshot': { + // Snapshot/full-file changes are only safe when ledger content is available. + return null; + } + case 'edit': case 'multi-edit': { // Guard: empty newString means deletion — can't find position to reverse diff --git a/src/main/services/team/HunkSnippetMatcher.ts b/src/main/services/team/HunkSnippetMatcher.ts index 173c964b..e98cdde2 100644 --- a/src/main/services/team/HunkSnippetMatcher.ts +++ b/src/main/services/team/HunkSnippetMatcher.ts @@ -144,8 +144,14 @@ export class HunkSnippetMatcher { ): boolean { if (!snippet.newString && !snippet.oldString) return false; - if (snippet.type === 'write-new' || snippet.type === 'write-update') { - // Full-file writes are intentionally excluded from localized hunk↔snippet matching. + if ( + snippet.type === 'write-new' || + snippet.type === 'write-update' || + snippet.type === 'notebook-edit' || + snippet.type === 'shell-snapshot' || + snippet.type === 'hook-snapshot' + ) { + // Full-file and snapshot changes are intentionally excluded from localized hunk↔snippet matching. // They are handled by whole-file reject logic or hunk-level inverse patch. return false; } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 380fa0f2..41af1ca3 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -1,8 +1,10 @@ import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import { applyPatch, structuredPatch } from 'diff'; -import { readFile, unlink, writeFile } from 'fs/promises'; +import { mkdir, readFile, unlink, writeFile } from 'fs/promises'; import { diff3Merge } from 'node-diff3'; +import { dirname } from 'path'; import { HunkSnippetMatcher } from './HunkSnippetMatcher'; @@ -11,6 +13,7 @@ import type { ApplyReviewResult, ConflictCheckResult, FileChangeWithContent, + LedgerChangeRelation, RejectResult, SnippetDiff, } from '@shared/types'; @@ -18,6 +21,12 @@ import type { StructuredPatchHunk } from 'diff'; const logger = createLogger('Service:ReviewApplierService'); +type ApplyErrorCode = NonNullable; +type LedgerApplyOutcome = + | { handled: false } + | { handled: true; status: 'applied' | 'skipped' } + | { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode }; + /** * Service for applying reject decisions from code review. * @@ -255,16 +264,43 @@ export class ReviewApplierService { const allHunksRejected = Object.keys(decision.hunkDecisions).length > 0 && Object.values(decision.hunkDecisions).every((d) => d === 'rejected'); - const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new'); + const hasNewFileSnippet = fileContent.snippets.some( + (s) => s.type === 'write-new' || s.ledger?.operation === 'create' + ); // Special case: rejecting an entirely new file should remove it from disk. // IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file. const shouldDeleteNewFile = fileContent.isNewFile && - hasWriteNewSnippet && + hasNewFileSnippet && original === '' && (decision.fileDecision === 'rejected' || allHunksRejected); + const ledgerOutcome = await this.tryApplyLedgerDecision( + decision.filePath, + original, + modified, + decision.fileDecision === 'rejected', + allHunksRejected, + rejectedHunkIndices, + fileContent.snippets + ); + if (ledgerOutcome.handled) { + if (ledgerOutcome.status === 'applied') { + applied++; + } else if (ledgerOutcome.status === 'skipped') { + skipped++; + } else if (ledgerOutcome.status === 'conflict' || ledgerOutcome.status === 'error') { + if (ledgerOutcome.status === 'conflict') conflicts++; + errors.push({ + filePath: decision.filePath, + error: ledgerOutcome.error, + code: ledgerOutcome.code, + }); + } + continue; + } + if (shouldDeleteNewFile) { // If we have an expected modified baseline, guard against deleting a user-modified file. if (modified !== null) { @@ -275,6 +311,7 @@ export class ReviewApplierService { filePath: decision.filePath, error: 'File was modified since review was computed; refusing to delete new file automatically.', + code: 'conflict', }); continue; } @@ -289,6 +326,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: 'Cannot delete new file: expected modified content is unavailable.', + code: 'unavailable', }); continue; } @@ -304,6 +342,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: `Failed to delete new file: ${msg}`, + code: 'io-error', }); } } @@ -314,6 +353,7 @@ export class ReviewApplierService { errors.push({ filePath: decision.filePath, error: 'Содержимое файла недоступно для применения review', + code: 'unavailable', }); continue; } @@ -393,6 +433,407 @@ export class ReviewApplierService { // ── Private: Rejection strategies ── + private async tryApplyLedgerDecision( + filePath: string, + original: string | null, + modified: string | null, + fileRejected: boolean, + allHunksRejected: boolean, + rejectedHunkIndices: number[], + snippets: SnippetDiff[] + ): Promise { + const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); + if (ledgerSnippets.length === 0) { + return { handled: false }; + } + + const firstLedger = ledgerSnippets[0]?.ledger; + const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger; + if (!firstLedger || !lastLedger) { + return { handled: false }; + } + + 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 || + snippet.ledger?.afterState?.unavailableReason + ); + const relation = this.resolveLedgerRelation(ledgerSnippets); + + if (!fullReject) { + if (relation?.kind === 'rename') { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename 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.', + }; + } + const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + if (!guard.ok) { + return guard.outcome; + } + const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices); + if (!patchResult) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger snapshot partial reject could not be applied safely.', + }; + } + try { + await writeFile(filePath, patchResult.newContent, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + if (relation?.kind === 'rename') { + return this.rejectLedgerRename(ledgerSnippets, relation, original, hasUnavailableState); + } + + const operation = this.resolveLedgerOperation(ledgerSnippets); + if (operation === 'create') { + const afterHash = lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined; + const current = await this.readCurrentText(filePath); + if (current.missing) { + return { handled: true, status: 'applied' }; + } + if (current.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: current.error, + }; + } + if (!afterHash) { + return { + handled: true, + status: 'error', + code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + error: 'Ledger after content hash is unavailable; refusing to delete file automatically.', + }; + } + if (this.hashText(current.content) !== afterHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File was modified since review was computed; refusing ledger delete.', + }; + } + try { + await unlink(filePath); + return { handled: true, status: 'applied' }; + } catch (err) { + const msg = String(err); + if (msg.includes('ENOENT')) { + return { handled: true, status: 'applied' }; + } + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Failed to delete new file: ${msg}`, + }; + } + } + + if (operation === 'delete') { + if (original === null) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger before content is unavailable; deleted file requires manual restore.', + }; + } + const current = await this.readCurrentText(filePath); + if (!current.missing) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: + current.error || 'File exists on disk; refusing to overwrite while rejecting delete.', + }; + } + try { + await writeFile(filePath, original, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + if (original === null) { + return { + handled: true, + status: 'error', + code: hasUnavailableState ? 'manual-review-required' : 'unavailable', + error: + 'Ledger before content is unavailable; rejecting this change requires manual review.', + }; + } + const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); + if (!guard.ok) { + return guard.outcome; + } + try { + await writeFile(filePath, original, 'utf8'); + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' { + if (snippets.some((snippet) => snippet.ledger?.operation === 'create')) return 'create'; + if (snippets[snippets.length - 1]?.ledger?.operation === 'delete') return 'delete'; + return 'modify'; + } + + private resolveLedgerRelation(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { + return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; + } + + private async rejectLedgerRename( + snippets: SnippetDiff[], + relation: LedgerChangeRelation, + original: string | null, + hasUnavailableState: boolean + ): Promise { + const oldSnippet = + snippets.find( + (snippet) => + snippet.ledger?.operation === 'delete' && + this.pathMatchesRelationPath(snippet.filePath, relation.oldPath) + ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'delete'); + const newSnippet = + snippets.find( + (snippet) => + snippet.ledger?.operation === 'create' && + this.pathMatchesRelationPath(snippet.filePath, relation.newPath) + ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'create'); + const oldFilePath = + oldSnippet?.filePath ?? + this.resolveRelatedLedgerPath(newSnippet?.filePath, relation.newPath, relation.oldPath); + const newFilePath = newSnippet?.filePath; + const oldContent = oldSnippet?.ledger?.originalFullContent ?? original; + const newHash = newSnippet?.ledger?.afterState?.sha256 ?? newSnippet?.ledger?.afterHash; + const oldHash = oldSnippet?.ledger?.beforeState?.sha256 ?? oldSnippet?.ledger?.beforeHash; + + if (!oldFilePath || !newFilePath || oldContent === null) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename metadata is incomplete; manual review is required.', + }; + } + if (hasUnavailableState || !newHash) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger rename content metadata is unavailable; manual review is required.', + }; + } + + const newCurrent = await this.readCurrentText(newFilePath); + if (!newCurrent.missing) { + if (newCurrent.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: newCurrent.error, + }; + } + if (this.hashText(newCurrent.content) !== newHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'Renamed file was modified since review was computed; refusing ledger reject.', + }; + } + } + + const oldCurrent = await this.readCurrentText(oldFilePath); + if (!oldCurrent.missing) { + if (oldCurrent.error) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: oldCurrent.error, + }; + } + if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) { + return { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'Original rename path already exists with different content; refusing overwrite.', + }; + } + } + + try { + if (oldCurrent.missing) { + await mkdir(dirname(oldFilePath), { recursive: true }); + await writeFile(oldFilePath, oldContent, 'utf8'); + } + if (!newCurrent.missing) { + await unlink(newFilePath); + } + return { handled: true, status: 'applied' }; + } catch (err) { + return { + handled: true, + status: 'error', + code: 'io-error', + error: `Failed to reject ledger rename: ${String(err)}`, + }; + } + } + + private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedRelationPath = relationPath.replace(/\\/g, '/'); + return ( + normalizedFilePath === normalizedRelationPath || + normalizedFilePath.endsWith(`/${normalizedRelationPath}`) + ); + } + + private resolveRelatedLedgerPath( + anchorPath: string | undefined, + anchorRelationPath: string, + targetRelationPath: string + ): string | null { + if (!anchorPath) { + return null; + } + const normalizedAnchor = anchorPath.replace(/\\/g, '/'); + const normalizedRelation = anchorRelationPath.replace(/\\/g, '/'); + if (!normalizedAnchor.endsWith(normalizedRelation)) { + return null; + } + return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; + } + + private async checkLedgerCurrentHash( + filePath: string, + expectedHash: string | undefined + ): Promise<{ ok: true } | { ok: false; outcome: LedgerApplyOutcome }> { + if (!expectedHash) { + return { + ok: false, + outcome: { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger expected content hash is unavailable; refusing automatic apply.', + }, + }; + } + const current = await this.readCurrentText(filePath); + if (current.missing) { + return { + ok: false, + outcome: { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File is missing on disk; refusing ledger apply.', + }, + }; + } + if (current.error) { + return { + ok: false, + outcome: { + handled: true, + status: 'error', + code: 'io-error', + error: current.error, + }, + }; + } + if (this.hashText(current.content) !== expectedHash) { + return { + ok: false, + outcome: { + handled: true, + status: 'conflict', + code: 'conflict', + error: 'File was modified since review was computed; refusing ledger apply.', + }, + }; + } + return { ok: true }; + } + + private async readCurrentText( + filePath: string + ): Promise< + | { missing: true; content: ''; error?: undefined } + | { missing: false; content: string; error?: undefined } + | { missing: false; content: ''; error: string } + > { + try { + return { missing: false, content: await readFile(filePath, 'utf8') }; + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? String((err as { code?: unknown }).code) + : ''; + if (code === 'ENOENT') { + return { missing: true, content: '' }; + } + return { missing: false, content: '', error: `Не удалось прочитать файл: ${String(err)}` }; + } + } + + private hashText(content: string): string { + return createHash('sha256').update(content).digest('hex'); + } + /** * Snippet-level rejection: reverse specific snippets by position (most accurate). * @@ -409,7 +850,13 @@ export class ReviewApplierService { // They are not localized, and matching a single hunk to a full-file write // can incorrectly delete/overwrite large parts of the file. const validSnippets = snippets.filter( - (s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update' + (s) => + !s.isError && + s.type !== 'write-new' && + s.type !== 'write-update' && + s.type !== 'notebook-edit' && + s.type !== 'shell-snapshot' && + s.type !== 'hook-snapshot' ); if (validSnippets.length === 0) return null; diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts new file mode 100644 index 00000000..01cd38b6 --- /dev/null +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -0,0 +1,538 @@ +import { createLogger } from '@shared/utils/logger'; +import { diffLines } from 'diff'; +import { readFile } from 'fs/promises'; +import * as path from 'path'; + +import type { + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; + +const logger = createLogger('Service:TaskChangeLedgerReader'); + +const TASK_CHANGE_LEDGER_SCHEMA_VERSION = 1; +const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; + +type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; + +interface LedgerContentRef { + sha256: string; + sizeBytes: number; + blobRef?: string; + 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; + eventId: string; + taskId: string; + taskRef: string; + taskRefKind: 'canonical' | 'display' | 'unknown'; + phase: 'work' | 'review'; + executionSeq: number; + sessionId: string; + agentId?: string; + toolUseId: string; + source: + | 'file_edit' + | 'file_write' + | 'notebook_edit' + | 'bash_simulated_sed' + | 'shell_snapshot' + | 'powershell_snapshot' + | 'post_tool_hook_snapshot'; + operation: 'create' | 'modify' | 'delete'; + confidence: LedgerConfidence; + workspaceRoot: string; + filePath: string; + relativePath: string; + timestamp: string; + toolStatus: 'succeeded' | 'failed' | 'killed' | 'backgrounded'; + before: LedgerContentRef | null; + after: LedgerContentRef | null; + beforeState?: LedgerContentState; + afterState?: LedgerContentState; + relation?: LedgerChangeRelation; + oldString?: string; + newString?: string; + linesAdded?: number; + linesRemoved?: number; + replaceAll?: boolean; + warnings?: string[]; +} + +interface LedgerNotice { + schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + noticeId: string; + taskId: string; + taskRef: string; + taskRefKind: 'canonical' | 'display' | 'unknown'; + phase: 'work' | 'review'; + executionSeq: number; + sessionId: string; + agentId?: string; + toolUseId: string; + timestamp: string; + severity: 'warning'; + message: string; +} + +interface LedgerBundle { + schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION; + 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; + }[]; + totalLinesAdded: number; + totalLinesRemoved: number; + totalFiles: number; + confidence: 'high' | 'medium' | 'low'; + warnings: string[]; + events: LedgerEvent[]; + notices?: LedgerNotice[]; +} + +export class TaskChangeLedgerReader { + async readTaskChanges(params: { + teamName: string; + taskId: string; + projectDir: string; + projectPath?: string; + includeDetails: boolean; + }): Promise { + const bundle = await this.readBundle(params.projectDir, params.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) { + 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.`); + } + if (event.toolStatus === 'killed') { + warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); + } + } + + 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, + scope, + warnings: [...warnings], + }; + } + + private async readBundle(projectDir: string, taskId: string): Promise { + const bundlePath = path.join( + projectDir, + TASK_CHANGE_LEDGER_DIRNAME, + 'bundles', + `${encodeURIComponent(taskId)}.json` + ); + + 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 buildSnippets(projectDir: string, events: LedgerEvent[]): Promise { + return Promise.all( + events.map(async (event) => { + const beforeContent = await this.readContentRef(projectDir, event.before); + const afterContent = await this.readContentRef(projectDir, event.after); + return this.eventToSnippet(event, beforeContent, afterContent); + }) + ); + } + + private async readContentRef( + projectDir: string, + ref: LedgerContentRef | null + ): Promise { + if (!ref?.blobRef) { + return null; + } + try { + return await readFile( + path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef), + 'utf8' + ); + } catch { + return null; + } + } + + private eventToSnippet( + event: LedgerEvent, + 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, + oldString: event.oldString ?? beforeContent ?? '', + newString: event.newString ?? afterContent ?? '', + replaceAll: event.replaceAll ?? false, + timestamp: event.timestamp, + isError: false, + ledger: { + eventId: event.eventId, + source, + confidence: event.confidence, + originalFullContent: beforeContent, + modifiedFullContent: afterContent, + beforeHash: event.before?.sha256 ?? null, + afterHash: event.after?.sha256 ?? null, + operation: event.operation, + beforeState: event.beforeState, + afterState: event.afterState, + relation: event.relation, + executionSeq: event.executionSeq, + }, + }; + } + + private mapToolName(eventSource: LedgerEvent['source']): SnippetDiff['toolName'] { + switch (eventSource) { + case 'file_edit': + return 'Edit'; + case 'file_write': + return 'Write'; + case 'notebook_edit': + return 'NotebookEdit'; + case 'bash_simulated_sed': + case 'shell_snapshot': + return 'Bash'; + case 'powershell_snapshot': + return 'PowerShell'; + case 'post_tool_hook_snapshot': + return 'PostToolUse'; + } + } + + private mapSnippetType(event: LedgerEvent): SnippetDiff['type'] { + if (event.source === 'file_write') { + return event.operation === 'create' ? 'write-new' : 'write-update'; + } + if (event.source === 'notebook_edit') { + return 'notebook-edit'; + } + if (event.source === 'shell_snapshot' || event.source === 'powershell_snapshot') { + return 'shell-snapshot'; + } + if (event.source === 'post_tool_hook_snapshot') { + return 'hook-snapshot'; + } + return 'edit'; + } + + private aggregateByFile( + snippets: SnippetDiff[], + projectPath: string | undefined, + includeDetails: boolean + ): FileChangeSummary[] { + const fileMap = new Map< + string, + { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + >(); + for (const snippet of snippets) { + const key = this.fileGroupKey(snippet); + const existing = fileMap.get(key); + if (existing) { + existing.snippets.push(snippet); + existing.isNewFile ||= + snippet.type === 'write-new' || snippet.ledger?.operation === 'create'; + } else { + fileMap.set(key, { + filePath: snippet.filePath, + snippets: [snippet], + isNewFile: snippet.type === 'write-new' || snippet.ledger?.operation === 'create', + }); + } + } + + return [...fileMap.values()].map((entry) => { + let linesAdded = 0; + let linesRemoved = 0; + for (const snippet of entry.snippets) { + 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 : [], + 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, + }); + } + } + + 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, + })); + } + + private buildScope( + taskId: string, + events: LedgerEvent[], + files: FileChangeSummary[], + 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; + return { + taskId, + memberName: first?.agentId ?? firstNotice?.agentId ?? '', + startLine: 0, + endLine: 0, + startTimestamp: first?.timestamp ?? firstNotice?.timestamp ?? new Date().toISOString(), + endTimestamp: + last?.timestamp ?? + first?.timestamp ?? + lastNotice?.timestamp ?? + firstNotice?.timestamp ?? + new Date().toISOString(), + toolUseIds: [ + ...new Set([ + ...events.map((event) => event.toolUseId), + ...notices.map((notice) => notice.toolUseId), + ]), + ], + filePaths: files.map((file) => file.filePath), + confidence: { + tier: worstConfidence, + label: worstConfidence === 1 ? 'high' : 'medium', + reason: 'Scoped by orchestrator task-change ledger', + }, + }; + } + + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { + const events: FileEditEvent[] = snippets.map((snippet, index) => { + const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); + return { + toolUseId: snippet.toolUseId, + toolName: snippet.toolName, + timestamp: snippet.timestamp, + summary: this.summaryForSnippet(snippet, added, removed), + linesAdded: added, + linesRemoved: removed, + snippetIndex: index, + }; + }); + const firstMs = Date.parse(events[0]?.timestamp ?? ''); + const lastMs = Date.parse(events[events.length - 1]?.timestamp ?? ''); + return { + filePath, + events, + durationMs: + Number.isFinite(firstMs) && Number.isFinite(lastMs) ? Math.max(0, lastMs - firstMs) : 0, + }; + } + + private summaryForSnippet(snippet: SnippetDiff, added: number, removed: number): string { + if (snippet.type === 'write-new') return `Created file (${added} lines)`; + if (snippet.type === 'write-update') return `Rewrote file (+${added}/-${removed})`; + if (snippet.type === 'shell-snapshot') { + return `${snippet.toolName === 'PowerShell' ? 'PowerShell' : 'Shell'} changed file (+${added}/-${removed})`; + } + if (snippet.type === 'hook-snapshot') return `Hook changed file (+${added}/-${removed})`; + if (snippet.type === 'notebook-edit') return `Edited notebook (+${added}/-${removed})`; + return `Edited file (+${added}/-${removed})`; + } + + private countLineChanges(before: string, after: string): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const change of diffLines(before, after)) { + if (change.added) added += change.count ?? 0; + if (change.removed) removed += change.count ?? 0; + } + return { added, removed }; + } + + private normalizePathKey(filePath: string): string { + return path.normalize(filePath).toLowerCase(); + } + + private fileGroupKey(snippet: SnippetDiff): string { + const relation = snippet.ledger?.relation; + if (relation) { + return `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(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) + ); + } + + 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 relativePath(filePath: string, projectPath?: string): string { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); + if (normalizedProjectPath && normalizedFilePath.startsWith(normalizedProjectPath + '/')) { + 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 0f99a0ce..cb23ee30 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -15,6 +15,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_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { @@ -288,7 +289,18 @@ export class TeamLogSourceTracker { } if ( changedPath && - this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath) + (this.handleTaskFreshnessSignalChange( + teamName, + current.projectDir, + changedPath, + BOARD_TASK_LOG_FRESHNESS_DIRNAME + ) || + this.handleTaskFreshnessSignalChange( + teamName, + current.projectDir, + changedPath, + BOARD_TASK_CHANGE_FRESHNESS_DIRNAME + )) ) { return; } @@ -311,12 +323,13 @@ export class TeamLogSourceTracker { }); } - private handleTaskLogFreshnessSignalChange( + private handleTaskFreshnessSignalChange( teamName: string, projectDir: string, - changedPath: string + changedPath: string, + signalDirName: string ): boolean { - const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME); + const signalDir = path.join(projectDir, signalDirName); const relativePath = path.relative(signalDir, changedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return path.normalize(changedPath) === path.normalize(signalDir); diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index 596614f7..73d151cd 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -117,6 +117,15 @@ export const FileSectionDiff = ({ const resolvedOriginal = fileContent?.originalFullContent ?? null; const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false; + const hasLedgerManualAction = file.snippets.some( + (snippet) => + !!snippet.ledger && + (snippet.ledger.relation?.kind === 'rename' || + (!!snippet.ledger.beforeState?.unavailableReason && + snippet.ledger.originalFullContent == null) || + (!!snippet.ledger.afterState?.unavailableReason && + snippet.ledger.modifiedFullContent == null)) + ); // Show CodeMirror only when we have a trustworthy original baseline: // - new files: original is legitimately empty @@ -168,8 +177,8 @@ export const FileSectionDiff = ({ original={originalForDiff} modified={resolvedModified} fileName={file.relativePath} - readOnly={false} - showMergeControls={!isMissingOnDisk} + readOnly={hasLedgerManualAction} + showMergeControls={!isMissingOnDisk && !hasLedgerManualAction} collapseUnchanged={collapseUnchanged} usePortionCollapse={true} onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)} diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index acf3d121..6aa415c9 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -9,6 +9,8 @@ import type { FileChangeWithContent, HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; const CONTENT_SOURCE_LABELS: Record = { + 'ledger-exact': 'Ledger Exact', + 'ledger-snapshot': 'Ledger Snapshot', 'file-history': 'File History', 'snippet-reconstruction': 'Reconstructed', 'disk-current': 'Current Disk', @@ -57,6 +59,14 @@ export const FileSectionHeader = ({ }: FileSectionHeaderProps): React.ReactElement => { const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false; const isPreviewOnly = isMissingOnDisk || fileContent?.contentSource === 'unavailable'; + const requiresManualLedgerReview = file.snippets.some( + (snippet) => + !!snippet.ledger && + (!!snippet.ledger.beforeState?.unavailableReason || + !!snippet.ledger.afterState?.unavailableReason) && + (snippet.ledger.originalFullContent == null || snippet.ledger.modifiedFullContent == null) + ); + const rejectDisabled = isPreviewOnly || requiresManualLedgerReview; const restoreContent = fileContent?.modifiedFullContent ?? (() => { @@ -189,6 +199,12 @@ export const FileSectionHeader = ({ )} + {requiresManualLedgerReview && ( + + MANUAL REVIEW + + )} +
{externalChange && onReloadFromDisk && onKeepDraft && (
@@ -242,7 +258,7 @@ export const FileSectionHeader = ({ - {isPreviewOnly && ( + {rejectDisabled && ( - Accept/Reject is disabled while the file is missing on disk. + {requiresManualLedgerReview + ? 'Reject is disabled because this ledger change has binary, large, or unavailable content.' + : 'Accept/Reject is disabled while the file is missing on disk.'} )} diff --git a/src/renderer/components/team/review/ReviewDiffContent.tsx b/src/renderer/components/team/review/ReviewDiffContent.tsx index 4cd22f27..b10d2034 100644 --- a/src/renderer/components/team/review/ReviewDiffContent.tsx +++ b/src/renderer/components/team/review/ReviewDiffContent.tsx @@ -83,7 +83,15 @@ const SnippetDiffView = ({ ? 'Full rewrite' : snippet.type === 'multi-edit' ? 'Multi-edit' - : 'Edit'; + : snippet.type === 'notebook-edit' + ? 'Notebook' + : snippet.type === 'shell-snapshot' + ? snippet.toolName === 'PowerShell' + ? 'PowerShell' + : 'Shell' + : snippet.type === 'hook-snapshot' + ? 'Hook' + : 'Edit'; return (
@@ -135,9 +143,33 @@ const SnippetDiffView = ({ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => { const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]); + const ledgerMetadataRows = useMemo(() => { + const rows = new Set(); + for (const snippet of nonErrorSnippets) { + const relation = snippet.ledger?.relation; + if (relation) { + rows.add( + `${relation.kind === 'rename' ? 'Rename' : 'Copy'}: ${relation.oldPath} -> ${relation.newPath}` + ); + } + const beforeReason = snippet.ledger?.beforeState?.unavailableReason; + const afterReason = snippet.ledger?.afterState?.unavailableReason; + if (beforeReason) rows.add(`Before content metadata only: ${beforeReason}`); + if (afterReason) rows.add(`After content metadata only: ${afterReason}`); + } + return [...rows]; + }, [nonErrorSnippets]); return (
+ {ledgerMetadataRows.length > 0 && ( +
+ {ledgerMetadataRows.map((row) => ( +
{row}
+ ))} +
+ )} + {nonErrorSnippets.map((snippet, index) => ( { expect(content.contentSource).toBe('snippet-reconstruction'); }); + it('maps ledger create original content to empty string without disk reconstruction', async () => { + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-create.txt', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/ledger-create.txt', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: 'created\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: 'created\n', + beforeHash: null, + afterHash: 'hash', + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: 'hash' }, + }, + }, + ]); + + expect(content.originalFullContent).toBe(''); + expect(content.modifiedFullContent).toBe('created\n'); + expect(content.contentSource).toBe('ledger-snapshot'); + }); + + it('maps ledger delete modified content to empty string for diff display', async () => { + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any); + + const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-delete.txt', [ + { + toolUseId: 'ledger-1', + filePath: '/tmp/ledger-delete.txt', + toolName: 'Bash', + type: 'shell-snapshot', + oldString: 'deleted\n', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: 'deleted\n', + modifiedFullContent: null, + beforeHash: 'hash', + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: 'hash' }, + afterState: { exists: false }, + }, + }, + ]); + + expect(content.originalFullContent).toBe('deleted\n'); + expect(content.modifiedFullContent).toBe(''); + expect(content.contentSource).toBe('ledger-snapshot'); + }); + 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; @@ -184,8 +254,18 @@ describe('FileContentResolver', () => { const resolver = new FileContentResolver(logsFinder as any); - const missing = await resolver.resolveFileContent('team', 'member', '/tmp/missing-vs-empty.txt', []); - const empty = await resolver.resolveFileContent('team', 'member', '/tmp/missing-vs-empty.txt', []); + const missing = await resolver.resolveFileContent( + 'team', + 'member', + '/tmp/missing-vs-empty.txt', + [] + ); + const empty = await resolver.resolveFileContent( + 'team', + 'member', + '/tmp/missing-vs-empty.txt', + [] + ); expect(missing.source).toBe('unavailable'); expect(empty.source).toBe('disk-current'); diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index 113f89b3..c94fae64 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'crypto'; import { structuredPatch } from 'diff'; import type { SnippetDiff } from '@shared/types'; @@ -9,17 +10,23 @@ vi.mock('fs/promises', async (importOriginal) => { const readFile = vi.fn(); const writeFile = vi.fn(); const unlink = vi.fn(); + const mkdir = vi.fn(); return { ...actual, + mkdir, readFile, writeFile, unlink, // ESM interop: some code paths expect a default export - default: { ...actual, readFile, writeFile, unlink }, + default: { ...actual, mkdir, readFile, writeFile, unlink }, }; }); describe('ReviewApplierService', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + it('previewReject avoids write-update snippet-level replacement', async () => { const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); const original = 'hello\nworld\n'; @@ -111,4 +118,471 @@ describe('ReviewApplierService', () => { expect(unlink).toHaveBeenCalledWith(filePath); expect(writeFile).not.toHaveBeenCalled(); }); + + it('ledger create reject deletes only when current hash matches', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + const content = 'created\n'; + readFile.mockResolvedValue(content); + unlink.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/ledger-created.txt'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'ledger-created.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: content, + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: content, + beforeHash: null, + afterHash: sha(content), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(content), sizeBytes: content.length }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + originalFullContent: '', + modifiedFullContent: content, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(unlink).toHaveBeenCalledWith(filePath); + }); + + it('ledger create reject blocks when current hash changed', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + readFile.mockResolvedValue('user changed\n'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/ledger-conflict.txt'; + const ledgerContent = 'created\n'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'ledger-conflict.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: ledgerContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: ledgerContent, + beforeHash: null, + afterHash: sha(ledgerContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(ledgerContent) }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + originalFullContent: '', + modifiedFullContent: ledgerContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.conflicts).toBe(1); + expect(res.errors[0]?.code).toBe('conflict'); + expect(unlink).not.toHaveBeenCalled(); + }); + + it('ledger delete reject restores only when file is missing', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + writeFile.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/deleted.txt'; + const original = 'restore me\n'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'deleted.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: original, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: original, + modifiedFullContent: null, + beforeHash: sha(original), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(original) }, + afterState: { exists: false }, + }, + }, + ], + linesAdded: 0, + linesRemoved: 1, + isNewFile: false, + originalFullContent: original, + modifiedFullContent: '', + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(1); + expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8'); + }); + + it('ledger binary or large unavailable content requires manual review', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + readFile.mockResolvedValue('binary placeholder'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/blob.bin'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'blob.bin', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + 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: null, + operation: 'modify', + beforeState: { exists: true, unavailableReason: 'binary file' }, + afterState: { exists: true, unavailableReason: 'binary file' }, + }, + }, + ], + linesAdded: 0, + linesRemoved: 0, + isNewFile: false, + originalFullContent: null, + modifiedFullContent: null, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.errors[0]?.code).toBe('manual-review-required'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('ledger rename reject restores old path and deletes new path with hash guards', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + const mkdir = fsPromises.mkdir as unknown as ReturnType; + + const oldPath = '/repo/src/old.ts'; + const newPath = '/repo/src/new.ts'; + const oldContent = 'old\n'; + const newContent = 'new\n'; + readFile.mockImplementation(async (filePath: string) => { + if (filePath === newPath) return newContent; + if (filePath === oldPath) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + throw new Error(`unexpected read ${filePath}`); + }); + mkdir.mockResolvedValue(undefined); + writeFile.mockResolvedValue(undefined); + unlink.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }, + ], + }, + new Map([ + [ + newPath, + { + filePath: newPath, + relativePath: 'src/new.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath: oldPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: oldContent, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-old', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: oldContent, + modifiedFullContent: null, + beforeHash: sha(oldContent), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(oldContent) }, + afterState: { exists: false }, + relation, + }, + }, + { + toolUseId: 'ledger-1', + filePath: newPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: newContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:01.000Z', + isError: false, + ledger: { + eventId: 'event-new', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: newContent, + beforeHash: null, + afterHash: sha(newContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(newContent) }, + relation, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: oldContent, + modifiedFullContent: newContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res).toMatchObject({ applied: 1, conflicts: 0 }); + expect(mkdir).toHaveBeenCalledWith('/repo/src', { recursive: true }); + expect(writeFile).toHaveBeenCalledWith(oldPath, oldContent, 'utf8'); + expect(unlink).toHaveBeenCalledWith(newPath); + }); + + it('ledger rename reject blocks when new path hash changed', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + const oldPath = '/repo/src/old.ts'; + const newPath = '/repo/src/new.ts'; + const oldContent = 'old\n'; + const newContent = 'new\n'; + readFile.mockResolvedValue('user changed\n'); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }, + ], + }, + new Map([ + [ + newPath, + { + filePath: newPath, + relativePath: 'src/new.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath: oldPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: oldContent, + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-old', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: oldContent, + modifiedFullContent: null, + beforeHash: sha(oldContent), + afterHash: null, + operation: 'delete', + beforeState: { exists: true, sha256: sha(oldContent) }, + afterState: { exists: false }, + relation, + }, + }, + { + toolUseId: 'ledger-1', + filePath: newPath, + toolName: 'Bash', + type: 'shell-snapshot', + oldString: '', + newString: newContent, + replaceAll: false, + timestamp: '2026-03-01T10:00:01.000Z', + isError: false, + ledger: { + eventId: 'event-new', + source: 'ledger-snapshot', + confidence: 'high', + originalFullContent: null, + modifiedFullContent: newContent, + beforeHash: null, + afterHash: sha(newContent), + operation: 'create', + beforeState: { exists: false }, + afterState: { exists: true, sha256: sha(newContent) }, + relation, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: oldContent, + modifiedFullContent: newContent, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.conflicts).toBe(1); + expect(res.errors[0]?.code).toBe('conflict'); + expect(writeFile).not.toHaveBeenCalled(); + expect(unlink).not.toHaveBeenCalled(); + }); }); + +function sha(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts new file mode 100644 index 00000000..5879b345 --- /dev/null +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -0,0 +1,222 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; + +import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader'; + +const TASK_ID = 'task-1'; + +describe('TaskChangeLedgerReader', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('returns warning-only notice bundles even when no file events exist', async () => { + tmpDir = await makeLedgerBundle({ + events: [], + notices: [ + { + schemaVersion: 1, + noticeId: 'notice-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + timestamp: '2026-03-01T10:00:00.000Z', + severity: 'warning', + message: + 'Task change ledger skipped attribution because multiple task scopes were active.', + }, + ], + }); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + 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.' + ); + expect(result?.scope.toolUseIds).toEqual(['tool-1']); + }); + + it('maps ledger state and rename relation into snippets', async () => { + tmpDir = await makeLedgerBundle({ + events: [ + { + schemaVersion: 1, + eventId: 'event-1', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'modify', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/new.ts', + relativePath: 'src/new.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + beforeState: { exists: true, unavailableReason: 'binary file' }, + afterState: { exists: true, unavailableReason: 'binary file' }, + relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' }, + linesAdded: 0, + linesRemoved: 0, + }, + ], + }); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: true, + }); + + const snippet = result?.files[0]?.snippets[0]; + expect(snippet?.ledger?.beforeState?.unavailableReason).toBe('binary file'); + expect(snippet?.ledger?.relation).toEqual({ + kind: 'rename', + oldPath: 'src/old.ts', + newPath: 'src/new.ts', + }); + expect(result?.files[0]?.relativePath).toBe('src/new.ts'); + }); + + it('groups rename relations in summary-only bundles without losing absolute paths', async () => { + const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' }; + tmpDir = await makeLedgerBundle({ + events: [ + { + schemaVersion: 1, + eventId: 'event-old', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'delete', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/old.ts', + relativePath: 'src/old.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + relation, + linesAdded: 0, + linesRemoved: 2, + }, + { + schemaVersion: 1, + eventId: 'event-new', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'session-1', + toolUseId: 'tool-1', + source: 'shell_snapshot', + operation: 'create', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/new.ts', + relativePath: 'src/new.ts', + timestamp: '2026-03-01T10:00:01.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + relation, + linesAdded: 3, + linesRemoved: 0, + }, + ], + }); + + 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/src/new.ts'); + expect(result?.files[0]?.relativePath).toBe('src/new.ts'); + expect(result?.files[0]?.isNewFile).toBe(false); + expect(result?.files[0]?.linesAdded).toBe(3); + expect(result?.files[0]?.linesRemoved).toBe(2); + }); +}); + +async function makeLedgerBundle(params: { + events: unknown[]; + notices?: unknown[]; +}): Promise { + const dir = await fsTempDir(); + const bundleDir = path.join(dir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId: TASK_ID, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: params.events.length, + files: params.events.map((event: any) => ({ + filePath: event.filePath, + relativePath: event.relativePath, + eventIds: [event.eventId], + linesAdded: event.linesAdded ?? 0, + linesRemoved: event.linesRemoved ?? 0, + isNewFile: event.operation === 'create', + latestAfterHash: event.after?.sha256 ?? null, + })), + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: params.events.length, + confidence: 'high', + warnings: [], + events: params.events, + ...(params.notices ? { notices: params.notices } : {}), + }), + 'utf8' + ); + return dir; +} + +async function fsTempDir(): Promise { + return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-')); +}