import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { Worker } from 'worker_threads'; import { afterAll, afterEach, describe, expect, it } from 'vitest'; import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; interface WorkerResponse { id: string; ok: boolean; result?: unknown; diag?: unknown; error?: string; } let bundledWorkerPathPromise: Promise | null = null; async function getWorkerPath(): Promise { bundledWorkerPathPromise ??= bundleWorkerForTests(); return bundledWorkerPathPromise; } async function bundleWorkerForTests(): Promise { const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-bundle-')); const outfile = path.join(outDir, 'team-fs-worker.cjs'); await fs.writeFile( outfile, [ "const path = require('node:path');", "const { createRequire } = require('node:module');", "const requireFromRepo = createRequire(path.join(process.cwd(), 'package.json'));", "const { register } = requireFromRepo('tsx/cjs/api');", "register({ tsconfigPath: path.join(process.cwd(), 'tsconfig.json') });", "require(path.join(process.cwd(), 'src', 'main', 'workers', 'team-fs-worker.ts'));", '', ].join('\n'), 'utf8' ); return outfile; } function createWorker(workerPath: string): Worker { return new Worker(workerPath); } function callWorker( worker: Worker, op: string, payload: Record = {} ): Promise<{ result: unknown; diag?: unknown }> { const requestId = `req-${Date.now()}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); reject(new Error('team-fs-worker test timed out')); }, 10_000); const cleanup = () => { clearTimeout(timeout); worker.off('message', onMessage); worker.off('error', onError); }; const onError = (error: Error) => { cleanup(); reject(error); }; const onMessage = (message: WorkerResponse) => { if (!message || message.id !== requestId) { return; } cleanup(); if (!message.ok) { reject(new Error(message.error || 'team-fs-worker returned an unknown error')); return; } resolve({ result: message.result, diag: message.diag }); }; worker.on('message', onMessage); worker.on('error', onError); worker.postMessage({ id: requestId, op, payload }); }); } async function callListTeams( worker: Worker, teamsDir: string ): Promise<{ teams: unknown[]; diag?: Record; }> { const { result, diag } = await callWorker(worker, 'listTeams', { teamsDir, largeConfigBytes: 8 * 1024, configHeadBytes: 4 * 1024, maxConfigBytes: 256 * 1024, maxConfigReadMs: 5_000, maxMembersMetaBytes: 256 * 1024, maxSessionHistoryInSummary: 10, maxProjectPathHistoryInSummary: 10, concurrency: 2, }); return { teams: Array.isArray(result) ? result : [], diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, }; } async function callGetAllTasks( worker: Worker, tasksBase: string ): Promise<{ tasks: unknown[]; diag?: Record; }> { const { result, diag } = await callWorker(worker, 'getAllTasks', { tasksBase, maxTaskBytes: 256 * 1024, maxTaskReadMs: 5_000, concurrency: 2, }); return { tasks: Array.isArray(result) ? result : [], diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, }; } async function callWarmup(worker: Worker): Promise { await callWorker(worker, 'warmup'); } describe('team-fs-worker integration', () => { let tempDir = ''; afterAll(async () => { const bundledWorkerPath = bundledWorkerPathPromise ? await bundledWorkerPathPromise : null; if (bundledWorkerPath) { await fs.rm(path.dirname(bundledWorkerPath), { recursive: true, force: true }); } }); afterEach(async () => { if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('uses launch-summary.json when launch-state.json is too large for mixed-team summaries', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'mixed-worker-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Mixed Worker Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8'); await fs.writeFile( path.join(teamDir, 'launch-summary.json'), JSON.stringify( createPersistedLaunchSummaryProjection({ version: 2, teamName, updatedAt: '2026-04-22T12:00:00.000Z', launchPhase: 'finished', expectedMembers: ['alice', 'bob'], bootstrapExpectedMembers: ['alice'], members: { alice: { name: 'alice', providerId: 'codex', laneId: 'primary', laneKind: 'primary', laneOwnerProviderId: 'codex', launchState: 'confirmed_alive', agentToolAccepted: true, runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, bob: { name: 'bob', providerId: 'opencode', laneId: 'secondary:opencode:bob', laneKind: 'secondary', laneOwnerProviderId: 'opencode', launchState: 'failed_to_start', agentToolAccepted: true, runtimeAlive: false, bootstrapConfirmed: false, hardFailure: true, hardFailureReason: 'Side lane failed', lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, }, summary: { confirmedCount: 1, pendingCount: 0, failedCount: 1, runtimeAlivePendingCount: 0, }, teamLaunchState: 'partial_failure', } as never), null, 2 ), 'utf8' ); const worker = createWorker(workerPath); try { const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, displayName: 'Mixed Worker Team', partialLaunchFailure: true, expectedMemberCount: 2, confirmedMemberCount: 1, missingMembers: ['bob'], teamLaunchState: 'partial_failure', confirmedCount: 1, pendingCount: 0, failedCount: 1, }); } finally { await worker.terminate(); } }); it('ignores removed and lead members when draft-team worker summary counts members', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'draft-worker-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'team.meta.json'), JSON.stringify({ version: 1, cwd: tempDir, displayName: 'Draft Worker Team', createdAt: Date.parse('2026-04-22T12:00:00.000Z'), }), 'utf8' ); await fs.writeFile( path.join(teamDir, 'members.meta.json'), JSON.stringify({ version: 1, members: [ { name: 'team-lead', agentType: 'team-lead', color: '#123456' }, { name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') }, { name: 'bob', role: 'developer' }, ], }), 'utf8' ); const worker = createWorker(workerPath); try { const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, displayName: 'Draft Worker Team', memberCount: 1, leadName: 'team-lead', leadColor: '#123456', }); } finally { await worker.terminate(); } }); it('uses lead cwd as the project path when config.projectPath is missing', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'lead-cwd-project-team'; const teamDir = path.join(tempDir, teamName); const projectPath = path.join(tempDir, 'project-321'); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Lead Cwd Project Team', projectPath: null, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], }), 'utf8' ); const worker = createWorker(workerPath); try { const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, displayName: 'Lead Cwd Project Team', projectPath, }); } finally { await worker.terminate(); } }); it('prewarms and reuses unchanged team summaries by fingerprint', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'cached-worker-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Cached Worker Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); await fs.writeFile( path.join(teamDir, 'members.meta.json'), JSON.stringify({ version: 1, members: [{ name: 'alice' }] }), 'utf8' ); const worker = createWorker(workerPath); try { await callWarmup(worker); const first = await callListTeams(worker, tempDir); expect(first.teams[0]).toMatchObject({ teamName, memberCount: 1 }); expect(first.diag?.cacheMisses).toBe(1); const second = await callListTeams(worker, tempDir); expect(second.teams[0]).toMatchObject({ teamName, memberCount: 1 }); expect(second.diag?.cacheHits).toBe(1); await fs.writeFile( path.join(teamDir, 'members.meta.json'), JSON.stringify({ version: 1, members: [{ name: 'alice' }, { name: 'bob' }] }), 'utf8' ); const changed = await callListTeams(worker, tempDir); expect(changed.teams[0]).toMatchObject({ teamName, memberCount: 2 }); expect(changed.diag?.cacheMisses).toBe(1); } finally { await worker.terminate(); } }); it('does not cache pending launch summaries because liveness can change without file writes', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'pending-launch-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Pending Launch Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); await fs.writeFile( path.join(teamDir, 'launch-summary.json'), JSON.stringify({ version: 1, teamName, updatedAt: new Date().toISOString(), launchPhase: 'active', teamLaunchState: 'partial_pending', expectedMemberCount: 1, pendingCount: 1, }), 'utf8' ); const worker = createWorker(workerPath); try { const first = await callListTeams(worker, tempDir); expect(first.teams[0]).toMatchObject({ teamName, teamLaunchState: 'partial_pending', pendingCount: 1, }); expect(first.diag?.cacheMisses).toBe(1); expect(first.diag?.cacheWriteSkips).toBe(1); const second = await callListTeams(worker, tempDir); expect(second.teams[0]).toMatchObject({ teamName, teamLaunchState: 'partial_pending', pendingCount: 1, }); expect(second.diag?.cacheHits).toBe(0); expect(second.diag?.cacheMisses).toBe(1); expect(second.diag?.cacheWriteSkips).toBe(1); } finally { await worker.terminate(); } }); it('ignores stale pending launch-summary fallbacks so offline teams do not stay reconciling', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'stale-pending-summary-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Stale Pending Summary Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); await fs.writeFile( path.join(teamDir, 'launch-summary.json'), JSON.stringify({ version: 1, teamName, updatedAt: '2026-04-09T20:35:57.962Z', launchUpdatedAt: '2026-04-09T20:35:57.962Z', teamLaunchState: 'partial_pending', expectedMemberCount: 1, pendingCount: 1, permissionPendingCount: 0, }), 'utf8' ); const worker = createWorker(workerPath); try { const first = await callListTeams(worker, tempDir); expect(first.teams[0]).toMatchObject({ teamName }); expect(first.teams[0]).not.toMatchObject({ teamLaunchState: 'partial_pending', }); expect(first.diag?.cacheMisses).toBe(1); expect(first.diag?.cacheWriteSkips).toBe(0); } finally { await worker.terminate(); } }); it('rereads launch-summary after caching a stale pending fallback as settled', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const teamName = 'stale-pending-cache-invalidation-team'; const teamDir = path.join(tempDir, teamName); const launchSummaryPath = path.join(teamDir, 'launch-summary.json'); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Stale Pending Cache Invalidation Team', members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); await fs.writeFile( launchSummaryPath, JSON.stringify({ version: 1, teamName, updatedAt: '2026-04-09T20:35:57.962Z', launchUpdatedAt: '2026-04-09T20:35:57.962Z', teamLaunchState: 'partial_pending', expectedMemberCount: 1, pendingCount: 1, permissionPendingCount: 0, }), 'utf8' ); const worker = createWorker(workerPath); try { const stale = await callListTeams(worker, tempDir); expect(stale.teams[0]).toMatchObject({ teamName }); expect(stale.teams[0]).not.toMatchObject({ teamLaunchState: 'partial_pending', }); expect(stale.diag?.cacheMisses).toBe(1); expect(stale.diag?.cacheWriteSkips).toBe(0); await fs.writeFile( launchSummaryPath, JSON.stringify({ version: 1, teamName, updatedAt: new Date().toISOString(), launchPhase: 'active', teamLaunchState: 'partial_pending', expectedMemberCount: 1, pendingCount: 1, permissionPendingCount: 0, }), 'utf8' ); const fresh = await callListTeams(worker, tempDir); expect(fresh.teams[0]).toMatchObject({ teamName, teamLaunchState: 'partial_pending', pendingCount: 1, }); expect(fresh.diag?.cacheHits).toBe(0); expect(fresh.diag?.cacheMisses).toBe(1); expect(fresh.diag?.cacheWriteSkips).toBe(1); } finally { await worker.terminate(); } }); it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); const tasksBase = path.join(tempDir, 'tasks'); const teamName = 'task-cache-team'; const tasksDir = path.join(tasksBase, teamName); await fs.mkdir(tasksDir, { recursive: true }); const taskPath = path.join(tasksDir, '1.json'); await fs.writeFile( taskPath, JSON.stringify({ id: '1', subject: 'First subject', status: 'pending', createdAt: '2026-05-02T12:00:00.000Z', }), 'utf8' ); const worker = createWorker(workerPath); try { const first = await callGetAllTasks(worker, tasksBase); expect(first.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); expect(first.diag?.cacheMisses).toBe(1); const second = await callGetAllTasks(worker, tasksBase); expect(second.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); expect(second.diag?.cacheHits).toBe(1); await fs.writeFile( taskPath, JSON.stringify({ id: '1', subject: 'Changed subject with a different size', status: 'pending', createdAt: '2026-05-02T12:00:00.000Z', }), 'utf8' ); const changed = await callGetAllTasks(worker, tasksBase); expect(changed.tasks[0]).toMatchObject({ teamName, subject: 'Changed subject with a different size', }); expect(changed.diag?.cacheMisses).toBe(1); } finally { await worker.terminate(); } }); });