fix(changes): keep metadata-only ledger events manual

This commit is contained in:
777genius 2026-04-28 11:28:20 +03:00
parent 8f7712ed74
commit e48ecf664a
4 changed files with 107 additions and 3 deletions

View file

@ -471,6 +471,15 @@ export class ReviewApplierService {
);
const relation = this.resolveLedgerRelation(ledgerSnippets);
if (hasUnavailableState) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger content metadata is unavailable; manual review is required.',
};
}
if (!fullReject) {
if (relation?.kind === 'rename' || relation?.kind === 'copy') {
return {

View file

@ -105,7 +105,8 @@ interface LedgerEvent {
| 'powershell_snapshot'
| 'post_tool_hook_snapshot'
| 'opencode_toolpart_write'
| 'opencode_toolpart_edit';
| 'opencode_toolpart_edit'
| 'opencode_toolpart_apply_patch';
operation: 'create' | 'modify' | 'delete';
confidence: LedgerConfidence;
workspaceRoot: string;
@ -1135,6 +1136,7 @@ export class TaskChangeLedgerReader {
case 'notebook_edit':
return 'NotebookEdit';
case 'opencode_toolpart_edit':
case 'opencode_toolpart_apply_patch':
return 'Edit';
case 'bash_simulated_sed':
case 'shell_snapshot':

View file

@ -189,6 +189,72 @@ describe('ReviewApplierService', () => {
expect(unlink).toHaveBeenCalledWith(filePath);
});
it('ledger create reject blocks metadata-only create even when final hash is known', 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 { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
const svc = new ReviewApplierService();
const filePath = '/tmp/metadata-only-created.txt';
const content = 'created\n';
const res = await svc.applyReviewDecisions(
{
teamName: 'team',
decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }],
},
new Map([
[
filePath,
{
filePath,
relativePath: 'metadata-only-created.txt',
snippets: [
{
toolUseId: 'ledger-1',
filePath,
toolName: 'Edit',
type: 'edit',
oldString: '',
newString: '',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-snapshot',
confidence: 'medium',
originalFullContent: null,
modifiedFullContent: null,
beforeHash: null,
afterHash: sha(content),
operation: 'create',
beforeState: {
exists: false,
unavailableReason: 'gitless-before-content-unavailable',
},
afterState: { exists: true, sha256: sha(content), sizeBytes: content.length },
},
},
],
linesAdded: 0,
linesRemoved: 0,
isNewFile: true,
originalFullContent: null,
modifiedFullContent: null,
contentSource: 'ledger-snapshot',
},
],
])
);
expect(res.applied).toBe(0);
expect(res.errors[0]?.code).toBe('manual-review-required');
expect(readFile).not.toHaveBeenCalled();
expect(unlink).not.toHaveBeenCalled();
});
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>;

View file

@ -241,6 +241,32 @@ describe('TaskChangeLedgerReader', () => {
linesAdded: 1,
linesRemoved: 1,
},
{
schemaVersion: 1,
eventId: 'event-apply-patch',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 3,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-apply-patch',
source: 'opencode_toolpart_apply_patch',
operation: 'modify',
confidence: 'medium',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:02:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
beforeState: { exists: true, unavailableReason: 'opencode-apply-patch-before-content-unavailable' },
afterState: { exists: true, unavailableReason: 'opencode-apply-patch-final-content-unavailable' },
linesAdded: 0,
linesRemoved: 0,
},
],
});
@ -254,8 +280,9 @@ describe('TaskChangeLedgerReader', () => {
});
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit']);
expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit']);
expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit', 'Edit']);
expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit', 'edit']);
expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot');
});
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {