import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ teamsBase: '', backupsBase: '', appDataPath: '', tasksBase: '', })); vi.mock('../../../../src/main/utils/pathDecoder', () => ({ getTeamsBasePath: () => hoisted.teamsBase, getBackupsBasePath: () => hoisted.backupsBase, getAppDataPath: () => hoisted.appDataPath, getTasksBasePath: () => hoisted.tasksBase, })); import { TeamBackupService } from '../../../../src/main/services/team/TeamBackupService'; describe('TeamBackupService', () => { let tempDir = ''; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-backup-service-')); hoisted.teamsBase = path.join(tempDir, 'teams'); hoisted.backupsBase = path.join(tempDir, 'backups'); hoisted.appDataPath = path.join(tempDir, 'app-data'); hoisted.tasksBase = path.join(tempDir, 'tasks'); await fs.mkdir(hoisted.teamsBase, { recursive: true }); await fs.mkdir(hoisted.backupsBase, { recursive: true }); await fs.mkdir(hoisted.appDataPath, { recursive: true }); await fs.mkdir(hoisted.tasksBase, { recursive: true }); }); afterEach(async () => { hoisted.teamsBase = ''; hoisted.backupsBase = ''; hoisted.appDataPath = ''; hoisted.tasksBase = ''; if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } }); it('backs up and restores additive mixed-lane metadata and launch snapshots', async () => { const service = new TeamBackupService(); const teamName = 'mixed-team'; const teamDir = path.join(hoisted.teamsBase, teamName); await fs.mkdir(teamDir, { recursive: true }); const config = { name: 'Mixed Team', projectPath: '/tmp/project', members: [{ name: 'team-lead', agentType: 'team-lead' }], }; const teamMeta = { version: 1, cwd: '/tmp/project', providerId: 'codex', providerBackendId: 'codex-native', fastMode: 'off', createdAt: Date.now(), }; const membersMeta = { version: 1, providerBackendId: 'codex-native', members: [ { name: 'alice', providerId: 'codex', role: 'reviewer' }, { name: 'tom', providerId: 'opencode', providerBackendId: 'opencode-cli', model: 'minimax-m2.5-free', fastMode: 'inherit', role: 'developer', }, ], }; const launchState = { version: 2, teamName, updatedAt: '2026-04-22T12:00:00.000Z', launchPhase: 'active', expectedMembers: ['alice', 'tom'], bootstrapExpectedMembers: ['alice'], members: { alice: { name: 'alice', providerId: 'codex', providerBackendId: 'codex-native', laneId: 'primary', laneKind: 'primary', laneOwnerProviderId: 'codex', launchState: 'confirmed_alive', agentToolAccepted: true, runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, tom: { name: 'tom', providerId: 'opencode', providerBackendId: 'opencode-cli', laneId: 'secondary:opencode:tom', laneKind: 'secondary', laneOwnerProviderId: 'opencode', launchState: 'starting', agentToolAccepted: true, runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, }, summary: { confirmedCount: 1, pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 0, }, teamLaunchState: 'partial_pending', }; const launchSummary = { version: 1, teamName, updatedAt: '2026-04-22T12:00:00.000Z', mixedAware: true, expectedMemberCount: 2, confirmedMemberCount: 1, pendingCount: 1, failedCount: 0, teamLaunchState: 'partial_pending', launchUpdatedAt: '2026-04-22T12:00:00.000Z', }; const runtimeLaneDir = path.join( teamDir, '.opencode-runtime', 'lanes', encodeURIComponent('secondary:opencode:tom') ); const runtimeLaneIndex = { version: 1, updatedAt: '2026-04-22T12:00:00.000Z', lanes: { 'secondary:opencode:tom': { laneId: 'secondary:opencode:tom', state: 'active', updatedAt: '2026-04-22T12:00:00.000Z', diagnostics: [], }, }, }; const runtimeManifest = { schemaVersion: 1, highWatermark: 12, activeRunId: 'lane-run-1', capabilitySnapshotId: 'cap-1', }; await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config), 'utf8'); await fs.writeFile(path.join(teamDir, 'team.meta.json'), JSON.stringify(teamMeta), 'utf8'); await fs.writeFile( path.join(teamDir, 'members.meta.json'), JSON.stringify(membersMeta), 'utf8' ); await fs.writeFile( path.join(teamDir, 'launch-state.json'), JSON.stringify(launchState), 'utf8' ); await fs.writeFile( path.join(teamDir, 'launch-summary.json'), JSON.stringify(launchSummary), 'utf8' ); await fs.mkdir(runtimeLaneDir, { recursive: true }); await fs.writeFile( path.join(teamDir, '.opencode-runtime', 'lanes.json'), JSON.stringify(runtimeLaneIndex), 'utf8' ); await fs.writeFile( path.join(runtimeLaneDir, 'runtime-store-manifest.json'), JSON.stringify(runtimeManifest), 'utf8' ); await service.initialize(); await service.backupTeam(teamName); await fs.rm(teamDir, { recursive: true, force: true }); const restored = await service.restoreIfNeeded(); service.dispose(); expect(restored).toContain(teamName); const restoredMembersMeta = JSON.parse( await fs.readFile(path.join(teamDir, 'members.meta.json'), 'utf8') ); const restoredLaunchState = JSON.parse( await fs.readFile(path.join(teamDir, 'launch-state.json'), 'utf8') ); const restoredLaunchSummary = JSON.parse( await fs.readFile(path.join(teamDir, 'launch-summary.json'), 'utf8') ); const restoredTeamMeta = JSON.parse( await fs.readFile(path.join(teamDir, 'team.meta.json'), 'utf8') ); const restoredRuntimeLaneIndex = JSON.parse( await fs.readFile(path.join(teamDir, '.opencode-runtime', 'lanes.json'), 'utf8') ); const restoredRuntimeManifest = JSON.parse( await fs.readFile(path.join(runtimeLaneDir, 'runtime-store-manifest.json'), 'utf8') ); expect(restoredTeamMeta.providerId).toBe('codex'); expect(restoredMembersMeta.members).toEqual(membersMeta.members); expect(restoredLaunchState.bootstrapExpectedMembers).toEqual(['alice']); expect(restoredLaunchState.members.tom.laneKind).toBe('secondary'); expect(restoredLaunchState.members.tom.laneOwnerProviderId).toBe('opencode'); expect(restoredLaunchSummary.mixedAware).toBe(true); expect(restoredLaunchSummary.teamLaunchState).toBe('partial_pending'); expect(restoredRuntimeLaneIndex.lanes['secondary:opencode:tom'].state).toBe('active'); expect(restoredRuntimeManifest.activeRunId).toBe('lane-run-1'); }); it('skips quarantined and temporary OpenCode runtime files during backup', async () => { const service = new TeamBackupService(); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const teamName = 'runtime-quarantine-team'; const teamDir = path.join(hoisted.teamsBase, teamName); const runtimeDir = path.join(teamDir, '.opencode-runtime'); const runtimeLaneIndex = { version: 1, updatedAt: '2026-04-22T12:00:00.000Z', lanes: { 'secondary:opencode:tom': { laneId: 'secondary:opencode:tom', state: 'active', updatedAt: '2026-04-22T12:00:00.000Z', }, }, }; try { await fs.mkdir(runtimeDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Runtime Quarantine Team' }), 'utf8' ); await fs.writeFile( path.join(runtimeDir, 'lanes.json'), JSON.stringify(runtimeLaneIndex), 'utf8' ); await fs.writeFile( path.join(runtimeDir, 'lanes.invalid.123.json'), '{"version":1}\n}', 'utf8' ); await fs.writeFile(path.join(runtimeDir, '.tmp.deadbeef'), '{"partial":', 'utf8'); await service.initialize(); await service.backupTeam(teamName); const backupRuntimeDir = path.join( hoisted.backupsBase, 'teams', teamName, '.opencode-runtime' ); await expect(fs.readFile(path.join(backupRuntimeDir, 'lanes.json'), 'utf8')).resolves.toBe( JSON.stringify(runtimeLaneIndex) ); await expect( fs.stat(path.join(backupRuntimeDir, 'lanes.invalid.123.json')) ).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.stat(path.join(backupRuntimeDir, '.tmp.deadbeef'))).rejects.toMatchObject({ code: 'ENOENT', }); const manifest = JSON.parse( await fs.readFile( path.join(hoisted.backupsBase, 'teams', teamName, 'manifest.json'), 'utf8' ) ) as { fileStats: Record }; expect( Object.prototype.hasOwnProperty.call( manifest.fileStats, '.opencode-runtime/lanes.invalid.123.json' ) ).toBe(false); expect( Object.prototype.hasOwnProperty.call( manifest.fileStats, '.opencode-runtime/.tmp.deadbeef' ) ).toBe(false); expect( warnSpy.mock.calls.some((args) => args.some((arg) => String(arg).includes('Skipping invalid JSON')) ) ).toBe(false); } finally { service.dispose(); warnSpy.mockRestore(); } }); it('backs up member-scoped work sync status without copying the append-only journal', async () => { const service = new TeamBackupService(); const teamName = 'member-work-sync-team'; const teamDir = path.join(hoisted.teamsBase, teamName); const memberDir = path.join(teamDir, 'members', 'jack'); const workSyncDir = path.join(memberDir, '.member-work-sync'); const runtimeWorkSyncDir = path.join(teamDir, '.opencode-runtime', '.member-work-sync'); const status = { teamName, memberName: 'jack', state: 'caught_up', evaluatedAt: '2026-04-30T12:00:00.000Z', agenda: { fingerprint: 'abc123', items: [] }, }; try { await fs.mkdir(workSyncDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), JSON.stringify({ name: 'Member Work Sync Team' }), 'utf8' ); await fs.writeFile( path.join(memberDir, 'member.meta.json'), JSON.stringify({ schemaVersion: 1, memberName: 'jack', memberKey: 'jack', updatedAt: '2026-04-30T12:00:00.000Z', }), 'utf8' ); await fs.writeFile( path.join(workSyncDir, 'status.json'), JSON.stringify({ schemaVersion: 2, status }), 'utf8' ); await fs.writeFile( path.join(workSyncDir, 'journal.jsonl'), `${JSON.stringify({ schemaVersion: 1, timestamp: '2026-04-30T12:00:00.000Z', teamName, memberName: 'jack', event: 'status_written', source: 'test', })}\n`, 'utf8' ); await fs.writeFile(path.join(workSyncDir, '.tmp.deadbeef'), '{"partial":', 'utf8'); await fs.writeFile(path.join(workSyncDir, 'journal.jsonl.lock'), '123\n', 'utf8'); await fs.mkdir(runtimeWorkSyncDir, { recursive: true }); await fs.writeFile( path.join(runtimeWorkSyncDir, 'journal.jsonl'), '{"runtime":true}\n', 'utf8' ); const staleBackupJournalPath = path.join( hoisted.backupsBase, 'teams', teamName, 'members', 'jack', '.member-work-sync', 'journal.jsonl' ); await fs.mkdir(path.dirname(staleBackupJournalPath), { recursive: true }); await fs.writeFile(staleBackupJournalPath, '{"old":true}\n', 'utf8'); await service.initialize(); await service.backupTeam(teamName); const backupMemberDir = path.join(hoisted.backupsBase, 'teams', teamName, 'members', 'jack'); await expect(fs.readFile(path.join(backupMemberDir, 'member.meta.json'), 'utf8')).resolves.toBe( JSON.stringify({ schemaVersion: 1, memberName: 'jack', memberKey: 'jack', updatedAt: '2026-04-30T12:00:00.000Z', }) ); await expect( fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'status.json'), 'utf8') ).resolves.toBe(JSON.stringify({ schemaVersion: 2, status })); await expect( fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl')) ).rejects.toMatchObject({ code: 'ENOENT' }); await expect( fs.stat(path.join(backupMemberDir, '.member-work-sync', '.tmp.deadbeef')) ).rejects.toMatchObject({ code: 'ENOENT' }); await expect( fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl.lock')) ).rejects.toMatchObject({ code: 'ENOENT' }); await expect( fs.readFile( path.join( hoisted.backupsBase, 'teams', teamName, '.opencode-runtime', '.member-work-sync', 'journal.jsonl' ), 'utf8' ) ).resolves.toBe('{"runtime":true}\n'); const manifest = JSON.parse( await fs.readFile( path.join(hoisted.backupsBase, 'teams', teamName, 'manifest.json'), 'utf8' ) ) as { fileStats: Record }; expect( Object.prototype.hasOwnProperty.call( manifest.fileStats, 'members/jack/.member-work-sync/status.json' ) ).toBe(true); expect( Object.prototype.hasOwnProperty.call( manifest.fileStats, 'members/jack/.member-work-sync/journal.jsonl' ) ).toBe(false); } finally { service.dispose(); } }); });