739 lines
23 KiB
TypeScript
739 lines
23 KiB
TypeScript
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,
|
|
getDefaultOperatorSourceLabel,
|
|
getRosterAgent,
|
|
loadFleetRoster,
|
|
mergeAgentEnv,
|
|
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('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
|
const generated = [
|
|
'MOSAIC_AGENT_NAME=coder0',
|
|
'MOSAIC_AGENT_RUNTIME=codex',
|
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
|
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
|
'',
|
|
].join('\n');
|
|
const existing = [
|
|
'MOSAIC_AGENT_NAME=old-name',
|
|
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
|
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
|
'MOSAIC_TMUX_SOCKET=old-socket',
|
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
|
'# site note',
|
|
'',
|
|
].join('\n');
|
|
|
|
expect(mergeAgentEnv(generated, existing)).toBe(
|
|
[
|
|
'MOSAIC_AGENT_NAME=coder0',
|
|
'MOSAIC_AGENT_RUNTIME=codex',
|
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
|
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
|
'# site note',
|
|
'',
|
|
].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', 'operator:mosaic-cli'),
|
|
).toEqual([
|
|
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
|
'-L',
|
|
'mosaic-factory',
|
|
'-S',
|
|
'operator:mosaic-cli',
|
|
'-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('verifies liveness with tmux has-session and does not trust systemd active exited', 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: 'active (exited)\n', stderr: '', exitCode: 0 };
|
|
};
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
|
|
try {
|
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
|
|
expect(calls).toEqual([
|
|
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
|
|
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
|
|
]);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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('passes a deterministic operator source label for agent sends', async () => {
|
|
const home = await tempDir();
|
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
await writeFile(
|
|
join(home, 'fleet', 'roster.yaml'),
|
|
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',
|
|
'send',
|
|
'json-agent',
|
|
'--message',
|
|
'status check',
|
|
]);
|
|
expect(calls).toEqual([
|
|
[
|
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
|
'-L',
|
|
'mosaic-factory',
|
|
'-S',
|
|
getDefaultOperatorSourceLabel(),
|
|
'-s',
|
|
'json-agent',
|
|
'-m',
|
|
'status check',
|
|
],
|
|
]);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows agent sends to override the source label explicitly', async () => {
|
|
const home = await tempDir();
|
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
await writeFile(
|
|
join(home, 'fleet', 'roster.yaml'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
transport: 'tmux',
|
|
agents: [{ name: 'coder0', runtime: 'codex' }],
|
|
}),
|
|
);
|
|
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',
|
|
'send',
|
|
'coder0',
|
|
'--message',
|
|
'handoff',
|
|
'--source-label',
|
|
'lead:manual',
|
|
]);
|
|
expect(calls).toEqual([
|
|
[
|
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
|
'-L',
|
|
'mosaic-factory',
|
|
'-S',
|
|
'lead:manual',
|
|
'-s',
|
|
'coder0',
|
|
'-m',
|
|
'handoff',
|
|
],
|
|
]);
|
|
} 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 });
|
|
}
|
|
});
|
|
|
|
it('keeps fleet framework assets in the published package file list', async () => {
|
|
const packageJson = JSON.parse(
|
|
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
|
|
) as {
|
|
files?: string[];
|
|
};
|
|
|
|
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
|
|
});
|
|
});
|