merge: opencode hybrid evidence ui continuation

This commit is contained in:
777genius 2026-04-29 11:19:19 +03:00
commit ca16ccecce
19 changed files with 1579 additions and 210 deletions

View file

@ -9,11 +9,12 @@ import {
} from '@shared/utils/taskChangeState';
import { createHash } from 'crypto';
import { existsSync } from 'fs';
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract';
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeTeamRuntimeDirectory,
@ -46,7 +47,7 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types
const logger = createLogger('Service:ChangeExtractorService');
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE = 'chain-only' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
/** Кеш-запись: данные + mtime файла + время протухания */
@ -71,18 +72,29 @@ interface OpenCodeBackfillCacheEntry {
expiresAt: number;
}
interface OpenCodeBackfillAttempt {
attempted: boolean;
backfilled: boolean;
}
interface OpenCodeDeliveryContextTempFile {
filePath: string | null;
hash: string | null;
cleanup: () => Promise<void>;
}
interface OpenCodeDeliveryContextPayload {
rawContext: string;
hash: string;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
private taskChangeSummaryVersionByTask = new Map<string, number>();
private taskChangeSummaryValidationInFlight = new Set<string>();
private openCodeBackfillInFlight = new Map<string, Promise<boolean>>();
private openCodeBackfillInFlight = new Map<string, Promise<OpenCodeBackfillAttempt>>();
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
@ -210,7 +222,8 @@ export class ChangeExtractorService {
return ledgerResult;
}
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) {
await this.recordTaskChangePresence(
@ -379,15 +392,17 @@ export class ChangeExtractorService {
}
}
private async tryBackfillOpenCodeLedger(input: ResolvedTaskChangeComputeInput): Promise<boolean> {
private async tryBackfillOpenCodeLedger(
input: ResolvedTaskChangeComputeInput
): Promise<OpenCodeBackfillAttempt> {
if (!this.openCodeLedgerBackfillPort) {
return false;
return { attempted: false, backfilled: false };
}
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
return false;
return { attempted: false, backfilled: false };
}
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return false;
return { attempted: false, backfilled: false };
}
const context = await this.logsFinder
@ -401,7 +416,7 @@ export class ChangeExtractorService {
!path.isAbsolute(projectDir) ||
!path.isAbsolute(workspaceRoot)
) {
return false;
return { attempted: false, backfilled: false };
}
const sourceGeneration = this.teamLogSourceTracker
@ -414,8 +429,16 @@ export class ChangeExtractorService {
input.teamName,
input.taskId
);
const deliveryContextFingerprint =
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload(
input.teamName,
input.taskId,
deliveryContextRecords
);
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
input.effectiveOptions.owner,
deliveryContextRecords
);
const deliveryContextFingerprint = deliveryContextPayload.hash;
const cacheKey = this.buildOpenCodeBackfillCacheKey({
teamName: input.teamName,
@ -426,12 +449,12 @@ export class ChangeExtractorService {
sourceGeneration,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
});
const now = Date.now();
const cached = this.openCodeBackfillCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.backfilledAt > 0;
return { attempted: false, backfilled: cached.backfilledAt > 0 };
}
this.openCodeBackfillCache.delete(cacheKey);
@ -446,15 +469,16 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: 0,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
}).catch(() => undefined);
return false;
return { attempted: false, backfilled: false };
}
const existing = this.openCodeBackfillInFlight.get(cacheKey);
@ -468,7 +492,9 @@ export class ChangeExtractorService {
workspaceRoot,
cacheKey,
deliveryContextRecords,
sourceGeneration
deliveryContextPayload,
sourceGeneration,
backfillMemberName
).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey);
});
@ -484,12 +510,15 @@ export class ChangeExtractorService {
deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>,
sourceGeneration: string | null
): Promise<boolean> {
deliveryContextPayload: OpenCodeDeliveryContextPayload,
sourceGeneration: string | null,
backfillMemberName?: string
): Promise<OpenCodeBackfillAttempt> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName,
input.taskId,
deliveryContextRecords
deliveryContextRecords,
deliveryContextPayload
);
try {
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
@ -497,26 +526,48 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
taskDisplayId: input.taskMeta?.displayId,
memberName: input.effectiveOptions.owner,
projectDir,
workspaceRoot,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
...(deliveryContext.filePath
? {
deliveryContextPath: deliveryContext.filePath,
deliveryContextHash: deliveryContext.hash ?? undefined,
}
: {}),
});
const evidenceContractVersion =
typeof result.opencodeTaskLedgerEvidenceContractVersion === 'number' &&
Number.isInteger(result.opencodeTaskLedgerEvidenceContractVersion)
? result.opencodeTaskLedgerEvidenceContractVersion
: 0;
const hasExpectedEvidenceContract =
evidenceContractVersion >= OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
const diagnostics = hasExpectedEvidenceContract
? (result.diagnostics ?? [])
: [
`OpenCode task ledger evidence contract is unsupported or missing: ${evidenceContractVersion}.`,
...(result.diagnostics ?? []),
];
void appendOpenCodeTaskChangeDiag({
event: 'backfill_result',
reason: this.classifyOpenCodeBackfillResult(result),
reason:
!hasExpectedEvidenceContract && result.importedEvents <= 0
? 'unsupported-evidence-contract'
: this.classifyOpenCodeBackfillResult(result),
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
result: {
opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion,
attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
outcome: result.outcome,
dryRun: result.dryRun,
@ -526,13 +577,14 @@ export class ChangeExtractorService {
importedEvents: result.importedEvents,
skippedEvents: result.skippedEvents,
},
diagnostics: (result.diagnostics ?? []).slice(0, 25),
diagnostics: diagnostics.slice(0, 25),
notices: (result.notices ?? []).slice(0, 25),
}).catch(() => undefined);
const backfilled =
result.importedEvents > 0 ||
result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0);
(hasExpectedEvidenceContract &&
(result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0)));
if (result.importedEvents > 0) {
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
@ -540,7 +592,7 @@ export class ChangeExtractorService {
});
}
if (backfilled || deliveryContextRecords.length === 0) {
if ((hasExpectedEvidenceContract && backfilled) || deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: backfilled ? Date.now() : 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
@ -549,12 +601,12 @@ export class ChangeExtractorService {
this.openCodeBackfillCache.delete(cacheKey);
}
if (result.diagnostics.length > 0 && result.outcome !== 'no-history') {
if (diagnostics.length > 0 && result.outcome !== 'no-history') {
logger.debug(
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}`
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${diagnostics.join('; ')}`
);
}
return backfilled;
return { attempted: true, backfilled };
} catch (error) {
logger.warn(
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
@ -565,11 +617,12 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined);
if (deliveryContextRecords.length === 0) {
@ -580,7 +633,7 @@ export class ChangeExtractorService {
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
return false;
return { attempted: true, backfilled: false };
} finally {
await deliveryContext.cleanup();
}
@ -647,36 +700,47 @@ export class ChangeExtractorService {
private async createOpenCodeDeliveryContextTempFile(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>,
payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records)
): Promise<OpenCodeDeliveryContextTempFile> {
if (records.length === 0) {
return { filePath: null, cleanup: async () => undefined };
return { filePath: null, hash: null, cleanup: async () => undefined };
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-'));
await chmod(dir, 0o700).catch(() => undefined);
const filePath = path.join(dir, 'delivery-context.json');
await writeFile(
filePath,
`${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 });
return {
filePath,
hash: payload.hash,
cleanup: async () => {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
private buildOpenCodeDeliveryContextPayload(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): OpenCodeDeliveryContextPayload {
const rawContext = `${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`;
return {
hash: createHash('sha256').update(rawContext).digest('hex'),
rawContext,
};
}
private async readOpenCodeDeliveryContextRecords(
teamName: string,
taskId: string
@ -748,6 +812,18 @@ export class ChangeExtractorService {
return records.slice(-200);
}
private resolveOpenCodeBackfillMemberName(
owner: string | undefined,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string | undefined {
const members = [...new Set(records.map((record) => record.memberName.trim()).filter(Boolean))];
const normalizedOwner = owner?.trim();
if (normalizedOwner && members.includes(normalizedOwner)) {
return normalizedOwner;
}
return members.length === 1 ? members[0] : undefined;
}
private async readOpenCodeRuntimeLaneIdsFromDisk(
teamsBasePath: string,
teamName: string
@ -770,50 +846,6 @@ export class ChangeExtractorService {
return laneIds.sort((left, right) => left.localeCompare(right));
}
private hashOpenCodeDeliveryContextRecords(
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string {
const stableRecords = records
.map((record) => ({
memberName: record.memberName,
laneId: record.laneId ?? '',
runtimeSessionId: record.runtimeSessionId ?? '',
inboxMessageId: record.inboxMessageId ?? '',
deliveredUserMessageId: record.deliveredUserMessageId ?? '',
taskRefs: record.taskRefs
.map((taskRef) => ({
taskId: taskRef.taskId,
displayId: taskRef.displayId,
teamName: taskRef.teamName,
}))
.sort((left, right) =>
`${left.teamName}\0${left.taskId}\0${left.displayId}`.localeCompare(
`${right.teamName}\0${right.taskId}\0${right.displayId}`
)
),
}))
.sort((left, right) =>
[
left.laneId,
left.memberName,
left.runtimeSessionId,
left.inboxMessageId,
left.deliveredUserMessageId,
]
.join('\0')
.localeCompare(
[
right.laneId,
right.memberName,
right.runtimeSessionId,
right.inboxMessageId,
right.deliveredUserMessageId,
].join('\0')
)
);
return createHash('sha256').update(JSON.stringify(stableRecords)).digest('hex');
}
private async readOpenCodePromptDeliveryLedgerRecords(
filePath: string
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
@ -841,7 +873,7 @@ export class ChangeExtractorService {
sourceGeneration?: string | null;
deliveryContextFingerprint: string;
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
evidenceMode: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE;
evidencePipeline: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE;
}): string {
return JSON.stringify({
teamName: input.teamName,
@ -852,7 +884,7 @@ export class ChangeExtractorService {
sourceGeneration: input.sourceGeneration ?? '',
deliveryContextFingerprint: input.deliveryContextFingerprint,
attributionMode: input.attributionMode,
evidenceMode: input.evidenceMode,
evidencePipeline: input.evidencePipeline,
});
}

View file

@ -293,6 +293,7 @@ export class ReviewApplierService {
decision.fileDecision === 'rejected',
allHunksRejected,
rejectedHunkIndices,
decision.hunkContextHashes,
fileContent.snippets
);
if (ledgerOutcome.handled) {
@ -450,6 +451,7 @@ export class ReviewApplierService {
fileRejected: boolean,
allHunksRejected: boolean,
rejectedHunkIndices: number[],
hunkContextHashes: Record<number, string> | undefined,
snippets: SnippetDiff[]
): Promise<LedgerApplyOutcome> {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
@ -497,6 +499,20 @@ export class ReviewApplierService {
error: 'Ledger full text is unavailable; partial reject requires manual review.',
};
}
const strictHunks = mapRejectedHunkIndicesByHashStrict(
original,
modified,
rejectedHunkIndices,
hunkContextHashes
);
if (!strictHunks.ok) {
return {
handled: true,
status: strictHunks.code === 'conflict' ? 'conflict' : 'error',
code: strictHunks.code,
error: strictHunks.error,
};
}
const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
@ -504,7 +520,7 @@ export class ReviewApplierService {
if (!guard.ok) {
return guard.outcome;
}
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
if (!patchResult) {
return {
handled: true,
@ -1035,6 +1051,46 @@ export class ReviewApplierService {
hadConflicts: false,
};
}
private tryStrictHunkLevelReject(
original: string,
modified: string,
hunkIndices: number[]
): RejectResult | null {
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return null;
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null;
const inversedHunks: StructuredPatchHunk[] = [];
for (const idx of validIndices) {
const hunk = patch.hunks[idx];
if (!hunk) return null;
inversedHunks.push(invertHunk(hunk));
}
const inversePatch = {
oldFileName: 'file',
newFileName: 'file',
oldHeader: undefined,
newHeader: undefined,
hunks: inversedHunks,
};
const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 });
if (result === false) {
logger.debug('Strict ledger hunk-level inverse patch не удался');
return null;
}
return {
success: true,
newContent: result,
hadConflicts: false,
};
}
}
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash(
return [...out].sort((a, b) => a - b);
}
function mapRejectedHunkIndicesByHashStrict(
original: string,
modified: string,
rejectedIndices: number[],
hunkContextHashes: Record<number, string> | undefined
): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } {
if (rejectedIndices.length === 0) {
return { ok: true, indices: [] };
}
if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject requires stable hunk context hashes.',
};
}
const hashMap = buildHunkHashIndexMap(original, modified);
const out = new Set<number>();
for (const idx of rejectedIndices) {
const hash = hunkContextHashes[idx];
if (!hash) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject is missing a hunk context hash.',
};
}
const candidates = hashMap.get(hash);
if (!candidates || candidates.length === 0) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context changed; please re-review.',
};
}
if (candidates.length > 1) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
};
}
out.add(candidates[0]!);
}
return { ok: true, indices: [...out].sort((a, b) => a - b) };
}
// ── Module-level helpers ──
/**

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;
@ -428,11 +435,7 @@ export class TaskChangeLedgerReader {
return null;
}
const provenance = this.buildLedgerProvenance(
bundle.journalStamp,
bundle.integrity,
bundle.schemaVersion
);
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
if (
freshness &&
@ -450,11 +453,7 @@ export class TaskChangeLedgerReader {
) {
return {
bundle,
provenance: this.buildLedgerProvenance(
journalStamp,
bundle.integrity,
bundle.schemaVersion
),
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
mode: 'validated',
};
}
@ -694,6 +693,86 @@ export class TaskChangeLedgerReader {
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
}
private buildLedgerProvenanceFromSummaryBundle(
bundle: LedgerSummaryBundleV2,
journalStamp: TaskChangeJournalStamp = bundle.journalStamp
): TaskChangeProvenance {
return {
sourceKind: 'ledger',
sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)),
journalStamp,
bundleSchemaVersion: bundle.schemaVersion,
integrity: bundle.integrity,
};
}
private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown {
return {
kind: 'ledger-summary-v2-projected-identity',
schemaVersion: bundle.schemaVersion,
bundleKind: bundle.bundleKind,
taskId: bundle.taskId,
integrity: bundle.integrity,
totalFiles: bundle.totalFiles,
totalLinesAdded: bundle.totalLinesAdded,
totalLinesRemoved: bundle.totalLinesRemoved,
diffStatCompleteness: bundle.diffStatCompleteness,
confidence: bundle.confidence,
files: [...bundle.files]
.map((file) => ({
changeKey: this.normalizeSummaryChangeKey(file),
filePath: normalizePathForComparison(file.filePath),
relativePath: normalizePathForComparison(file.relativePath),
displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined,
linesAdded: file.linesAdded,
linesRemoved: file.linesRemoved,
diffStatKnown: file.diffStatKnown,
latestOperation: file.latestOperation,
createdInTask: file.createdInTask,
deletedInTask: file.deletedInTask,
baselineExists: file.baselineExists,
finalExists: file.finalExists,
latestBeforeHash: file.latestBeforeHash,
latestAfterHash: file.latestAfterHash,
latestBeforeState: this.contentStateFingerprint(file.latestBeforeState),
latestAfterState: this.contentStateFingerprint(file.latestAfterState),
contentAvailability: file.contentAvailability,
reviewability: file.reviewability,
relation: file.relation
? {
kind: file.relation.kind,
oldPath: normalizePathForComparison(file.relation.oldPath),
newPath: normalizePathForComparison(file.relation.newPath),
}
: undefined,
worktreePath: file.worktreePath
? normalizePathForComparison(file.worktreePath)
: undefined,
worktreeBranch: file.worktreeBranch,
baseWorkspaceRoot: file.baseWorkspaceRoot
? normalizePathForComparison(file.baseWorkspaceRoot)
: undefined,
}))
.sort(
(left, right) =>
left.changeKey.localeCompare(right.changeKey) ||
left.filePath.localeCompare(right.filePath)
),
};
}
private contentStateFingerprint(state: LedgerContentState | undefined): unknown {
if (!state) {
return undefined;
}
return {
exists: state.exists,
sha256: state.sha256,
sizeBytes: state.sizeBytes,
unavailableReason: state.unavailableReason,
};
}
private hashFingerprintPayload(payload: unknown): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
@ -793,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,
});
@ -836,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)
@ -883,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 {
@ -893,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.',
@ -972,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),
@ -993,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 } : {}),
@ -1021,6 +1103,7 @@ export class TaskChangeLedgerReader {
scope: LedgerSummaryScopeV2,
files: LedgerSummaryFileV2[]
): TaskChangeScope {
const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : [];
return {
taskId,
memberName:
@ -1039,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 } : {}),
@ -1064,6 +1147,75 @@ 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 = this.hasFullTextEvidence(event);
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 hasFullTextEvidence(event: Pick<LedgerEvent, 'before' | 'after' | 'operation'>): boolean {
if (event.operation === 'create') {
return event.after !== null;
}
if (event.operation === 'delete') {
return event.before !== null;
}
return event.before !== null && event.after !== null;
}
private async readContentRef(
projectDir: string,
ref: LedgerContentRef | null

View file

@ -1,6 +1,7 @@
import { createHash } from 'crypto';
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
export type OpenCodeBridgeCommandName =
| 'opencode.handshake'
@ -239,18 +240,12 @@ export interface OpenCodeBackfillTaskLedgerCommandBody {
projectDir?: string;
workspaceRoot?: string;
deliveryContextPath?: string;
deliveryContextHash?: string;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
dryRun?: boolean;
}
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
export type OpenCodeBackfillTaskLedgerEvidenceMode =
| 'off'
| 'metadata-only'
| 'chain-only'
| 'snapshot-probe'
| 'snapshot-auto';
export type OpenCodeBackfillTaskLedgerOutcome =
| 'imported'
@ -265,13 +260,13 @@ export type OpenCodeBackfillTaskLedgerOutcome =
export interface OpenCodeBackfillTaskLedgerCommandData {
schemaVersion: 1;
providerId: 'opencode';
opencodeTaskLedgerEvidenceContractVersion?: number;
teamName: string;
taskId?: string;
projectDir?: string;
workspaceRoot?: string;
dryRun: boolean;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
strictWindowCandidateCount?: number;
openCodeDbFingerprint?: string;
deliveryLedgerFingerprint?: string;
@ -369,6 +364,7 @@ export interface OpenCodeBridgePeerIdentity {
minVersion: number;
currentVersion: number;
supportedCommands: OpenCodeBridgeCommandName[];
opencodeTaskLedgerEvidenceContractVersion?: number;
};
runtime: {
providerId: 'opencode';
@ -853,7 +849,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
(bridgeProtocol.minVersion as number) < 1 ||
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
!Array.isArray(bridgeProtocol.supportedCommands) ||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName)
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
) {
return false;
}

View file

@ -308,7 +308,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
dryRun: input.dryRun === true,
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}),
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,

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

@ -1,10 +1,12 @@
import * as os from 'os';
import * as path from 'path';
import { createHash } from 'crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
@ -79,6 +81,7 @@ async function writeOpenCodeDeliveryLedger(
taskId: string;
displayId: string;
teamName: string;
taskRefs: { taskId: string; displayId: string; teamName: string }[];
}>
): Promise<string> {
const memberName = overrides?.memberName ?? 'bob';
@ -108,7 +111,7 @@ async function writeOpenCodeDeliveryLedger(
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [
taskRefs: overrides?.taskRefs ?? [
{
taskId: overrides?.taskId ?? TASK_ID,
displayId: overrides?.displayId ?? 'abc12345',
@ -126,6 +129,70 @@ async function writeOpenCodeDeliveryLedger(
return filePath;
}
async function writeOpenCodeLedgerBundle(
projectDir: string,
projectPath: string,
taskId: string = TASK_ID
): Promise<void> {
const bundleDir = path.join(projectDir, '.board-task-changes', 'bundles');
await fs.mkdir(bundleDir, { recursive: true });
await fs.writeFile(
path.join(bundleDir, `${encodeURIComponent(taskId)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 1,
files: [
{
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
eventIds: ['event-1'],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
latestAfterHash: null,
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
totalFiles: 1,
confidence: 'high',
warnings: [],
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId,
taskRef: taskId,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
},
],
}),
'utf8'
);
}
function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
}
@ -934,64 +1001,13 @@ describe('ChangeExtractorService', () => {
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
let deliveryContextHashVerified = false;
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
await fs.mkdir(bundleDir, { recursive: true });
await fs.writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 1,
files: [
{
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
eventIds: ['event-1'],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
latestAfterHash: null,
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
totalFiles: 1,
confidence: 'high',
warnings: [],
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
},
],
}),
'utf8'
);
deliveryContextHashVerified =
createHash('sha256')
.update(await fs.readFile(input.deliveryContextPath, 'utf8'))
.digest('hex') === input.deliveryContextHash;
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
return {
schemaVersion: 1,
providerId: 'opencode',
@ -1058,12 +1074,340 @@ describe('ChangeExtractorService', () => {
projectDir,
workspaceRoot: projectPath,
attributionMode: 'strict-delivery',
evidenceMode: 'chain-only',
})
);
const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0];
expect(backfillInput.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
expect(deliveryContextHashVerified).toBe(true);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('evidenceMode');
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('rereads ledger when OpenCode backfill writes artifacts and then fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
throw new Error('timeout after import');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(result.files[0]?.snippets[0]?.toolName).toBe('Write');
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('uses the OpenCode delivery member when the current task owner changed later', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob' });
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({
memberName: 'bob',
attributionMode: 'strict-delivery',
})
);
});
it('omits member filter when multiple OpenCode delivery members match the task', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob', runtimeSessionId: 'session-1' });
await writeOpenCodeDeliveryLedger(tmpDir, {
memberName: 'carol',
runtimeSessionId: 'session-2',
});
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('memberName');
});
it('ignores OpenCode delivery records that match only a recreated task display id', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, {
taskId: 'old-task',
displayId: 'abc12345',
memberName: 'bob',
});
const backfillOpenCodeTaskLedger = vi.fn(async () => {
throw new Error('display-id-only delivery record must not backfill');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(0);
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
});
it('ignores OpenCode delivery records that only mention related tasks', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, {
taskId: 'related-task',
displayId: 'def67890',
memberName: 'bob',
});
const backfillOpenCodeTaskLedger = vi.fn(async () => {
throw new Error('related-only delivery record must not backfill');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(0);
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
});
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
@ -1182,11 +1526,13 @@ describe('ChangeExtractorService', () => {
projectDir,
workspaceRoot: projectPath,
deliveryContextPath: expect.stringContaining('delivery-context.json'),
deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/),
attributionMode: 'strict-delivery',
evidenceMode: 'chain-only',
})
);
});
const backfillCalls = backfillOpenCodeTaskLedger.mock.calls as unknown as Array<[Record<string, unknown>]>;
expect(backfillCalls[0]?.[0]).not.toHaveProperty('evidenceMode');
expect(settled).toBe(false);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
@ -1289,6 +1635,7 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
});
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
@ -1331,24 +1678,30 @@ describe('ChangeExtractorService', () => {
'utf8'
);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-attribution',
notices: [],
diagnostics: [],
}));
let backfillAttempt = 0;
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution';
return {
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome,
notices: [],
diagnostics: outcome === 'transient-error'
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
: [],
};
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
@ -1394,8 +1747,158 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
});
it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 1,
candidateEvents: 1,
importedEvents: 0,
skippedEvents: 1,
outcome: 'duplicates-only',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
});
it('caches duplicates-only OpenCode backfill from the current evidence contract', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
opencodeTaskLedgerEvidenceContractVersion:
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 1,
candidateEvents: 1,
importedEvents: 0,
skippedEvents: 1,
outcome: 'duplicates-only',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
});
});

View file

@ -6,6 +6,7 @@ import {
createOpenCodeBridgeHandshakeIdentityHash,
createOpenCodeBridgeIdempotencyKey,
isOpenCodeBridgeCommandName,
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
parseSingleBridgeJsonResult,
stableHash,
validateBridgeResultEnvelope,
@ -202,6 +203,42 @@ describe('OpenCodeBridgeCommandContract', () => {
});
});
it('accepts handshake evidence contract version and rejects invalid values', () => {
const client = peerIdentity('claude_team');
const server = peerIdentity('agent_teams_orchestrator');
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion =
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
const validHandshake = buildHandshake({ client, server });
expect(
validateOpenCodeBridgeHandshake({
handshake: validHandshake,
expectedClient: client,
requiredCommand: 'opencode.launchTeam',
expectedCapabilitySnapshotId: 'cap-1',
expectedManifestHighWatermark: 10,
expectedRunId: 'run-1',
})
).toEqual({ ok: true });
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion = 0;
const invalidHandshake = buildHandshake({ client, server });
expect(
validateOpenCodeBridgeHandshake({
handshake: invalidHandshake,
expectedClient: client,
requiredCommand: 'opencode.launchTeam',
expectedCapabilitySnapshotId: 'cap-1',
expectedManifestHighWatermark: 10,
expectedRunId: 'run-1',
})
).toEqual({
ok: false,
reason: 'Bridge handshake peer identity is invalid',
});
});
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
const first = createOpenCodeBridgeIdempotencyKey({
command: 'opencode.launchTeam',

View file

@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => {
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
deliveryContextHash: 'a'.repeat(64),
})
).resolves.toMatchObject({
outcome: 'imported',
@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => {
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
deliveryContextHash: 'a'.repeat(64),
},
{
cwd: '/repo',

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'crypto';
import { structuredPatch } from 'diff';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import type { SnippetDiff } from '@shared/types';
@ -985,7 +986,8 @@ describe('ReviewApplierService', () => {
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected' },
hunkDecisions: { 0: 'rejected', 1: 'pending' },
hunkContextHashes: buildHunkContextHashes(original, modified),
},
],
},
@ -1034,8 +1036,99 @@ describe('ReviewApplierService', () => {
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
});
it('ledger partial reject refuses stale hunk context instead of falling back to index', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
const filePath = '/tmp/stale-ledger.ts';
const original = 'const value = 1;\nconst keep = true;\n';
const modified = 'const value = 2;\nconst keep = true;\n';
readFile.mockResolvedValue(modified);
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
const svc = new ReviewApplierService();
const res = await svc.applyReviewDecisions(
{
teamName: 'team',
decisions: [
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected', 1: 'pending' },
hunkContextHashes: { 0: 'stale-context-hash' },
},
],
},
new Map([
[
filePath,
{
filePath,
relativePath: 'stale-ledger.ts',
snippets: [
{
toolUseId: 'ledger-1',
filePath,
toolName: 'Edit',
type: 'edit',
oldString: 'const value = 1;\n',
newString: 'const value = 2;\n',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-exact',
confidence: 'exact',
originalFullContent: original,
modifiedFullContent: modified,
beforeHash: sha(original),
afterHash: sha(modified),
operation: 'modify',
beforeState: { exists: true, sha256: sha(original) },
afterState: { exists: true, sha256: sha(modified) },
},
},
],
linesAdded: 1,
linesRemoved: 1,
isNewFile: false,
originalFullContent: original,
modifiedFullContent: modified,
contentSource: 'ledger-exact',
},
],
])
);
expect(res.applied).toBe(0);
expect(res.conflicts).toBe(1);
expect(res.errors[0]?.code).toBe('conflict');
expect(writeFile).not.toHaveBeenCalled();
});
});
function sha(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
function buildHunkContextHashes(original: string, modified: string): Record<number, string> {
const patch = structuredPatch('file', 'file', original, modified);
const out: Record<number, string> = {};
for (let i = 0; i < patch.hunks.length; i++) {
const hunk = patch.hunks[i]!;
const oldSideContent = hunk.lines
.filter((line) => !line.startsWith('+'))
.map((line) => line.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((line) => !line.startsWith('-'))
.map((line) => line.slice(1))
.join('\n');
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
}
return out;
}

View file

@ -285,6 +285,84 @@ describe('TaskChangeLedgerReader', () => {
expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot');
});
it('projects partial OpenCode snapshot journal evidence to a later full-text upgrade', 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 afterContent = 'export const value = 2;\n';
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
await writeFile(path.join(blobsDir, 'after.txt'), afterContent, 'utf8');
const sourceImportKey = 'opencode\0session-1\0part-edit\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',
toolUseId: 'part-edit',
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',
sourceImportKey,
evidenceProof: 'opencode-snapshot',
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
afterState: { exists: true, sha256: sha(afterContent), sizeBytes: afterContent.length },
linesAdded: 1,
linesRemoved: 1,
};
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
...baseEvent,
eventId: 'event-partial',
before: null,
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
{
...baseEvent,
eventId: 'event-full',
supersedesEventId: 'event-partial',
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
]
.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);
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets).toHaveLength(1);
expect(snippets[0]?.ledger?.eventId).toBe('event-full');
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
});
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({
@ -614,6 +692,91 @@ describe('TaskChangeLedgerReader', () => {
);
});
it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } },
eventCount: 1,
noticeCount: 0,
warningCount: 0,
warnings: [],
},
file: {
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
agentIds: ['alice@team'],
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
generatedAt: '2026-03-01T11:00:00.000Z',
journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } },
eventCount: 7,
noticeCount: 3,
warningCount: 1,
warnings: ['raw journal had a recovered warning'],
},
file: {
eventCount: 7,
firstTimestamp: '2026-03-01T09:00:00.000Z',
lastTimestamp: '2026-03-01T11:00:00.000Z',
agentIds: ['alice@team', 'bob@team'],
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint);
});
it('changes v2 provenance fingerprint when projected file evidence changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v1'),
latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 },
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v2'),
latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 },
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint);
});
it('keeps identical relative rename relations isolated by worktree path', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
@ -969,6 +1132,74 @@ async function makeLedgerBundle(params: {
return dir;
}
async function makeSummaryLedgerBundleV2(params: {
bundle?: Record<string, unknown>;
file?: Record<string, unknown>;
} = {}): Promise<string> {
const dir = await fsTempDir();
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
const file = {
changeKey: 'path:/repo/src/file.ts',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
linesAdded: 1,
linesRemoved: 1,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: sha('before'),
latestAfterHash: sha('after'),
latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 },
latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 },
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
...params.file,
};
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [file],
totalLinesAdded: 1,
totalLinesRemoved: 1,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
...params.bundle,
}),
'utf8'
);
return dir;
}
async function fsTempDir(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
}

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

View file

@ -1651,4 +1651,153 @@ describe('changeReviewSlice task changes', () => {
});
expect(store.getState().activeChangeSet).toEqual(current);
});
it('does not force re-review when ledger provenance stays stable despite warning changes', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-stable',
},
warnings: [],
};
const fresh = {
...current,
computedAt: '2026-03-01T13:00:00.000Z',
warnings: ['raw journal warning changed'],
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
hoisted.applyDecisions.mockResolvedValueOnce({
applied: 1,
skipped: 0,
conflicts: 0,
errors: [],
});
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
changeSetEpoch: 0,
fileContentVersionByPath: {},
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(store.getState().applyError).toBeNull();
expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1);
expect(store.getState().activeChangeSet).toEqual(current);
});
it('forces re-review when ledger projected provenance changes with the same file paths', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v1',
},
};
const fresh = {
...current,
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v2',
},
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }],
changeSetEpoch: 2,
fileContentVersionByPath: { '/repo/file.ts': 3 },
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().applyError).toBe(
'Changes have been updated since you started reviewing. Please re-review.'
);
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().fileDecisions).toEqual({});
expect(store.getState().reviewUndoStack).toEqual([]);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
it('clears metadata-only decisions when ledger evidence upgrades to full text for the same changeKey', async () => {
const store = createSliceStore();
const changeKey = 'path:/repo/file.ts';
const currentFile = {
...makeFile('/repo/file.ts'),
changeKey,
snippets: [],
ledgerSummary: {
latestOperation: 'modify',
contentAvailability: 'metadata-only',
reviewability: 'metadata-only',
},
};
const freshFile = {
...makeFile('/repo/file.ts'),
changeKey,
ledgerSummary: {
latestOperation: 'modify',
contentAvailability: 'full-text',
reviewability: 'full-text',
beforeState: { exists: true, sha256: 'before-hash', sizeBytes: 6 },
afterState: { exists: true, sha256: 'after-hash', sizeBytes: 5 },
},
};
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
files: [currentFile],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'metadata-only-projection',
},
};
const fresh = {
...current,
files: [freshFile],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'snapshot-full-text-projection',
},
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
fileDecisions: { [changeKey]: 'rejected' },
hunkContextHashesByFile: { [changeKey]: { 0: 'metadata-only-context' } },
fileChunkCounts: { [changeKey]: 1 },
reviewUndoStack: [
{
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
fileDecisions: { [changeKey]: 'rejected' },
},
],
changeSetEpoch: 4,
fileContentVersionByPath: { '/repo/file.ts': 2 },
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().fileDecisions).toEqual({});
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().reviewUndoStack).toEqual([]);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
});