One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
// Seat registration: one small record per seat under <dataRoot>/seats/<layout>/<seat>/
|
|
// registration.json, written by `mosaic launch <seat>` and read by the control
|
|
// board so the board can show what a seat was told to do instead of guessing.
|
|
//
|
|
// A registration is rewritten on every launch. It is a launch record, not a
|
|
// board file: the board must not write here, and a scan never changes it.
|
|
// `mosaic seat task <seat> <text>` changes the task field only.
|
|
//
|
|
// Records are keyed by layout and seat name, <seats>/<layout>/<seat>/, because
|
|
// a name alone is not unique (this repository and the fleet both have a
|
|
// "darkwing"). Two layouts of seat directory are known:
|
|
// repo <repo>/agents/<seat>/launch.sh, where <repo>/.git exists.
|
|
// Sessions live in <repo>/.pi/state/<seat>/sessions.
|
|
// fleet <seatDir>/launch.sh with <seatDir>/.pi (the ~/.mosaic fleet layout).
|
|
// Sessions live in <seatDir>/.pi/agent/sessions.
|
|
// Anything else is layout "unknown" and records nulls; nothing is guessed.
|
|
|
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
|
|
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
export const REGISTRATION_VERSION = 1;
|
|
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
export const TASK_LIMIT = 2000;
|
|
// Who set the task (#1511): caller-supplied attribution, bounded to one
|
|
// short token so it cannot carry markup or whitespace. The bound is a syntax
|
|
// limit, not a privacy filter: a numeric ID or an email-like name still fits,
|
|
// so callers choose what they put here. It is what the caller claimed, not
|
|
// an authenticated identity, and it grants nothing: the board displays it
|
|
// and does no more with it.
|
|
export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
|
|
export const SET_BY_UNKNOWN = "unknown";
|
|
export const SET_BY_ENV = "MOSAIC_AGENT_NAME";
|
|
|
|
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
|
|
// configuration, 4 usage.
|
|
export class SeatError extends Error {
|
|
constructor(message, exitCode = 2) {
|
|
super(message);
|
|
this.name = "SeatError";
|
|
this.exitCode = exitCode;
|
|
}
|
|
}
|
|
|
|
export function defaultConfigPath(env = process.env) {
|
|
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
|
|
}
|
|
|
|
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
|
|
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
|
|
export function loadDataRoot(path = defaultConfigPath()) {
|
|
if (!existsSync(path)) throw new SeatError(`config not found: ${path}`);
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (err) {
|
|
throw new SeatError(`config is not valid JSON: ${path} (${err.message})`);
|
|
}
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
|
|
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
|
|
return raw.dataRoot;
|
|
}
|
|
|
|
export function seatsDir(dataRoot) {
|
|
return join(dataRoot, "seats");
|
|
}
|
|
|
|
export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]);
|
|
|
|
// Keyed by layout and seat, because a seat name is not unique across
|
|
// layouts (this repository and the fleet both have a "darkwing").
|
|
export function registrationPath(seats, seat, layout) {
|
|
if (!SEAT_NAME.test(String(seat))) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
|
|
if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout: ${JSON.stringify(layout)}`, 4);
|
|
return join(seats, layout, seat, "registration.json");
|
|
}
|
|
|
|
// Turn "<name>" or "<path to seat dir>" into everything a launch needs to
|
|
// know. A name resolves under <repo>/agents; a path is taken as the seat dir.
|
|
export function resolveSeat(arg, { repo = process.cwd() } = {}) {
|
|
if (typeof arg !== "string" || arg.length === 0) throw new SeatError("seat name or seat directory required", 4);
|
|
let seatDir;
|
|
if (arg.includes("/") || (existsSync(arg) && statSync(arg).isDirectory())) seatDir = resolve(arg);
|
|
else if (SEAT_NAME.test(arg)) seatDir = join(resolve(repo), "agents", arg);
|
|
else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4);
|
|
const seat = basename(seatDir);
|
|
if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
|
|
if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: ${seatDir}`);
|
|
const launchScript = join(seatDir, "launch.sh");
|
|
if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: ${seatDir}`);
|
|
if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: ${launchScript}`);
|
|
|
|
const parent = dirname(seatDir);
|
|
const root = basename(parent) === "agents" ? dirname(parent) : null;
|
|
if (root && existsSync(join(root, ".git"))) {
|
|
return {
|
|
seat, seatDir, launchScript, layout: "repo",
|
|
project: basename(root),
|
|
sessionsDir: join(root, ".pi", "state", seat, "sessions"),
|
|
defaultWorkspace: root,
|
|
};
|
|
}
|
|
if (existsSync(join(seatDir, ".pi"))) {
|
|
return { seat, seatDir, launchScript, layout: "fleet", project: null, sessionsDir: join(seatDir, ".pi", "agent", "sessions"), defaultWorkspace: null };
|
|
}
|
|
return { seat, seatDir, launchScript, layout: "unknown", project: null, sessionsDir: null, defaultWorkspace: null };
|
|
}
|
|
|
|
// The tmux session this process runs in, from the TMUX/TMUX_PANE variables
|
|
// tmux sets for its panes. null outside tmux or when tmux cannot answer.
|
|
// socket is null on the default server, else the socket file's name
|
|
// (the value tmux -L takes), matching the control board's spec shape.
|
|
export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
|
|
const tmux = env.TMUX;
|
|
if (typeof tmux !== "string" || tmux.length === 0) return null;
|
|
const socketPath = tmux.split(",")[0];
|
|
if (!socketPath) return null;
|
|
const args = ["-S", socketPath, "display-message", "-p"];
|
|
if (env.TMUX_PANE) args.push("-t", env.TMUX_PANE);
|
|
args.push("#{session_name}");
|
|
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
|
|
if (r.error || r.status !== 0) return null;
|
|
const session = String(r.stdout ?? "").trim();
|
|
if (!session) return null;
|
|
const socket = basename(socketPath);
|
|
return { socket: socket === "default" ? null : socket, session };
|
|
}
|
|
|
|
const FIELDS = Object.freeze([
|
|
"version", "seat", "project", "task", "workspace", "tmux", "harness",
|
|
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
|
|
"taskSetBy",
|
|
]);
|
|
|
|
const isNullableString = (v) => v === null || typeof v === "string";
|
|
const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v));
|
|
|
|
// Shape check for a record read from disk or about to be written. Throws
|
|
// SeatError with the failing field; never echoes the offending value.
|
|
export function validateRegistration(record) {
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object");
|
|
for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${key}`);
|
|
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
|
|
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
|
|
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
|
|
// Optional: records written before #1511 have no taskSetBy and still load
|
|
// (the board shows "unknown"); no version change, no migration on read.
|
|
if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid");
|
|
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
|
|
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
|
|
}
|
|
if (!LAYOUTS.includes(record.layout)) throw new SeatError("registration.layout is invalid");
|
|
if (record.tmux !== null) {
|
|
const t = record.tmux;
|
|
if (!t || typeof t !== "object" || Array.isArray(t)) throw new SeatError("registration.tmux must be an object or null");
|
|
if (!isNullableString(t.socket) || typeof t.session !== "string") throw new SeatError("registration.tmux is invalid");
|
|
}
|
|
if (!isTimestamp(record.startedAt)) throw new SeatError("registration.startedAt must be a timestamp");
|
|
if (record.updatedAt !== null && !isTimestamp(record.updatedAt)) throw new SeatError("registration.updatedAt must be a timestamp or null");
|
|
if (record.pid !== null && !(Number.isInteger(record.pid) && record.pid > 0)) throw new SeatError("registration.pid must be a positive integer or null");
|
|
return record;
|
|
}
|
|
|
|
export function makeRegistration({ resolved, task = "", project, workspace, harness = null, tmux = null, pid = null, now = () => new Date() }) {
|
|
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
|
|
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
|
|
return validateRegistration({
|
|
version: REGISTRATION_VERSION,
|
|
seat: resolved.seat,
|
|
project: project ?? resolved.project ?? null,
|
|
task,
|
|
workspace: workspace ?? resolved.defaultWorkspace ?? null,
|
|
tmux,
|
|
harness,
|
|
startedAt: now().toISOString(),
|
|
pid,
|
|
sessionsDir: resolved.sessionsDir,
|
|
seatDir: resolved.seatDir,
|
|
launchScript: resolved.launchScript,
|
|
layout: resolved.layout,
|
|
updatedAt: null,
|
|
});
|
|
}
|
|
|
|
// Private: directory 0700, file 0600, atomic tmp+rename so a reader never
|
|
// sees a half-written record.
|
|
export function writeRegistration(seats, record) {
|
|
validateRegistration(record);
|
|
const path = registrationPath(seats, record.seat, record.layout);
|
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
const tmp = `${path}.tmp-${process.pid}`;
|
|
writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
|
|
renameSync(tmp, path);
|
|
return path;
|
|
}
|
|
|
|
// null when the seat has no registration. A present but unreadable or
|
|
// malformed record throws SeatError rather than being treated as absent.
|
|
export function readRegistration(seats, seat, layout) {
|
|
const path = registrationPath(seats, seat, layout);
|
|
if (!existsSync(path)) return null;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (err) {
|
|
throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`);
|
|
}
|
|
try {
|
|
const record = validateRegistration(parsed);
|
|
if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path");
|
|
return record;
|
|
} catch (err) {
|
|
throw new SeatError(`${err.message}: ${path}`);
|
|
}
|
|
}
|
|
|
|
// Every registration for a seat name, across layouts. Unreadable records
|
|
// throw; a name with no record gives [].
|
|
export function findRegistrations(seats, seat) {
|
|
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
|
|
}
|
|
|
|
// Who a `seat task` is attributed to: an explicit `--by NAME`, else the
|
|
// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match
|
|
// SET_BY_NAME; so must a non-empty environment value, because a malformed
|
|
// or unexpected value there is not the same as no value and is refused
|
|
// rather than silently reported as "unknown". Neither value is echoed back.
|
|
// The result is a claim by the caller, nothing more.
|
|
export function resolveSetBy({ by = null, env = process.env } = {}) {
|
|
if (by !== null && by !== undefined) {
|
|
if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4);
|
|
return by;
|
|
}
|
|
const fromEnv = env[SET_BY_ENV];
|
|
if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN;
|
|
if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4);
|
|
return fromEnv;
|
|
}
|
|
|
|
// Change the task field, and record who set it (taskSetBy), and updatedAt.
|
|
// Every other field, startedAt included, is carried over unchanged. Refuses
|
|
// when the seat was never launched through `mosaic launch`, because there is
|
|
// nothing to attach the task to. A name that exists in more than one layout
|
|
// must be qualified with layout.
|
|
export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) {
|
|
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
|
|
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
|
|
if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4);
|
|
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
|
|
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
|
|
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
|
|
const record = found[0];
|
|
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
|
|
writeRegistration(seats, updated);
|
|
return updated;
|
|
}
|
|
|
|
// True when two paths name the same directory: equal once resolved, or the
|
|
// same real path when both exist (symlinked checkouts).
|
|
export function samePath(a, b) {
|
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
if (resolve(a) === resolve(b)) return true;
|
|
try {
|
|
return realpathSync(a) === realpathSync(b);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|