2370 lines
80 KiB
TypeScript
2370 lines
80 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { createHash } from 'crypto';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import * as fs from 'fs/promises';
|
|
|
|
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
|
|
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
|
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
|
|
|
const TEAM_NAME = 'team-a';
|
|
const TASK_ID = '1';
|
|
const PROJECT_PATH = '/repo';
|
|
const SUMMARY_OPTIONS = {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
stateBucket: 'completed' as const,
|
|
summaryOnly: true,
|
|
};
|
|
|
|
function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) {
|
|
return {
|
|
timestamp,
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: toolUseId,
|
|
name: 'Write',
|
|
input: { file_path: filePath, content },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
|
|
await fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
|
|
}
|
|
|
|
async function writeTaskFile(
|
|
baseDir: string,
|
|
overrides?: Record<string, unknown>
|
|
): Promise<string> {
|
|
const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${TASK_ID}.json`);
|
|
await fs.mkdir(path.dirname(taskPath), { recursive: true });
|
|
await fs.writeFile(
|
|
taskPath,
|
|
JSON.stringify(
|
|
{
|
|
id: TASK_ID,
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
createdAt: '2026-03-01T09:55:00.000Z',
|
|
updatedAt: '2026-03-01T10:10:00.000Z',
|
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
|
|
historyEvents: [],
|
|
...overrides,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
return taskPath;
|
|
}
|
|
|
|
async function writeOpenCodeDeliveryLedger(
|
|
baseDir: string,
|
|
overrides?: Partial<{
|
|
memberName: string;
|
|
laneId: string;
|
|
runtimeSessionId: string;
|
|
inboxMessageId: string;
|
|
deliveredUserMessageId: string;
|
|
observedAssistantMessageId: string | null;
|
|
taskId: string;
|
|
displayId: string;
|
|
teamName: string;
|
|
taskRefs: { taskId: string; displayId: string; teamName: string }[];
|
|
}>
|
|
): Promise<string> {
|
|
const memberName = overrides?.memberName ?? 'bob';
|
|
const laneId = overrides?.laneId ?? `secondary:opencode:${memberName}`;
|
|
const filePath = path.join(
|
|
baseDir,
|
|
'teams',
|
|
overrides?.teamName ?? TEAM_NAME,
|
|
'.opencode-runtime',
|
|
'lanes',
|
|
encodeURIComponent(laneId),
|
|
'opencode-prompt-delivery-ledger.json'
|
|
);
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(
|
|
filePath,
|
|
JSON.stringify(
|
|
{
|
|
data: [
|
|
{
|
|
teamName: overrides?.teamName ?? TEAM_NAME,
|
|
memberName,
|
|
laneId,
|
|
runtimeSessionId: overrides?.runtimeSessionId ?? 'session-1',
|
|
inboxMessageId: overrides?.inboxMessageId ?? 'user-1',
|
|
deliveredUserMessageId: overrides?.deliveredUserMessageId ?? 'user-1',
|
|
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
|
|
prePromptCursor: null,
|
|
postPromptCursor: null,
|
|
taskRefs: overrides?.taskRefs ?? [
|
|
{
|
|
taskId: overrides?.taskId ?? TASK_ID,
|
|
displayId: overrides?.displayId ?? 'abc12345',
|
|
teamName: overrides?.teamName ?? TEAM_NAME,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
return filePath;
|
|
}
|
|
|
|
async function writeOpenCodeLedgerBundle(
|
|
projectDir: string,
|
|
projectPath: string,
|
|
taskId: string = TASK_ID
|
|
): Promise<void> {
|
|
const bundleDir = path.join(projectDir, '.board-task-changes', 'bundles');
|
|
await fs.mkdir(bundleDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(bundleDir, `${encodeURIComponent(taskId)}.json`),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
source: 'task-change-ledger',
|
|
taskId,
|
|
generatedAt: '2026-03-01T10:00:00.000Z',
|
|
eventCount: 1,
|
|
files: [
|
|
{
|
|
filePath: path.join(projectPath, 'src/opencode.ts'),
|
|
relativePath: 'src/opencode.ts',
|
|
eventIds: ['event-1'],
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
latestAfterHash: null,
|
|
},
|
|
],
|
|
totalLinesAdded: 1,
|
|
totalLinesRemoved: 0,
|
|
totalFiles: 1,
|
|
confidence: 'high',
|
|
warnings: [],
|
|
events: [
|
|
{
|
|
schemaVersion: 1,
|
|
eventId: 'event-1',
|
|
taskId,
|
|
taskRef: taskId,
|
|
taskRefKind: 'canonical',
|
|
phase: 'work',
|
|
executionSeq: 0,
|
|
sessionId: 'opencode-session-1',
|
|
memberName: 'bob',
|
|
toolUseId: 'part-1',
|
|
source: 'opencode_toolpart_write',
|
|
operation: 'create',
|
|
confidence: 'exact',
|
|
workspaceRoot: projectPath,
|
|
filePath: path.join(projectPath, 'src/opencode.ts'),
|
|
relativePath: 'src/opencode.ts',
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
toolStatus: 'succeeded',
|
|
before: null,
|
|
after: null,
|
|
oldString: '',
|
|
newString: 'export const source = "opencode";\n',
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
},
|
|
],
|
|
}),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function writeWarningOnlyLedgerNotice(
|
|
projectDir: string,
|
|
overrides?: Partial<{
|
|
taskId: string;
|
|
memberName: string;
|
|
message: string;
|
|
}>
|
|
): Promise<void> {
|
|
const taskId = overrides?.taskId ?? TASK_ID;
|
|
const noticeDir = path.join(projectDir, '.board-task-changes', 'notices');
|
|
await fs.mkdir(noticeDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(noticeDir, `${encodeURIComponent(taskId)}.jsonl`),
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
noticeId: 'notice-1',
|
|
taskId,
|
|
taskRef: taskId,
|
|
taskRefKind: 'canonical',
|
|
phase: 'work',
|
|
executionSeq: 0,
|
|
sessionId: 'session-1',
|
|
memberName: overrides?.memberName ?? 'alice',
|
|
toolUseId: 'tool-1',
|
|
timestamp: '2026-03-01T10:05:00.000Z',
|
|
severity: 'warning',
|
|
code: 'multi-scope-skipped',
|
|
message:
|
|
overrides?.message ??
|
|
'Task change ledger skipped attribution because multiple task scopes were active.',
|
|
})}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function writeOpenCodeLedgerEventJournal(
|
|
projectDir: string,
|
|
projectPath: string,
|
|
taskId: string = TASK_ID
|
|
): Promise<void> {
|
|
const eventDir = path.join(projectDir, '.board-task-changes', 'events');
|
|
await fs.mkdir(eventDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(eventDir, `${encodeURIComponent(taskId)}.jsonl`),
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
eventId: 'event-1',
|
|
taskId,
|
|
taskRef: taskId,
|
|
taskRefKind: 'canonical',
|
|
phase: 'work',
|
|
executionSeq: 0,
|
|
sessionId: 'opencode-session-1',
|
|
memberName: 'bob',
|
|
toolUseId: 'part-1',
|
|
source: 'opencode_toolpart_write',
|
|
operation: 'create',
|
|
confidence: 'exact',
|
|
workspaceRoot: projectPath,
|
|
filePath: path.join(projectPath, 'src/opencode.ts'),
|
|
relativePath: 'src/opencode.ts',
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
toolStatus: 'succeeded',
|
|
before: null,
|
|
after: null,
|
|
oldString: '',
|
|
newString: 'export const source = "opencode";\n',
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
})}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function persistedEntryPath(baseDir: string): string {
|
|
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function makeTaskChangeResult(
|
|
taskId = TASK_ID,
|
|
overrides: Partial<{
|
|
teamName: string;
|
|
taskId: string;
|
|
filePath: string;
|
|
confidence: 'high' | 'medium' | 'low' | 'fallback';
|
|
content: string;
|
|
warning: string;
|
|
scope: Partial<{
|
|
memberName: string;
|
|
startTimestamp: string;
|
|
endTimestamp: string;
|
|
toolUseIds: string[];
|
|
filePaths: string[];
|
|
confidence: {
|
|
tier: 1 | 2 | 3 | 4;
|
|
label: 'high' | 'medium' | 'low' | 'fallback';
|
|
reason: string;
|
|
};
|
|
}>;
|
|
}> = {}
|
|
) {
|
|
const teamName = overrides.teamName ?? TEAM_NAME;
|
|
const targetTaskId = overrides.taskId ?? taskId;
|
|
const filePath = overrides.filePath ?? '/repo/src/file.ts';
|
|
const content = overrides.content ?? 'export const value = 1;\n';
|
|
const confidence = overrides.confidence ?? 'high';
|
|
const confidenceTierByLabel = {
|
|
high: 1,
|
|
medium: 2,
|
|
low: 3,
|
|
fallback: 4,
|
|
} as const;
|
|
const files =
|
|
content.length > 0
|
|
? [
|
|
{
|
|
filePath,
|
|
relativePath: 'src/file.ts',
|
|
snippets: [],
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
return {
|
|
teamName,
|
|
taskId: targetTaskId,
|
|
files,
|
|
totalFiles: files.length,
|
|
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
|
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
|
confidence,
|
|
computedAt: '2026-03-01T12:00:00.000Z',
|
|
scope: {
|
|
taskId: targetTaskId,
|
|
memberName: overrides.scope?.memberName ?? 'alice',
|
|
startLine: 0,
|
|
endLine: 0,
|
|
startTimestamp: overrides.scope?.startTimestamp ?? '',
|
|
endTimestamp: overrides.scope?.endTimestamp ?? '',
|
|
toolUseIds: overrides.scope?.toolUseIds ?? [],
|
|
filePaths: overrides.scope?.filePaths ?? files.map((file) => file.filePath),
|
|
confidence:
|
|
overrides.scope?.confidence ?? {
|
|
tier: confidenceTierByLabel[confidence],
|
|
label: confidence,
|
|
reason: 'test fixture',
|
|
},
|
|
},
|
|
warnings: overrides.warning ? [overrides.warning] : [],
|
|
};
|
|
}
|
|
|
|
function pendingTaskChangeResult(): Promise<ReturnType<typeof makeTaskChangeResult>> {
|
|
return new Promise<ReturnType<typeof makeTaskChangeResult>>(() => {
|
|
// Keep pending to exercise summary timeout handling.
|
|
});
|
|
}
|
|
|
|
function createService(params: {
|
|
logPaths: string[];
|
|
projectPath?: string;
|
|
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
|
taskChangePresenceRepository?: {
|
|
upsertEntry: ReturnType<typeof vi.fn>;
|
|
deleteEntry?: ReturnType<typeof vi.fn>;
|
|
};
|
|
teamLogSourceTracker?: {
|
|
ensureTracking: ReturnType<
|
|
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
|
>;
|
|
};
|
|
taskChangeWorkerClient?: {
|
|
isAvailable: ReturnType<typeof vi.fn<() => boolean>>;
|
|
computeTaskChanges: ReturnType<typeof vi.fn<() => Promise<unknown>>>;
|
|
};
|
|
}) {
|
|
const findLogFileRefsForTask =
|
|
params.findLogFileRefsForTask ??
|
|
vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' })));
|
|
const taskChangeWorkerClient =
|
|
params.taskChangeWorkerClient ??
|
|
({
|
|
isAvailable: vi.fn(() => false),
|
|
computeTaskChanges: vi.fn(async () => {
|
|
throw new Error('worker disabled in test');
|
|
}),
|
|
} as const);
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
findLogFileRefsForTask,
|
|
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: params.projectPath ?? PROJECT_PATH })) } as any,
|
|
undefined,
|
|
taskChangeWorkerClient as any
|
|
);
|
|
|
|
if (params.taskChangePresenceRepository && params.teamLogSourceTracker) {
|
|
service.setTaskChangePresenceServices(
|
|
params.taskChangePresenceRepository as any,
|
|
params.teamLogSourceTracker as any
|
|
);
|
|
}
|
|
|
|
return {
|
|
findLogFileRefsForTask,
|
|
service,
|
|
};
|
|
}
|
|
|
|
describe('ChangeExtractorService', () => {
|
|
let tmpDir: string | null = null;
|
|
|
|
afterEach(async () => {
|
|
setClaudeBasePathOverride(null);
|
|
if (tmpDir) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
tmpDir = null;
|
|
}
|
|
});
|
|
|
|
it('loads team task change summaries with capped requests and per-task errors', async () => {
|
|
const { service } = createService({ logPaths: [] });
|
|
const getTaskChanges = vi
|
|
.spyOn(service, 'getTaskChanges')
|
|
.mockImplementation(async (_teamName, taskId, options) => {
|
|
expect(options?.summaryOnly).toBe(true);
|
|
if (taskId === 'task-2') {
|
|
throw new Error('broken summary');
|
|
}
|
|
return makeTaskChangeResult(taskId, { taskId });
|
|
});
|
|
|
|
const response = await service.getTeamTaskChangeSummaries(
|
|
TEAM_NAME,
|
|
Array.from({ length: 205 }, (_, index) => ({
|
|
taskId: `task-${index}`,
|
|
options: { ...SUMMARY_OPTIONS, summaryOnly: false },
|
|
}))
|
|
);
|
|
|
|
expect(response.teamName).toBe(TEAM_NAME);
|
|
expect(response.truncated).toBe(true);
|
|
expect(response.items).toHaveLength(200);
|
|
expect(getTaskChanges).toHaveBeenCalledTimes(200);
|
|
expect(response.items[0].changeSet?.taskId).toBe('task-0');
|
|
expect(response.items[2]).toMatchObject({
|
|
taskId: 'task-2',
|
|
changeSet: null,
|
|
error: 'broken summary',
|
|
});
|
|
});
|
|
|
|
it('times out slow team task change summaries without blocking faster items', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { service } = createService({ logPaths: [] });
|
|
const getTaskChanges = vi
|
|
.spyOn(service, 'getTaskChanges')
|
|
.mockImplementation((_teamName, taskId) => {
|
|
if (taskId === 'slow-task') {
|
|
return pendingTaskChangeResult();
|
|
}
|
|
return Promise.resolve(makeTaskChangeResult(taskId, { taskId }));
|
|
});
|
|
|
|
const responsePromise = service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
|
{ taskId: 'slow-task', options: SUMMARY_OPTIONS },
|
|
{ taskId: 'fast-task', options: SUMMARY_OPTIONS },
|
|
]);
|
|
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
const response = await responsePromise;
|
|
|
|
expect(getTaskChanges).toHaveBeenCalledTimes(2);
|
|
expect(response.truncated).toBe(true);
|
|
expect(response.items[0]).toMatchObject({
|
|
taskId: 'slow-task',
|
|
changeSet: null,
|
|
error: expect.stringContaining('timed out'),
|
|
});
|
|
expect(response.items[1].changeSet?.taskId).toBe('fast-task');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('omits unscanned task summary placeholders after the batch deadline', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { service } = createService({ logPaths: [] });
|
|
vi.spyOn(service, 'getTaskChanges').mockImplementation(() => pendingTaskChangeResult());
|
|
|
|
const responsePromise = service.getTeamTaskChangeSummaries(
|
|
TEAM_NAME,
|
|
Array.from({ length: 8 }, (_, index) => ({
|
|
taskId: `slow-task-${index}`,
|
|
options: SUMMARY_OPTIONS,
|
|
}))
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
const response = await responsePromise;
|
|
|
|
expect(response.truncated).toBe(true);
|
|
expect(response.items).toHaveLength(6);
|
|
expect(response.items.every((item) => item.error?.includes('timed out'))).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('deduplicates team task change summary requests before loading', async () => {
|
|
const { service } = createService({ logPaths: [] });
|
|
const getTaskChanges = vi
|
|
.spyOn(service, 'getTaskChanges')
|
|
.mockImplementation((_teamName, taskId) =>
|
|
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
|
);
|
|
|
|
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
|
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
|
|
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
|
|
{ taskId: ' task-2 ', options: SUMMARY_OPTIONS },
|
|
]);
|
|
|
|
expect(response.items.map((item) => item.taskId)).toEqual(['task-1', 'task-2']);
|
|
expect(response.truncated).toBeUndefined();
|
|
expect(getTaskChanges).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('ignores malformed team task change summary requests', async () => {
|
|
const { service } = createService({ logPaths: [] });
|
|
const getTaskChanges = vi
|
|
.spyOn(service, 'getTaskChanges')
|
|
.mockImplementation((_teamName, taskId) =>
|
|
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
|
);
|
|
|
|
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
|
null,
|
|
{ taskId: '' },
|
|
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
|
|
{ taskId: 42 },
|
|
] as unknown as Parameters<typeof service.getTeamTaskChangeSummaries>[1]);
|
|
|
|
expect(response.items.map((item) => item.taskId)).toEqual(['task-1']);
|
|
expect(getTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('limits raw team task summary request inspection before loading', async () => {
|
|
const { service } = createService({ logPaths: [] });
|
|
const getTaskChanges = vi
|
|
.spyOn(service, 'getTaskChanges')
|
|
.mockImplementation((_teamName, taskId) =>
|
|
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
|
);
|
|
|
|
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
|
...Array.from({ length: 1000 }, () => null),
|
|
{ taskId: 'beyond-inspect-limit', options: SUMMARY_OPTIONS },
|
|
] as unknown as Parameters<typeof service.getTeamTaskChangeSummaries>[1]);
|
|
|
|
expect(response.items).toEqual([]);
|
|
expect(response.truncated).toBe(true);
|
|
expect(getTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reuse detailed task-change cache across different scope inputs', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
|
|
const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
|
|
await writeJsonl(aliceLogPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
|
|
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
|
);
|
|
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service;
|
|
|
|
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' });
|
|
const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(empty.files).toHaveLength(0);
|
|
expect(populated.files).toHaveLength(1);
|
|
expect(findLogFileRefsForTask).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('caches terminal summary requests in memory but keeps detailed requests fresh', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-summary.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
stateBucket: 'completed',
|
|
});
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
stateBucket: 'completed',
|
|
});
|
|
|
|
expect(findLogFileRefsForTask).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('restores a persisted terminal summary after a simulated restart', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-restart.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const first = createService({ logPaths: [logPath] });
|
|
const initial = await first.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
const second = createService({ logPaths: [logPath] });
|
|
const restored = await second.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(initial.files).toHaveLength(1);
|
|
expect(restored.files).toHaveLength(1);
|
|
expect(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')).toContain('"taskId": "1"');
|
|
// The second service restores from persisted cache; findLogFileRefsForTask may be called
|
|
// at most once for background validation (setTimeout(0) in schedulePersistedTaskChangeSummaryValidation)
|
|
expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('forceFresh overwrites the persisted terminal summary snapshot', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const { service } = createService({ logPaths: [logPath] });
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'),
|
|
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'),
|
|
]);
|
|
|
|
const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
...SUMMARY_OPTIONS,
|
|
forceFresh: true,
|
|
});
|
|
const after = await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
|
TEAM_NAME,
|
|
TASK_ID,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
expect(refreshed.totalFiles).toBe(2);
|
|
expect(after.totalFiles).toBe(2);
|
|
});
|
|
|
|
it('invalidates old terminal summaries when the task moves into review', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-review.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const { service } = createService({ logPaths: [logPath] });
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await writeTaskFile(tmpDir, {
|
|
historyEvents: [
|
|
{
|
|
id: 'evt-review',
|
|
type: 'review_requested',
|
|
to: 'review',
|
|
timestamp: '2026-03-01T11:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
stateBucket: 'review',
|
|
summaryOnly: true,
|
|
});
|
|
|
|
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('rejects persisted summaries after project/worktree drift', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
|
|
TEAM_NAME,
|
|
TASK_ID,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
|
|
await drifted.service.getTaskChanges(
|
|
TEAM_NAME,
|
|
TASK_ID,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('rejects persisted summaries when the task file is missing on restart', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
const taskPath = await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await fs.unlink(taskPath);
|
|
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('falls back safely when the persisted summary file is corrupted', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
|
|
|
|
const restored = await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
|
TEAM_NAME,
|
|
TASK_ID,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
expect(restored.files).toHaveLength(1);
|
|
});
|
|
|
|
it('does not persist low-confidence fallback summaries', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { workIntervals: [], historyEvents: [] });
|
|
|
|
const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
findLogFileRefsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: false,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any,
|
|
undefined,
|
|
{
|
|
isAvailable: vi.fn(() => false),
|
|
computeTaskChanges: vi.fn(async () => {
|
|
throw new Error('worker disabled in test');
|
|
}),
|
|
} as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(result.confidence).toBe('fallback');
|
|
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('merges fallback changes for the same Windows file across slash variants', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
|
|
const firstLogPath = path.join(tmpDir, 'first.jsonl');
|
|
const secondLogPath = path.join(tmpDir, 'second.jsonl');
|
|
await writeJsonl(firstLogPath, [
|
|
buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
await writeJsonl(secondLogPath, [
|
|
buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'),
|
|
]);
|
|
|
|
const service = createService({
|
|
logPaths: [firstLogPath, secondLogPath],
|
|
projectPath: 'C:\\repo',
|
|
}).service;
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.files[0]?.relativePath).toBe('src/same.ts');
|
|
expect(result.totalLinesAdded).toBe(2);
|
|
});
|
|
|
|
it('prefers worker task-change results when the worker is available', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const workerResult = makeTaskChangeResult();
|
|
const computeTaskChanges = vi.fn(async () => workerResult);
|
|
const { service, findLogFileRefsForTask } = createService({
|
|
logPaths: [],
|
|
taskChangeWorkerClient: {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges,
|
|
},
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result).toEqual(workerResult);
|
|
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back inline when task-change worker is unavailable', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const computeTaskChanges = vi.fn();
|
|
const { service, findLogFileRefsForTask } = createService({
|
|
logPaths: [logPath],
|
|
taskChangeWorkerClient: {
|
|
isAvailable: vi.fn(() => false),
|
|
computeTaskChanges,
|
|
},
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(findLogFileRefsForTask).toHaveBeenCalled();
|
|
expect(computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back inline when task-change worker throws', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const computeTaskChanges = vi.fn(async () => {
|
|
throw new Error('worker failed');
|
|
});
|
|
const { service, findLogFileRefsForTask } = createService({
|
|
logPaths: [logPath],
|
|
taskChangeWorkerClient: {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges,
|
|
},
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
expect(findLogFileRefsForTask).toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
|
|
const { service } = createService({
|
|
logPaths: [logPath],
|
|
taskChangeWorkerClient: {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges,
|
|
},
|
|
});
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('restores persisted summaries without invoking worker compute', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
|
]);
|
|
|
|
const firstWorker = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
|
|
};
|
|
await createService({
|
|
logPaths: [logPath],
|
|
taskChangeWorkerClient: firstWorker,
|
|
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
const secondWorker = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })),
|
|
};
|
|
const restored = await createService({
|
|
logPaths: [logPath],
|
|
taskChangeWorkerClient: secondWorker,
|
|
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(restored.files).toHaveLength(1);
|
|
expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not let stale worker results populate summary cache after invalidation', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const first = deferred<ReturnType<typeof makeTaskChangeResult>>();
|
|
const firstStarted = deferred<void>();
|
|
const worker = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi
|
|
.fn()
|
|
.mockImplementationOnce(() => {
|
|
firstStarted.resolve();
|
|
return first.promise;
|
|
})
|
|
.mockImplementationOnce(async () =>
|
|
makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' })
|
|
),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [],
|
|
taskChangeWorkerClient: worker,
|
|
});
|
|
|
|
const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
await firstStarted.promise;
|
|
await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true });
|
|
const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
// Flush microtasks so freshPromise advances past its internal awaits
|
|
// and reaches the worker mock before we resolve the stale deferred.
|
|
// Without this, CI timing can cause the stale resolution to race with
|
|
// the fresh worker call, making the test flaky.
|
|
await vi.advanceTimersByTimeAsync?.(0).catch(() => undefined);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
first.resolve(makeTaskChangeResult());
|
|
const stale = await stalePromise;
|
|
const fresh = await freshPromise;
|
|
|
|
expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts');
|
|
expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts');
|
|
expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('writes has_changes presence entries after successful task diff computation', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const logPath = path.join(tmpDir, 'alice-presence.jsonl');
|
|
await writeJsonl(logPath, [
|
|
buildAssistantWriteEntry(
|
|
'tool-1',
|
|
'/repo/src/file.ts',
|
|
'export const value = 1;\n',
|
|
'2026-03-01T10:00:00.000Z'
|
|
),
|
|
]);
|
|
|
|
const upsertEntry = vi.fn(async () => undefined);
|
|
const ensureTracking = vi.fn(async () => ({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [logPath],
|
|
taskChangePresenceRepository: { upsertEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
taskChangeWorkerClient: workerClient,
|
|
});
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(upsertEntry).toHaveBeenCalledWith(
|
|
TEAM_NAME,
|
|
expect.objectContaining({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
}),
|
|
expect.objectContaining({
|
|
taskId: TASK_ID,
|
|
presence: 'has_changes',
|
|
taskSignature: buildTaskChangePresenceDescriptor({
|
|
createdAt: '2026-03-01T09:55:00.000Z',
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
intervals: [
|
|
{
|
|
startedAt: '2026-03-01T10:00:00.000Z',
|
|
completedAt: '2026-03-01T10:10:00.000Z',
|
|
},
|
|
],
|
|
reviewState: 'none',
|
|
historyEvents: [],
|
|
}).taskSignature,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('clears cached presence for diagnostic-only multi-scope task diff results', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
|
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
|
const ensureTracking = vi.fn(() =>
|
|
Promise.resolve({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
})
|
|
);
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(() =>
|
|
Promise.resolve(
|
|
makeTaskChangeResult(TASK_ID, {
|
|
content: '',
|
|
confidence: 'fallback',
|
|
warning: 'Ledger skipped attribution because multiple task scopes were active.',
|
|
})
|
|
)
|
|
),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [],
|
|
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
taskChangeWorkerClient: workerClient,
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.warnings).toEqual([
|
|
'Ledger skipped attribution because multiple task scopes were active.',
|
|
]);
|
|
expect(upsertEntry).not.toHaveBeenCalled();
|
|
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
|
});
|
|
|
|
it('writes needs_attention presence entries for unclassified warning-only task diff results', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
|
const ensureTracking = vi.fn(() =>
|
|
Promise.resolve({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
})
|
|
);
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(() =>
|
|
Promise.resolve(
|
|
makeTaskChangeResult(TASK_ID, {
|
|
content: '',
|
|
confidence: 'fallback',
|
|
warning: 'Unexpected ledger warning.',
|
|
})
|
|
)
|
|
),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [],
|
|
taskChangePresenceRepository: { upsertEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
taskChangeWorkerClient: workerClient,
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.warnings).toEqual(['Unexpected ledger warning.']);
|
|
expect(upsertEntry).toHaveBeenCalledWith(
|
|
TEAM_NAME,
|
|
expect.objectContaining({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
}),
|
|
expect.objectContaining({
|
|
taskId: TASK_ID,
|
|
presence: 'needs_attention',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('does not write warning-only presence for active interval summaries with no observed file edits yet', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const upsertEntry = vi.fn(async () => undefined);
|
|
const ensureTracking = vi.fn(async () => ({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, {
|
|
content: '',
|
|
confidence: 'medium',
|
|
warning: 'No file edits found within persisted workIntervals.',
|
|
scope: {
|
|
memberName: 'echo',
|
|
startTimestamp: '2026-03-01T12:00:00.000Z',
|
|
endTimestamp: '',
|
|
toolUseIds: [],
|
|
filePaths: [],
|
|
confidence: {
|
|
tier: 2,
|
|
label: 'medium',
|
|
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
|
},
|
|
},
|
|
})
|
|
),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [],
|
|
taskChangePresenceRepository: { upsertEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
taskChangeWorkerClient: workerClient,
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.warnings).toEqual(['No file edits found within persisted workIntervals.']);
|
|
expect(upsertEntry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not write no_changes presence entries for uncertain empty task diff results', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir);
|
|
|
|
const upsertEntry = vi.fn(async () => undefined);
|
|
const ensureTracking = vi.fn(async () => ({
|
|
projectFingerprint: 'project-fingerprint',
|
|
logSourceGeneration: 'log-generation',
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })),
|
|
};
|
|
const { service } = createService({
|
|
logPaths: [],
|
|
taskChangePresenceRepository: { upsertEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
taskChangeWorkerClient: workerClient,
|
|
});
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false);
|
|
expect(upsertEntry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs OpenCode recovery when a ledger result only contains warning notices', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'bob' });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
|
await writeOpenCodeLedgerEventJournal(input.projectDir, projectPath);
|
|
return {
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 1,
|
|
scannedToolparts: 1,
|
|
candidateEvents: 1,
|
|
importedEvents: 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, {
|
|
...SUMMARY_OPTIONS,
|
|
owner: 'bob',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.warnings).toContain(
|
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
|
);
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recovers Codex warning-only ledger results through the scoped worker path', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { owner: 'tom' });
|
|
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 writeWarningOnlyLedgerNotice(projectDir, { memberName: 'tom' });
|
|
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, {
|
|
filePath: path.join(projectPath, 'src/codex.ts'),
|
|
scope: { memberName: 'tom' },
|
|
})
|
|
),
|
|
};
|
|
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: 'tom', providerId: 'codex' }],
|
|
})),
|
|
} as any,
|
|
undefined,
|
|
workerClient as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
...SUMMARY_OPTIONS,
|
|
owner: 'tom',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.warnings).toContain(
|
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
|
);
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps non-Codex warning-only ledger results as diagnostics instead of adding legacy changes', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { owner: 'atlas' });
|
|
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 writeWarningOnlyLedgerNotice(projectDir, { memberName: 'atlas' });
|
|
|
|
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: 'atlas', providerId: 'anthropic' }],
|
|
})),
|
|
} as any,
|
|
undefined,
|
|
workerClient as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
...SUMMARY_OPTIONS,
|
|
owner: 'atlas',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(result.warnings).toContain(
|
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
|
);
|
|
expect(workerClient.computeTaskChanges).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 });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
let deliveryContextHashVerified = false;
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
|
deliveryContextHashVerified =
|
|
createHash('sha256')
|
|
.update(await fs.readFile(input.deliveryContextPath, 'utf8'))
|
|
.digest('hex') === input.deliveryContextHash;
|
|
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
|
|
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',
|
|
})
|
|
);
|
|
const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0];
|
|
expect(backfillInput.deliveryContextPath).toEqual(
|
|
expect.stringContaining('delivery-context.json')
|
|
);
|
|
expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(deliveryContextHashVerified).toBe(true);
|
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rereads ledger when OpenCode backfill writes artifacts and then fails', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
|
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
|
|
throw new Error('timeout after import');
|
|
});
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.files[0]?.snippets[0]?.toolName).toBe('Write');
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the OpenCode delivery member when the current task owner changed later', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob' });
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 0,
|
|
scannedToolparts: 0,
|
|
candidateEvents: 0,
|
|
importedEvents: 0,
|
|
skippedEvents: 0,
|
|
outcome: 'no-history',
|
|
notices: [],
|
|
diagnostics: [],
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
memberName: 'bob',
|
|
attributionMode: 'strict-delivery',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('omits member filter when multiple OpenCode delivery members match the task', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob', runtimeSessionId: 'session-1' });
|
|
await writeOpenCodeDeliveryLedger(tmpDir, {
|
|
memberName: 'carol',
|
|
runtimeSessionId: 'session-2',
|
|
});
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 0,
|
|
scannedToolparts: 0,
|
|
candidateEvents: 0,
|
|
importedEvents: 0,
|
|
skippedEvents: 0,
|
|
outcome: 'no-history',
|
|
notices: [],
|
|
diagnostics: [],
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('memberName');
|
|
});
|
|
|
|
it('ignores OpenCode delivery records that match only a recreated task display id', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir, {
|
|
taskId: 'old-task',
|
|
displayId: 'abc12345',
|
|
memberName: 'bob',
|
|
});
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async () => {
|
|
throw new Error('display-id-only delivery record must not backfill');
|
|
});
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('ignores OpenCode delivery records that only mention related tasks', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir, {
|
|
taskId: 'related-task',
|
|
displayId: 'def67890',
|
|
memberName: 'bob',
|
|
});
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async () => {
|
|
throw new Error('related-only delivery record must not backfill');
|
|
});
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(result.files).toHaveLength(0);
|
|
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
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('awaits OpenCode backfill for summary-only requests with delivery context before falling back', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
const 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
|
|
);
|
|
|
|
let settled = false;
|
|
const request = service
|
|
.getTaskChanges(TEAM_NAME, TASK_ID, { ...SUMMARY_OPTIONS, owner: 'bob' })
|
|
.finally(() => {
|
|
settled = true;
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: TEAM_NAME,
|
|
taskId: TASK_ID,
|
|
projectDir,
|
|
workspaceRoot: projectPath,
|
|
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
|
deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
attributionMode: 'strict-delivery',
|
|
})
|
|
);
|
|
});
|
|
expect(settled).toBe(false);
|
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
|
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: [],
|
|
});
|
|
|
|
const result = await request;
|
|
expect(result.files).toHaveLength(0);
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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).not.toHaveBeenCalled();
|
|
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
|
expect.stringContaining('delivery-context.json')
|
|
);
|
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(
|
|
/^[a-f0-9]{64}$/
|
|
);
|
|
});
|
|
|
|
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
|
|
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'
|
|
);
|
|
|
|
let backfillAttempt = 0;
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
|
const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution';
|
|
return {
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 1,
|
|
scannedToolparts: 0,
|
|
candidateEvents: 0,
|
|
importedEvents: 0,
|
|
skippedEvents: 0,
|
|
outcome,
|
|
notices: [],
|
|
diagnostics:
|
|
outcome === 'transient-error'
|
|
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
|
|
: [],
|
|
};
|
|
});
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
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[0]?.[0]?.deliveryContextHash).toMatch(
|
|
/^[a-f0-9]{64}$/
|
|
);
|
|
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
|
expect.stringContaining('delivery-context.json')
|
|
);
|
|
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(
|
|
/^[a-f0-9]{64}$/
|
|
);
|
|
});
|
|
|
|
it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 1,
|
|
scannedToolparts: 1,
|
|
candidateEvents: 1,
|
|
importedEvents: 0,
|
|
skippedEvents: 1,
|
|
outcome: 'duplicates-only',
|
|
notices: [],
|
|
diagnostics: [],
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('caches duplicates-only OpenCode backfill from the current evidence contract', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
|
const projectDir = path.join(tmpDir, 'project-dir');
|
|
const projectPath = path.join(tmpDir, 'repo');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
|
|
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
|
schemaVersion: 1,
|
|
providerId: 'opencode',
|
|
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
|
teamName: input.teamName,
|
|
taskId: input.taskId,
|
|
projectDir: input.projectDir,
|
|
workspaceRoot: input.workspaceRoot,
|
|
dryRun: false,
|
|
attributionMode: input.attributionMode,
|
|
scannedSessions: 1,
|
|
scannedToolparts: 1,
|
|
candidateEvents: 1,
|
|
importedEvents: 0,
|
|
skippedEvents: 1,
|
|
outcome: 'duplicates-only',
|
|
notices: [],
|
|
diagnostics: [],
|
|
}));
|
|
const workerClient = {
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges: vi.fn(async () =>
|
|
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
|
),
|
|
};
|
|
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir,
|
|
projectPath,
|
|
sessionIds: [],
|
|
})),
|
|
findLogFileRefsForTask: vi.fn(async () => []),
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => ({
|
|
boundaries: [],
|
|
scopes: [],
|
|
isSingleTaskSession: true,
|
|
detectedMechanism: 'none' as const,
|
|
})),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
|
undefined,
|
|
workerClient as any,
|
|
{ backfillOpenCodeTaskLedger } as any,
|
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
|
);
|
|
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
|
owner: 'bob',
|
|
status: 'completed',
|
|
});
|
|
|
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|