313 lines
9.3 KiB
TypeScript
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');
|
|
});
|
|
});
|