fix(changes): project opencode upgrades in detail view

This commit is contained in:
777genius 2026-04-28 23:34:17 +03:00
parent 143f905b86
commit fdf5ddeb61
9 changed files with 157 additions and 13 deletions

View file

@ -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<TaskChangeSetV2> {
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

View file

@ -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": []
}
}

View file

@ -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"}

View file

@ -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":[]}

View file

@ -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"}

View file

@ -0,0 +1 @@
export const snapshot = 2;

View file

@ -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);