Files
stack/packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
jason.woltje 499090508e
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(fleet): reconcile local roster state (#785)
2026-07-15 15:03:31 +00:00

258 lines
8.0 KiB
TypeScript

import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
const roster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
await chmod(join(cleanup, directory), 0o700);
}
await chmod(cleanup, 0o700);
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
mode: 0o600,
});
return cleanup;
}
function program(
mosaicHome: string,
runner: FleetCommandDeps['runner'],
reconcileOverrides: Partial<FleetReconcileDeps> = {},
): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, {
mosaicHome,
runner,
reconcileDeps: {
homeDirectory: '/home/mosaic',
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
applyProjection: async () => undefined,
...reconcileOverrides,
},
});
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
function ownedRunner(
calls: string[][],
): (command: string, args: string[]) => Promise<CommandResult> {
return async (command: string, args: string[]): Promise<CommandResult> => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
};
}
describe('mosaic fleet reconciler commands', (): void => {
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, ownedRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'apply',
'--expected-generation',
'7',
'--dry-run',
]);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'not-applied',
});
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-agent@coder0.service',
]);
});
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
const calls: string[][] = [];
const cli = program(home, ownedRunner(calls));
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
{ applied: false, lifecycle: 'not-applied' },
{ applied: false, lifecycle: 'not-applied' },
]);
expect(
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
).toBe(true);
});
it.each(['start', 'stop', 'restart'] as const)(
'uses exact roster-owned systemd targeting for %s',
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, ownedRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
operation,
'coder0',
'--expected-generation',
'7',
]);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(calls).toContainEqual([
'systemctl',
'--user',
operation,
'mosaic-agent@coder0.service',
]);
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
false,
);
},
);
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, ownedRunner([]), {
acquireMutationLock: async () => async () => {
throw new Error('cleanup failure');
},
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
applied: true,
projections: 'complete',
lifecycle: 'complete',
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
});
expect(process.exitCode).toBe(1);
});
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
const cleanupFailure = async () => async () => {
throw new Error('cleanup failure');
};
const projectionHome = await fleetHome();
const projectionLines = capture();
await program(projectionHome, ownedRunner([]), {
applyProjection: async () => {
throw new Error('projection failure');
},
acquireMutationLock: cleanupFailure,
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
projections: 'incomplete',
lifecycle: 'not-applied',
recovery: { code: 'projection-apply-failed' },
cleanup: { code: 'lock-cleanup-failed' },
});
expect(process.exitCode).toBe(1);
});
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
const home = await fleetHome();
const cli = program(home, ownedRunner([]));
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(process.exitCode).toBe(0);
process.exitCode = undefined;
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(process.exitCode).toBe(0);
});
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, ownedRunner([])).parseAsync([
'node',
'mosaic',
'fleet',
'apply',
'--expected-generation',
'6',
]);
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
expect(process.exitCode).toBe(1);
});
});