fix(changes): hide suppressed opencode ledger imports
This commit is contained in:
parent
371bf948c2
commit
5bc9f6db7b
2 changed files with 160 additions and 16 deletions
|
|
@ -139,6 +139,9 @@ interface LedgerEvent {
|
||||||
sourceImportKey?: string;
|
sourceImportKey?: string;
|
||||||
evidenceProof?: string;
|
evidenceProof?: string;
|
||||||
supersedesEventId?: string;
|
supersedesEventId?: string;
|
||||||
|
suppressed?: true;
|
||||||
|
suppressionReason?: string;
|
||||||
|
suppressedAt?: string;
|
||||||
snapshotId?: string;
|
snapshotId?: string;
|
||||||
snapshotSource?: string;
|
snapshotSource?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1209,10 +1212,12 @@ export class TaskChangeLedgerReader {
|
||||||
events.forEach((event, index) => {
|
events.forEach((event, index) => {
|
||||||
const sourceImportKey = this.sourceImportKeyForEvent(event);
|
const sourceImportKey = this.sourceImportKeyForEvent(event);
|
||||||
if (!sourceImportKey) {
|
if (!sourceImportKey) {
|
||||||
passthrough.push({ event, index });
|
if (event.suppressed !== true) {
|
||||||
|
passthrough.push({ event, index });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rank = this.evidenceRankForEvent(event);
|
const rank = this.projectionRankForEvent(event);
|
||||||
const existing = selectedBySourceImportKey.get(sourceImportKey);
|
const existing = selectedBySourceImportKey.get(sourceImportKey);
|
||||||
if (!existing || rank >= existing.rank) {
|
if (!existing || rank >= existing.rank) {
|
||||||
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
|
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
|
||||||
|
|
@ -1221,7 +1226,9 @@ export class TaskChangeLedgerReader {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...passthrough,
|
...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)
|
.sort((left, right) => left.index - right.index)
|
||||||
.map(({ event }) => event);
|
.map(({ event }) => event);
|
||||||
|
|
@ -1241,6 +1248,10 @@ export class TaskChangeLedgerReader {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private projectionRankForEvent(event: LedgerEvent): number {
|
||||||
|
return event.suppressed === true ? Number.MAX_SAFE_INTEGER : this.evidenceRankForEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
private evidenceRankForEvent(event: LedgerEvent): number {
|
private evidenceRankForEvent(event: LedgerEvent): number {
|
||||||
const hasFullText = this.hasFullTextEvidence(event);
|
const hasFullText = this.hasFullTextEvidence(event);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
|
import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
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';
|
const TASK_ID = 'task-1';
|
||||||
|
|
||||||
function safeTaskIdSegment(taskId: string): string {
|
function safeTaskIdSegment(taskId: string): string {
|
||||||
|
|
@ -363,6 +361,130 @@ describe('TaskChangeLedgerReader', () => {
|
||||||
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
|
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 () => {
|
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' };
|
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
||||||
tmpDir = await makeLedgerBundle({
|
tmpDir = await makeLedgerBundle({
|
||||||
|
|
@ -1110,15 +1232,26 @@ async function makeLedgerBundle(params: {
|
||||||
taskId: TASK_ID,
|
taskId: TASK_ID,
|
||||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||||
eventCount: params.events.length,
|
eventCount: params.events.length,
|
||||||
files: params.events.map((event: any) => ({
|
files: params.events.map((event) => {
|
||||||
filePath: event.filePath,
|
const record = event as {
|
||||||
relativePath: event.relativePath,
|
filePath?: string;
|
||||||
eventIds: [event.eventId],
|
relativePath?: string;
|
||||||
linesAdded: event.linesAdded ?? 0,
|
eventId?: string;
|
||||||
linesRemoved: event.linesRemoved ?? 0,
|
linesAdded?: number;
|
||||||
isNewFile: event.operation === 'create',
|
linesRemoved?: number;
|
||||||
latestAfterHash: event.after?.sha256 ?? null,
|
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,
|
totalLinesAdded: 0,
|
||||||
totalLinesRemoved: 0,
|
totalLinesRemoved: 0,
|
||||||
totalFiles: params.events.length,
|
totalFiles: params.events.length,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue