feat: add opencode ledger bridge ui

This commit is contained in:
777genius 2026-04-26 12:41:44 +03:00
parent 49982a1db8
commit f2c43dc4b3
13 changed files with 4234 additions and 10 deletions

File diff suppressed because it is too large Load diff

View file

@ -1086,7 +1086,14 @@ async function initializeServices(): Promise<void> {
});
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
const changeExtractor = new ChangeExtractorService(
teamMemberLogsFinder,
taskBoundaryParser,
undefined,
undefined,
undefined,
openCodeLifecycleBridge
);
teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
const gitDiffFallback = new GitDiffFallback();

View file

@ -1,4 +1,4 @@
import { getTasksBasePath } from '@main/utils/pathDecoder';
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import {
@ -7,12 +7,20 @@ import {
type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState';
import { createHash } from 'crypto';
import { readFile, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { TeamMetaStore } from './TeamMetaStore';
import { TaskChangeComputer } from './TaskChangeComputer';
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeTeamRuntimeDirectory,
readOpenCodeRuntimeLaneIndex,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import {
buildTaskChangePresenceDescriptor,
computeTaskChangePresenceProjectFingerprint,
@ -31,9 +39,13 @@ import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { OpenCodeLedgerBackfillPort } from './opencode/bridge/OpenCodeReadinessBridge';
import type { OpenCodePromptDeliveryLedgerRecord } from './opencode/delivery/OpenCodePromptDeliveryLedger';
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
const logger = createLogger('Service:ChangeExtractorService');
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
/** Кеш-запись: данные + mtime файла + время протухания */
interface CacheEntry {
@ -52,16 +64,31 @@ interface LogFileRef {
memberName: string;
}
interface OpenCodeBackfillCacheEntry {
backfilledAt: number;
expiresAt: number;
}
interface OpenCodeDeliveryContextTempFile {
filePath: string | null;
cleanup: () => Promise<void>;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
private taskChangeSummaryVersionByTask = new Map<string, number>();
private taskChangeSummaryValidationInFlight = new Set<string>();
private openCodeBackfillInFlight = new Map<string, Promise<boolean>>();
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly taskChangeSummaryCacheTtl = 60 * 1000;
private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000;
private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000;
private readonly openCodeBackfillCacheTtl = 60 * 1000;
private readonly openCodeTeamEligibilityCacheTtl = 30 * 1000;
private readonly maxTaskChangeSummaryCacheEntries = 200;
private readonly isPersistedTaskChangeCacheEnabled =
process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0';
@ -75,7 +102,9 @@ export class ChangeExtractorService {
boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(),
private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient()
private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient(),
private readonly openCodeLedgerBackfillPort: OpenCodeLedgerBackfillPort | null = null,
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
) {
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
}
@ -179,6 +208,22 @@ export class ChangeExtractorService {
return ledgerResult;
}
if (!includeDetails) {
this.enqueueOpenCodeLedgerBackfill(resolvedInput);
} else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) {
await this.recordTaskChangePresence(
teamName,
taskId,
taskMeta,
effectiveOptions,
backfilledLedgerResult
);
return backfilledLedgerResult;
}
}
if (!shouldUseSummaryCache) {
const result = await this.computeTaskChangesPreferred(resolvedInput);
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
@ -334,6 +379,404 @@ export class ChangeExtractorService {
}
}
private async tryBackfillOpenCodeLedger(input: ResolvedTaskChangeComputeInput): Promise<boolean> {
if (!this.openCodeLedgerBackfillPort) {
return false;
}
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
return false;
}
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return false;
}
const context = await this.logsFinder
.getLogSourceWatchContext(input.teamName)
.catch(() => null);
const projectDir = context?.projectDir;
const workspaceRoot = input.projectPath ?? context?.projectPath;
if (
!projectDir ||
!workspaceRoot ||
!path.isAbsolute(projectDir) ||
!path.isAbsolute(workspaceRoot)
) {
return false;
}
const sourceGeneration = this.teamLogSourceTracker
? await this.teamLogSourceTracker
.ensureTracking(input.teamName)
.then((snapshot) => snapshot.logSourceGeneration)
.catch(() => null)
: null;
const deliveryContextRecords = await this.readOpenCodeDeliveryContextRecords(
input.teamName,
input.taskId
);
const deliveryContextFingerprint =
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
const cacheKey = this.buildOpenCodeBackfillCacheKey({
teamName: input.teamName,
taskId: input.taskId,
projectDir,
workspaceRoot,
displayId: input.taskMeta?.displayId,
sourceGeneration,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
});
const now = Date.now();
const cached = this.openCodeBackfillCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.backfilledAt > 0;
}
this.openCodeBackfillCache.delete(cacheKey);
const existing = this.openCodeBackfillInFlight.get(cacheKey);
if (existing) {
return existing;
}
const promise = this.runOpenCodeBackfill(
input,
projectDir,
workspaceRoot,
cacheKey,
deliveryContextRecords
).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey);
});
this.openCodeBackfillInFlight.set(cacheKey, promise);
return promise;
}
private enqueueOpenCodeLedgerBackfill(input: ResolvedTaskChangeComputeInput): void {
void this.tryBackfillOpenCodeLedger(input).catch((error) => {
logger.debug(
`Background OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
});
}
private async runOpenCodeBackfill(
input: ResolvedTaskChangeComputeInput,
projectDir: string,
workspaceRoot: string,
cacheKey: string,
deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>
): Promise<boolean> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName,
input.taskId,
deliveryContextRecords
);
try {
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
teamId: input.teamName,
teamName: input.teamName,
taskId: input.taskId,
taskDisplayId: input.taskMeta?.displayId,
memberName: input.effectiveOptions.owner,
projectDir,
workspaceRoot,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
});
const backfilled =
result.importedEvents > 0 ||
result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0);
if (result.importedEvents > 0) {
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
deletePersisted: true,
});
}
if (backfilled || deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: backfilled ? Date.now() : 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
if (result.diagnostics.length > 0 && result.outcome !== 'no-history') {
logger.debug(
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}`
);
}
return backfilled;
} catch (error) {
logger.warn(
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
if (deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
return false;
} finally {
await deliveryContext.cleanup();
}
}
private async isOpenCodeTeamCandidate(teamName: string): Promise<boolean> {
const cached = this.openCodeTeamEligibilityCache.get(teamName);
const now = Date.now();
if (cached && cached.expiresAt > now) {
return cached.value;
}
let value = false;
try {
const [meta, config] = await Promise.all([
this.teamMetaStore.getMeta(teamName).catch(() => null),
this.configReader.getConfig(teamName).catch(() => null),
]);
const hasOpenCodeMember = (config?.members ?? []).some(
(member) => member.providerId === 'opencode'
);
const hasExplicitNonOpenCodeProvider =
(meta?.providerId != null && meta.providerId !== 'opencode') ||
((config?.members?.length ?? 0) > 0 &&
!hasOpenCodeMember &&
(config?.members ?? []).some((member) => typeof member.providerId === 'string'));
value =
meta?.providerId === 'opencode' ||
hasOpenCodeMember ||
(!hasExplicitNonOpenCodeProvider &&
existsSync(getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName)));
} catch {
value = false;
}
this.openCodeTeamEligibilityCache.set(teamName, {
value,
expiresAt: now + this.openCodeTeamEligibilityCacheTtl,
});
return value;
}
private async createOpenCodeDeliveryContextTempFile(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): Promise<OpenCodeDeliveryContextTempFile> {
if (records.length === 0) {
return { filePath: null, cleanup: async () => undefined };
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-'));
const filePath = path.join(dir, 'delivery-context.json');
await writeFile(
filePath,
`${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
return {
filePath,
cleanup: async () => {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
private async readOpenCodeDeliveryContextRecords(
teamName: string,
taskId: string
): Promise<
Array<{
memberName: string;
laneId?: string;
runtimeSessionId: string | null;
inboxMessageId: string | null;
deliveredUserMessageId: string | null;
observedAssistantMessageId: string | null;
prePromptCursor: string | null;
postPromptCursor: string | null;
taskRefs: Array<{ taskId: string; displayId: string; teamName: string }>;
}>
> {
const teamsBasePath = getTeamsBasePath();
const laneIds = new Set<string>(['primary']);
try {
const index = await readOpenCodeRuntimeLaneIndex(teamsBasePath, teamName);
for (const laneId of Object.keys(index.lanes)) {
if (laneId.trim()) laneIds.add(laneId);
}
} catch {
// Old teams may not have a lane index. The primary fallback covers the initial runtime shape.
}
for (const laneId of await this.readOpenCodeRuntimeLaneIdsFromDisk(teamsBasePath, teamName)) {
laneIds.add(laneId);
}
const records: Array<{
memberName: string;
laneId?: string;
runtimeSessionId: string | null;
inboxMessageId: string | null;
deliveredUserMessageId: string | null;
observedAssistantMessageId: string | null;
prePromptCursor: string | null;
postPromptCursor: string | null;
taskRefs: Array<{ taskId: string; displayId: string; teamName: string }>;
}> = [];
for (const laneId of laneIds) {
const filePath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath,
teamName,
laneId,
fileName: 'opencode-prompt-delivery-ledger.json',
});
const laneRecords = await this.readOpenCodePromptDeliveryLedgerRecords(filePath);
for (const record of laneRecords) {
if (record.teamName !== teamName) continue;
const taskRefs = record.taskRefs.filter((taskRef) => taskRef.teamName === teamName);
if (!taskRefs.some((taskRef) => taskRef.taskId === taskId)) continue;
records.push({
memberName: record.memberName,
laneId: record.laneId || laneId,
runtimeSessionId: record.runtimeSessionId,
inboxMessageId: record.inboxMessageId,
deliveredUserMessageId: record.deliveredUserMessageId,
observedAssistantMessageId: record.observedAssistantMessageId,
prePromptCursor: record.prePromptCursor,
postPromptCursor: record.postPromptCursor,
taskRefs,
});
}
}
return records.slice(-200);
}
private async readOpenCodeRuntimeLaneIdsFromDisk(
teamsBasePath: string,
teamName: string
): Promise<string[]> {
const lanesDir = path.join(getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), 'lanes');
const entries = await readdir(lanesDir, { withFileTypes: true }).catch(() => []);
const laneIds: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
let laneId: string;
try {
laneId = decodeURIComponent(entry.name);
} catch {
continue;
}
if (!laneId.trim() || laneId.includes('\0')) continue;
laneIds.push(laneId);
if (laneIds.length >= OPEN_CODE_MAX_DISCOVERED_LANES) break;
}
return laneIds.sort((left, right) => left.localeCompare(right));
}
private hashOpenCodeDeliveryContextRecords(
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string {
const stableRecords = records
.map((record) => ({
memberName: record.memberName,
laneId: record.laneId ?? '',
runtimeSessionId: record.runtimeSessionId ?? '',
inboxMessageId: record.inboxMessageId ?? '',
deliveredUserMessageId: record.deliveredUserMessageId ?? '',
taskRefs: record.taskRefs
.map((taskRef) => ({
taskId: taskRef.taskId,
displayId: taskRef.displayId,
teamName: taskRef.teamName,
}))
.sort((left, right) =>
`${left.teamName}\0${left.taskId}\0${left.displayId}`.localeCompare(
`${right.teamName}\0${right.taskId}\0${right.displayId}`
)
),
}))
.sort((left, right) =>
[
left.laneId,
left.memberName,
left.runtimeSessionId,
left.inboxMessageId,
left.deliveredUserMessageId,
]
.join('\0')
.localeCompare(
[
right.laneId,
right.memberName,
right.runtimeSessionId,
right.inboxMessageId,
right.deliveredUserMessageId,
].join('\0')
)
);
return createHash('sha256').update(JSON.stringify(stableRecords)).digest('hex');
}
private async readOpenCodePromptDeliveryLedgerRecords(
filePath: string
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
try {
const raw = await readFile(filePath, 'utf8');
if (Buffer.byteLength(raw, 'utf8') > 1024 * 1024) {
return [];
}
const parsed = JSON.parse(raw) as { data?: unknown };
if (!Array.isArray(parsed.data)) {
return [];
}
return parsed.data.filter(isOpenCodePromptDeliveryLedgerRecord);
} catch {
return [];
}
}
private buildOpenCodeBackfillCacheKey(input: {
teamName: string;
taskId: string;
displayId?: string;
projectDir: string;
workspaceRoot: string;
sourceGeneration?: string | null;
deliveryContextFingerprint: string;
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
}): string {
return JSON.stringify({
teamName: input.teamName,
taskId: input.taskId,
displayId: input.displayId ?? '',
projectDir: normalizeTaskChangePresenceFilePath(input.projectDir),
workspaceRoot: normalizeTaskChangePresenceFilePath(input.workspaceRoot),
sourceGeneration: input.sourceGeneration ?? '',
deliveryContextFingerprint: input.deliveryContextFingerprint,
attributionMode: input.attributionMode,
});
}
private isValidWorkerTaskChangeResult(
result: TaskChangeSetV2,
input: ResolvedTaskChangeComputeInput
@ -413,6 +856,10 @@ export class ChangeExtractorService {
return derived.length > 0 ? derived : undefined;
})();
return {
displayId:
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
? parsed.displayId.trim()
: undefined,
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
status: typeof parsed.status === 'string' ? parsed.status : undefined,
@ -794,3 +1241,45 @@ export class ChangeExtractorService {
);
}
}
function isOpenCodePromptDeliveryLedgerRecord(
value: unknown
): value is OpenCodePromptDeliveryLedgerRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.teamName === 'string' &&
typeof record.memberName === 'string' &&
typeof record.laneId === 'string' &&
typeof record.inboxMessageId === 'string' &&
Array.isArray(record.taskRefs) &&
record.taskRefs.every(isOpenCodeTaskRefLike) &&
isNullableString(record.runtimeSessionId) &&
isNullableString(record.deliveredUserMessageId) &&
isNullableString(record.observedAssistantMessageId) &&
isNullableString(record.prePromptCursor) &&
isNullableString(record.postPromptCursor)
);
}
function isOpenCodeTaskRefLike(
value: unknown
): value is { taskId: string; displayId: string; teamName: string } {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const ref = value as Record<string, unknown>;
return (
typeof ref.taskId === 'string' &&
ref.taskId.length > 0 &&
typeof ref.displayId === 'string' &&
typeof ref.teamName === 'string' &&
ref.teamName.length > 0
);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}

View file

@ -103,7 +103,9 @@ interface LedgerEvent {
| 'bash_simulated_sed'
| 'shell_snapshot'
| 'powershell_snapshot'
| 'post_tool_hook_snapshot';
| 'post_tool_hook_snapshot'
| 'opencode_toolpart_write'
| 'opencode_toolpart_edit';
operation: 'create' | 'modify' | 'delete';
confidence: LedgerConfidence;
workspaceRoot: string;
@ -1128,9 +1130,12 @@ export class TaskChangeLedgerReader {
case 'file_edit':
return 'Edit';
case 'file_write':
case 'opencode_toolpart_write':
return 'Write';
case 'notebook_edit':
return 'NotebookEdit';
case 'opencode_toolpart_edit':
return 'Edit';
case 'bash_simulated_sed':
case 'shell_snapshot':
return 'Bash';
@ -1142,7 +1147,7 @@ export class TaskChangeLedgerReader {
}
private mapSnippetType(event: LedgerEvent): SnippetDiff['type'] {
if (event.source === 'file_write') {
if (event.source === 'file_write' || event.source === 'opencode_toolpart_write') {
return event.operation === 'create' ? 'write-new' : 'write-update';
}
if (event.source === 'notebook_edit') {

View file

@ -15,7 +15,8 @@ export type OpenCodeBridgeCommandName =
| 'opencode.answerPermission'
| 'opencode.listRuntimePermissions'
| 'opencode.getRuntimeTranscript'
| 'opencode.recoverDeliveryJournal';
| 'opencode.recoverDeliveryJournal'
| 'opencode.backfillTaskLedger';
export type OpenCodeTeamLaunchBridgeState =
| 'blocked'
@ -227,6 +228,51 @@ export interface OpenCodeObserveMessageDeliveryCommandData {
diagnostics: OpenCodeTeamBridgeDiagnostic[];
}
export interface OpenCodeBackfillTaskLedgerCommandBody {
teamId?: string;
teamName: string;
taskId?: string;
taskDisplayId?: string;
memberName?: string;
laneId?: string;
projectDir?: string;
workspaceRoot?: string;
deliveryContextPath?: string;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
dryRun?: boolean;
}
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
export type OpenCodeBackfillTaskLedgerOutcome =
| 'imported'
| 'duplicates-only'
| 'no-history'
| 'no-attribution'
| 'manual-only'
| 'skipped-capability'
| 'transient-error'
| 'unsafe-input';
export interface OpenCodeBackfillTaskLedgerCommandData {
schemaVersion: 1;
providerId: 'opencode';
teamName: string;
taskId?: string;
projectDir?: string;
workspaceRoot?: string;
dryRun: boolean;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
scannedSessions: number;
scannedToolparts: number;
candidateEvents: number;
importedEvents: number;
skippedEvents: number;
outcome: OpenCodeBackfillTaskLedgerOutcome;
notices: Array<{ severity: 'warning'; message: string; code: string }>;
diagnostics: string[];
}
export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator';
export type OpenCodeBridgeFailureKind =
@ -374,6 +420,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',
'opencode.recoverDeliveryJournal',
'opencode.backfillTaskLedger',
]);
const VALID_FAILURE_KINDS: ReadonlySet<OpenCodeBridgeFailureKind> = new Set([

View file

@ -94,6 +94,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',
'opencode.recoverDeliveryJournal',
'opencode.backfillTaskLedger',
],
},
runtime: {

View file

@ -9,6 +9,8 @@ import type {
OpenCodeBridgeFailureKind,
OpenCodeBridgeResult,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeBackfillTaskLedgerCommandBody,
OpenCodeBackfillTaskLedgerCommandData,
OpenCodeCleanupHostsCommandBody,
OpenCodeCleanupHostsCommandData,
OpenCodeLaunchTeamCommandBody,
@ -23,6 +25,12 @@ import type {
} from './OpenCodeBridgeCommandContract';
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
export interface OpenCodeLedgerBackfillPort {
backfillOpenCodeTaskLedger(
input: OpenCodeBackfillTaskLedgerCommandBody
): Promise<OpenCodeBackfillTaskLedgerCommandData>;
}
export interface OpenCodeReadinessBridgeCommandExecutor {
execute<TBody, TData>(
command: OpenCodeBridgeCommandName,
@ -61,6 +69,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 30_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
@ -274,6 +283,45 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
};
}
async backfillOpenCodeTaskLedger(
input: OpenCodeBackfillTaskLedgerCommandBody
): Promise<OpenCodeBackfillTaskLedgerCommandData> {
const cwd = input.workspaceRoot ?? input.projectDir ?? process.cwd();
const result = await this.bridge.execute<
OpenCodeBackfillTaskLedgerCommandBody,
OpenCodeBackfillTaskLedgerCommandData
>('opencode.backfillTaskLedger', input, {
cwd,
timeoutMs: DEFAULT_BACKFILL_TIMEOUT_MS,
stdoutLimitBytes: 2_000_000,
stderrLimitBytes: 512_000,
});
if (result.ok) {
return result.data;
}
return {
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
...(input.taskId ? { taskId: input.taskId } : {}),
...(input.projectDir ? { projectDir: input.projectDir } : {}),
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
dryRun: input.dryRun === true,
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: result.error.retryable ? 'transient-error' : 'unsafe-input',
notices: [],
diagnostics: [
`OpenCode task ledger backfill bridge failed: ${result.error.kind}: ${result.error.message}`,
...result.diagnostics.map(formatDiagnosticEvent),
],
};
}
private async executeStateChangingCommand<TBody, TData>(
command: OpenCodeStateChangingTeamCommandName,
body: TBody,

View file

@ -1,6 +1,7 @@
import type { TaskChangeSetV2 } from '@shared/types';
export interface TaskChangeTaskMeta {
displayId?: string;
createdAt?: string;
owner?: string;
status?: string;

View file

@ -864,4 +864,498 @@ describe('ChangeExtractorService', () => {
expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false);
expect(upsertEntry).not.toHaveBeenCalled();
});
it('backfills OpenCode ledger artifacts once before falling back to legacy extraction', 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 });
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
await fs.mkdir(bundleDir, { recursive: true });
await fs.writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 1,
files: [
{
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
eventIds: ['event-1'],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
latestAfterHash: null,
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
totalFiles: 1,
confidence: 'high',
warnings: [],
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
},
],
}),
'utf8'
);
return {
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
scannedSessions: 1,
scannedToolparts: 1,
candidateEvents: 1,
importedEvents: 1,
skippedEvents: 0,
outcome: 'imported',
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
);
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).toHaveBeenCalledWith(
expect.objectContaining({
teamName: TEAM_NAME,
taskId: TASK_ID,
taskDisplayId: 'abc12345',
memberName: 'bob',
projectDir,
workspaceRoot: projectPath,
attributionMode: 'strict-delivery',
})
);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
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 fs.mkdir(path.join(tmpDir, 'teams', TEAM_NAME, '.opencode-runtime'), {
recursive: true,
});
const backfillOpenCodeTaskLedger = vi.fn(async () => {
throw new Error('OpenCode backfill should not run for non-OpenCode teams');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID)),
};
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,
members: [{ name: 'alice', providerId: 'codex' }],
})),
} as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
});
it('queues OpenCode backfill for summary-only requests without blocking board rendering', 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 });
const pendingBackfill = deferred<any>();
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
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, SUMMARY_OPTIONS);
expect(result.files).toHaveLength(0);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({
teamName: TEAM_NAME,
taskId: TASK_ID,
projectDir,
workspaceRoot: projectPath,
attributionMode: 'strict-delivery',
})
);
});
pendingBackfill.resolve({
schemaVersion: 1,
providerId: 'opencode',
teamName: TEAM_NAME,
taskId: TASK_ID,
projectDir,
workspaceRoot: projectPath,
dryRun: false,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
});
});
it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', 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 });
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-attribution',
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',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toBeUndefined();
const deliveryLedgerPath = path.join(
tmpDir,
'teams',
TEAM_NAME,
'.opencode-runtime',
'lanes',
encodeURIComponent('secondary:opencode:bob'),
'opencode-prompt-delivery-ledger.json'
);
await fs.mkdir(path.dirname(deliveryLedgerPath), { recursive: true });
await fs.writeFile(
deliveryLedgerPath,
JSON.stringify(
{
data: [
{
teamName: TEAM_NAME,
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runtimeSessionId: 'session-1',
inboxMessageId: 'user-1',
deliveredUserMessageId: 'user-1',
observedAssistantMessageId: null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [{ taskId: TASK_ID, displayId: 'abc12345', teamName: TEAM_NAME }],
},
],
},
null,
2
),
'utf8'
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
});
it('does not cache negative OpenCode backfill while delivery context already exists', 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 });
const deliveryLedgerPath = path.join(
tmpDir,
'teams',
TEAM_NAME,
'.opencode-runtime',
'lanes',
encodeURIComponent('secondary:opencode:bob'),
'opencode-prompt-delivery-ledger.json'
);
await fs.mkdir(path.dirname(deliveryLedgerPath), { recursive: true });
await fs.writeFile(
deliveryLedgerPath,
JSON.stringify({
data: [
{
teamName: TEAM_NAME,
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runtimeSessionId: 'session-1',
inboxMessageId: 'user-1',
deliveredUserMessageId: 'user-1',
observedAssistantMessageId: null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [{ taskId: TASK_ID, displayId: 'abc12345', teamName: TEAM_NAME }],
},
],
}),
'utf8'
);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-attribution',
notices: [],
diagnostics: [],
}));
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(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
});
});

View file

@ -54,6 +54,10 @@ describe('OpenCodeBridgeCommandContract', () => {
expect(isOpenCodeBridgeCommandName('opencode.cleanupHosts')).toBe(true);
});
it('accepts opencode.backfillTaskLedger as a read-only bridge command', () => {
expect(isOpenCodeBridgeCommandName('opencode.backfillTaskLedger')).toBe(true);
});
it('validates result request id and command against the command envelope', () => {
const envelope: OpenCodeBridgeCommandEnvelope<Record<string, never>> = {
schemaVersion: 1,

View file

@ -137,6 +137,63 @@ describe('OpenCodeReadinessBridge', () => {
);
});
it('executes OpenCode task ledger backfill through a direct read-only bridge command', async () => {
const executor = fakeExecutor(
bridgeCommandSuccess({
command: 'opencode.backfillTaskLedger',
requestId: 'backfill-req-1',
data: {
schemaVersion: 1,
providerId: 'opencode',
teamName: 'team-a',
taskId: 'task-1',
projectDir: '/claude/project',
workspaceRoot: '/repo',
dryRun: false,
scannedSessions: 1,
scannedToolparts: 2,
candidateEvents: 2,
importedEvents: 2,
skippedEvents: 0,
outcome: 'imported',
notices: [],
diagnostics: [],
},
})
);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.backfillOpenCodeTaskLedger({
teamName: 'team-a',
taskId: 'task-1',
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
})
).resolves.toMatchObject({
outcome: 'imported',
importedEvents: 2,
});
expect(executor.execute).toHaveBeenCalledWith(
'opencode.backfillTaskLedger',
{
teamName: 'team-a',
taskId: 'task-1',
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
},
{
cwd: '/repo',
timeoutMs: 45_000,
stdoutLimitBytes: 2_000_000,
stderrLimitBytes: 512_000,
}
);
});
it('routes state-changing launch commands through the guarded command service when configured', async () => {
const executor = fakeExecutor(
bridgeFailure('internal_error', 'direct bridge must not run', [])

View file

@ -563,6 +563,7 @@ async function runModelGauntlet(input: {
runtimeTransportFailures,
modelBehaviorFailures,
harnessFailures,
consistencyScore: scoreStability.consistencyScore,
stageFailureImpact,
taskRefPassRates,
protocolViolationTotals,
@ -952,9 +953,10 @@ async function runGauntletOnce(input: {
diagnostics,
};
} finally {
if (harness) {
await harness.svc.stopTeam(teamName).catch(() => undefined);
await harness.dispose().catch(() => undefined);
const activeHarness = harness as Awaited<ReturnType<typeof createOpenCodeLiveHarness>> | null;
if (activeHarness) {
await activeHarness.svc.stopTeam(teamName).catch(() => undefined);
await activeHarness.dispose().catch(() => undefined);
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
}
setClaudeBasePathOverride(null);

View file

@ -186,6 +186,78 @@ describe('TaskChangeLedgerReader', () => {
expect(result?.files[0]?.relativePath).toBe('src/new.ts');
});
it('maps OpenCode toolpart sources into normal review snippet semantics', async () => {
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-write',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-write',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const value = 1;\n',
linesAdded: 1,
linesRemoved: 0,
},
{
schemaVersion: 1,
eventId: 'event-edit',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 2,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-edit',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:01:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'value = 1',
newString: 'value = 2',
linesAdded: 1,
linesRemoved: 1,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit']);
expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit']);
});
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
tmpDir = await makeLedgerBundle({