fix(changes): hide suppressed opencode ledger imports

This commit is contained in:
777genius 2026-05-28 21:24:35 +03:00
parent 371bf948c2
commit 5bc9f6db7b
2 changed files with 160 additions and 16 deletions

View file

@ -139,6 +139,9 @@ interface LedgerEvent {
sourceImportKey?: string;
evidenceProof?: string;
supersedesEventId?: string;
suppressed?: true;
suppressionReason?: string;
suppressedAt?: string;
snapshotId?: string;
snapshotSource?: string;
}
@ -1209,10 +1212,12 @@ export class TaskChangeLedgerReader {
events.forEach((event, index) => {
const sourceImportKey = this.sourceImportKeyForEvent(event);
if (!sourceImportKey) {
passthrough.push({ event, index });
if (event.suppressed !== true) {
passthrough.push({ event, index });
}
return;
}
const rank = this.evidenceRankForEvent(event);
const rank = this.projectionRankForEvent(event);
const existing = selectedBySourceImportKey.get(sourceImportKey);
if (!existing || rank >= existing.rank) {
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
@ -1221,7 +1226,9 @@ export class TaskChangeLedgerReader {
return [
...passthrough,
...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })),
...[...selectedBySourceImportKey.values()]
.filter(({ event }) => event.suppressed !== true)
.map(({ event, index }) => ({ event, index })),
]
.sort((left, right) => left.index - right.index)
.map(({ event }) => event);
@ -1241,6 +1248,10 @@ export class TaskChangeLedgerReader {
return null;
}
private projectionRankForEvent(event: LedgerEvent): number {
return event.suppressed === true ? Number.MAX_SAFE_INTEGER : this.evidenceRankForEvent(event);
}
private evidenceRankForEvent(event: LedgerEvent): number {
const hasFullText = this.hasFullTextEvidence(event);

View file

@ -1,12 +1,10 @@
import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader';
import { createHash } from 'crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
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';
function safeTaskIdSegment(taskId: string): string {
@ -363,6 +361,130 @@ describe('TaskChangeLedgerReader', () => {
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
});
it('hides suppressed OpenCode journal imports without hiding legitimate same-file imports', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs');
await mkdir(eventsDir, { recursive: true });
await mkdir(blobsDir, { recursive: true });
const beforeContent = 'export const value = 1;\n';
const staleAfterContent = 'export const value = "ambient";\n';
const legitAfterContent = 'export const value = 2;\n';
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
await writeFile(path.join(blobsDir, 'stale-after.txt'), staleAfterContent, 'utf8');
await writeFile(path.join(blobsDir, 'legit-after.txt'), legitAfterContent, 'utf8');
const staleSourceImportKey = 'opencode\0session-1\0part-stale\0src/file.ts';
const legitSourceImportKey = 'opencode\0session-1\0part-legit\0src/file.ts';
const baseEvent = {
schemaVersion: 1,
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
sourceRuntime: 'opencode',
sourceProvider: 'opencode',
evidenceProof: 'opencode-snapshot',
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
};
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
...baseEvent,
eventId: 'event-stale',
toolUseId: 'part-stale',
sourceImportKey: staleSourceImportKey,
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: {
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
blobRef: 'stale-after.txt',
},
afterState: {
exists: true,
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
},
linesAdded: 1,
linesRemoved: 1,
},
{
...baseEvent,
eventId: 'event-stale-suppressed',
toolUseId: 'opencode-snapshot-only-suppression',
sourceImportKey: staleSourceImportKey,
before: null,
after: null,
afterState: {
exists: true,
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
},
linesAdded: 0,
linesRemoved: 0,
suppressed: true,
suppressionReason: 'snapshot-only evidence does not prove file authorship',
suppressedAt: '2026-03-01T10:01:00.000Z',
supersedesEventId: 'event-stale',
},
{
...baseEvent,
eventId: 'event-legit',
toolUseId: 'part-legit',
sourceImportKey: legitSourceImportKey,
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: {
sha256: sha(legitAfterContent),
sizeBytes: legitAfterContent.length,
blobRef: 'legit-after.txt',
},
afterState: {
exists: true,
sha256: sha(legitAfterContent),
sizeBytes: legitAfterContent.length,
},
linesAdded: 1,
linesRemoved: 1,
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.relativePath).toBe('src/file.ts');
expect(result?.files[0]?.linesAdded).toBe(1);
expect(result?.files[0]?.linesRemoved).toBe(1);
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets).toHaveLength(1);
expect(snippets[0]?.ledger?.eventId).toBe('event-legit');
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(legitAfterContent);
});
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({
@ -1110,15 +1232,26 @@ async function makeLedgerBundle(params: {
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,
})),
files: params.events.map((event) => {
const record = event as {
filePath?: string;
relativePath?: string;
eventId?: string;
linesAdded?: number;
linesRemoved?: number;
operation?: string;
after?: { sha256?: string } | null;
};
return {
filePath: record.filePath,
relativePath: record.relativePath,
eventIds: [record.eventId],
linesAdded: record.linesAdded ?? 0,
linesRemoved: record.linesRemoved ?? 0,
isNewFile: record.operation === 'create',
latestAfterHash: record.after?.sha256 ?? null,
};
}),
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: params.events.length,