Add mosaic launch <seat> with seat registration for the control board (#1504)
New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes <dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board reads registrations, matches by sessions directory, and lets a registered task, project or workspace override the derived value with a source tag. The four repository launch scripts register themselves unless already registered or run with --check. Fleet launchers untouched; one-liner on the plan page. Review found the record path keyed by seat name alone (repo and fleet "darkwing" would collide); fixed by keying on layout. Also: the Pi pin refusal now names installed and required versions. Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
// Usage:
|
||||
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
//
|
||||
// `launch` writes <dataRoot>/seats/<seat>/registration.json, then replaces
|
||||
// itself with the seat's launch.sh, unchanged, with everything after `--` as
|
||||
// its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch
|
||||
// script that sees that variable is already registered and must not call
|
||||
// mosaic launch again. The exit code is the launch script's own.
|
||||
//
|
||||
// Exit codes: the launch script's code (launch); 0 ok; 1 operation failed;
|
||||
// 2 invalid data or configuration; 4 usage.
|
||||
import { rmSync } from "node:fs";
|
||||
import {
|
||||
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
|
||||
makeRegistration, writeRegistration, updateTask,
|
||||
} from "./seat.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
||||
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
|
||||
].join("\n");
|
||||
|
||||
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
|
||||
|
||||
function parseLaunch(argv) {
|
||||
const opts = { seat: null, task: "", project: undefined, workspace: undefined, harness: null, repo: process.cwd(), config: defaultConfigPath(), args: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
|
||||
return argv[++i];
|
||||
};
|
||||
if (a === "--") { opts.args = argv.slice(i + 1); break; }
|
||||
else if (a === "--task") opts.task = next();
|
||||
else if (a === "--project") opts.project = next();
|
||||
else if (a === "--workspace") opts.workspace = next();
|
||||
else if (a === "--harness") opts.harness = next();
|
||||
else if (a === "--repo") opts.repo = next();
|
||||
else if (a === "--config") opts.config = next();
|
||||
else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
|
||||
else if (opts.seat === null) opts.seat = a;
|
||||
else throw new SeatError(`unexpected argument: ${a} (put launch script arguments after --)`, 4);
|
||||
}
|
||||
if (opts.seat === null) throw new SeatError(USAGE, 4);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function parseSeatTask(argv) {
|
||||
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--config" || a === "--layout") {
|
||||
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
|
||||
opts[a.slice(2)] = argv[++i];
|
||||
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
|
||||
else if (opts.seat === null) opts.seat = a;
|
||||
else if (opts.task === null) opts.task = a;
|
||||
else throw new SeatError(`unexpected argument: ${a}`, 4);
|
||||
}
|
||||
if (opts.seat === null || opts.task === null) throw new SeatError(USAGE, 4);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function launch(argv) {
|
||||
const opts = parseLaunch(argv);
|
||||
const resolved = resolveSeat(opts.seat, { repo: opts.repo });
|
||||
const seats = seatsDir(loadDataRoot(opts.config));
|
||||
// Register first, then replace this process with the launch script (same
|
||||
// pid, same terminal, same process group). The launch script's exit code
|
||||
// and signals are then the shell's to see directly, and the tmux pane's
|
||||
// foreground command stays the harness itself, which the control board's
|
||||
// liveness check depends on.
|
||||
const record = makeRegistration({
|
||||
resolved, task: opts.task, project: opts.project, workspace: opts.workspace,
|
||||
harness: opts.harness, tmux: tmuxContext(), pid: process.pid,
|
||||
});
|
||||
const path = writeRegistration(seats, record);
|
||||
process.stderr.write(`mosaic launch: registered ${resolved.seat} (${resolved.layout} layout) at ${path}\n`);
|
||||
try {
|
||||
process.execve(resolved.launchScript, [resolved.launchScript, ...opts.args], { ...process.env, [REGISTERED_ENV]: path });
|
||||
} catch (err) {
|
||||
rmSync(path, { force: true });
|
||||
throw new SeatError(`could not run ${resolved.launchScript}: ${err.message}`, 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function seatTask(argv) {
|
||||
const opts = parseSeatTask(argv);
|
||||
const seats = seatsDir(loadDataRoot(opts.config));
|
||||
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
|
||||
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
if (process.env[REGISTERED_ENV] && argv[0] === "launch") {
|
||||
throw new SeatError(`${REGISTERED_ENV} is already set; refusing to register a seat from inside a registered launch`, 4);
|
||||
}
|
||||
if (argv[0] === "launch") return launch(argv.slice(1));
|
||||
if (argv[0] === "seat" && argv[1] === "task") return seatTask(argv.slice(2));
|
||||
throw new SeatError(USAGE, 4);
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (!(err instanceof SeatError)) throw err;
|
||||
process.stderr.write(`refused: ${err.message}\n`);
|
||||
process.exitCode = err.exitCode;
|
||||
}
|
||||
Reference in New Issue
Block a user