feat(ledger): integrate task change ledger functionality into file content resolution and review application processes
This commit is contained in:
parent
98a9c25cfe
commit
99102565f3
13 changed files with 2000 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
538
src/main/services/team/TaskChangeLedgerReader.ts
Normal file
538
src/main/services/team/TaskChangeLedgerReader.ts
Normal 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('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
222
test/main/services/team/TaskChangeLedgerReader.test.ts
Normal file
222
test/main/services/team/TaskChangeLedgerReader.test.ts
Normal 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-'));
|
||||
}
|
||||
Loading…
Reference in a new issue