ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|