feat(fleet): add local canary CLI (#563)
This commit was merged in pull request #563.
This commit is contained in:
563
packages/mosaic/src/commands/fleet.spec.ts
Normal file
563
packages/mosaic/src/commands/fleet.spec.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildAgentSendCommand,
|
||||
buildFleetServiceCommand,
|
||||
generateAgentEnv,
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
registerFleetCommand,
|
||||
resolveFleetPaths,
|
||||
type CommandRunner,
|
||||
} from './fleet.js';
|
||||
import { registerAgentCommand } from './agent.js';
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program);
|
||||
registerAgentCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
}
|
||||
|
||||
describe('registerFleetCommand', () => {
|
||||
it('registers local canary fleet subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const fleet = program.commands.find((command) => command.name() === 'fleet');
|
||||
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'restart',
|
||||
'start',
|
||||
'status',
|
||||
'stop',
|
||||
'verify',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds fleet-backed agent subcommands without removing existing options', () => {
|
||||
const program = buildProgram();
|
||||
const agent = program.commands.find((command) => command.name() === 'agent');
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'reset',
|
||||
'roster',
|
||||
'send',
|
||||
'status',
|
||||
'tail',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet roster parsing', () => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
' class: canary',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(roster.tmux.socketName).toBe('mosaic-factory');
|
||||
expect(roster.tmux.holderSession).toBe('_holder');
|
||||
expect(roster.agents).toHaveLength(1);
|
||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-factory' },
|
||||
defaults: { working_directory: '/srv/mosaic' },
|
||||
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
|
||||
}),
|
||||
);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socketNamee: prod-fleet',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster tmux has unknown field(s): socketNamee.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 123 },
|
||||
defaults: { working_directory: '/srv/mosaic' },
|
||||
agents: [{ name: 'canary-pi', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster tmux socket_name must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects wrong-typed agent fields', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'canary-pi', runtime: 42 }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster agent "canary-pi" runtime must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate agent names before install can overwrite env files', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
' - name: canary-pi',
|
||||
' runtime: codex',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster has duplicate agent name: canary-pi.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
|
||||
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
|
||||
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
|
||||
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
|
||||
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
|
||||
|
||||
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
|
||||
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
|
||||
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
|
||||
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet command construction', () => {
|
||||
it('builds exact systemd user commands for holder and agent operations', () => {
|
||||
expect(buildFleetServiceCommand('status')).toEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'status',
|
||||
'mosaic-tmux-holder.service',
|
||||
]);
|
||||
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'restart',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds socket-scoped agent send commands', () => {
|
||||
const paths = resolveFleetPaths('/home/test/.config/mosaic');
|
||||
expect(buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory')).toEqual([
|
||||
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
||||
'-L',
|
||||
'mosaic-factory',
|
||||
'-s',
|
||||
'coder0',
|
||||
'-m',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
});
|
||||
|
||||
it('writes init output to the explicit roster path', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const content = await readFile(rosterPath, 'utf8');
|
||||
expect(content).toContain('name: canary-pi');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing roster unless --force is provided', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||||
await mkdir(dirname(rosterPath), { recursive: true });
|
||||
await writeFile(rosterPath, 'site-owned: true\n');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
]),
|
||||
).rejects.toThrow('Fleet roster already exists');
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
'--force',
|
||||
]);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown init profiles instead of silently falling back', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(3);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'json-canary', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('starts the holder before agents and stops agents before the holder', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
|
||||
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: codex',
|
||||
' - name: reviewer0',
|
||||
' runtime: pi',
|
||||
].join('\n'),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (args.includes('mosaic-agent@coder0.service')) {
|
||||
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
|
||||
'Fleet stop completed with 1 failure(s)',
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
|
||||
const home = await tempDir();
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
mosaicHome: home,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
|
||||
).rejects.toThrow('install-systemd only supports the default Mosaic home');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||||
'rejects single-agent %s for agents outside the roster',
|
||||
async (action) => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
|
||||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'json-agent', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
|
||||
expect(calls).toEqual([
|
||||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects agent status typos before invoking the runner', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
|
||||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user