chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||
|
||||
/**
|
||||
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
|
||||
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
|
||||
* never get its units placed. These tests pin the three behaviours that fix
|
||||
* gives it, and the two it deliberately does NOT give it.
|
||||
*
|
||||
* The load-bearing negative is that `install` on v2 writes no generated env:
|
||||
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
|
||||
* second writer here — necessarily through the v1 mapping — is exactly the
|
||||
* drift the #791 single-SSOT invariant exists to prevent.
|
||||
*/
|
||||
|
||||
const rosterV2 = `
|
||||
version: 2
|
||||
generation: 4
|
||||
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
|
||||
- name: coder1
|
||||
alias: Coder 1
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: medium
|
||||
tool_policy: code
|
||||
working_directory: /srv/other
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let tempHome: string | undefined;
|
||||
const savedHome = process.env.HOME;
|
||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||
* anything has been installed, applied or started.
|
||||
*/
|
||||
async function v2Home(): Promise<string> {
|
||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
const mosaicHome = join(tempHome, '.config', 'mosaic');
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
|
||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
return mosaicHome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a box where nothing is running: every systemctl and tmux probe
|
||||
* fails the way it does before the holder has ever started. `ps` must survive
|
||||
* this — it is the command an operator reaches for to find out *why* there is
|
||||
* no seat, so it has to report the emptiness rather than fail on it.
|
||||
*/
|
||||
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
|
||||
if (command === 'tmux') {
|
||||
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
};
|
||||
|
||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('mosaic fleet ps — roster v2', (): void => {
|
||||
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
|
||||
await v2Home();
|
||||
const lines = capture();
|
||||
|
||||
await expect(
|
||||
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const rows = JSON.parse(lines.join('\n')) as {
|
||||
name: string;
|
||||
runtime: string;
|
||||
alias?: string;
|
||||
paneAlive: boolean;
|
||||
source: string;
|
||||
}[];
|
||||
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
|
||||
// The v2 roster's per-agent fields must survive the read model, not be
|
||||
// flattened into defaults.
|
||||
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
|
||||
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
|
||||
// Nothing is running, and that is a report, not an error.
|
||||
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
|
||||
expect(rows.every((row) => row.source === 'roster')).toBe(true);
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic fleet install — roster v2', (): void => {
|
||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
capture();
|
||||
|
||||
await expect(
|
||||
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Units live in the systemd user dir, not under the Mosaic home.
|
||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
||||
for (const unit of [
|
||||
'mosaic-tmux-holder.service',
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
]) {
|
||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||
}
|
||||
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
||||
expect(await exists(launcher)).toBe(true);
|
||||
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
||||
});
|
||||
|
||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
capture();
|
||||
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
const agentDir = join(mosaicHome, 'fleet', 'agents');
|
||||
expect(await readdir(agentDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('tells the operator which command does own the env', async (): Promise<void> => {
|
||||
await v2Home();
|
||||
const lines = capture();
|
||||
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
expect(lines.join('\n')).toContain('mosaic fleet apply');
|
||||
});
|
||||
});
|
||||
|
||||
describe('[email protected]', (): void => {
|
||||
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
||||
|
||||
/**
|
||||
* Every `ConditionPathExists=` value declared by the unit template, in file
|
||||
* order, `|` triggering prefix included.
|
||||
*
|
||||
* Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
|
||||
* a content assertion, and the count pin was itself the defect: when #1408
|
||||
* required a second triggering line (the `%h/.mosaic` brain-home shape),
|
||||
* this spec was a second consumer of the unit template that the shell suite
|
||||
* and the enumeration guard could not see, so the fix failed here first
|
||||
* (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
|
||||
* content per line, never count.
|
||||
*/
|
||||
async function conditionPaths(): Promise<string[]> {
|
||||
const unit = await readFile(unitPath, 'utf8');
|
||||
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
|
||||
}
|
||||
|
||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
||||
// The pairing that makes "install writes no env" safe: install enables the
|
||||
// unit (WantedBy=default.target) but does not start it, so without this
|
||||
// condition a reboot between `install` and the first `apply` would run
|
||||
// ExecStart against an absent env file and fail every seat unit.
|
||||
//
|
||||
// Two lines since #1408: the env projection lives under %h/.config/mosaic
|
||||
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
|
||||
// carry the `|` triggering prefix — systemd ANDs same-type conditions
|
||||
// unless every line is triggering (then they OR), and a bare spelling
|
||||
// would demand BOTH home shapes on one host, which is never true, so
|
||||
// every seat would silently skip.
|
||||
expect(await conditionPaths()).toEqual([
|
||||
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
|
||||
'|%h/.mosaic/fleet/agents/%i.env.generated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
|
||||
// Invariant ported from test-fleet-units.sh, held separately from the
|
||||
// literal pin above so it survives future edits to the path set: every
|
||||
// ConditionPathExists line must stay triggering (`|`).
|
||||
for (const value of await conditionPaths()) {
|
||||
expect(value.startsWith('|')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The two halves of the guard's *effect*, which no assertion on the literal
|
||||
* string can cover on its own.
|
||||
*
|
||||
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
|
||||
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
|
||||
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
|
||||
* and journals "skipped, unmet condition check". With the condition removed by
|
||||
* drop-in and nothing else changed, the same start returns rc=1,
|
||||
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
|
||||
*
|
||||
* systemd is not available in this suite, so these two tests pin the parts
|
||||
* that can drift in code: the condition naming a *different* file than the one
|
||||
* the fleet actually writes, and the launcher quietly becoming tolerant of an
|
||||
* absent env — either of which turns the condition into decoration while the
|
||||
* literal-string assertion above still passes.
|
||||
*/
|
||||
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const rendered = (await conditionPaths()).map((value) =>
|
||||
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
|
||||
);
|
||||
|
||||
// Per home shape, the guard must render to exactly the file the
|
||||
// reconciler writes there: mosaicHome (%h/.config/mosaic) on
|
||||
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
|
||||
// line pinned to its file, not merely present.
|
||||
expect(rendered).toEqual([
|
||||
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||
await v2Home();
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
// Exactly what ExecStart runs, against the state the condition exists to
|
||||
// catch: unit enabled, reconciler has not written env yet.
|
||||
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
|
||||
const child = execFile(
|
||||
'/bin/bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
'coder0',
|
||||
],
|
||||
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
|
||||
(_error, _stdout, stderr) => {
|
||||
settle({ code: child.exitCode, stderr });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(launched.code).not.toBe(0);
|
||||
expect(launched.stderr).toContain('missing-file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic fleet add / remove — roster v2', (): void => {
|
||||
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
|
||||
await v2Home();
|
||||
|
||||
await expect(
|
||||
program().parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'coder2',
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--class',
|
||||
'code',
|
||||
]),
|
||||
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
|
||||
});
|
||||
|
||||
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
|
||||
await v2Home();
|
||||
|
||||
await expect(
|
||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
||||
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
|
||||
});
|
||||
|
||||
// Note: this one passes on the unmodified tree too — there `remove` throws in
|
||||
// the v1 parser, before it can touch anything. It is a regression guard on the
|
||||
// ordering of the new guard clause, not evidence that the fix works.
|
||||
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
const before = await readFile(rosterPath, 'utf8');
|
||||
|
||||
await expect(
|
||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user