From fdf5ddeb61753b5f9830ef823a0806fe2ccbce77 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:34:17 +0300 Subject: [PATCH] fix(changes): project opencode upgrades in detail view --- .../services/team/TaskChangeLedgerReader.ts | 104 +++++++++++++++--- .../opencode-snapshot-upgrade/manifest.json | 15 +++ .../fixture-opencode-snapshot-upgrade.json | 1 + ...bb741815adaa06f6d396f46739c279eec0fc25cfb6 | 1 + ...fe77a71f364d012bad8e892b1ba9adaa30909fb887 | 1 + .../fixture-opencode-snapshot-upgrade.json | 1 + .../fixture-opencode-snapshot-upgrade.jsonl | 2 + .../project/src/snapshot-only.js | 1 + ...skChangeLedgerFixtures.integration.test.ts | 44 ++++++++ 9 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index f07e523e..3f5d3cff 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -129,6 +129,13 @@ interface LedgerEvent { linesRemoved?: number; replaceAll?: boolean; warnings?: string[]; + sourceRuntime?: 'opencode'; + sourceProvider?: 'opencode'; + sourceImportKey?: string; + evidenceProof?: string; + supersedesEventId?: string; + snapshotId?: string; + snapshotSource?: string; } interface LedgerNotice { @@ -196,7 +203,7 @@ interface LedgerSummaryScopeV2 { primaryAgentId?: string; primaryMemberName?: string; memberName: string; - agentIds: string[]; + agentIds?: string[]; memberNames?: string[]; startTimestamp: string; endTimestamp: string; @@ -865,9 +872,10 @@ export class TaskChangeLedgerReader { bundle?: LedgerSummaryBundleV2; provenance: TaskChangeProvenance; }): Promise { - const snippets = await this.buildSnippets(params.projectDir, params.journal.events); + const projectedEvents = this.projectJournalEventsForUi(params.journal.events); + const snippets = await this.buildSnippets(params.projectDir, projectedEvents); const groupedSnippets = this.groupSnippets(snippets); - const warnings = this.collectWarnings(params.journal.events, params.journal.notices, { + const warnings = this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }); @@ -908,15 +916,15 @@ export class TaskChangeLedgerReader { totalLinesAdded = fallback.totalLinesAdded; totalLinesRemoved = fallback.totalLinesRemoved; totalFiles = fallback.files.length; - confidence = params.journal.events.some((event) => event.confidence === 'low') + confidence = projectedEvents.some((event) => event.confidence === 'low') ? 'low' - : params.journal.events.some((event) => event.confidence === 'medium') + : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high'; scope = this.buildFallbackScope( params.taskId, files, - params.journal.events, + projectedEvents, params.journal.notices ); diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) @@ -955,7 +963,8 @@ export class TaskChangeLedgerReader { undefined, params.journal.recovered ? 'recovered' : 'ok' ); - const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null)); + const projectedEvents = this.projectJournalEventsForUi(params.journal.events); + const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null)); const grouped = this.groupSnippets(snippets); const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); return { @@ -965,20 +974,20 @@ export class TaskChangeLedgerReader { totalLinesAdded: fallback.totalLinesAdded, totalLinesRemoved: fallback.totalLinesRemoved, totalFiles: fallback.files.length, - confidence: params.journal.events.some((event) => event.confidence === 'low') + confidence: projectedEvents.some((event) => event.confidence === 'low') ? 'low' - : params.journal.events.some((event) => event.confidence === 'medium') + : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high', computedAt: new Date().toISOString(), scope: this.buildFallbackScope( params.taskId, fallback.files, - params.journal.events, + projectedEvents, params.journal.notices ), warnings: [ - ...this.collectWarnings(params.journal.events, params.journal.notices, { + ...this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }), 'Task change summary fell back to journal reconstruction.', @@ -1044,6 +1053,7 @@ export class TaskChangeLedgerReader { private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { const displayPath = file.displayPath ?? file.filePath; const filePath = this.normalizeLedgerFilePath(file.filePath); + const agentIds = Array.isArray(file.agentIds) ? file.agentIds : []; return { filePath, relativePath: this.relativePath(displayPath, projectPath, file.relativePath), @@ -1065,7 +1075,7 @@ export class TaskChangeLedgerReader { ...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}), ...(file.latestAfterState ? { afterState: file.latestAfterState } : {}), ...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}), - ...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}), + ...(agentIds.length > 0 ? { agentIds } : {}), ...(file.memberNames ? { memberNames: file.memberNames } : {}), ...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}), ...(file.worktreePath ? { worktreePath: file.worktreePath } : {}), @@ -1093,6 +1103,7 @@ export class TaskChangeLedgerReader { scope: LedgerSummaryScopeV2, files: LedgerSummaryFileV2[] ): TaskChangeScope { + const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : []; return { taskId, memberName: @@ -1111,7 +1122,7 @@ export class TaskChangeLedgerReader { ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), ...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}), - ...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}), + ...(agentIds.length > 0 ? { agentIds } : {}), ...(scope.memberNames ? { memberNames: scope.memberNames } : {}), ...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}), ...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}), @@ -1136,6 +1147,73 @@ export class TaskChangeLedgerReader { ); } + private projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] { + const selectedBySourceImportKey = new Map< + string, + { event: LedgerEvent; index: number; rank: number } + >(); + const passthrough: Array<{ event: LedgerEvent; index: number }> = []; + + events.forEach((event, index) => { + const sourceImportKey = this.sourceImportKeyForEvent(event); + if (!sourceImportKey) { + passthrough.push({ event, index }); + return; + } + const rank = this.evidenceRankForEvent(event); + const existing = selectedBySourceImportKey.get(sourceImportKey); + if (!existing || rank >= existing.rank) { + selectedBySourceImportKey.set(sourceImportKey, { event, index, rank }); + } + }); + + return [ + ...passthrough, + ...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })), + ] + .sort((left, right) => left.index - right.index) + .map(({ event }) => event); + } + + private sourceImportKeyForEvent(event: LedgerEvent): string | null { + if ( + event.sourceImportKey && + (event.sourceRuntime === 'opencode' || + event.sourceProvider === 'opencode' || + event.source === 'opencode_toolpart_write' || + event.source === 'opencode_toolpart_edit' || + event.source === 'opencode_toolpart_apply_patch') + ) { + return event.sourceImportKey; + } + return null; + } + + private evidenceRankForEvent(event: LedgerEvent): number { + const hasFullText = + event.before !== null || + event.after !== null || + (event.operation === 'create' && + event.afterState?.exists === true && + !event.afterState.unavailableReason) || + (event.operation === 'delete' && + event.beforeState?.exists === true && + !event.beforeState.unavailableReason); + + switch (event.evidenceProof) { + case 'opencode-snapshot': + return hasFullText ? 50 : 35; + case 'inverse-apply-patch-chain': + case 'inverse-edit-chain': + case 'toolpart-chain': + return hasFullText ? 40 : 25; + case 'metadata-only-fallback': + return 10; + default: + return hasFullText ? 30 : 5; + } + } + private async readContentRef( projectDir: string, ref: LedgerContentRef | null diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json new file mode 100644 index 00000000..1b0344f6 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "opencode-snapshot-upgrade", + "taskId": "fixture-opencode-snapshot-upgrade", + "description": "OpenCode metadata-only import upgraded by source-driven snapshot evidence into one visible full-text row.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/snapshot-only.js" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json new file mode 100644 index 00000000..2c97190e --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-opencode-snapshot-upgrade","updatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 new file mode 100644 index 00000000..d10a8442 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 @@ -0,0 +1 @@ +export const snapshot = 1; diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 new file mode 100644 index 00000000..99778790 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 @@ -0,0 +1 @@ +export const snapshot = 2; diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json new file mode 100644 index 00000000..4ebb1b89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-opencode-snapshot-upgrade","generatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"integrity":"ok","eventCount":2,"projectedEventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:bob","primaryMemberName":"bob","memberName":"bob","memberNames":["bob"],"startTimestamp":"2026-04-26T10:00:01.000Z","endTimestamp":"2026-04-26T10:00:01.000Z","toolUseIds":["bob-edit-snapshot-only"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":0,"end":0},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:bob","memberName":"bob","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z"}]},"files":[{"changeKey":"modify:__PROJECT_ROOT__/src/snapshot-only.js","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"journalEventCount":2,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","latestAfterHash":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","latestBeforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"latestAfterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:bob","memberNames":["bob"],"executionSeqRange":{"start":0,"end":0}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl new file mode 100644 index 00000000..23734d04 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl @@ -0,0 +1,2 @@ +{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"medium","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:00.000Z","toolStatus":"succeeded","before":null,"after":null,"beforeState":{"exists":true,"unavailableReason":"opencode-before-content-unavailable"},"afterState":{"exists":true,"unavailableReason":"opencode-edit-final-content-unavailable"},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":0,"linesRemoved":0,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"metadata-only-fallback","warnings":["OpenCode edit was captured without a git/snapshot baseline; apply/reject is manual-only."],"eventId":"opencode-metadata-only-event"} +{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:01.000Z","toolStatus":"succeeded","before":{"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27,"blobRef":"sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6"},"after":{"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27,"blobRef":"sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887"},"beforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"afterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":1,"linesRemoved":1,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"inverse-edit-chain","snapshotId":"opencode-snapshot-window-fixture","snapshotSource":"opencode","supersedesEventId":"opencode-metadata-only-event","eventId":"opencode-snapshot-upgrade-event"} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js new file mode 100644 index 00000000..99778790 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js @@ -0,0 +1 @@ +export const snapshot = 2; diff --git a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts index 1eb45550..212a4b35 100644 --- a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts +++ b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts @@ -315,6 +315,50 @@ describe('task change ledger golden fixtures', () => { expect(resolved.contentSource).toBe('ledger-snapshot'); }); + it('reads OpenCode snapshot upgrade fixtures as one full-text ledger row', async () => { + const fixture = await materializeTaskChangeLedgerFixture('opencode-snapshot-upgrade'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + + expect(changeSet?.files).toHaveLength(1); + const file = changeSet!.files[0]!; + expect(file.relativePath).toBe('src/snapshot-only.js'); + expect(file.ledgerSummary).toMatchObject({ + reviewability: 'full-text', + contentAvailability: 'full-text', + }); + expect(file.snippets).toHaveLength(1); + const snippet = file.snippets[0]!; + expect(snippet.toolName).toBe('Edit'); + expect(snippet.type).toBe('edit'); + expect(snippet.ledger).toMatchObject({ + source: 'ledger-snapshot', + confidence: 'high', + textAvailability: 'full-text', + operation: 'modify', + }); + expect(snippet.ledger?.originalFullContent).toBe('export const snapshot = 1;\n'); + expect(snippet.ledger?.modifiedFullContent).toBe('export const snapshot = 2;\n'); + + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent( + TEAM_NAME, + 'bob', + file.filePath, + file.snippets + ); + expect(resolved.originalFullContent).toBe('export const snapshot = 1;\n'); + expect(resolved.modifiedFullContent).toBe('export const snapshot = 2;\n'); + expect(resolved.contentSource).toBe('ledger-snapshot'); + }); + it('rejects grouped copy fixtures by deleting only the copied path', async () => { const fixture = await materializeTaskChangeLedgerFixture('copy'); cleanups.push(fixture.cleanup);