agent-ecosystem/test/main/services/team/TeamBackupService.test.ts

318 lines
9.9 KiB
TypeScript

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<string, unknown> };
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();
}
});
});