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';
|
} from '@shared/utils/taskChangeState';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { existsSync } from 'fs';
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
|
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
|
||||||
|
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract';
|
||||||
import {
|
import {
|
||||||
getOpenCodeLaneScopedRuntimeFilePath,
|
getOpenCodeLaneScopedRuntimeFilePath,
|
||||||
getOpenCodeTeamRuntimeDirectory,
|
getOpenCodeTeamRuntimeDirectory,
|
||||||
|
|
@ -46,7 +47,7 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types
|
||||||
|
|
||||||
const logger = createLogger('Service:ChangeExtractorService');
|
const logger = createLogger('Service:ChangeExtractorService');
|
||||||
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
|
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;
|
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
|
||||||
|
|
||||||
/** Кеш-запись: данные + mtime файла + время протухания */
|
/** Кеш-запись: данные + mtime файла + время протухания */
|
||||||
|
|
@ -71,18 +72,29 @@ interface OpenCodeBackfillCacheEntry {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenCodeBackfillAttempt {
|
||||||
|
attempted: boolean;
|
||||||
|
backfilled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface OpenCodeDeliveryContextTempFile {
|
interface OpenCodeDeliveryContextTempFile {
|
||||||
filePath: string | null;
|
filePath: string | null;
|
||||||
|
hash: string | null;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenCodeDeliveryContextPayload {
|
||||||
|
rawContext: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ChangeExtractorService {
|
export class ChangeExtractorService {
|
||||||
private cache = new Map<string, CacheEntry>();
|
private cache = new Map<string, CacheEntry>();
|
||||||
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
|
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
|
||||||
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
|
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
|
||||||
private taskChangeSummaryVersionByTask = new Map<string, number>();
|
private taskChangeSummaryVersionByTask = new Map<string, number>();
|
||||||
private taskChangeSummaryValidationInFlight = new Set<string>();
|
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 openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
|
||||||
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
|
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||||
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
||||||
|
|
@ -210,7 +222,8 @@ export class ChangeExtractorService {
|
||||||
return ledgerResult;
|
return ledgerResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
|
||||||
|
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
|
||||||
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
||||||
if (backfilledLedgerResult) {
|
if (backfilledLedgerResult) {
|
||||||
await this.recordTaskChangePresence(
|
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) {
|
if (!this.openCodeLedgerBackfillPort) {
|
||||||
return false;
|
return { attempted: false, backfilled: false };
|
||||||
}
|
}
|
||||||
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
|
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
|
||||||
return false;
|
return { attempted: false, backfilled: false };
|
||||||
}
|
}
|
||||||
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
|
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
|
||||||
return false;
|
return { attempted: false, backfilled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await this.logsFinder
|
const context = await this.logsFinder
|
||||||
|
|
@ -401,7 +416,7 @@ export class ChangeExtractorService {
|
||||||
!path.isAbsolute(projectDir) ||
|
!path.isAbsolute(projectDir) ||
|
||||||
!path.isAbsolute(workspaceRoot)
|
!path.isAbsolute(workspaceRoot)
|
||||||
) {
|
) {
|
||||||
return false;
|
return { attempted: false, backfilled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceGeneration = this.teamLogSourceTracker
|
const sourceGeneration = this.teamLogSourceTracker
|
||||||
|
|
@ -414,8 +429,16 @@ export class ChangeExtractorService {
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.taskId
|
input.taskId
|
||||||
);
|
);
|
||||||
const deliveryContextFingerprint =
|
const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload(
|
||||||
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
|
input.teamName,
|
||||||
|
input.taskId,
|
||||||
|
deliveryContextRecords
|
||||||
|
);
|
||||||
|
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
|
||||||
|
input.effectiveOptions.owner,
|
||||||
|
deliveryContextRecords
|
||||||
|
);
|
||||||
|
const deliveryContextFingerprint = deliveryContextPayload.hash;
|
||||||
|
|
||||||
const cacheKey = this.buildOpenCodeBackfillCacheKey({
|
const cacheKey = this.buildOpenCodeBackfillCacheKey({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -426,12 +449,12 @@ export class ChangeExtractorService {
|
||||||
sourceGeneration,
|
sourceGeneration,
|
||||||
deliveryContextFingerprint,
|
deliveryContextFingerprint,
|
||||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
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 now = Date.now();
|
||||||
const cached = this.openCodeBackfillCache.get(cacheKey);
|
const cached = this.openCodeBackfillCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt > now) {
|
if (cached && cached.expiresAt > now) {
|
||||||
return cached.backfilledAt > 0;
|
return { attempted: false, backfilled: cached.backfilledAt > 0 };
|
||||||
}
|
}
|
||||||
this.openCodeBackfillCache.delete(cacheKey);
|
this.openCodeBackfillCache.delete(cacheKey);
|
||||||
|
|
||||||
|
|
@ -446,15 +469,16 @@ export class ChangeExtractorService {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
displayId: input.taskMeta?.displayId ?? null,
|
displayId: input.taskMeta?.displayId ?? null,
|
||||||
memberName: input.effectiveOptions.owner ?? null,
|
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
sourceGeneration,
|
sourceGeneration,
|
||||||
deliveryRecordCount: 0,
|
deliveryRecordCount: 0,
|
||||||
deliveryContextFingerprint,
|
deliveryContextFingerprint,
|
||||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||||
|
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
return false;
|
return { attempted: false, backfilled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = this.openCodeBackfillInFlight.get(cacheKey);
|
const existing = this.openCodeBackfillInFlight.get(cacheKey);
|
||||||
|
|
@ -468,7 +492,9 @@ export class ChangeExtractorService {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
deliveryContextRecords,
|
deliveryContextRecords,
|
||||||
sourceGeneration
|
deliveryContextPayload,
|
||||||
|
sourceGeneration,
|
||||||
|
backfillMemberName
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
this.openCodeBackfillInFlight.delete(cacheKey);
|
this.openCodeBackfillInFlight.delete(cacheKey);
|
||||||
});
|
});
|
||||||
|
|
@ -484,12 +510,15 @@ export class ChangeExtractorService {
|
||||||
deliveryContextRecords: Awaited<
|
deliveryContextRecords: Awaited<
|
||||||
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
|
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
|
||||||
>,
|
>,
|
||||||
sourceGeneration: string | null
|
deliveryContextPayload: OpenCodeDeliveryContextPayload,
|
||||||
): Promise<boolean> {
|
sourceGeneration: string | null,
|
||||||
|
backfillMemberName?: string
|
||||||
|
): Promise<OpenCodeBackfillAttempt> {
|
||||||
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
|
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.taskId,
|
input.taskId,
|
||||||
deliveryContextRecords
|
deliveryContextRecords,
|
||||||
|
deliveryContextPayload
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
|
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
|
||||||
|
|
@ -497,26 +526,48 @@ export class ChangeExtractorService {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
taskDisplayId: input.taskMeta?.displayId,
|
taskDisplayId: input.taskMeta?.displayId,
|
||||||
memberName: input.effectiveOptions.owner,
|
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||||
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
|
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
|
||||||
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
|
...(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({
|
void appendOpenCodeTaskChangeDiag({
|
||||||
event: 'backfill_result',
|
event: 'backfill_result',
|
||||||
reason: this.classifyOpenCodeBackfillResult(result),
|
reason:
|
||||||
|
!hasExpectedEvidenceContract && result.importedEvents <= 0
|
||||||
|
? 'unsupported-evidence-contract'
|
||||||
|
: this.classifyOpenCodeBackfillResult(result),
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
displayId: input.taskMeta?.displayId ?? null,
|
displayId: input.taskMeta?.displayId ?? null,
|
||||||
memberName: input.effectiveOptions.owner ?? null,
|
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
sourceGeneration,
|
sourceGeneration,
|
||||||
deliveryRecordCount: deliveryContextRecords.length,
|
deliveryRecordCount: deliveryContextRecords.length,
|
||||||
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
|
deliveryContextFingerprint: deliveryContextPayload.hash,
|
||||||
|
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||||
result: {
|
result: {
|
||||||
|
opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion,
|
||||||
attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||||
outcome: result.outcome,
|
outcome: result.outcome,
|
||||||
dryRun: result.dryRun,
|
dryRun: result.dryRun,
|
||||||
|
|
@ -526,13 +577,14 @@ export class ChangeExtractorService {
|
||||||
importedEvents: result.importedEvents,
|
importedEvents: result.importedEvents,
|
||||||
skippedEvents: result.skippedEvents,
|
skippedEvents: result.skippedEvents,
|
||||||
},
|
},
|
||||||
diagnostics: (result.diagnostics ?? []).slice(0, 25),
|
diagnostics: diagnostics.slice(0, 25),
|
||||||
notices: (result.notices ?? []).slice(0, 25),
|
notices: (result.notices ?? []).slice(0, 25),
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
const backfilled =
|
const backfilled =
|
||||||
result.importedEvents > 0 ||
|
result.importedEvents > 0 ||
|
||||||
result.outcome === 'imported' ||
|
(hasExpectedEvidenceContract &&
|
||||||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0);
|
(result.outcome === 'imported' ||
|
||||||
|
(result.outcome === 'duplicates-only' && result.candidateEvents > 0)));
|
||||||
|
|
||||||
if (result.importedEvents > 0) {
|
if (result.importedEvents > 0) {
|
||||||
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
|
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, {
|
this.openCodeBackfillCache.set(cacheKey, {
|
||||||
backfilledAt: backfilled ? Date.now() : 0,
|
backfilledAt: backfilled ? Date.now() : 0,
|
||||||
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
|
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
|
||||||
|
|
@ -549,12 +601,12 @@ export class ChangeExtractorService {
|
||||||
this.openCodeBackfillCache.delete(cacheKey);
|
this.openCodeBackfillCache.delete(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.diagnostics.length > 0 && result.outcome !== 'no-history') {
|
if (diagnostics.length > 0 && result.outcome !== 'no-history') {
|
||||||
logger.debug(
|
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) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
|
`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,
|
teamName: input.teamName,
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
displayId: input.taskMeta?.displayId ?? null,
|
displayId: input.taskMeta?.displayId ?? null,
|
||||||
memberName: input.effectiveOptions.owner ?? null,
|
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
deliveryRecordCount: deliveryContextRecords.length,
|
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),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
if (deliveryContextRecords.length === 0) {
|
if (deliveryContextRecords.length === 0) {
|
||||||
|
|
@ -580,7 +633,7 @@ export class ChangeExtractorService {
|
||||||
} else {
|
} else {
|
||||||
this.openCodeBackfillCache.delete(cacheKey);
|
this.openCodeBackfillCache.delete(cacheKey);
|
||||||
}
|
}
|
||||||
return false;
|
return { attempted: true, backfilled: false };
|
||||||
} finally {
|
} finally {
|
||||||
await deliveryContext.cleanup();
|
await deliveryContext.cleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -647,36 +700,47 @@ export class ChangeExtractorService {
|
||||||
private async createOpenCodeDeliveryContextTempFile(
|
private async createOpenCodeDeliveryContextTempFile(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
|
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>,
|
||||||
|
payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records)
|
||||||
): Promise<OpenCodeDeliveryContextTempFile> {
|
): Promise<OpenCodeDeliveryContextTempFile> {
|
||||||
if (records.length === 0) {
|
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-'));
|
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');
|
const filePath = path.join(dir, 'delivery-context.json');
|
||||||
await writeFile(
|
await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 });
|
||||||
filePath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
teamName,
|
|
||||||
taskId,
|
|
||||||
records,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}\n`,
|
|
||||||
{ encoding: 'utf8', mode: 0o600 }
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
filePath,
|
filePath,
|
||||||
|
hash: payload.hash,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
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(
|
private async readOpenCodeDeliveryContextRecords(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string
|
taskId: string
|
||||||
|
|
@ -748,6 +812,18 @@ export class ChangeExtractorService {
|
||||||
return records.slice(-200);
|
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(
|
private async readOpenCodeRuntimeLaneIdsFromDisk(
|
||||||
teamsBasePath: string,
|
teamsBasePath: string,
|
||||||
teamName: string
|
teamName: string
|
||||||
|
|
@ -770,50 +846,6 @@ export class ChangeExtractorService {
|
||||||
return laneIds.sort((left, right) => left.localeCompare(right));
|
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(
|
private async readOpenCodePromptDeliveryLedgerRecords(
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
||||||
|
|
@ -841,7 +873,7 @@ export class ChangeExtractorService {
|
||||||
sourceGeneration?: string | null;
|
sourceGeneration?: string | null;
|
||||||
deliveryContextFingerprint: string;
|
deliveryContextFingerprint: string;
|
||||||
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
|
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 {
|
}): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -852,7 +884,7 @@ export class ChangeExtractorService {
|
||||||
sourceGeneration: input.sourceGeneration ?? '',
|
sourceGeneration: input.sourceGeneration ?? '',
|
||||||
deliveryContextFingerprint: input.deliveryContextFingerprint,
|
deliveryContextFingerprint: input.deliveryContextFingerprint,
|
||||||
attributionMode: input.attributionMode,
|
attributionMode: input.attributionMode,
|
||||||
evidenceMode: input.evidenceMode,
|
evidencePipeline: input.evidencePipeline,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,7 @@ export class ReviewApplierService {
|
||||||
decision.fileDecision === 'rejected',
|
decision.fileDecision === 'rejected',
|
||||||
allHunksRejected,
|
allHunksRejected,
|
||||||
rejectedHunkIndices,
|
rejectedHunkIndices,
|
||||||
|
decision.hunkContextHashes,
|
||||||
fileContent.snippets
|
fileContent.snippets
|
||||||
);
|
);
|
||||||
if (ledgerOutcome.handled) {
|
if (ledgerOutcome.handled) {
|
||||||
|
|
@ -450,6 +451,7 @@ export class ReviewApplierService {
|
||||||
fileRejected: boolean,
|
fileRejected: boolean,
|
||||||
allHunksRejected: boolean,
|
allHunksRejected: boolean,
|
||||||
rejectedHunkIndices: number[],
|
rejectedHunkIndices: number[],
|
||||||
|
hunkContextHashes: Record<number, string> | undefined,
|
||||||
snippets: SnippetDiff[]
|
snippets: SnippetDiff[]
|
||||||
): Promise<LedgerApplyOutcome> {
|
): Promise<LedgerApplyOutcome> {
|
||||||
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
|
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.',
|
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(
|
const guard = await this.checkLedgerCurrentHash(
|
||||||
filePath,
|
filePath,
|
||||||
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
|
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
|
||||||
|
|
@ -504,7 +520,7 @@ export class ReviewApplierService {
|
||||||
if (!guard.ok) {
|
if (!guard.ok) {
|
||||||
return guard.outcome;
|
return guard.outcome;
|
||||||
}
|
}
|
||||||
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
|
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
|
||||||
if (!patchResult) {
|
if (!patchResult) {
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
|
|
@ -1035,6 +1051,46 @@ export class ReviewApplierService {
|
||||||
hadConflicts: false,
|
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[]> {
|
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
|
||||||
|
|
@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash(
|
||||||
return [...out].sort((a, b) => a - b);
|
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 ──
|
// ── Module-level helpers ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,13 @@ interface LedgerEvent {
|
||||||
linesRemoved?: number;
|
linesRemoved?: number;
|
||||||
replaceAll?: boolean;
|
replaceAll?: boolean;
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
|
sourceRuntime?: 'opencode';
|
||||||
|
sourceProvider?: 'opencode';
|
||||||
|
sourceImportKey?: string;
|
||||||
|
evidenceProof?: string;
|
||||||
|
supersedesEventId?: string;
|
||||||
|
snapshotId?: string;
|
||||||
|
snapshotSource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LedgerNotice {
|
interface LedgerNotice {
|
||||||
|
|
@ -196,7 +203,7 @@ interface LedgerSummaryScopeV2 {
|
||||||
primaryAgentId?: string;
|
primaryAgentId?: string;
|
||||||
primaryMemberName?: string;
|
primaryMemberName?: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
agentIds: string[];
|
agentIds?: string[];
|
||||||
memberNames?: string[];
|
memberNames?: string[];
|
||||||
startTimestamp: string;
|
startTimestamp: string;
|
||||||
endTimestamp: string;
|
endTimestamp: string;
|
||||||
|
|
@ -428,11 +435,7 @@ export class TaskChangeLedgerReader {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provenance = this.buildLedgerProvenance(
|
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
|
||||||
bundle.journalStamp,
|
|
||||||
bundle.integrity,
|
|
||||||
bundle.schemaVersion
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
freshness &&
|
freshness &&
|
||||||
|
|
@ -450,11 +453,7 @@ export class TaskChangeLedgerReader {
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
bundle,
|
bundle,
|
||||||
provenance: this.buildLedgerProvenance(
|
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
|
||||||
journalStamp,
|
|
||||||
bundle.integrity,
|
|
||||||
bundle.schemaVersion
|
|
||||||
),
|
|
||||||
mode: 'validated',
|
mode: 'validated',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -694,6 +693,86 @@ export class TaskChangeLedgerReader {
|
||||||
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
|
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 {
|
private hashFingerprintPayload(payload: unknown): string {
|
||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
@ -793,9 +872,10 @@ export class TaskChangeLedgerReader {
|
||||||
bundle?: LedgerSummaryBundleV2;
|
bundle?: LedgerSummaryBundleV2;
|
||||||
provenance: TaskChangeProvenance;
|
provenance: TaskChangeProvenance;
|
||||||
}): Promise<TaskChangeSetV2> {
|
}): 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 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,
|
recovered: params.journal.recovered,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -836,15 +916,15 @@ export class TaskChangeLedgerReader {
|
||||||
totalLinesAdded = fallback.totalLinesAdded;
|
totalLinesAdded = fallback.totalLinesAdded;
|
||||||
totalLinesRemoved = fallback.totalLinesRemoved;
|
totalLinesRemoved = fallback.totalLinesRemoved;
|
||||||
totalFiles = fallback.files.length;
|
totalFiles = fallback.files.length;
|
||||||
confidence = params.journal.events.some((event) => event.confidence === 'low')
|
confidence = projectedEvents.some((event) => event.confidence === 'low')
|
||||||
? 'low'
|
? 'low'
|
||||||
: params.journal.events.some((event) => event.confidence === 'medium')
|
: projectedEvents.some((event) => event.confidence === 'medium')
|
||||||
? 'medium'
|
? 'medium'
|
||||||
: 'high';
|
: 'high';
|
||||||
scope = this.buildFallbackScope(
|
scope = this.buildFallbackScope(
|
||||||
params.taskId,
|
params.taskId,
|
||||||
files,
|
files,
|
||||||
params.journal.events,
|
projectedEvents,
|
||||||
params.journal.notices
|
params.journal.notices
|
||||||
);
|
);
|
||||||
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||||
|
|
@ -883,7 +963,8 @@ export class TaskChangeLedgerReader {
|
||||||
undefined,
|
undefined,
|
||||||
params.journal.recovered ? 'recovered' : 'ok'
|
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 grouped = this.groupSnippets(snippets);
|
||||||
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
||||||
return {
|
return {
|
||||||
|
|
@ -893,20 +974,20 @@ export class TaskChangeLedgerReader {
|
||||||
totalLinesAdded: fallback.totalLinesAdded,
|
totalLinesAdded: fallback.totalLinesAdded,
|
||||||
totalLinesRemoved: fallback.totalLinesRemoved,
|
totalLinesRemoved: fallback.totalLinesRemoved,
|
||||||
totalFiles: fallback.files.length,
|
totalFiles: fallback.files.length,
|
||||||
confidence: params.journal.events.some((event) => event.confidence === 'low')
|
confidence: projectedEvents.some((event) => event.confidence === 'low')
|
||||||
? 'low'
|
? 'low'
|
||||||
: params.journal.events.some((event) => event.confidence === 'medium')
|
: projectedEvents.some((event) => event.confidence === 'medium')
|
||||||
? 'medium'
|
? 'medium'
|
||||||
: 'high',
|
: 'high',
|
||||||
computedAt: new Date().toISOString(),
|
computedAt: new Date().toISOString(),
|
||||||
scope: this.buildFallbackScope(
|
scope: this.buildFallbackScope(
|
||||||
params.taskId,
|
params.taskId,
|
||||||
fallback.files,
|
fallback.files,
|
||||||
params.journal.events,
|
projectedEvents,
|
||||||
params.journal.notices
|
params.journal.notices
|
||||||
),
|
),
|
||||||
warnings: [
|
warnings: [
|
||||||
...this.collectWarnings(params.journal.events, params.journal.notices, {
|
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||||
recovered: params.journal.recovered,
|
recovered: params.journal.recovered,
|
||||||
}),
|
}),
|
||||||
'Task change summary fell back to journal reconstruction.',
|
'Task change summary fell back to journal reconstruction.',
|
||||||
|
|
@ -972,6 +1053,7 @@ export class TaskChangeLedgerReader {
|
||||||
private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary {
|
private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary {
|
||||||
const displayPath = file.displayPath ?? file.filePath;
|
const displayPath = file.displayPath ?? file.filePath;
|
||||||
const filePath = this.normalizeLedgerFilePath(file.filePath);
|
const filePath = this.normalizeLedgerFilePath(file.filePath);
|
||||||
|
const agentIds = Array.isArray(file.agentIds) ? file.agentIds : [];
|
||||||
return {
|
return {
|
||||||
filePath,
|
filePath,
|
||||||
relativePath: this.relativePath(displayPath, projectPath, file.relativePath),
|
relativePath: this.relativePath(displayPath, projectPath, file.relativePath),
|
||||||
|
|
@ -993,7 +1075,7 @@ export class TaskChangeLedgerReader {
|
||||||
...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}),
|
...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}),
|
||||||
...(file.latestAfterState ? { afterState: file.latestAfterState } : {}),
|
...(file.latestAfterState ? { afterState: file.latestAfterState } : {}),
|
||||||
...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}),
|
...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}),
|
||||||
...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}),
|
...(agentIds.length > 0 ? { agentIds } : {}),
|
||||||
...(file.memberNames ? { memberNames: file.memberNames } : {}),
|
...(file.memberNames ? { memberNames: file.memberNames } : {}),
|
||||||
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
|
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
|
||||||
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
|
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
|
||||||
|
|
@ -1021,6 +1103,7 @@ export class TaskChangeLedgerReader {
|
||||||
scope: LedgerSummaryScopeV2,
|
scope: LedgerSummaryScopeV2,
|
||||||
files: LedgerSummaryFileV2[]
|
files: LedgerSummaryFileV2[]
|
||||||
): TaskChangeScope {
|
): TaskChangeScope {
|
||||||
|
const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : [];
|
||||||
return {
|
return {
|
||||||
taskId,
|
taskId,
|
||||||
memberName:
|
memberName:
|
||||||
|
|
@ -1039,7 +1122,7 @@ export class TaskChangeLedgerReader {
|
||||||
...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}),
|
...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}),
|
||||||
...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}),
|
...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}),
|
||||||
...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}),
|
...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}),
|
||||||
...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}),
|
...(agentIds.length > 0 ? { agentIds } : {}),
|
||||||
...(scope.memberNames ? { memberNames: scope.memberNames } : {}),
|
...(scope.memberNames ? { memberNames: scope.memberNames } : {}),
|
||||||
...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}),
|
...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}),
|
||||||
...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}),
|
...(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(
|
private async readContentRef(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
ref: LedgerContentRef | null
|
ref: LedgerContentRef | null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
|
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 =
|
export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.handshake'
|
| 'opencode.handshake'
|
||||||
|
|
@ -239,18 +240,12 @@ export interface OpenCodeBackfillTaskLedgerCommandBody {
|
||||||
projectDir?: string;
|
projectDir?: string;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
deliveryContextPath?: string;
|
deliveryContextPath?: string;
|
||||||
|
deliveryContextHash?: string;
|
||||||
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
||||||
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
|
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
|
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
|
||||||
export type OpenCodeBackfillTaskLedgerEvidenceMode =
|
|
||||||
| 'off'
|
|
||||||
| 'metadata-only'
|
|
||||||
| 'chain-only'
|
|
||||||
| 'snapshot-probe'
|
|
||||||
| 'snapshot-auto';
|
|
||||||
|
|
||||||
export type OpenCodeBackfillTaskLedgerOutcome =
|
export type OpenCodeBackfillTaskLedgerOutcome =
|
||||||
| 'imported'
|
| 'imported'
|
||||||
|
|
@ -265,13 +260,13 @@ export type OpenCodeBackfillTaskLedgerOutcome =
|
||||||
export interface OpenCodeBackfillTaskLedgerCommandData {
|
export interface OpenCodeBackfillTaskLedgerCommandData {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
providerId: 'opencode';
|
providerId: 'opencode';
|
||||||
|
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
projectDir?: string;
|
projectDir?: string;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
||||||
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
|
|
||||||
strictWindowCandidateCount?: number;
|
strictWindowCandidateCount?: number;
|
||||||
openCodeDbFingerprint?: string;
|
openCodeDbFingerprint?: string;
|
||||||
deliveryLedgerFingerprint?: string;
|
deliveryLedgerFingerprint?: string;
|
||||||
|
|
@ -369,6 +364,7 @@ export interface OpenCodeBridgePeerIdentity {
|
||||||
minVersion: number;
|
minVersion: number;
|
||||||
currentVersion: number;
|
currentVersion: number;
|
||||||
supportedCommands: OpenCodeBridgeCommandName[];
|
supportedCommands: OpenCodeBridgeCommandName[];
|
||||||
|
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||||
};
|
};
|
||||||
runtime: {
|
runtime: {
|
||||||
providerId: 'opencode';
|
providerId: 'opencode';
|
||||||
|
|
@ -853,7 +849,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
|
||||||
(bridgeProtocol.minVersion as number) < 1 ||
|
(bridgeProtocol.minVersion as number) < 1 ||
|
||||||
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
|
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
|
||||||
!Array.isArray(bridgeProtocol.supportedCommands) ||
|
!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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
|
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
|
||||||
dryRun: input.dryRun === true,
|
dryRun: input.dryRun === true,
|
||||||
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
|
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
|
||||||
...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}),
|
|
||||||
scannedSessions: 0,
|
scannedSessions: 0,
|
||||||
scannedToolparts: 0,
|
scannedToolparts: 0,
|
||||||
candidateEvents: 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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
|
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 { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
|
|
@ -79,6 +81,7 @@ async function writeOpenCodeDeliveryLedger(
|
||||||
taskId: string;
|
taskId: string;
|
||||||
displayId: string;
|
displayId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
taskRefs: { taskId: string; displayId: string; teamName: string }[];
|
||||||
}>
|
}>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const memberName = overrides?.memberName ?? 'bob';
|
const memberName = overrides?.memberName ?? 'bob';
|
||||||
|
|
@ -108,7 +111,7 @@ async function writeOpenCodeDeliveryLedger(
|
||||||
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
|
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
|
||||||
prePromptCursor: null,
|
prePromptCursor: null,
|
||||||
postPromptCursor: null,
|
postPromptCursor: null,
|
||||||
taskRefs: [
|
taskRefs: overrides?.taskRefs ?? [
|
||||||
{
|
{
|
||||||
taskId: overrides?.taskId ?? TASK_ID,
|
taskId: overrides?.taskId ?? TASK_ID,
|
||||||
displayId: overrides?.displayId ?? 'abc12345',
|
displayId: overrides?.displayId ?? 'abc12345',
|
||||||
|
|
@ -126,6 +129,70 @@ async function writeOpenCodeDeliveryLedger(
|
||||||
return filePath;
|
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 {
|
function persistedEntryPath(baseDir: string): string {
|
||||||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
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 fs.mkdir(projectPath, { recursive: true });
|
||||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||||
|
|
||||||
|
let deliveryContextHashVerified = false;
|
||||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||||
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
|
deliveryContextHashVerified =
|
||||||
await fs.mkdir(bundleDir, { recursive: true });
|
createHash('sha256')
|
||||||
await fs.writeFile(
|
.update(await fs.readFile(input.deliveryContextPath, 'utf8'))
|
||||||
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
|
.digest('hex') === input.deliveryContextHash;
|
||||||
JSON.stringify({
|
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
|
||||||
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'
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -1058,12 +1074,340 @@ describe('ChangeExtractorService', () => {
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot: projectPath,
|
workspaceRoot: projectPath,
|
||||||
attributionMode: 'strict-delivery',
|
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();
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
@ -1182,11 +1526,13 @@ describe('ChangeExtractorService', () => {
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot: projectPath,
|
workspaceRoot: projectPath,
|
||||||
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
||||||
|
deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
attributionMode: 'strict-delivery',
|
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(settled).toBe(false);
|
||||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||||
|
|
@ -1289,6 +1635,7 @@ describe('ChangeExtractorService', () => {
|
||||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||||
expect.stringContaining('delivery-context.json')
|
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 () => {
|
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
|
||||||
|
|
@ -1331,24 +1678,30 @@ describe('ChangeExtractorService', () => {
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
let backfillAttempt = 0;
|
||||||
schemaVersion: 1,
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||||
providerId: 'opencode',
|
const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution';
|
||||||
teamName: input.teamName,
|
return {
|
||||||
taskId: input.taskId,
|
schemaVersion: 1,
|
||||||
projectDir: input.projectDir,
|
providerId: 'opencode',
|
||||||
workspaceRoot: input.workspaceRoot,
|
teamName: input.teamName,
|
||||||
dryRun: false,
|
taskId: input.taskId,
|
||||||
attributionMode: input.attributionMode,
|
projectDir: input.projectDir,
|
||||||
scannedSessions: 1,
|
workspaceRoot: input.workspaceRoot,
|
||||||
scannedToolparts: 0,
|
dryRun: false,
|
||||||
candidateEvents: 0,
|
attributionMode: input.attributionMode,
|
||||||
importedEvents: 0,
|
scannedSessions: 1,
|
||||||
skippedEvents: 0,
|
scannedToolparts: 0,
|
||||||
outcome: 'no-attribution',
|
candidateEvents: 0,
|
||||||
notices: [],
|
importedEvents: 0,
|
||||||
diagnostics: [],
|
skippedEvents: 0,
|
||||||
}));
|
outcome,
|
||||||
|
notices: [],
|
||||||
|
diagnostics: outcome === 'transient-error'
|
||||||
|
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
const workerClient = {
|
const workerClient = {
|
||||||
isAvailable: vi.fn(() => true),
|
isAvailable: vi.fn(() => true),
|
||||||
computeTaskChanges: vi.fn(async () =>
|
computeTaskChanges: vi.fn(async () =>
|
||||||
|
|
@ -1394,8 +1747,158 @@ describe('ChangeExtractorService', () => {
|
||||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||||
expect.stringContaining('delivery-context.json')
|
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(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
||||||
expect.stringContaining('delivery-context.json')
|
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,
|
createOpenCodeBridgeHandshakeIdentityHash,
|
||||||
createOpenCodeBridgeIdempotencyKey,
|
createOpenCodeBridgeIdempotencyKey,
|
||||||
isOpenCodeBridgeCommandName,
|
isOpenCodeBridgeCommandName,
|
||||||
|
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
parseSingleBridgeJsonResult,
|
parseSingleBridgeJsonResult,
|
||||||
stableHash,
|
stableHash,
|
||||||
validateBridgeResultEnvelope,
|
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', () => {
|
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
|
||||||
const first = createOpenCodeBridgeIdempotencyKey({
|
const first = createOpenCodeBridgeIdempotencyKey({
|
||||||
command: 'opencode.launchTeam',
|
command: 'opencode.launchTeam',
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
taskDisplayId: 'abc12345',
|
taskDisplayId: 'abc12345',
|
||||||
projectDir: '/claude/project',
|
projectDir: '/claude/project',
|
||||||
workspaceRoot: '/repo',
|
workspaceRoot: '/repo',
|
||||||
|
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
|
||||||
|
deliveryContextHash: 'a'.repeat(64),
|
||||||
})
|
})
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
outcome: 'imported',
|
outcome: 'imported',
|
||||||
|
|
@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
taskDisplayId: 'abc12345',
|
taskDisplayId: 'abc12345',
|
||||||
projectDir: '/claude/project',
|
projectDir: '/claude/project',
|
||||||
workspaceRoot: '/repo',
|
workspaceRoot: '/repo',
|
||||||
|
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
|
||||||
|
deliveryContextHash: 'a'.repeat(64),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { structuredPatch } from 'diff';
|
import { structuredPatch } from 'diff';
|
||||||
|
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||||
|
|
||||||
import type { SnippetDiff } from '@shared/types';
|
import type { SnippetDiff } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -985,7 +986,8 @@ describe('ReviewApplierService', () => {
|
||||||
{
|
{
|
||||||
filePath,
|
filePath,
|
||||||
fileDecision: 'pending',
|
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(res).toMatchObject({ applied: 1, conflicts: 0 });
|
||||||
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
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 {
|
function sha(content: string): string {
|
||||||
return createHash('sha256').update(content).digest('hex');
|
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');
|
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 () => {
|
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {
|
||||||
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
||||||
tmpDir = await makeLedgerBundle({
|
tmpDir = await makeLedgerBundle({
|
||||||
|
|
@ -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 () => {
|
it('keeps identical relative rename relations isolated by worktree path', async () => {
|
||||||
tmpDir = await fsTempDir();
|
tmpDir = await fsTempDir();
|
||||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||||
|
|
@ -969,6 +1132,74 @@ async function makeLedgerBundle(params: {
|
||||||
return dir;
|
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> {
|
async function fsTempDir(): Promise<string> {
|
||||||
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
|
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,50 @@ describe('task change ledger golden fixtures', () => {
|
||||||
expect(resolved.contentSource).toBe('ledger-snapshot');
|
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 () => {
|
it('rejects grouped copy fixtures by deleting only the copied path', async () => {
|
||||||
const fixture = await materializeTaskChangeLedgerFixture('copy');
|
const fixture = await materializeTaskChangeLedgerFixture('copy');
|
||||||
cleanups.push(fixture.cleanup);
|
cleanups.push(fixture.cleanup);
|
||||||
|
|
|
||||||
|
|
@ -1651,4 +1651,153 @@ describe('changeReviewSlice task changes', () => {
|
||||||
});
|
});
|
||||||
expect(store.getState().activeChangeSet).toEqual(current);
|
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