feat(ledger): integrate task change ledger functionality into file content resolution and review application processes

This commit is contained in:
777genius 2026-04-21 12:25:42 +03:00
parent 98a9c25cfe
commit 99102565f3
13 changed files with 2000 additions and 25 deletions

View file

@ -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<TaskChangeSetV2 | null> {
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

View file

@ -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

View file

@ -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;
}

View file

@ -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<ApplyReviewResult['errors'][number]['code']>;
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<LedgerApplyOutcome> {
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<LedgerApplyOutcome> {
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;

View file

@ -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<TaskChangeSetV2 | null> {
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<LedgerBundle | null> {
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<SnippetDiff[]> {
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<string | null> {
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('/');
}
}

View file

@ -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);

View file

@ -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)}

View file

@ -9,6 +9,8 @@ import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
const CONTENT_SOURCE_LABELS: Record<string, string> = {
'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 = ({
</span>
)}
{requiresManualLedgerReview && (
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
MANUAL REVIEW
</span>
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{externalChange && onReloadFromDisk && onKeepDraft && (
<div className="mr-1 flex items-center gap-1.5">
@ -242,7 +258,7 @@ export const FileSectionHeader = ({
<span>
<button
onClick={() => onRejectFile(file.filePath)}
disabled={applying || isPreviewOnly}
disabled={applying || rejectDisabled}
className={[
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
fileDecision === 'rejected'
@ -254,9 +270,11 @@ export const FileSectionHeader = ({
</button>
</span>
</TooltipTrigger>
{isPreviewOnly && (
{rejectDisabled && (
<TooltipContent side="bottom">
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.'}
</TooltipContent>
)}
</Tooltip>

View file

@ -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 (
<div className="overflow-hidden rounded-lg border border-border">
@ -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<string>();
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 (
<div className="space-y-4 p-4">
{ledgerMetadataRows.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{ledgerMetadataRows.map((row) => (
<div key={row}>{row}</div>
))}
</div>
)}
{nonErrorSnippets.map((snippet, index) => (
<SnippetDiffView
key={`${snippet.toolUseId}-${index}`}

View file

@ -1,9 +1,29 @@
/** Один snippet-level дифф от одного tool_use */
export interface LedgerContentState {
exists?: boolean;
sha256?: string;
sizeBytes?: number;
unavailableReason?: string;
}
export interface LedgerChangeRelation {
kind: 'rename' | 'copy';
oldPath: string;
newPath: string;
}
export interface SnippetDiff {
toolUseId: string;
filePath: string;
toolName: 'Edit' | 'Write' | 'MultiEdit';
type: 'edit' | 'write-new' | 'write-update' | 'multi-edit';
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit' | 'Bash' | 'PowerShell' | 'PostToolUse';
type:
| 'edit'
| 'write-new'
| 'write-update'
| 'multi-edit'
| 'notebook-edit'
| 'shell-snapshot'
| 'hook-snapshot';
oldString: string;
newString: string;
replaceAll: boolean;
@ -11,6 +31,21 @@ export interface SnippetDiff {
isError: boolean;
/** Hash of ±3 surrounding context lines for reliable hunk↔snippet matching */
contextHash?: string;
/** Exact content captured by the orchestrator task-change ledger. */
ledger?: {
eventId: string;
source: 'ledger-exact' | 'ledger-snapshot';
confidence: 'exact' | 'high' | 'medium' | 'low' | 'ambiguous';
originalFullContent: string | null;
modifiedFullContent: string | null;
beforeHash: string | null;
afterHash: string | null;
operation?: 'create' | 'modify' | 'delete';
beforeState?: LedgerContentState;
afterState?: LedgerContentState;
relation?: LedgerChangeRelation;
executionSeq?: number;
};
}
/** Агрегированные изменения по файлу */
@ -106,7 +141,11 @@ export interface ApplyReviewResult {
applied: number;
skipped: number;
conflicts: number;
errors: { filePath: string; error: string }[];
errors: {
filePath: string;
error: string;
code?: 'conflict' | 'unavailable' | 'manual-review-required' | 'io-error';
}[];
}
/** Полный file content для CodeMirror */
@ -114,6 +153,8 @@ export interface FileChangeWithContent extends FileChangeSummary {
originalFullContent: string | null;
modifiedFullContent: string | null;
contentSource:
| 'ledger-exact'
| 'ledger-snapshot'
| 'file-history'
| 'snippet-reconstruction'
| 'disk-current'
@ -174,7 +215,7 @@ export interface FileEditEvent {
/** tool_use.id */
toolUseId: string;
/** Тип операции */
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit';
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit' | 'Bash' | 'PowerShell' | 'PostToolUse';
/** Timestamp из JSONL */
timestamp: string;
/** Краткое описание: "Edited 3 lines", "Created new file", etc */

View file

@ -56,6 +56,76 @@ describe('FileContentResolver', () => {
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<typeof vi.fn>;
@ -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');

View file

@ -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<typeof vi.fn>;
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
const mkdir = fsPromises.mkdir as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
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');
}

View file

@ -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<string> {
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<string> {
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
}