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:
@@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
|||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
|
import { registerWatchCommand } from './commands/watch.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
@@ -428,6 +429,7 @@ registerSkillCommand(program);
|
|||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
registerWatchCommand(program);
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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).',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user