mosaic watch: first embedded brain-tool dispatch command

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).
This commit is contained in:
2026-08-28 17:37:15 -05:00
parent 41f1bc0d37
commit 18f3dd49ec
3 changed files with 161 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerWatchCommand } from './commands/watch.js';
import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
@@ -428,6 +429,7 @@ registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
registerWatchCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
@@ -0,0 +1,72 @@
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);
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { join } from 'node:path';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { resolveBrainHome } from '../fleet/brain-home.js';
/**
* `mosaic watch` — dispatch to the brain's agent-watch suite.
*
* Architecture (fleet CLI integration, Jason ruling 2026-08-28): the npm
* package embeds the COMMAND SURFACE; operator-owned tool implementations
* stay in the brain (`~/.mosaic/tools/`). The dispatch resolves the brain
* home (MOSAIC_BRAIN_HOME wins, else canonical ~/.mosaic when adopted —
* see brain-home.ts) and execs the tool there. Nothing operator-specific
* ships inside the package, keeping the framework boundary clean.
*
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
* to the tool. The CLI adds nothing on success. When the tool is absent the
* CLI fails loudly with the resolved path (exit 127) instead of guessing.
*/
export function resolveAgentWatchTool(mosaicHome: string): string {
return join(resolveBrainHome(mosaicHome), 'tools', 'agent-watch', 'agent-watch.sh');
}
/** Map a spawnSync result + tool existence to the CLI exit status. */
export function exitStatusFor(
result: { status: number | null; error?: NodeJS.ErrnoException },
toolExists: boolean,
): number {
if (!toolExists) return 127;
if (result.status !== null) return result.status;
return 125; // killed by signal / could not run
}
export function registerWatchCommand(program: Command): void {
const cmd: Command = program
.command('watch')
.description('Wake-me-when watchers (agent-watch): start, list, stop')
// allowUnknownOption + variadic = full ordered pass-through: unknown
// options (--name, --when, ...) and their values land in args verbatim
// (commander 13 measured behavior), so the tool owns its own flag
// surface without the CLI needing passThroughOptions (which would
// force enablePositionalOptions fleet-wide on the root program).
.allowUnknownOption()
.argument('[args...]', 'args passed through to agent-watch.sh')
.action(async (args: string[], _opts: unknown, command: Command) => {
// --mosaic-home is not global in this CLI; walk parents for it and
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
// it) wins inside resolveBrainHome regardless.
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const tool = resolveAgentWatchTool(mosaicHome ?? DEFAULT_MOSAIC_HOME);
let toolExists = true;
try {
accessSync(tool, constants.X_OK);
} catch {
toolExists = false;
}
if (!toolExists) {
console.error(
`mosaic watch: agent-watch not found (expected ${tool}). ` +
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
process.exitCode = 127;
return;
}
const result = spawnSync(tool, args, { stdio: 'inherit', env: process.env });
process.exitCode = exitStatusFor(result, true);
});
cmd.addHelpText(
'after',
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
);
}