feat(fleet): provision roster from system-type profile (H3) (#665)
This commit was merged in pull request #665.
This commit is contained in:
270
packages/mosaic/src/commands/fleet-provision.spec.ts
Normal file
270
packages/mosaic/src/commands/fleet-provision.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadFleetRoster } from './fleet.js';
|
||||
import { generateRoster, runProvision } from './fleet-provision.js';
|
||||
import { loadProfile } from './fleet-profiles.js';
|
||||
|
||||
// These are INTEGRATION tests: each exercises real filesystem I/O — scanning the
|
||||
// committed framework/fleet persona library, rendering YAML, writing to a temp
|
||||
// mosaicHome, and round-tripping through the real roster parser. On a heavily
|
||||
// contended CI runner (the whole monorepo's suites run in parallel) that genuine
|
||||
// I/O can exceed vitest's 5s default even though it completes in ~400ms locally.
|
||||
// Give the legitimately I/O-bound work generous headroom so CI is deterministic.
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
// The real, committed library: packages/mosaic/src/commands -> framework/fleet.
|
||||
const frameworkFleet = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'framework',
|
||||
'fleet',
|
||||
);
|
||||
const rolesDir = join(frameworkFleet, 'roles');
|
||||
const profilesDir = join(frameworkFleet, 'profiles');
|
||||
|
||||
/** A fresh temp mosaicHome whose fleet/ dir is empty (for write-path tests). */
|
||||
async function freshMosaicHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-provision-'));
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('provision software-delivery (floor, default)', () => {
|
||||
it('materializes only the floor seats with correct flags + valid scaffold', async () => {
|
||||
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
||||
const { seats, yaml } = await generateRoster(profile, { profilesDir, rolesDir });
|
||||
|
||||
// Floor is orchestrator + enhancer.
|
||||
expect(seats.map((s) => s.name).sort()).toEqual(['enhancer', 'orchestrator']);
|
||||
const orch = seats.find((s) => s.name === 'orchestrator');
|
||||
const enh = seats.find((s) => s.name === 'enhancer');
|
||||
// RULE 2: floor + lead get persistent_persona.
|
||||
expect(orch?.persistentPersona).toBe(true);
|
||||
expect(enh?.persistentPersona).toBe(true);
|
||||
// RULE 3: floor/lead do NOT reset between tasks.
|
||||
expect(orch?.resetBetweenTasks).toBeUndefined();
|
||||
expect(enh?.resetBetweenTasks).toBeUndefined();
|
||||
// RULE 4: default runtime claude.
|
||||
expect(orch?.runtime).toBe('claude');
|
||||
|
||||
// Scaffold round-trips through the real parser.
|
||||
expect(yaml).toContain('version: 1');
|
||||
expect(yaml).toContain('transport: tmux');
|
||||
expect(yaml).toContain('socket_name: mosaic-fleet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provision --full', () => {
|
||||
it('expands the entire roster, including multiplicity, deterministically', async () => {
|
||||
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
||||
const { seats } = await generateRoster(profile, { full: true, profilesDir, rolesDir });
|
||||
|
||||
const names = seats.map((s) => s.name);
|
||||
// code multiplicity 2 -> code0/code1 (RULE 1).
|
||||
expect(names).toContain('code0');
|
||||
expect(names).toContain('code1');
|
||||
expect(names).not.toContain('code');
|
||||
// Singleton seats keep the bare class name.
|
||||
expect(names).toContain('planner');
|
||||
expect(names).toContain('merge-gate');
|
||||
|
||||
// Deterministic ordering: profile roster order, multiplicity expanded inline.
|
||||
const codeIdx0 = names.indexOf('code0');
|
||||
expect(names[codeIdx0 + 1]).toBe('code1');
|
||||
|
||||
// RULE 3: a non-floor, non-lead execution seat resets between tasks.
|
||||
const code0 = seats.find((s) => s.name === 'code0');
|
||||
expect(code0?.resetBetweenTasks).toBe(true);
|
||||
expect(code0?.persistentPersona).toBeUndefined();
|
||||
|
||||
// Seat count == sum of multiplicities.
|
||||
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
|
||||
expect(seats.length).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated roster round-trips through the real parser', () => {
|
||||
it('feeds generated YAML back through loadFleetRoster (key correctness proof)', async () => {
|
||||
const home = await freshMosaicHome();
|
||||
try {
|
||||
const result = await runProvision('software-delivery', {
|
||||
mosaicHome: home,
|
||||
profilesDir,
|
||||
rolesDir,
|
||||
full: true,
|
||||
write: true,
|
||||
});
|
||||
expect(result.wrote).toBe(join(home, 'fleet', 'roster.yaml'));
|
||||
|
||||
const parsed = await loadFleetRoster(result.wrote!);
|
||||
// It parses with no error and carries every seat.
|
||||
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
||||
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
|
||||
expect(parsed.agents.length).toBe(expected);
|
||||
// Classes survive the round-trip.
|
||||
expect(parsed.agents.some((a) => a.className === 'orchestrator')).toBe(true);
|
||||
expect(parsed.agents.filter((a) => a.className === 'code').length).toBe(2);
|
||||
// reports_to must NOT have been emitted (parser rejects unknown keys).
|
||||
expect(result.yaml).not.toContain('reports_to');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('override-aware persona validation', () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'mosaic-provision-ov-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves a user-added (roles.local-only) persona without a false unresolved error', async () => {
|
||||
const overrideDir = join(dir, 'roles.local');
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
// A brand-new class that exists ONLY in roles.local.
|
||||
await writeFile(
|
||||
join(overrideDir, 'widget-maker.md'),
|
||||
'# widget-maker\n\nThe widget-maker persona (`class: widget-maker`).\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(customProfilesDir, 'custom.yaml'),
|
||||
[
|
||||
'id: custom',
|
||||
'title: Custom',
|
||||
'description: a custom system',
|
||||
'lead: widget-maker',
|
||||
'floor: [widget-maker]',
|
||||
'roster:',
|
||||
' - class: widget-maker',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await runProvision('custom', {
|
||||
mosaicHome: dir,
|
||||
profilesDir: customProfilesDir,
|
||||
// Point baseline rolesDir at a missing dir so resolution depends on override.
|
||||
rolesDir: join(dir, 'no-baseline'),
|
||||
overrideDir,
|
||||
});
|
||||
expect(result.yaml).toContain('class: widget-maker');
|
||||
// It resolved from the override layer.
|
||||
// (generateRoster records personaLayer; the seat is present.)
|
||||
expect(result.summary).toContain('persona=override');
|
||||
});
|
||||
|
||||
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(customProfilesDir, 'bogus.yaml'),
|
||||
[
|
||||
'id: bogus',
|
||||
'title: Bogus',
|
||||
'description: bad system',
|
||||
'lead: orchestrator',
|
||||
'floor: [orchestrator]',
|
||||
'roster:',
|
||||
' - class: orchestrator',
|
||||
' - class: not-a-real-persona-xyz',
|
||||
' reports_to: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await expect(
|
||||
runProvision('bogus', {
|
||||
mosaicHome: dir,
|
||||
profilesDir: customProfilesDir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
}),
|
||||
).rejects.toThrow(/not-a-real-persona-xyz|not a known persona class/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('--write protection', () => {
|
||||
it('refuses to clobber an existing roster.yaml without --force', async () => {
|
||||
const home = await freshMosaicHome();
|
||||
try {
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await writeFile(rosterPath, 'version: 1\n# operator customizations\n');
|
||||
await expect(
|
||||
runProvision('software-delivery', { mosaicHome: home, profilesDir, rolesDir, write: true }),
|
||||
).rejects.toThrow(/Refusing to overwrite/);
|
||||
// The original file is untouched.
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('operator customizations');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('--write --force overwrites an existing roster', async () => {
|
||||
const home = await freshMosaicHome();
|
||||
try {
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await writeFile(rosterPath, 'version: 1\n# old\n');
|
||||
const result = await runProvision('software-delivery', {
|
||||
mosaicHome: home,
|
||||
profilesDir,
|
||||
rolesDir,
|
||||
write: true,
|
||||
force: true,
|
||||
});
|
||||
expect(result.wrote).toBe(rosterPath);
|
||||
const parsed = await loadFleetRoster(rosterPath);
|
||||
expect(parsed.agents.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('--write to a fresh mosaicHome creates the roster file', async () => {
|
||||
const home = await freshMosaicHome();
|
||||
try {
|
||||
const result = await runProvision('software-delivery', {
|
||||
mosaicHome: home,
|
||||
profilesDir,
|
||||
rolesDir,
|
||||
write: true,
|
||||
});
|
||||
expect(await fileExists(result.wrote!)).toBe(true);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('dry run (no --write) writes nothing', async () => {
|
||||
const home = await freshMosaicHome();
|
||||
try {
|
||||
const result = await runProvision('software-delivery', {
|
||||
mosaicHome: home,
|
||||
profilesDir,
|
||||
rolesDir,
|
||||
});
|
||||
expect(result.wrote).toBeUndefined();
|
||||
expect(await fileExists(join(home, 'fleet', 'roster.yaml'))).toBe(false);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user