agent-ecosystem/test/main/services/team/OpenCodeTaskLogAttributionStore.test.ts
2026-04-21 20:28:22 +03:00

313 lines
9.3 KiB
TypeScript

import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
teamsBasePath: '',
}));
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => hoisted.teamsBasePath,
}));
import {
OpenCodeTaskLogAttributionStore,
getOpenCodeTaskLogAttributionPath,
} from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore';
describe('OpenCodeTaskLogAttributionStore', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-attribution-'));
hoisted.teamsBasePath = tempDir;
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('reads normalized task records from tasks map and flat records without duplicates', async () => {
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify(
{
schemaVersion: 1,
tasks: {
'task-a': [
{
memberName: ' bob ',
scope: 'member_session_window',
sessionId: ' session-bob ',
since: '2026-04-21T12:00:00Z',
until: '2026-04-21T12:10:00Z',
source: 'launch_runtime',
},
{
memberName: 'bob',
scope: 'member_session_window',
sessionId: 'session-bob',
since: '2026-04-21T12:00:00.000Z',
until: '2026-04-21T12:10:00.000Z',
source: 'launch_runtime',
},
{
memberName: '',
since: '2026-04-21T12:00:00Z',
},
],
},
records: [
{
taskId: 'task-a',
memberName: 'carol',
scope: 'task_session',
sessionId: 'session-carol',
startMessageUuid: 'm-1',
endMessageUuid: 'm-3',
createdAt: '2026-04-21T11:59:00Z',
},
{
taskId: 'other-task',
memberName: 'dave',
},
{
taskId: 'task-a',
memberName: 'erin',
since: '2026-04-21T13:00:00Z',
until: '2026-04-21T12:00:00Z',
},
],
},
null,
2
),
'utf8'
);
const records = await new OpenCodeTaskLogAttributionStore().readTaskRecords('team-a', 'task-a');
expect(records).toEqual([
{
taskId: 'task-a',
memberName: 'carol',
scope: 'task_session',
sessionId: 'session-carol',
startMessageUuid: 'm-1',
endMessageUuid: 'm-3',
createdAt: '2026-04-21T11:59:00.000Z',
},
{
taskId: 'task-a',
memberName: 'bob',
scope: 'member_session_window',
sessionId: 'session-bob',
since: '2026-04-21T12:00:00.000Z',
until: '2026-04-21T12:10:00.000Z',
source: 'launch_runtime',
},
]);
});
it('degrades to empty records for missing, invalid, or unsupported files', async () => {
const store = new OpenCodeTaskLogAttributionStore();
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{bad-json', 'utf8');
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
expect(vi.mocked(console.warn)).toHaveBeenCalledWith(
'[OpenCodeTaskLogAttributionStore]',
expect.stringContaining('invalid OpenCode task-log attribution JSON')
);
vi.mocked(console.warn).mockClear();
await fs.writeFile(
filePath,
JSON.stringify({
schemaVersion: 2,
records: [{ taskId: 'task-a', memberName: 'alice' }],
}),
'utf8'
);
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
});
it('upserts records atomically and does not rewrite unchanged attribution', async () => {
const store = new OpenCodeTaskLogAttributionStore();
const now = new Date('2026-04-21T12:00:00.000Z');
await expect(
store.upsertTaskRecord(
'team-a',
{
taskId: 'task-a',
memberName: ' alice ',
scope: 'member_session_window',
sessionId: ' session-a ',
since: '2026-04-21T11:59:00Z',
until: '2026-04-21T12:05:00Z',
source: 'launch_runtime',
},
{ now }
)
).resolves.toBe('created');
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
const firstRaw = await fs.readFile(filePath, 'utf8');
await expect(
store.upsertTaskRecord(
'team-a',
{
taskId: 'task-a',
memberName: 'alice',
scope: 'member_session_window',
sessionId: 'session-a',
since: '2026-04-21T11:59:00.000Z',
until: '2026-04-21T12:05:00.000Z',
source: 'launch_runtime',
},
{ now: new Date('2026-04-21T12:10:00.000Z') }
)
).resolves.toBe('unchanged');
expect(await fs.readFile(filePath, 'utf8')).toBe(firstRaw);
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([
{
taskId: 'task-a',
memberName: 'alice',
scope: 'member_session_window',
sessionId: 'session-a',
since: '2026-04-21T11:59:00.000Z',
until: '2026-04-21T12:05:00.000Z',
source: 'launch_runtime',
createdAt: '2026-04-21T12:00:00.000Z',
updatedAt: '2026-04-21T12:00:00.000Z',
},
]);
});
it('serializes concurrent upserts without losing records', async () => {
const store = new OpenCodeTaskLogAttributionStore();
await Promise.all([
store.upsertTaskRecord(
'team-a',
{
taskId: 'task-a',
memberName: 'alice',
scope: 'member_session_window',
sessionId: 'session-a',
since: '2026-04-21T10:00:00Z',
},
{ now: new Date('2026-04-21T10:00:01.000Z') }
),
store.upsertTaskRecord(
'team-a',
{
taskId: 'task-b',
memberName: 'bob',
scope: 'task_session',
sessionId: 'session-b',
startMessageUuid: 'm-1',
endMessageUuid: 'm-3',
},
{ now: new Date('2026-04-21T10:00:02.000Z') }
),
]);
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toMatchObject([
{
taskId: 'task-a',
memberName: 'alice',
sessionId: 'session-a',
},
]);
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toMatchObject([
{
taskId: 'task-b',
memberName: 'bob',
sessionId: 'session-b',
startMessageUuid: 'm-1',
endMessageUuid: 'm-3',
},
]);
});
it('replaces and clears only the requested task records', async () => {
const store = new OpenCodeTaskLogAttributionStore();
await store.upsertTaskRecord('team-a', {
taskId: 'task-a',
memberName: 'alice',
scope: 'member_session_window',
sessionId: 'session-a',
since: '2026-04-21T10:00:00Z',
});
await store.upsertTaskRecord('team-a', {
taskId: 'task-b',
memberName: 'bob',
scope: 'member_session_window',
sessionId: 'session-b',
since: '2026-04-21T11:00:00Z',
});
await expect(
store.replaceTaskRecords(
'team-a',
'task-a',
[
{
taskId: 'ignored-by-replace',
memberName: 'carol',
scope: 'task_session',
sessionId: 'session-c',
startMessageUuid: 'm-1',
},
],
{ now: new Date('2026-04-21T12:00:00.000Z') }
)
).resolves.toBe('updated');
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toMatchObject([
{
taskId: 'task-a',
memberName: 'carol',
sessionId: 'session-c',
startMessageUuid: 'm-1',
},
]);
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toMatchObject([
{
taskId: 'task-b',
memberName: 'bob',
sessionId: 'session-b',
},
]);
await expect(store.clearTaskRecords('team-a', 'task-a')).resolves.toBe('deleted');
await expect(store.readTaskRecords('team-a', 'task-a')).resolves.toEqual([]);
await expect(store.readTaskRecords('team-a', 'task-b')).resolves.toHaveLength(1);
});
it('fails closed instead of overwriting invalid attribution JSON during writes', async () => {
const store = new OpenCodeTaskLogAttributionStore();
const filePath = getOpenCodeTaskLogAttributionPath('team-a');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{bad-json', 'utf8');
await expect(
store.upsertTaskRecord('team-a', {
taskId: 'task-a',
memberName: 'alice',
scope: 'member_session_window',
sessionId: 'session-a',
})
).rejects.toThrow('Invalid OpenCode task-log attribution JSON');
expect(await fs.readFile(filePath, 'utf8')).toBe('{bad-json');
});
});