merge: opencode hybrid evidence ui continuation
This commit is contained in:
commit
ca16ccecce
19 changed files with 1579 additions and 210 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
15
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 1;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 2;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
1
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js
vendored
Normal file
1
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 2;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue