fix(#792): guard fleet roster schema errors
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -20,6 +20,7 @@ Make expected missing or malformed fleet roster configuration fail with an actio
|
|||||||
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
||||||
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
||||||
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
|
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
|
||||||
|
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ Make expected missing or malformed fleet roster configuration fail with an actio
|
|||||||
- `pnpm format:check` — PASS
|
- `pnpm format:check` — PASS
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
|
||||||
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
||||||
|
|
||||||
## Risks / blockers
|
## Risks / blockers
|
||||||
|
|||||||
@@ -214,6 +214,42 @@ describe('fleet roster configuration failures', () => {
|
|||||||
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
|
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(rosterPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'fleet.roster',
|
||||||
|
exitCode: 1,
|
||||||
|
message: 'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
});
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).toContain(
|
||||||
|
'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
);
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/);
|
||||||
|
} finally {
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fleet roster parsing', () => {
|
describe('fleet roster parsing', () => {
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||||
|
try {
|
||||||
|
return normalizeFleetRosterV1Unchecked(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) throw error;
|
||||||
|
if (error instanceof Error) throw new FleetRosterConfigurationError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||||
assertObject(raw, 'Fleet roster');
|
assertObject(raw, 'Fleet roster');
|
||||||
assertKnownKeys(raw, 'Fleet roster', [
|
assertKnownKeys(raw, 'Fleet roster', [
|
||||||
'version',
|
'version',
|
||||||
|
|||||||
Reference in New Issue
Block a user