diff --git a/docs/scratchpads/ms-792-fleet-enoent-installer.md b/docs/scratchpads/ms-792-fleet-enoent-installer.md index c1cd415..a24cac4 100644 --- a/docs/scratchpads/ms-792-fleet-enoent-installer.md +++ b/docs/scratchpads/ms-792-fleet-enoent-installer.md @@ -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: 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: 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 @@ -29,6 +30,7 @@ Make expected missing or malformed fleet roster configuration fail with an actio - `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-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. ## Risks / blockers diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 65cf7c8..9aaaf05 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -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`.', ); }); + + 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', () => { diff --git a/packages/mosaic/src/fleet/fleet-roster-v1.ts b/packages/mosaic/src/fleet/fleet-roster-v1.ts index 13288ed..a07eb2a 100644 --- a/packages/mosaic/src/fleet/fleet-roster-v1.ts +++ b/packages/mosaic/src/fleet/fleet-roster-v1.ts @@ -197,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent { } 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'); assertKnownKeys(raw, 'Fleet roster', [ 'version',