feat: add opencode ledger bridge ui
This commit is contained in:
parent
49982a1db8
commit
f2c43dc4b3
13 changed files with 4234 additions and 10 deletions
2997
docs/opencode-ledger-bridge-plan.md
Normal file
2997
docs/opencode-ledger-bridge-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
|||
'opencode.listRuntimePermissions',
|
||||
'opencode.getRuntimeTranscript',
|
||||
'opencode.recoverDeliveryJournal',
|
||||
'opencode.backfillTaskLedger',
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
export interface TaskChangeTaskMeta {
|
||||
displayId?: string;
|
||||
createdAt?: string;
|
||||
owner?: string;
|
||||
status?: string;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', [])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue