Compare commits
1 Commits
feat/fleet
...
release/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c84d1b77 |
@@ -1,32 +0,0 @@
|
|||||||
version: 1
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-factory
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: ~
|
|
||||||
runtimes:
|
|
||||||
claude:
|
|
||||||
reset_command: /clear
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
runtime: claude
|
|
||||||
class: orchestrator
|
|
||||||
persistent_persona: true
|
|
||||||
- name: coder0
|
|
||||||
runtime: pi
|
|
||||||
class: implementer
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: coder1
|
|
||||||
runtime: pi
|
|
||||||
class: implementer
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: reviewer
|
|
||||||
runtime: pi
|
|
||||||
class: reviewer
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
version: 1
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-factory
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: ~
|
|
||||||
runtimes:
|
|
||||||
claude:
|
|
||||||
reset_command: /clear
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
runtime: claude
|
|
||||||
class: orchestrator
|
|
||||||
persistent_persona: true
|
|
||||||
- name: generalist
|
|
||||||
runtime: pi
|
|
||||||
class: worker
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
version: 1
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-factory
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: ~
|
|
||||||
runtimes:
|
|
||||||
claude:
|
|
||||||
reset_command: /clear
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
runtime: claude
|
|
||||||
class: orchestrator
|
|
||||||
persistent_persona: true
|
|
||||||
- name: coder0
|
|
||||||
runtime: pi
|
|
||||||
class: implementer
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: researcher0
|
|
||||||
runtime: pi
|
|
||||||
class: researcher
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: reviewer
|
|
||||||
runtime: pi
|
|
||||||
class: reviewer
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
version: 1
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-factory
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: ~
|
|
||||||
runtimes:
|
|
||||||
claude:
|
|
||||||
reset_command: /clear
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
runtime: claude
|
|
||||||
class: orchestrator
|
|
||||||
persistent_persona: true
|
|
||||||
- name: researcher0
|
|
||||||
runtime: pi
|
|
||||||
class: researcher
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: researcher1
|
|
||||||
runtime: pi
|
|
||||||
class: researcher
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
- name: analyst
|
|
||||||
runtime: pi
|
|
||||||
class: analyst
|
|
||||||
model_hint: openai-codex/gpt-5.5:high
|
|
||||||
reset_between_tasks: true
|
|
||||||
@@ -15,12 +15,9 @@ import {
|
|||||||
buildSystemdEnableCommand,
|
buildSystemdEnableCommand,
|
||||||
buildSystemdShowCommand,
|
buildSystemdShowCommand,
|
||||||
buildTmuxListPanesCommand,
|
buildTmuxListPanesCommand,
|
||||||
buildTmuxListSessionsCommand,
|
|
||||||
classifySendResult,
|
classifySendResult,
|
||||||
countOrchestrators,
|
|
||||||
detectDrift,
|
detectDrift,
|
||||||
enableFleetUnits,
|
enableFleetUnits,
|
||||||
FLEET_PROFILES,
|
|
||||||
generateAgentEnv,
|
generateAgentEnv,
|
||||||
getDefaultOperatorSourceLabel,
|
getDefaultOperatorSourceLabel,
|
||||||
getDefaultTenantAndHost,
|
getDefaultTenantAndHost,
|
||||||
@@ -30,19 +27,15 @@ import {
|
|||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
mergeAgentEnv,
|
mergeAgentEnv,
|
||||||
parseHeartbeat,
|
parseHeartbeat,
|
||||||
parseInitProfile,
|
|
||||||
parseSystemdShow,
|
parseSystemdShow,
|
||||||
parseTmuxListPanes,
|
parseTmuxListPanes,
|
||||||
parseTmuxListSessions,
|
|
||||||
registerFleetCommand,
|
registerFleetCommand,
|
||||||
resolveFleetPaths,
|
resolveFleetPaths,
|
||||||
resolvePresetFilename,
|
|
||||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||||
VERIFY_DEFAULT_TIMEOUT_MS,
|
VERIFY_DEFAULT_TIMEOUT_MS,
|
||||||
VERIFY_POLL_INTERVAL_MS,
|
VERIFY_POLL_INTERVAL_MS,
|
||||||
type AgentPsRow,
|
type AgentPsRow,
|
||||||
type CommandRunner,
|
type CommandRunner,
|
||||||
type FleetProfile,
|
|
||||||
type FleetRoster,
|
type FleetRoster,
|
||||||
type InteractiveRunner,
|
type InteractiveRunner,
|
||||||
type SleepFn,
|
type SleepFn,
|
||||||
@@ -1081,10 +1074,6 @@ describe('fleet ps — JSON output shape (FR-6)', () => {
|
|||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fullArgs.includes('list-sessions')) {
|
|
||||||
// Only the roster agent session on the socket (no unmanaged sessions)
|
|
||||||
return { stdout: 'canary-pi\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1128,15 +1117,11 @@ describe('fleet ps — JSON output shape (FR-6)', () => {
|
|||||||
expect(row.runtime).toBe('pi');
|
expect(row.runtime).toBe('pi');
|
||||||
expect(row.systemdActive).toBe('active');
|
expect(row.systemdActive).toBe('active');
|
||||||
expect(row.systemdEnabled).toBe('disabled');
|
expect(row.systemdEnabled).toBe('disabled');
|
||||||
|
|
||||||
// managed/source fields for roster agents
|
|
||||||
expect(row.managed).toBe(true);
|
|
||||||
expect(row.source).toBe('roster');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fleet ps — command sequences issued', () => {
|
describe('fleet ps — command sequences issued', () => {
|
||||||
it('issues systemd show + tmux list-panes per agent, then list-sessions for socket discovery', async () => {
|
it('issues systemd show + tmux list-panes per agent', async () => {
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
@@ -1150,10 +1135,6 @@ describe('fleet ps — command sequences issued', () => {
|
|||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const runner: CommandRunner = async (command, args) => {
|
const runner: CommandRunner = async (command, args) => {
|
||||||
calls.push([command, ...args]);
|
calls.push([command, ...args]);
|
||||||
if ([command, ...args].join(' ').includes('list-sessions')) {
|
|
||||||
// Only the roster agent — no unmanaged sessions
|
|
||||||
return { stdout: 'coder0\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
|
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
|
||||||
stderr: '',
|
stderr: '',
|
||||||
@@ -1174,7 +1155,6 @@ describe('fleet ps — command sequences issued', () => {
|
|||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
buildSystemdShowCommand('coder0'),
|
buildSystemdShowCommand('coder0'),
|
||||||
buildTmuxListPanesCommand('coder0', 'mosaic-factory'),
|
buildTmuxListPanesCommand('coder0', 'mosaic-factory'),
|
||||||
buildTmuxListSessionsCommand('mosaic-factory'),
|
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
console.log = origLog;
|
console.log = origLog;
|
||||||
@@ -1183,258 +1163,6 @@ describe('fleet ps — command sequences issued', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildTmuxListSessionsCommand', () => {
|
|
||||||
it('builds exact list-sessions command with session_name format', () => {
|
|
||||||
expect(buildTmuxListSessionsCommand('mosaic-factory')).toEqual([
|
|
||||||
'tmux',
|
|
||||||
'-L',
|
|
||||||
'mosaic-factory',
|
|
||||||
'list-sessions',
|
|
||||||
'-F',
|
|
||||||
'#{session_name}',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
|
|
||||||
const cmd = buildTmuxListSessionsCommand();
|
|
||||||
expect(cmd[2]).toBe('mosaic-factory');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseTmuxListSessions', () => {
|
|
||||||
it('splits newline-delimited session names', () => {
|
|
||||||
expect(parseTmuxListSessions('canary-pi\n_holder\nsome-adhoc\n')).toEqual([
|
|
||||||
'canary-pi',
|
|
||||||
'_holder',
|
|
||||||
'some-adhoc',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for blank output', () => {
|
|
||||||
expect(parseTmuxListSessions('')).toEqual([]);
|
|
||||||
expect(parseTmuxListSessions(' \n \n')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims whitespace from each line', () => {
|
|
||||||
expect(parseTmuxListSessions(' canary-pi \n some-adhoc \n')).toEqual([
|
|
||||||
'canary-pi',
|
|
||||||
'some-adhoc',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet ps — unmanaged socket sessions', () => {
|
|
||||||
it('includes unmanaged session row flagged UNMANAGED and excludes _holder', async () => {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: canary-pi',
|
|
||||||
' runtime: pi',
|
|
||||||
' class: canary',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const activityEpoch = Math.floor((nowMs - 10_000) / 1000);
|
|
||||||
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
const full = [command, ...args].join(' ');
|
|
||||||
if (full.includes('list-sessions')) {
|
|
||||||
// Socket has: canary-pi (roster), _holder (excluded), some-adhoc (unmanaged)
|
|
||||||
return { stdout: 'canary-pi\n_holder\nsome-adhoc\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (full.includes('list-panes')) {
|
|
||||||
return { stdout: `99999 bash 0 ${activityEpoch}\n`, stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (full.includes('systemctl') && full.includes('show')) {
|
|
||||||
return {
|
|
||||||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
console.log = (msg: string) => {
|
|
||||||
lines.push(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
|
|
||||||
} finally {
|
|
||||||
console.log = origLog;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.parse(lines.join('')) as AgentPsRow[];
|
|
||||||
expect(Array.isArray(json)).toBe(true);
|
|
||||||
|
|
||||||
// Should have 2 rows: canary-pi (roster) + some-adhoc (unmanaged); _holder excluded
|
|
||||||
expect(json).toHaveLength(2);
|
|
||||||
|
|
||||||
// Roster agent comes first
|
|
||||||
const rosterRow = json[0]!;
|
|
||||||
expect(rosterRow.name).toBe('canary-pi');
|
|
||||||
expect(rosterRow.managed).toBe(true);
|
|
||||||
expect(rosterRow.source).toBe('roster');
|
|
||||||
|
|
||||||
// Unmanaged session comes second
|
|
||||||
const unmanagedRow = json[1]!;
|
|
||||||
expect(unmanagedRow.name).toBe('some-adhoc');
|
|
||||||
expect(unmanagedRow.managed).toBe(false);
|
|
||||||
expect(unmanagedRow.source).toBe('socket');
|
|
||||||
expect(unmanagedRow.runtime).toBe('unknown');
|
|
||||||
|
|
||||||
// _holder must not appear
|
|
||||||
expect(json.map((r) => r.name)).not.toContain('_holder');
|
|
||||||
|
|
||||||
// tenant_id and host must be present on unmanaged rows
|
|
||||||
expect(typeof unmanagedRow.tenant_id).toBe('string');
|
|
||||||
expect(unmanagedRow.tenant_id.length).toBeGreaterThan(0);
|
|
||||||
expect(typeof unmanagedRow.host).toBe('string');
|
|
||||||
expect(unmanagedRow.host.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// driftFlag must be false for unmanaged (no roster runtime to compare)
|
|
||||||
expect(unmanagedRow.driftFlag).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows UNMANAGED flag in table output for unmanaged sessions', async () => {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: canary-pi',
|
|
||||||
' runtime: pi',
|
|
||||||
' class: canary',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
const full = [command, ...args].join(' ');
|
|
||||||
if (full.includes('list-sessions')) {
|
|
||||||
return { stdout: 'canary-pi\nsome-adhoc\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (full.includes('list-panes')) {
|
|
||||||
return { stdout: '0 bash 1 0\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (full.includes('systemctl') && full.includes('show')) {
|
|
||||||
return {
|
|
||||||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
console.log = (msg: string) => {
|
|
||||||
lines.push(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
|
||||||
} finally {
|
|
||||||
console.log = origLog;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableOutput = lines.join('\n');
|
|
||||||
// some-adhoc row must appear with UNMANAGED flag
|
|
||||||
expect(tableOutput).toMatch(/some-adhoc/);
|
|
||||||
expect(tableOutput).toMatch(/UNMANAGED/);
|
|
||||||
// canary-pi roster row must not have UNMANAGED
|
|
||||||
const rosterLine = lines.find((l) => l.includes('canary-pi'));
|
|
||||||
expect(rosterLine).toBeDefined();
|
|
||||||
expect(rosterLine).not.toMatch(/UNMANAGED/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gracefully shows only roster rows when list-sessions fails (socket missing)', async () => {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: canary-pi',
|
|
||||||
' runtime: pi',
|
|
||||||
' class: canary',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
const full = [command, ...args].join(' ');
|
|
||||||
if (full.includes('list-sessions')) {
|
|
||||||
// Simulate socket missing
|
|
||||||
return { stdout: '', stderr: 'no server running on /tmp/...', exitCode: 1 };
|
|
||||||
}
|
|
||||||
if (full.includes('list-panes')) {
|
|
||||||
return { stdout: '12345 pi 0 0\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (full.includes('systemctl') && full.includes('show')) {
|
|
||||||
return {
|
|
||||||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
console.log = (msg: string) => {
|
|
||||||
lines.push(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Must not throw
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
} finally {
|
|
||||||
console.log = origLog;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.parse(lines.join('')) as AgentPsRow[];
|
|
||||||
// Only roster agent visible; no crash
|
|
||||||
expect(json).toHaveLength(1);
|
|
||||||
expect(json[0]!.name).toBe('canary-pi');
|
|
||||||
expect(json[0]!.managed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('agent watch', () => {
|
describe('agent watch', () => {
|
||||||
it('builds exact grouped-viewer creation command', () => {
|
it('builds exact grouped-viewer creation command', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -2137,270 +1865,3 @@ describe('agent send --verify', () => {
|
|||||||
expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000);
|
expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fleet Phase F1: config-type presets + AI-free init wizard
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('fleet preset rosters', () => {
|
|
||||||
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
|
|
||||||
|
|
||||||
it.each(['general', 'coding', 'research', 'hybrid'] as FleetProfile[])(
|
|
||||||
'%s preset: loads via loadFleetRoster and has exactly one orchestrator',
|
|
||||||
async (preset) => {
|
|
||||||
const rosterPath = join(examplesDir, `${preset}.yaml`);
|
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
|
||||||
expect(countOrchestrators(roster)).toBe(1);
|
|
||||||
expect(roster.agents.find((a) => a.name === 'orchestrator')).toBeDefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('general preset: orchestrator + one generalist worker', async () => {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
|
|
||||||
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
|
||||||
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
|
||||||
'orchestrator',
|
|
||||||
'coder0',
|
|
||||||
'coder1',
|
|
||||||
'reviewer',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
|
||||||
'orchestrator',
|
|
||||||
'researcher0',
|
|
||||||
'researcher1',
|
|
||||||
'analyst',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
|
||||||
'orchestrator',
|
|
||||||
'coder0',
|
|
||||||
'researcher0',
|
|
||||||
'reviewer',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
|
||||||
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
|
|
||||||
for (const worker of workers) {
|
|
||||||
expect(worker.runtime).toBe('pi');
|
|
||||||
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('orchestrator in new presets uses claude runtime with persistent_persona', async () => {
|
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
|
||||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
|
||||||
const orch = roster.agents.find((a) => a.name === 'orchestrator');
|
|
||||||
expect(orch?.runtime).toBe('claude');
|
|
||||||
expect(orch?.persistentPersona).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('new presets are sanitized: no operator identity tokens', async () => {
|
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
|
||||||
const text = await readFile(join(examplesDir, `${preset}.yaml`), 'utf8');
|
|
||||||
expect(text).not.toMatch(/jarvis|jason|woltje/i);
|
|
||||||
// working_directory must not reference ~/src or /home
|
|
||||||
expect(text).not.toMatch(/~\/src|\/home\//);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseInitProfile', () => {
|
|
||||||
it('accepts all six fleet profiles', () => {
|
|
||||||
expect(parseInitProfile('general')).toBe('general');
|
|
||||||
expect(parseInitProfile('coding')).toBe('coding');
|
|
||||||
expect(parseInitProfile('research')).toBe('research');
|
|
||||||
expect(parseInitProfile('hybrid')).toBe('hybrid');
|
|
||||||
expect(parseInitProfile('minimal')).toBe('minimal');
|
|
||||||
expect(parseInitProfile('local-canary')).toBe('local-canary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown profiles with a message listing all valid names', () => {
|
|
||||||
expect(() => parseInitProfile('typo')).toThrow('Unsupported fleet profile');
|
|
||||||
expect(() => parseInitProfile('typo')).toThrow('general');
|
|
||||||
expect(() => parseInitProfile('typo')).toThrow('coding');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FLEET_PROFILES contains all six valid profile names', () => {
|
|
||||||
expect(FLEET_PROFILES).toContain('general');
|
|
||||||
expect(FLEET_PROFILES).toContain('coding');
|
|
||||||
expect(FLEET_PROFILES).toContain('research');
|
|
||||||
expect(FLEET_PROFILES).toContain('hybrid');
|
|
||||||
expect(FLEET_PROFILES).toContain('minimal');
|
|
||||||
expect(FLEET_PROFILES).toContain('local-canary');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePresetFilename', () => {
|
|
||||||
it.each(FLEET_PROFILES)('maps %s to %s.yaml', (profile) => {
|
|
||||||
expect(resolvePresetFilename(profile)).toBe(`${profile}.yaml`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet init wizard', () => {
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (cleanup) {
|
|
||||||
await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to general when stdin is not a TTY and no --profile is given', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
|
||||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
|
||||||
const stderrMessages: string[] = [];
|
|
||||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
|
||||||
stderrMessages.push(String(msg));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
// isStdinTTY: false simulates non-interactive environment
|
|
||||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--write',
|
|
||||||
]);
|
|
||||||
const content = await readFile(rosterPath, 'utf8');
|
|
||||||
// Should have written the general preset
|
|
||||||
expect(content).toContain('name: orchestrator');
|
|
||||||
expect(content).toContain('name: generalist');
|
|
||||||
// Stderr should explain the fallback
|
|
||||||
expect(stderrMessages.join('')).toMatch(/defaulting to fleet profile "general"/);
|
|
||||||
} finally {
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses --profile to select preset without wizard (non-TTY path)', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
|
||||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--profile',
|
|
||||||
'coding',
|
|
||||||
'--write',
|
|
||||||
]);
|
|
||||||
const content = await readFile(rosterPath, 'utf8');
|
|
||||||
expect(content).toContain('name: coder0');
|
|
||||||
expect(content).toContain('name: reviewer');
|
|
||||||
} finally {
|
|
||||||
// cleanup handled by afterEach
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('written roster has exactly one orchestrator agent (countOrchestrators validation)', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
|
||||||
const rosterPath = join(cleanup, `${preset}-roster.yaml`);
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--profile',
|
|
||||||
preset,
|
|
||||||
'--write',
|
|
||||||
]);
|
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
|
||||||
expect(countOrchestrators(roster)).toBe(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-init with --write and existing roster requires --force (R8 idempotency)', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
|
|
||||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
|
|
||||||
|
|
||||||
// First write
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--profile',
|
|
||||||
'general',
|
|
||||||
'--write',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Second write without --force must fail
|
|
||||||
await expect(
|
|
||||||
program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--profile',
|
|
||||||
'general',
|
|
||||||
'--write',
|
|
||||||
]),
|
|
||||||
).rejects.toThrow('Fleet roster already exists');
|
|
||||||
|
|
||||||
// With --force must succeed
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--roster',
|
|
||||||
rosterPath,
|
|
||||||
'init',
|
|
||||||
'--profile',
|
|
||||||
'coding',
|
|
||||||
'--write',
|
|
||||||
'--force',
|
|
||||||
]);
|
|
||||||
const content = await readFile(rosterPath, 'utf8');
|
|
||||||
expect(content).toContain('name: coder0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { homedir, hostname, userInfo } from 'node:os';
|
|||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import * as readline from 'node:readline';
|
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
|
|
||||||
@@ -42,11 +41,6 @@ export interface FleetCommandDeps {
|
|||||||
sleepFn?: SleepFn;
|
sleepFn?: SleepFn;
|
||||||
mosaicHome?: string;
|
mosaicHome?: string;
|
||||||
frameworkRoot?: string;
|
frameworkRoot?: string;
|
||||||
/**
|
|
||||||
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
|
|
||||||
* Tests stub this to simulate interactive or non-interactive environments.
|
|
||||||
*/
|
|
||||||
isStdinTTY?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawFleetRoster {
|
interface RawFleetRoster {
|
||||||
@@ -395,10 +389,6 @@ export interface AgentPsRow {
|
|||||||
driftFlag: boolean;
|
driftFlag: boolean;
|
||||||
/** active but UnitFileState=disabled */
|
/** active but UnitFileState=disabled */
|
||||||
bootEnableWarning: boolean;
|
bootEnableWarning: boolean;
|
||||||
/** true = came from roster; false = found on socket but not in roster */
|
|
||||||
managed: boolean;
|
|
||||||
/** "roster" = defined in roster.yaml; "socket" = discovered via tmux list-sessions */
|
|
||||||
source: 'roster' | 'socket';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -441,26 +431,6 @@ export function buildTmuxListPanesCommand(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the tmux list-sessions command to enumerate all sessions on a socket.
|
|
||||||
* Format: `tmux -L <socket> list-sessions -F '#{session_name}'`
|
|
||||||
* Used to discover ad-hoc sessions that are not in the roster.
|
|
||||||
*/
|
|
||||||
export function buildTmuxListSessionsCommand(socketName = DEFAULT_SOCKET_NAME): string[] {
|
|
||||||
return ['tmux', '-L', socketName, 'list-sessions', '-F', '#{session_name}'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the output of `tmux list-sessions -F '#{session_name}'` into an array of session names.
|
|
||||||
* Returns an empty array on empty/blank output.
|
|
||||||
*/
|
|
||||||
export function parseTmuxListSessions(output: string): string[] {
|
|
||||||
return output
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the heartbeat file path for an agent.
|
* Returns the heartbeat file path for an agent.
|
||||||
*/
|
*/
|
||||||
@@ -805,42 +775,19 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
cmd
|
cmd
|
||||||
.command('init')
|
.command('init')
|
||||||
.description('Initialize a local fleet roster')
|
.description('Initialize a local fleet roster')
|
||||||
.option(
|
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
|
||||||
'--profile <name>',
|
|
||||||
`Roster profile: ${FLEET_PROFILES.join(', ')} (skips interactive wizard)`,
|
|
||||||
)
|
|
||||||
.option('--write', 'Write the roster to Mosaic home')
|
.option('--write', 'Write the roster to Mosaic home')
|
||||||
.option('--force', 'Overwrite an existing roster when used with --write')
|
.option('--force', 'Overwrite an existing roster when used with --write')
|
||||||
.action(async (opts: { profile?: string; write?: boolean; force?: boolean }) => {
|
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
|
const profile = parseInitProfile(opts.profile);
|
||||||
let profile: FleetProfile;
|
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
|
||||||
if (opts.profile !== undefined) {
|
|
||||||
// Explicit --profile flag: validate and use it (non-interactive path).
|
|
||||||
profile = parseInitProfile(opts.profile);
|
|
||||||
} else {
|
|
||||||
// No --profile: use wizard when stdin is a TTY, else default to 'general'.
|
|
||||||
const isTTY = deps.isStdinTTY ?? process.stdin.isTTY ?? false;
|
|
||||||
if (isTTY) {
|
|
||||||
profile = await promptFleetProfile();
|
|
||||||
} else {
|
|
||||||
process.stderr.write(
|
|
||||||
'Note: stdin is not a TTY; defaulting to fleet profile "general". ' +
|
|
||||||
'Use --profile <name> to select a different preset.\n',
|
|
||||||
);
|
|
||||||
profile = 'general';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = join(frameworkRoot, 'fleet', 'examples', resolvePresetFilename(profile));
|
|
||||||
const content = await readFile(source, 'utf8');
|
const content = await readFile(source, 'utf8');
|
||||||
|
|
||||||
if (!opts.write) {
|
if (!opts.write) {
|
||||||
console.log(content.trimEnd());
|
console.log(content.trimEnd());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const destination = commandOpts.roster ?? activePaths.rosterPath;
|
const destination = commandOpts.roster ?? activePaths.rosterPath;
|
||||||
if (!opts.force && (await canRead(destination))) {
|
if (!opts.force && (await canRead(destination))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -849,23 +796,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
}
|
}
|
||||||
await mkdir(dirname(destination), { recursive: true });
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
await writeFile(destination, content);
|
await writeFile(destination, content);
|
||||||
|
console.log(`Wrote fleet roster: ${destination}`);
|
||||||
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
|
|
||||||
const written = await loadFleetRoster(destination);
|
|
||||||
const orchCount = countOrchestrators(written);
|
|
||||||
if (orchCount !== 1) {
|
|
||||||
process.stderr.write(
|
|
||||||
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const workerCount = written.agents.length - 1;
|
|
||||||
console.log(
|
|
||||||
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
@@ -966,9 +897,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('ps')
|
.command('ps')
|
||||||
.description(
|
.description('Show real-time status for all roster agents (systemd + tmux + heartbeat)')
|
||||||
'Show real-time status for all roster agents and unmanaged socket sessions (systemd + tmux + heartbeat)',
|
|
||||||
)
|
|
||||||
.option('--json', 'Print JSON array')
|
.option('--json', 'Print JSON array')
|
||||||
.action(async (opts: { json?: boolean }) => {
|
.action(async (opts: { json?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
@@ -979,9 +908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
const rows: AgentPsRow[] = [];
|
const rows: AgentPsRow[] = [];
|
||||||
|
|
||||||
// Build the set of roster agent names for quick lookup when filtering socket sessions.
|
|
||||||
const rosterAgentNames = new Set(roster.agents.map((a) => a.name));
|
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
// systemd show
|
// systemd show
|
||||||
const showResult = await runner(...splitCommand(buildSystemdShowCommand(agent.name)));
|
const showResult = await runner(...splitCommand(buildSystemdShowCommand(agent.name)));
|
||||||
@@ -1022,75 +948,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
heartbeat: hb,
|
heartbeat: hb,
|
||||||
driftFlag,
|
driftFlag,
|
||||||
bootEnableWarning,
|
bootEnableWarning,
|
||||||
managed: true,
|
|
||||||
source: 'roster',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enumerate all live sessions on the socket to surface unmanaged (ad-hoc) sessions.
|
|
||||||
// If list-sessions fails (socket not up), silently skip — show roster rows only.
|
|
||||||
try {
|
|
||||||
const listSessionsResult = await runner(
|
|
||||||
...splitCommand(buildTmuxListSessionsCommand(roster.tmux.socketName)),
|
|
||||||
);
|
|
||||||
if (listSessionsResult.exitCode === 0) {
|
|
||||||
const socketSessions = parseTmuxListSessions(listSessionsResult.stdout);
|
|
||||||
const holderSession = roster.tmux.holderSession;
|
|
||||||
|
|
||||||
for (const sessionName of socketSessions) {
|
|
||||||
// Skip roster agents (already in rows) and the holder session (infrastructure).
|
|
||||||
if (rosterAgentNames.has(sessionName) || sessionName === holderSession) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tmux list-panes for pane info
|
|
||||||
const panesResult = await runner(
|
|
||||||
...splitCommand(buildTmuxListPanesCommand(sessionName, roster.tmux.socketName)),
|
|
||||||
);
|
|
||||||
const paneInfo = parseTmuxListPanes(panesResult.stdout, nowMs);
|
|
||||||
|
|
||||||
// heartbeat — try reading the .hb file using the same path convention
|
|
||||||
const hbFile = heartbeatPath(sessionName, activePaths.mosaicHome);
|
|
||||||
let hbContent: string | null = null;
|
|
||||||
try {
|
|
||||||
hbContent = await readFile(hbFile, 'utf8');
|
|
||||||
} catch {
|
|
||||||
hbContent = null;
|
|
||||||
}
|
|
||||||
const hb = parseHeartbeat(hbContent, nowMs);
|
|
||||||
|
|
||||||
// systemd — check if mosaic-agent@<name>.service exists (usually inactive for ad-hoc)
|
|
||||||
const showResult = await runner(...splitCommand(buildSystemdShowCommand(sessionName)));
|
|
||||||
const sysInfo = parseSystemdShow(showResult.stdout);
|
|
||||||
|
|
||||||
const bootEnableWarning =
|
|
||||||
sysInfo.ActiveState === 'active' && sysInfo.UnitFileState === 'disabled';
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
name: sessionName,
|
|
||||||
tenant_id,
|
|
||||||
host,
|
|
||||||
// runtime unknown — not in roster
|
|
||||||
runtime: 'unknown',
|
|
||||||
systemdActive: sysInfo.ActiveState,
|
|
||||||
systemdEnabled: sysInfo.UnitFileState,
|
|
||||||
paneAlive: !paneInfo.dead,
|
|
||||||
panePid: paneInfo.pid,
|
|
||||||
paneCommand: paneInfo.command,
|
|
||||||
idleSeconds: paneInfo.idleSeconds,
|
|
||||||
heartbeat: hb,
|
|
||||||
// No roster runtime to compare — drift is not meaningful for unmanaged sessions
|
|
||||||
driftFlag: false,
|
|
||||||
bootEnableWarning,
|
|
||||||
managed: false,
|
|
||||||
source: 'socket',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// list-sessions failed (socket missing or permission error) — show roster rows only
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -1122,7 +982,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}`
|
? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}`
|
||||||
: `unknown`;
|
: `unknown`;
|
||||||
const flags: string[] = [];
|
const flags: string[] = [];
|
||||||
if (!row.managed) flags.push('UNMANAGED');
|
|
||||||
if (row.driftFlag) flags.push('DRIFT');
|
if (row.driftFlag) flags.push('DRIFT');
|
||||||
if (row.bootEnableWarning) flags.push('BOOT-ENABLE');
|
if (row.bootEnableWarning) flags.push('BOOT-ENABLE');
|
||||||
|
|
||||||
@@ -1713,96 +1572,11 @@ function splitCommand(command: string[]): [string, string[]] {
|
|||||||
return [bin, args];
|
return [bin, args];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All supported fleet profile names. */
|
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
|
||||||
export type FleetProfile =
|
if (profile === 'minimal' || profile === 'local-canary') {
|
||||||
| 'general'
|
return profile;
|
||||||
| 'coding'
|
|
||||||
| 'research'
|
|
||||||
| 'hybrid'
|
|
||||||
| 'minimal'
|
|
||||||
| 'local-canary';
|
|
||||||
|
|
||||||
/** The list of all valid fleet profile names, for wizard menus and error messages. */
|
|
||||||
export const FLEET_PROFILES: readonly FleetProfile[] = [
|
|
||||||
'general',
|
|
||||||
'coding',
|
|
||||||
'research',
|
|
||||||
'hybrid',
|
|
||||||
'minimal',
|
|
||||||
'local-canary',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a fleet profile name to its example YAML filename (without the path).
|
|
||||||
* Pure function — testable without I/O.
|
|
||||||
*/
|
|
||||||
export function resolvePresetFilename(profile: FleetProfile): string {
|
|
||||||
return `${profile}.yaml`;
|
|
||||||
}
|
}
|
||||||
|
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
|
||||||
/**
|
|
||||||
* Validate and normalise a fleet profile name string.
|
|
||||||
* Throws with a clear message on unknown values.
|
|
||||||
*/
|
|
||||||
export function parseInitProfile(profile: string): FleetProfile {
|
|
||||||
if ((FLEET_PROFILES as readonly string[]).includes(profile)) {
|
|
||||||
return profile as FleetProfile;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported fleet profile "${profile}". Use: ${FLEET_PROFILES.join(', ')}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count orchestrator agents in a parsed roster.
|
|
||||||
* Returns the count; callers assert === 1.
|
|
||||||
*/
|
|
||||||
export function countOrchestrators(roster: FleetRoster): number {
|
|
||||||
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt interactively for a fleet profile via stdin readline.
|
|
||||||
* AI-free: no LLM calls — pure readline menu.
|
|
||||||
* Resolves with the chosen profile string, or rejects on I/O error.
|
|
||||||
*/
|
|
||||||
function promptFleetProfile(): Promise<FleetProfile> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const menu = [
|
|
||||||
'',
|
|
||||||
'Choose a fleet configuration type:',
|
|
||||||
' 1) general — orchestrator + generalist worker',
|
|
||||||
' 2) coding — orchestrator + coder0 + coder1 + reviewer',
|
|
||||||
' 3) research — orchestrator + researcher0 + researcher1 + analyst',
|
|
||||||
' 4) hybrid — orchestrator + coder0 + researcher0 + reviewer',
|
|
||||||
' 5) minimal — single canary-pi agent (no orchestrator)',
|
|
||||||
' 6) local-canary — legacy canary preset with lead + coder + reviewer',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
process.stdout.write(menu);
|
|
||||||
rl.question('Enter number or name [1]: ', (answer) => {
|
|
||||||
rl.close();
|
|
||||||
const trimmed = answer.trim();
|
|
||||||
// Map numeric shortcut → name
|
|
||||||
const byNumber: Record<string, FleetProfile> = {
|
|
||||||
'1': 'general',
|
|
||||||
'2': 'coding',
|
|
||||||
'3': 'research',
|
|
||||||
'4': 'hybrid',
|
|
||||||
'5': 'minimal',
|
|
||||||
'6': 'local-canary',
|
|
||||||
'': 'general', // default on empty enter
|
|
||||||
};
|
|
||||||
if (trimmed in byNumber) {
|
|
||||||
resolve(byNumber[trimmed]!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(parseInitProfile(trimmed));
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCommandOutput(result: CommandResult): void {
|
function writeCommandOutput(result: CommandResult): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user