Architecture (fleet CLI integration, Jason ruling 2026-08-28): the package embeds the command surface; operator-owned implementations stay in the brain (tools/agent-watch). The command resolves the brain home via resolveBrainHome (MOSAIC_BRAIN_HOME wins, canonical ~/.mosaic adoption otherwise) and execs the tool there. - Pass-through contract: allowUnknownOption + variadic args capture the full ordered argument list (commander 13 measured behavior; no passThroughOptions, which would force enablePositionalOptions on the root program fleet-wide). Args, stdout/stderr, and the exit code belong to the tool. - Absent tool: exit 127 naming the resolved path (no guessing). - Signal death / null spawn status: exit 125. - Spec (6/6 green): brain-home precedence, exit mapping, absent-tool path. Live smoke verified through the fleet launcher: mosaic watch list returns real watcher state with exit-code passthrough. Pre-existing local vitest failures in fleet-agent-crud/fleet-regen/ compose-contract specs measured identical on a clean origin/next stash; not caused by this change (PR #1462 CI was terminal-green).
73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
import { mkdirSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { Command } from 'commander';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
|
|
|
|
// The dispatch command execs a real process with inherited stdio; the spec
|
|
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
|
|
// path (which exits without spawning). Live pass-through is exercised by the
|
|
// fleet smoke test against the real brain tool.
|
|
|
|
describe('resolveAgentWatchTool', () => {
|
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
|
});
|
|
|
|
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
|
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
|
|
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
|
|
);
|
|
});
|
|
|
|
it('resolves inside the brain tools tree', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
|
const tool = resolveAgentWatchTool(tmp);
|
|
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('exitStatusFor', () => {
|
|
it('maps absent tool to 127', () => {
|
|
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
|
});
|
|
|
|
it('passes the tool exit status through', () => {
|
|
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
|
|
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
|
|
});
|
|
|
|
it('maps signal death / null status to 125', () => {
|
|
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
|
});
|
|
});
|
|
|
|
describe('registerWatchCommand absent-tool path', () => {
|
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
|
|
// The suite directory exists but the tool file does not.
|
|
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
|
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
|
|
|
const program = new Command();
|
|
registerWatchCommand(program);
|
|
await program.parseAsync(['watch', 'list'], { from: 'user' });
|
|
expect(process.exitCode).toBe(127);
|
|
});
|
|
});
|