From 67df06f1c484a6e897d3da1a1cdd2a77c68e7e23 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 21 Jun 2026 23:26:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(fleet):=20orchestrator-mutable=20fleet=20?= =?UTF-8?q?=E2=80=94=20fleet=20add/remove=20(F5/R9)=20(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mosaic/src/commands/fleet.spec.ts | 471 +++++++++++++++++++++ packages/mosaic/src/commands/fleet.ts | 207 ++++++++- 2 files changed, 677 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index e7fe766..f32bbf2 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + addAgentToRoster, buildAgentSendCommand, buildAgentWatchAttachCommand, buildAgentWatchCommand, @@ -35,9 +36,11 @@ import { parseTmuxListPanes, parseTmuxListSessions, registerFleetCommand, + removeAgentFromRoster, resolveFleetPaths, resolvePresetFilename, RUNTIME_ACCEPTABLE_COMMANDS, + serializeRosterToYaml, VERIFY_DEFAULT_TIMEOUT_MS, VERIFY_POLL_INTERVAL_MS, type AgentPsRow, @@ -68,10 +71,12 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ + 'add', 'init', 'install', 'install-systemd', 'ps', + 'remove', 'restart', 'start', 'status', @@ -2271,6 +2276,472 @@ describe('resolvePresetFilename', () => { }); }); +// --------------------------------------------------------------------------- +// Fleet Phase F5: orchestrator-mutable fleet — pure helper tests (R9) +// --------------------------------------------------------------------------- + +describe('fleet add/remove — pure helpers', () => { + const baseRoster: FleetRoster = { + version: 1, + transport: 'tmux', + tmux: { socketName: 'mosaic-factory', holderSession: '_holder' }, + defaults: { workingDirectory: '~/src' }, + runtimes: { codex: { resetCommand: '/clear' } }, + agents: [ + { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }, + { name: 'coder0', runtime: 'codex', className: 'worker' }, + ], + }; + + it('addAgentToRoster appends a new agent and returns a new roster object', () => { + const newAgent = { name: 'reviewer0', runtime: 'pi', className: 'worker' }; + const updated = addAgentToRoster(baseRoster, newAgent); + expect(updated.agents).toHaveLength(3); + expect(updated.agents[2]).toEqual(newAgent); + // immutable — original unchanged + expect(baseRoster.agents).toHaveLength(2); + expect(updated).not.toBe(baseRoster); + }); + + it('addAgentToRoster throws on duplicate name', () => { + expect(() => + addAgentToRoster(baseRoster, { name: 'coder0', runtime: 'claude', className: 'worker' }), + ).toThrow('Agent "coder0" already exists in the fleet roster.'); + }); + + it('addAgentToRoster throws on invalid name (invalid characters)', () => { + expect(() => + addAgentToRoster(baseRoster, { name: 'bad name!', runtime: 'claude', className: 'worker' }), + ).toThrow('Invalid fleet agent name'); + }); + + it('addAgentToRoster throws on empty name', () => { + expect(() => + addAgentToRoster(baseRoster, { name: '', runtime: 'claude', className: 'worker' }), + ).toThrow('Invalid fleet agent name'); + }); + + it('removeAgentFromRoster removes the agent and returns new roster', () => { + const updated = removeAgentFromRoster(baseRoster, 'coder0'); + expect(updated.agents).toHaveLength(1); + expect(updated.agents[0]!.name).toBe('orchestrator'); + // immutable + expect(baseRoster.agents).toHaveLength(2); + expect(updated).not.toBe(baseRoster); + }); + + it('removeAgentFromRoster throws when agent not found', () => { + expect(() => removeAgentFromRoster(baseRoster, 'nonexistent')).toThrow( + 'Agent "nonexistent" is not in the fleet roster.', + ); + }); + + it('removeAgentFromRoster throws when removing the sole orchestrator (guard)', () => { + const rosterWithOnlyOrch: FleetRoster = { + ...baseRoster, + agents: [{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }], + }; + expect(() => removeAgentFromRoster(rosterWithOnlyOrch, 'orchestrator')).toThrow( + 'sole orchestrator', + ); + }); + + it('removeAgentFromRoster allows removing an orchestrator when another remains', () => { + const rosterWithTwoOrchs: FleetRoster = { + ...baseRoster, + agents: [ + { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }, + { name: 'orchestrator2', runtime: 'claude', className: 'orchestrator' }, + { name: 'coder0', runtime: 'codex', className: 'worker' }, + ], + }; + const updated = removeAgentFromRoster(rosterWithTwoOrchs, 'orchestrator'); + expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']); + }); + + it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => { + const yaml = serializeRosterToYaml(baseRoster); + expect(typeof yaml).toBe('string'); + expect(yaml).toContain('version: 1'); + expect(yaml).toContain('name: orchestrator'); + expect(yaml).toContain('name: coder0'); + + // Round-trip: write to disk and re-load + const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); + const rosterPath = join(dir, 'roster.yaml'); + try { + await writeFile(rosterPath, yaml); + const loaded = await loadFleetRoster(rosterPath); + expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']); + expect(loaded.tmux.socketName).toBe('mosaic-factory'); + expect(loaded.agents[0]!.className).toBe('orchestrator'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => { + const rosterWithOptionals: FleetRoster = { + ...baseRoster, + agents: [ + { + name: 'orchestrator', + runtime: 'claude', + className: 'orchestrator', + modelHint: 'claude-3-5-sonnet', + workingDirectory: '/tmp/work', + persistentPersona: true, + resetBetweenTasks: false, + }, + ], + }; + const yaml = serializeRosterToYaml(rosterWithOptionals); + expect(yaml).toContain('model_hint: claude-3-5-sonnet'); + expect(yaml).toContain('working_directory: /tmp/work'); + expect(yaml).toContain('persistent_persona: true'); + + const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); + const rosterPath = join(dir, 'roster.yaml'); + try { + await writeFile(rosterPath, yaml); + const loaded = await loadFleetRoster(rosterPath); + expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet'); + expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work'); + expect(loaded.agents[0]!.persistentPersona).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Fleet Phase F5: fleet add command tests +// --------------------------------------------------------------------------- + +describe('fleet add command', () => { + let home: string; + + afterEach(async () => { + if (home) { + await rm(home, { recursive: true, force: true }); + } + }); + + async function makeHome(agents = ['orchestrator']): Promise { + const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-')); + await mkdir(join(dir, 'fleet', 'agents'), { recursive: true }); + const agentLines = agents.map((name) => { + const cls = name === 'orchestrator' ? 'orchestrator' : 'worker'; + return ` - name: ${name}\n runtime: claude\n class: ${cls}`; + }); + await writeFile( + join(dir, 'fleet', 'roster.yaml'), + ['version: 1', 'transport: tmux', 'agents:', ...agentLines].join('\n'), + ); + return dir; + } + + it('appends agent to roster file and writes env file', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'codex', + '--class', + 'worker', + ]); + + const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml')); + expect(roster.agents.map((a) => a.name)).toContain('coder0'); + + const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8'); + expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); + expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex'); + }); + + it('--no-start skips the start command', async () => { + home = await makeHome(); + 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 }); + + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'codex', + '--class', + 'worker', + '--no-start', + ]); + + // No start command should have been issued + const startCalls = calls.filter((c) => c.includes('start')); + expect(startCalls).toHaveLength(0); + }); + + it('without --no-start, issues start command for the new agent', async () => { + home = await makeHome(); + 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 }); + + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'codex', + '--class', + 'worker', + ]); + + expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']); + }); + + it('throws when adding a duplicate agent name', async () => { + home = await makeHome(['orchestrator', 'coder0']); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await expect( + program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'codex', + '--class', + 'worker', + ]), + ).rejects.toThrow('already exists'); + }); + + it('throws when runtime is invalid', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await expect( + program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'notaruntime', + '--class', + 'worker', + ]), + ).rejects.toThrow('Invalid runtime'); + }); + + it('accepts optional --model and --working-dir options', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await program.parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder0', + '--runtime', + 'claude', + '--class', + 'worker', + '--model', + 'claude-sonnet', + '--working-dir', + '/tmp/work', + ]); + + const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml')); + const agent = roster.agents.find((a) => a.name === 'coder0'); + expect(agent?.modelHint).toBe('claude-sonnet'); + expect(agent?.workingDirectory).toBe('/tmp/work'); + }); +}); + +// --------------------------------------------------------------------------- +// Fleet Phase F5: fleet remove command tests +// --------------------------------------------------------------------------- + +describe('fleet remove command', () => { + let home: string; + + afterEach(async () => { + if (home) { + await rm(home, { recursive: true, force: true }); + } + }); + + async function makeHome(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-remove-')); + await mkdir(join(dir, 'fleet', 'agents'), { recursive: true }); + await mkdir(join(dir, 'fleet', 'run'), { recursive: true }); + await writeFile( + join(dir, 'fleet', 'roster.yaml'), + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: orchestrator', + ' runtime: claude', + ' class: orchestrator', + ' - name: coder0', + ' runtime: codex', + ' class: worker', + ].join('\n'), + ); + // Create env and heartbeat files for coder0 + await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n'); + await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n'); + return dir; + } + + it('removes agent from roster and writes back', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']); + + const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml')); + expect(roster.agents.map((a) => a.name)).not.toContain('coder0'); + expect(roster.agents.map((a) => a.name)).toContain('orchestrator'); + }); + + it('stop is called before roster write (stop is the first runner call)', async () => { + home = await makeHome(); + 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 }); + + await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']); + + expect(calls[0]).toEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']); + }); + + it('stop failure is non-fatal — warns but still removes from roster', async () => { + home = await makeHome(); + const stderrMessages: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => { + stderrMessages.push(String(msg)); + return true; + }); + + const runner: CommandRunner = async (command, args) => { + if (args.includes('stop')) { + return { stdout: '', stderr: 'unit not found', exitCode: 5 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }; + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + try { + // Must not reject + await expect( + program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']), + ).resolves.toBeDefined(); + + // Agent should be removed from roster despite stop failure + const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml')); + expect(roster.agents.map((a) => a.name)).not.toContain('coder0'); + + // Warning must have been emitted + expect(stderrMessages.join('')).toMatch(/Warning/); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('--keep-files skips env file deletion', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']); + + // Env file should still exist + const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8'); + expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); + }); + + it('env file is removed by default (no --keep-files)', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']); + + await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow(); + }); + + it('removing the sole orchestrator throws with a clear error about the guard', async () => { + home = await makeHome(); + const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + // First remove the worker so only the orchestrator remains + await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']); + + // Now attempt to remove the sole orchestrator + await expect( + program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'orchestrator']), + ).rejects.toThrow('sole orchestrator'); + }); +}); + describe('fleet init wizard', () => { let cleanup: string | undefined; diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 4a166d2..f7e5c4b 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import { homedir, hostname, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -1153,6 +1153,112 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = } }); + cmd + .command('add ') + .description('Add a new agent to the fleet roster and optionally start it') + .requiredOption('--runtime ', `Agent runtime (${VALID_FLEET_RUNTIMES.join(', ')})`) + .requiredOption('--class ', 'Agent class (e.g. worker, orchestrator, canary)') + .option('--model ', 'Model hint for the agent') + .option('--working-dir ', 'Working directory for the agent') + .option('--no-start', 'Skip starting the agent after adding') + .action( + async ( + name: string, + opts: { + runtime: string; + class: string; + model?: string; + workingDir?: string; + start: boolean; + }, + ) => { + if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { + throw new Error( + `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, + ); + } + const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); + const activePaths = resolveFleetPaths(commandOpts.mosaicHome); + const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); + const roster = await loadFleetRoster(rosterPath); + + const newAgent: FleetAgent = { + name, + runtime: opts.runtime, + className: opts.class, + ...(opts.workingDir !== undefined && { workingDirectory: opts.workingDir }), + ...(opts.model !== undefined && { modelHint: opts.model }), + }; + + const updatedRoster = addAgentToRoster(roster, newAgent); + await writeFile(rosterPath, serializeRosterToYaml(updatedRoster)); + + const envPath = join(activePaths.agentEnvDir, `${name}.env`); + const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined; + await mkdir(activePaths.agentEnvDir, { recursive: true }); + await writeFile( + envPath, + mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv), + ); + + console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`); + + if (opts.start !== false) { + await runChecked(runner, buildFleetServiceCommand('start', name)); + console.log(`Started mosaic-agent@${name}.service.`); + } else { + console.log(`Agent queued (--no-start); run: mosaic fleet start ${name}`); + } + }, + ); + + cmd + .command('remove ') + .description('Remove an agent from the fleet roster') + .option('--keep-files', 'Skip deleting env and heartbeat files') + .action(async (name: string, opts: { keepFiles?: boolean }) => { + const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); + const activePaths = resolveFleetPaths(commandOpts.mosaicHome); + const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); + const roster = await loadFleetRoster(rosterPath); + + // Guard: throws if removing leaves 0 orchestrators or agent not in roster + const updatedRoster = removeAgentFromRoster(roster, name); + + // Stop agent (non-fatal) + try { + const stopResult = await runner(...splitCommand(buildFleetServiceCommand('stop', name))); + if (stopResult.exitCode !== 0) { + process.stderr.write( + `Warning: could not stop mosaic-agent@${name}.service: ${stopResult.stderr || stopResult.stdout || 'non-zero exit'}\n`, + ); + } + } catch (err) { + process.stderr.write( + `Warning: stop command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + + // Write updated roster + await writeFile(rosterPath, serializeRosterToYaml(updatedRoster)); + + // Delete env and heartbeat files (best-effort, non-fatal) + if (!opts.keepFiles) { + try { + await unlink(join(activePaths.agentEnvDir, `${name}.env`)); + } catch { + // best-effort + } + try { + await unlink(heartbeatPath(name, activePaths.mosaicHome)); + } catch { + // best-effort + } + } + + console.log(`Removed ${name} from the fleet.`); + }); + return cmd; } @@ -1769,6 +1875,105 @@ export function countOrchestrators(roster: FleetRoster): number { return roster.agents.filter((a) => a.className === 'orchestrator').length; } +/** Valid runtime identifiers for fleet agents. */ +export const VALID_FLEET_RUNTIMES: readonly string[] = [ + 'pi', + 'claude', + 'codex', + 'opencode', + 'dogfood', +]; + +/** + * Add a new agent to a fleet roster (immutable — returns a new FleetRoster). + * Throws on invalid name, duplicate name. + */ +export function addAgentToRoster(roster: FleetRoster, agent: FleetAgent): FleetRoster { + if (!agent.name || !/^[A-Za-z0-9_.-]+$/.test(agent.name)) { + throw new Error(`Invalid fleet agent name: ${agent.name || ''}`); + } + if (roster.agents.some((a) => a.name === agent.name)) { + throw new Error(`Agent "${agent.name}" already exists in the fleet roster.`); + } + return { + ...roster, + agents: [...roster.agents, agent], + }; +} + +/** + * Remove an agent from a fleet roster (immutable — returns a new FleetRoster). + * Throws if the agent is not found, or if removal would leave zero orchestrators. + */ +export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetRoster { + const agent = roster.agents.find((a) => a.name === name); + if (!agent) { + throw new Error(`Agent "${name}" is not in the fleet roster.`); + } + const remaining = roster.agents.filter((a) => a.name !== name); + const remainingOrchCount = remaining.filter((a) => a.className === 'orchestrator').length; + if (remainingOrchCount === 0 && agent.className === 'orchestrator') { + throw new Error( + `Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`, + ); + } + return { + ...roster, + agents: remaining, + }; +} + +/** + * Serialize a FleetRoster to YAML text (snake_case keys). + * The output is parseable by loadFleetRoster. + */ +export function serializeRosterToYaml(roster: FleetRoster): string { + const agents = roster.agents.map((agent) => { + const raw: Record = { + name: agent.name, + runtime: agent.runtime, + class: agent.className, + }; + if (agent.workingDirectory !== undefined) { + raw['working_directory'] = agent.workingDirectory; + } + if (agent.modelHint !== undefined) { + raw['model_hint'] = agent.modelHint; + } + if (agent.persistentPersona !== undefined) { + raw['persistent_persona'] = agent.persistentPersona; + } + if (agent.resetBetweenTasks !== undefined) { + raw['reset_between_tasks'] = agent.resetBetweenTasks; + } + if (agent.kickstartTemplate !== undefined) { + raw['kickstart_template'] = agent.kickstartTemplate; + } + return raw; + }); + + const runtimes: Record = {}; + for (const [runtime, config] of Object.entries(roster.runtimes)) { + runtimes[runtime] = { reset_command: config.resetCommand }; + } + + const raw: Record = { + version: roster.version, + transport: roster.transport, + tmux: { + socket_name: roster.tmux.socketName, + holder_session: roster.tmux.holderSession, + }, + defaults: { + working_directory: roster.defaults.workingDirectory, + }, + runtimes, + agents, + }; + + return YAML.stringify(raw); +} + /** * Prompt interactively for a fleet profile via stdin readline. * AI-free: no LLM calls — pure readline menu.