feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
This commit was merged in pull request #591.
This commit is contained in:
@@ -17,8 +17,10 @@ import {
|
||||
buildTmuxListPanesCommand,
|
||||
buildTmuxListSessionsCommand,
|
||||
classifySendResult,
|
||||
countOrchestrators,
|
||||
detectDrift,
|
||||
enableFleetUnits,
|
||||
FLEET_PROFILES,
|
||||
generateAgentEnv,
|
||||
getDefaultOperatorSourceLabel,
|
||||
getDefaultTenantAndHost,
|
||||
@@ -28,16 +30,19 @@ import {
|
||||
loadFleetRoster,
|
||||
mergeAgentEnv,
|
||||
parseHeartbeat,
|
||||
parseInitProfile,
|
||||
parseSystemdShow,
|
||||
parseTmuxListPanes,
|
||||
parseTmuxListSessions,
|
||||
registerFleetCommand,
|
||||
resolveFleetPaths,
|
||||
resolvePresetFilename,
|
||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||
VERIFY_DEFAULT_TIMEOUT_MS,
|
||||
VERIFY_POLL_INTERVAL_MS,
|
||||
type AgentPsRow,
|
||||
type CommandRunner,
|
||||
type FleetProfile,
|
||||
type FleetRoster,
|
||||
type InteractiveRunner,
|
||||
type SleepFn,
|
||||
@@ -2132,3 +2137,270 @@ describe('agent send --verify', () => {
|
||||
expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fleet Phase F1: config-type presets + AI-free init wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('fleet preset rosters', () => {
|
||||
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
|
||||
|
||||
it.each(['general', 'coding', 'research', 'hybrid'] as FleetProfile[])(
|
||||
'%s preset: loads via loadFleetRoster and has exactly one orchestrator',
|
||||
async (preset) => {
|
||||
const rosterPath = join(examplesDir, `${preset}.yaml`);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
expect(countOrchestrators(roster)).toBe(1);
|
||||
expect(roster.agents.find((a) => a.name === 'orchestrator')).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('general preset: orchestrator + one generalist worker', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
|
||||
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
||||
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'coder0',
|
||||
'coder1',
|
||||
'reviewer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'researcher0',
|
||||
'researcher1',
|
||||
'analyst',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'coder0',
|
||||
'researcher0',
|
||||
'reviewer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
|
||||
for (const worker of workers) {
|
||||
expect(worker.runtime).toBe('pi');
|
||||
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('orchestrator in new presets uses claude runtime with persistent_persona', async () => {
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||
const orch = roster.agents.find((a) => a.name === 'orchestrator');
|
||||
expect(orch?.runtime).toBe('claude');
|
||||
expect(orch?.persistentPersona).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('new presets are sanitized: no operator identity tokens', async () => {
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const text = await readFile(join(examplesDir, `${preset}.yaml`), 'utf8');
|
||||
expect(text).not.toMatch(/jarvis|jason|woltje/i);
|
||||
// working_directory must not reference ~/src or /home
|
||||
expect(text).not.toMatch(/~\/src|\/home\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInitProfile', () => {
|
||||
it('accepts all six fleet profiles', () => {
|
||||
expect(parseInitProfile('general')).toBe('general');
|
||||
expect(parseInitProfile('coding')).toBe('coding');
|
||||
expect(parseInitProfile('research')).toBe('research');
|
||||
expect(parseInitProfile('hybrid')).toBe('hybrid');
|
||||
expect(parseInitProfile('minimal')).toBe('minimal');
|
||||
expect(parseInitProfile('local-canary')).toBe('local-canary');
|
||||
});
|
||||
|
||||
it('rejects unknown profiles with a message listing all valid names', () => {
|
||||
expect(() => parseInitProfile('typo')).toThrow('Unsupported fleet profile');
|
||||
expect(() => parseInitProfile('typo')).toThrow('general');
|
||||
expect(() => parseInitProfile('typo')).toThrow('coding');
|
||||
});
|
||||
|
||||
it('FLEET_PROFILES contains all six valid profile names', () => {
|
||||
expect(FLEET_PROFILES).toContain('general');
|
||||
expect(FLEET_PROFILES).toContain('coding');
|
||||
expect(FLEET_PROFILES).toContain('research');
|
||||
expect(FLEET_PROFILES).toContain('hybrid');
|
||||
expect(FLEET_PROFILES).toContain('minimal');
|
||||
expect(FLEET_PROFILES).toContain('local-canary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePresetFilename', () => {
|
||||
it.each(FLEET_PROFILES)('maps %s to %s.yaml', (profile) => {
|
||||
expect(resolvePresetFilename(profile)).toBe(`${profile}.yaml`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet init wizard', () => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to general when stdin is not a TTY and no --profile is given', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const stderrMessages: string[] = [];
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||||
stderrMessages.push(String(msg));
|
||||
return true;
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
// isStdinTTY: false simulates non-interactive environment
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--write',
|
||||
]);
|
||||
const content = await readFile(rosterPath, 'utf8');
|
||||
// Should have written the general preset
|
||||
expect(content).toContain('name: orchestrator');
|
||||
expect(content).toContain('name: generalist');
|
||||
// Stderr should explain the fallback
|
||||
expect(stderrMessages.join('')).toMatch(/defaulting to fleet profile "general"/);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --profile to select preset without wizard (non-TTY path)', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'coding',
|
||||
'--write',
|
||||
]);
|
||||
const content = await readFile(rosterPath, 'utf8');
|
||||
expect(content).toContain('name: coder0');
|
||||
expect(content).toContain('name: reviewer');
|
||||
} finally {
|
||||
// cleanup handled by afterEach
|
||||
}
|
||||
});
|
||||
|
||||
it('written roster has exactly one orchestrator agent (countOrchestrators validation)', async () => {
|
||||
cleanup = await tempDir();
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const rosterPath = join(cleanup, `${preset}-roster.yaml`);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
preset,
|
||||
'--write',
|
||||
]);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
expect(countOrchestrators(roster)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-init with --write and existing roster requires --force (R8 idempotency)', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
||||
|
||||
// First write
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'general',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
// Second write without --force must fail
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'general',
|
||||
'--write',
|
||||
]),
|
||||
).rejects.toThrow('Fleet roster already exists');
|
||||
|
||||
// With --force must succeed
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'coding',
|
||||
'--write',
|
||||
'--force',
|
||||
]);
|
||||
const content = await readFile(rosterPath, 'utf8');
|
||||
expect(content).toContain('name: coder0');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user