feat(fleet): add local canary CLI (#563)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #563.
This commit is contained in:
2026-06-20 17:49:01 +00:00
parent 45e2c2aad8
commit 6dfd78f643
15 changed files with 1835 additions and 3 deletions

View 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 });
}
});
});