feat(fleet): add local canary CLI (#563)
This commit was merged in pull request #563.
This commit is contained in:
851
packages/mosaic/src/commands/fleet.ts
Normal file
851
packages/mosaic/src/commands/fleet.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>;
|
||||
|
||||
export interface FleetCommandDeps {
|
||||
runner?: CommandRunner;
|
||||
mosaicHome?: string;
|
||||
frameworkRoot?: string;
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
mosaicHome: string;
|
||||
rosterPath: string;
|
||||
toolsDir: string;
|
||||
fleetToolsDir: string;
|
||||
tmuxToolsDir: string;
|
||||
systemdUserDir: string;
|
||||
agentEnvDir: string;
|
||||
}
|
||||
|
||||
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||
|
||||
const DEFAULT_SOCKET_NAME = 'mosaic-factory';
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||
return {
|
||||
mosaicHome,
|
||||
rosterPath: join(mosaicHome, 'fleet', 'roster.yaml'),
|
||||
toolsDir: join(mosaicHome, 'tools'),
|
||||
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
|
||||
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
|
||||
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
return join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
||||
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) {
|
||||
throw new Error(
|
||||
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const rawText = await readFile(path, 'utf8');
|
||||
const parsed = parseRosterText(rawText, path);
|
||||
return normalizeRoster(parsed);
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return [
|
||||
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
|
||||
return ['systemctl', '--user', action, service];
|
||||
}
|
||||
|
||||
export function buildAgentSendCommand(
|
||||
paths: FleetPaths,
|
||||
agentName: string,
|
||||
message: string,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
||||
'-L',
|
||||
socketName,
|
||||
'-s',
|
||||
agentName,
|
||||
'-m',
|
||||
message,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentResetCommand(
|
||||
paths: FleetPaths,
|
||||
agentName: string,
|
||||
resetCommand: string,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
join(paths.tmuxToolsDir, 'send-message.sh'),
|
||||
'-L',
|
||||
socketName,
|
||||
'-t',
|
||||
`=${agentName}`,
|
||||
'-m',
|
||||
resetCommand,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentTailCommand(
|
||||
agentName: string,
|
||||
lines: number,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'capture-pane',
|
||||
'-t',
|
||||
`=${agentName}:0.0`,
|
||||
'-p',
|
||||
'-S',
|
||||
`-${lines}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||
|
||||
const cmd = program
|
||||
.command('fleet')
|
||||
.description('Manage the local Mosaic tmux fleet canary')
|
||||
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
||||
.option('--roster <path>', 'Fleet roster path');
|
||||
|
||||
cmd
|
||||
.command('init')
|
||||
.description('Initialize a local fleet roster')
|
||||
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
|
||||
.option('--write', 'Write the roster to Mosaic home')
|
||||
.option('--force', 'Overwrite an existing roster when used with --write')
|
||||
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const profile = parseInitProfile(opts.profile);
|
||||
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
|
||||
const content = await readFile(source, 'utf8');
|
||||
if (!opts.write) {
|
||||
console.log(content.trimEnd());
|
||||
return;
|
||||
}
|
||||
const destination = commandOpts.roster ?? activePaths.rosterPath;
|
||||
if (!opts.force && (await canRead(destination))) {
|
||||
throw new Error(
|
||||
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
console.log(`Wrote fleet roster: ${destination}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('install')
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.action(async () => installFleet(cmd, frameworkRoot));
|
||||
|
||||
cmd
|
||||
.command('install-systemd')
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.action(async () => installFleet(cmd, frameworkRoot));
|
||||
|
||||
for (const action of ['start', 'stop', 'restart'] as const) {
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.action(async (agent?: string) => {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
.command('status [agent]')
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
}
|
||||
const result = await runner(...splitCommand(buildFleetServiceCommand('status', agent)));
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}),
|
||||
);
|
||||
setExitCodeFromResult(result);
|
||||
return;
|
||||
}
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'has-session',
|
||||
'-t',
|
||||
`=${roster.tmux.holderSession}:0.0`,
|
||||
]);
|
||||
for (const agent of roster.agents) {
|
||||
await runChecked(runner, [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'has-session',
|
||||
'-t',
|
||||
`=${agent.name}:0.0`,
|
||||
]);
|
||||
}
|
||||
console.log(`Verified fleet on tmux socket ${socketName}.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function registerFleetAgentCommands(
|
||||
agentCommand: Command,
|
||||
deps: FleetCommandDeps = {},
|
||||
): void {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
|
||||
agentCommand
|
||||
.command('roster')
|
||||
.description('List agents from the local fleet roster')
|
||||
.option('--json', 'Print JSON')
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(roster, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const agent of roster.agents) {
|
||||
console.log(`${agent.name}\t${agent.runtime}\t${agent.className}`);
|
||||
}
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('status [agent]')
|
||||
.description('Show tmux status for the local fleet or one agent')
|
||||
.option('--json', 'Print JSON')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
}
|
||||
const command = agent
|
||||
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`]
|
||||
: ['tmux', '-L', roster.tmux.socketName, 'ls'];
|
||||
const result = await runner(...splitCommand(command));
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}),
|
||||
);
|
||||
setExitCodeFromResult(result);
|
||||
return;
|
||||
}
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('send <agent>')
|
||||
.description('Send a message to a local fleet agent')
|
||||
.requiredOption('--message <text>', 'Message text')
|
||||
.action(async (agent: string, opts: { message: string }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
getRosterAgent(roster, agent);
|
||||
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
|
||||
await runChecked(
|
||||
runner,
|
||||
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName),
|
||||
);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('reset <agent>')
|
||||
.description('Reset a local fleet agent by sending the runtime reset command')
|
||||
.option('--clear', 'Send /clear')
|
||||
.option('--new', 'Send /new')
|
||||
.action(async (agent: string, opts: { clear?: boolean; new?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
const rosterAgent = getRosterAgent(roster, agent);
|
||||
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
|
||||
const resetCommand = opts.clear
|
||||
? '/clear'
|
||||
: opts.new
|
||||
? '/new'
|
||||
: (roster.runtimes[rosterAgent.runtime]?.resetCommand ?? '/clear');
|
||||
await runChecked(
|
||||
runner,
|
||||
buildAgentResetCommand(paths, agent, resetCommand, roster.tmux.socketName),
|
||||
);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('tail <agent>')
|
||||
.description('Print recent pane output for a local fleet agent')
|
||||
.option('-n, --lines <number>', 'Number of pane history lines', '80')
|
||||
.action(async (agent: string, opts: { lines: string }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
getRosterAgent(roster, agent);
|
||||
const lines = Number.parseInt(opts.lines, 10);
|
||||
const result = await runner(
|
||||
...splitCommand(
|
||||
buildAgentTailCommand(agent, Number.isFinite(lines) ? lines : 80, roster.tmux.socketName),
|
||||
),
|
||||
);
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
}
|
||||
|
||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
join(activePaths.fleetToolsDir, 'start-agent-session.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'),
|
||||
join(activePaths.tmuxToolsDir, 'send-message.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'),
|
||||
join(activePaths.tmuxToolsDir, 'agent-send.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
||||
);
|
||||
|
||||
for (const agent of roster.agents) {
|
||||
await writeFile(
|
||||
join(activePaths.agentEnvDir, `${agent.name}.env`),
|
||||
generateAgentEnv(roster, agent),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||
}
|
||||
|
||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
): Promise<FleetRoster> {
|
||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||
const opts = command.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
||||
const trimmed = text.trim();
|
||||
if (path.endsWith('.json')) {
|
||||
return JSON.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
return YAML.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) {
|
||||
throw new Error('Fleet roster version must be 1.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') {
|
||||
throw new Error('Fleet roster transport must be "tmux".');
|
||||
}
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
socketName: stringValue(
|
||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||
DEFAULT_SOCKET_NAME,
|
||||
'Fleet roster tmux socket_name',
|
||||
),
|
||||
holderSession: stringValue(
|
||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) {
|
||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
raw.working_directory ?? raw.workingDirectory,
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
config.reset_command ?? config.resetCommand,
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
|
||||
function shellEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
const command = buildFleetServiceCommand('stop', agentName);
|
||||
const result = await runner(...splitCommand(command));
|
||||
writeSuccessfulCommandOutput(result);
|
||||
if (result.exitCode !== 0) {
|
||||
failures.push(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const holderCommand = buildFleetServiceCommand('stop');
|
||||
const holderResult = await runner(...splitCommand(holderCommand));
|
||||
writeSuccessfulCommandOutput(holderResult);
|
||||
if (holderResult.exitCode !== 0) {
|
||||
failures.push(
|
||||
holderResult.stderr || holderResult.stdout || `Command failed: ${holderCommand.join(' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Fleet stop completed with ${failures.length} failure(s): ${failures.join('; ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runChecked(runner: CommandRunner, command: string[]): Promise<void> {
|
||||
const result = await runner(...splitCommand(command));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
|
||||
}
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
function splitCommand(command: string[]): [string, string[]] {
|
||||
const [bin, ...args] = command;
|
||||
if (!bin) {
|
||||
throw new Error('Cannot run an empty command.');
|
||||
}
|
||||
return [bin, args];
|
||||
}
|
||||
|
||||
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
|
||||
if (profile === 'minimal' || profile === 'local-canary') {
|
||||
return profile;
|
||||
}
|
||||
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
|
||||
}
|
||||
|
||||
function writeCommandOutput(result: CommandResult): void {
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
} else if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
setExitCodeFromResult(result);
|
||||
}
|
||||
|
||||
function writeSuccessfulCommandOutput(result: CommandResult): void {
|
||||
if (result.exitCode !== 0) {
|
||||
return;
|
||||
}
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
function setExitCodeFromResult(result: CommandResult): void {
|
||||
if (result.exitCode !== 0) {
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[]): Promise<CommandResult> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
resolvePromise({ stdout, stderr: error.message, exitCode: 127 });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolvePromise({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFrameworkRoot(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
return resolve(dirname(currentFile), '..', '..', 'framework');
|
||||
}
|
||||
|
||||
async function canRead(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRosterPath(
|
||||
mosaicHome: string,
|
||||
explicitPath?: string,
|
||||
): Promise<string> {
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
const yamlPath = resolveFleetPaths(mosaicHome).rosterPath;
|
||||
if (await canRead(yamlPath)) {
|
||||
return yamlPath;
|
||||
}
|
||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
||||
return jsonPath;
|
||||
}
|
||||
Reference in New Issue
Block a user