From 5bc9f6db7bebc8216b4170c5df16a62dbb9f211e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 21:24:35 +0300 Subject: [PATCH] fix(changes): hide suppressed opencode ledger imports --- .../services/team/TaskChangeLedgerReader.ts | 17 +- .../team/TaskChangeLedgerReader.test.ts | 159 ++++++++++++++++-- 2 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 3982cee2..93d990e4 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -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); diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 11b494cd..46d71530 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -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,