feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)
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]>
This commit is contained in:
+27
-3
@@ -3,7 +3,7 @@
|
||||
`mosaic launch <seat>` starts a seat through its existing launch script,
|
||||
unchanged, and leaves one registration record that the control board reads
|
||||
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
|
||||
that record. Nothing else. No provider or auth registry, no roster schema
|
||||
that record and notes who set it. Nothing else. No provider or auth registry, no roster schema
|
||||
change, no stopping or killing of seats, one record per seat.
|
||||
|
||||
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
|
||||
@@ -17,7 +17,7 @@ or `seat` command; run it by path.
|
||||
```
|
||||
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
```
|
||||
|
||||
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
|
||||
@@ -36,6 +36,17 @@ scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config P
|
||||
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
|
||||
launch script that sees this variable is already registered and must not
|
||||
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
|
||||
- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME`
|
||||
if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word
|
||||
`unknown`. An explicit value must be one token of 1 to 64 characters
|
||||
(letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit);
|
||||
anything else refuses with exit 4 before the record is touched. A set but
|
||||
malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as
|
||||
`unknown`; `--by` is the explicit way past it. The value is never echoed
|
||||
back in a refusal. **This is what the caller said, not a verified
|
||||
identity.** Nothing checks it, nothing grants on it; the board displays
|
||||
it next to the task and does no more with it. `launch --task` does not
|
||||
set the field.
|
||||
|
||||
The four repository seats (`agents/darkwing`, `agents/dewey`,
|
||||
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
|
||||
@@ -81,6 +92,10 @@ scan never changes it.
|
||||
}
|
||||
```
|
||||
|
||||
After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the
|
||||
same record also carries `"taskSetBy": "jason"` and a non-null
|
||||
`updatedAt`; `startedAt` and every other field are unchanged.
|
||||
|
||||
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
|
||||
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
|
||||
socket, from the `TMUX` variable of the pane the launch ran in; null outside
|
||||
@@ -100,6 +115,11 @@ Fields added, and why:
|
||||
the record is still written but the board cannot match it).
|
||||
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
|
||||
from a relaunch.
|
||||
- `taskSetBy`: set only by `seat task` (see above). Optional in the shape:
|
||||
a record written before the field existed loads unchanged, the board shows
|
||||
`unknown` for it, and nothing rewrites it until the next `seat task` or
|
||||
launch. The record version stays 1; a value that is not one bounded token
|
||||
makes the record unreadable (refused, not misread).
|
||||
- `version`: so a later shape change can be refused rather than misread.
|
||||
|
||||
`project` and `workspace` are derived only for the repo layout (the
|
||||
@@ -119,7 +139,11 @@ registration are unchanged. A malformed record is reported in
|
||||
`registrationErrors` on the index and skipped; it never takes the board down
|
||||
and it is never treated as absent silently. A record whose pid is no
|
||||
longer running is stale: still shown on the Registered line, but it does
|
||||
not override anything.
|
||||
not override anything. The row's `taskSetBy` is the record's setter only
|
||||
while the task shown is the registered one (`taskSource` `registration`);
|
||||
otherwise it is null, so a stale or empty registration never attributes a
|
||||
transcript-derived task, and a Discord connector's fixed task never borrows
|
||||
a native record's setter.
|
||||
|
||||
The repository launch scripts are the only launchers that register.
|
||||
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// 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]
|
||||
// mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
//
|
||||
// `seat task` records who set the task as taskSetBy: `--by NAME`, else
|
||||
// $MOSAIC_AGENT_NAME, else "unknown". This is what the caller claimed; it is
|
||||
// not verified and it authorizes nothing.
|
||||
//
|
||||
// `launch` writes <dataRoot>/seats/<layout>/<seat>/registration.json, then replaces
|
||||
// itself with the seat's launch.sh, unchanged, with everything after `--` as
|
||||
@@ -15,12 +19,12 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import {
|
||||
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
|
||||
makeRegistration, writeRegistration, updateTask,
|
||||
makeRegistration, writeRegistration, updateTask, resolveSetBy,
|
||||
} 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]",
|
||||
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
|
||||
].join("\n");
|
||||
|
||||
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
|
||||
@@ -49,10 +53,10 @@ function parseLaunch(argv) {
|
||||
}
|
||||
|
||||
function parseSeatTask(argv) {
|
||||
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
|
||||
const opts = { seat: null, task: null, layout: null, by: null, config: defaultConfigPath() };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--config" || a === "--layout") {
|
||||
if (a === "--config" || a === "--layout" || a === "--by") {
|
||||
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);
|
||||
@@ -90,9 +94,12 @@ function launch(argv) {
|
||||
|
||||
function seatTask(argv) {
|
||||
const opts = parseSeatTask(argv);
|
||||
// Attribution is resolved (and an invalid value refused) before any read
|
||||
// or write, so a refusal leaves the record exactly as it was.
|
||||
const setBy = resolveSetBy({ by: opts.by, env: process.env });
|
||||
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`);
|
||||
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout, setBy });
|
||||
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters, set by ${setBy})\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ 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.
|
||||
@@ -121,6 +130,7 @@ export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
|
||||
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";
|
||||
@@ -134,6 +144,9 @@ export function validateRegistration(record) {
|
||||
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`);
|
||||
}
|
||||
@@ -208,17 +221,37 @@ export function findRegistrations(seats, seat) {
|
||||
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Change the task field only. 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() } = {}) {
|
||||
// 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, updatedAt: now().toISOString() };
|
||||
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
|
||||
writeRegistration(seats, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
updateTask,
|
||||
findRegistrations,
|
||||
samePath,
|
||||
resolveSetBy,
|
||||
TASK_LIMIT,
|
||||
SET_BY_NAME,
|
||||
SET_BY_UNKNOWN,
|
||||
} from "../src/seat.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -91,6 +94,9 @@ function buildConfig(root, dataRoot = join(root, "data")) {
|
||||
function cliEnv(overrides = {}) {
|
||||
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
|
||||
delete env.MOSAIC_LAUNCH_REGISTERED;
|
||||
// The developer's shell may carry a seat name (agents/rocko/launch.sh
|
||||
// exports one); attribution tests set it explicitly or not at all.
|
||||
delete env.MOSAIC_AGENT_NAME;
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
@@ -290,7 +296,7 @@ test("writeRegistration/readRegistration: round trip, permissions, absence, and
|
||||
// 7. updateTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
|
||||
test("updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately", () => {
|
||||
const root = makeRoot();
|
||||
const seatDir = buildRepoSeat(root, "myseat");
|
||||
const resolved = resolveSeat("myseat", { repo: root });
|
||||
@@ -300,11 +306,14 @@ test("updateTask: changes task and updatedAt only, and refuses appropriately", (
|
||||
|
||||
const updated = updateTask(seats, "myseat", "revised");
|
||||
assert.equal(updated.task, "revised");
|
||||
assert.equal(updated.taskSetBy, SET_BY_UNKNOWN, "no setBy option records the literal unknown");
|
||||
assert.notEqual(updated.updatedAt, null);
|
||||
for (const key of Object.keys(record)) {
|
||||
if (key === "task" || key === "updatedAt") continue;
|
||||
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
assert.equal(updated.startedAt, record.startedAt, "startedAt is the launch time and survives a task update");
|
||||
assert.deepEqual(Object.keys(updated).sort(), [...Object.keys(record), "taskSetBy"].sort(), "exactly one field is added");
|
||||
|
||||
// Refuses when there is no registration to attach to.
|
||||
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
|
||||
@@ -560,3 +569,165 @@ test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", ()
|
||||
assert.equal(samePath(1, "x"), false);
|
||||
assert.equal(samePath(null, target), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. Task attribution (#1511): taskSetBy on the record, resolveSetBy, and the
|
||||
// --by / MOSAIC_AGENT_NAME precedence at the CLI. Attribution is a claim by
|
||||
// the caller: bounded, escaped by the presentations, never authority.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4", () => {
|
||||
assert.equal(resolveSetBy({ by: "darkwing", env: { MOSAIC_AGENT_NAME: "rocko" } }), "darkwing");
|
||||
assert.equal(resolveSetBy({ by: null, env: { MOSAIC_AGENT_NAME: "rocko" } }), "rocko");
|
||||
assert.equal(resolveSetBy({ env: {} }), SET_BY_UNKNOWN);
|
||||
assert.equal(resolveSetBy({ env: { MOSAIC_AGENT_NAME: "" } }), SET_BY_UNKNOWN);
|
||||
assert.equal(resolveSetBy({ by: "jason@host:1", env: {} }), "jason@host:1");
|
||||
assert.equal(resolveSetBy({ by: "a".repeat(64), env: {} }), "a".repeat(64), "64 characters is the bound");
|
||||
const refused = (fn, pattern) => assert.throws(fn, (err) => {
|
||||
assert.ok(err instanceof SeatError);
|
||||
assert.equal(err.exitCode, 4);
|
||||
assert.match(err.message, pattern);
|
||||
return true;
|
||||
});
|
||||
for (const bad of ["", " ", "a".repeat(65), "<b>x</b>", "two words", "-leading", ".leading", "tab\tname", "new\nline", "ünïcode", "a/b", "a;b", "$(x)"]) {
|
||||
refused(() => resolveSetBy({ by: bad, env: { MOSAIC_AGENT_NAME: "valid" } }), /--by/);
|
||||
assert.equal(SET_BY_NAME.test(bad), false);
|
||||
// The offending value is never echoed back.
|
||||
if (bad.trim().length > 1) assert.throws(() => resolveSetBy({ by: bad, env: {} }), (err) => !err.message.includes(bad));
|
||||
}
|
||||
refused(() => resolveSetBy({ by: 42, env: {} }), /--by/);
|
||||
// A set but malformed environment value is refused, not read as unknown:
|
||||
// silently reporting "unknown" would hide a misconfigured seat, and --by
|
||||
// is the explicit way past it.
|
||||
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), /MOSAIC_AGENT_NAME/);
|
||||
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "a".repeat(65) } }), /MOSAIC_AGENT_NAME/);
|
||||
assert.throws(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), (err) => !err.message.includes("<script>"));
|
||||
assert.equal(resolveSetBy({ by: "explicit", env: { MOSAIC_AGENT_NAME: "<script>" } }), "explicit", "an explicit value does not consult the environment at all");
|
||||
});
|
||||
|
||||
test("validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change", () => {
|
||||
const root = makeRoot();
|
||||
const seatDir = buildRepoSeat(root, "old");
|
||||
const resolved = resolveSeat("old", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const record = makeRegistration({ resolved, task: "launched task", pid: 1 });
|
||||
assert.equal("taskSetBy" in record, false, "launch does not attribute; only seat task does");
|
||||
assert.equal(record.version, 1);
|
||||
// An old on-disk record, byte for byte, without the field.
|
||||
const path = registrationPath(seats, "old", "repo");
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const oldBytes = JSON.stringify(record, null, 2) + "\n";
|
||||
writeFileSync(path, oldBytes);
|
||||
const loaded = readRegistration(seats, "old", "repo");
|
||||
assert.deepEqual(loaded, record);
|
||||
assert.equal(loaded.taskSetBy, undefined);
|
||||
assert.equal(readFileSync(path, "utf8"), oldBytes, "reading never rewrites (no implicit migration)");
|
||||
assert.equal(findRegistrations(seats, "old").length, 1);
|
||||
// Valid values.
|
||||
for (const ok of ["unknown", "darkwing", "jason@host:1", "a".repeat(64)]) validateRegistration({ ...record, taskSetBy: ok });
|
||||
// Invalid values, never echoed.
|
||||
for (const bad of [null, 7, "", "<b>", "two words", "a".repeat(65), {}]) {
|
||||
assert.throws(() => validateRegistration({ ...record, taskSetBy: bad }), (err) => {
|
||||
assert.ok(err instanceof SeatError);
|
||||
assert.match(err.message, /taskSetBy/);
|
||||
if (typeof bad === "string" && bad.length > 1) assert.equal(err.message.includes(bad), false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
writeFileSync(path, JSON.stringify({ ...record, taskSetBy: "<b>" }));
|
||||
assert.throws(() => readRegistration(seats, "old", "repo"), /taskSetBy/);
|
||||
});
|
||||
|
||||
test("updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const resolved = resolveSeat("myseat", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const started = new Date("2026-09-10T10:00:00Z");
|
||||
const record = makeRegistration({ resolved, task: "original", harness: "pi", tmux: { socket: "mosaic-fleet", session: "myseat" }, pid: 1, workspace: "/w", project: "proj", now: () => started });
|
||||
writeRegistration(seats, record);
|
||||
|
||||
const first = updateTask(seats, "myseat", "by darkwing", { setBy: "darkwing", now: () => new Date("2026-09-14T10:00:00Z") });
|
||||
assert.equal(first.taskSetBy, "darkwing");
|
||||
assert.equal(first.startedAt, started.toISOString());
|
||||
assert.equal(first.updatedAt, "2026-09-14T10:00:00.000Z");
|
||||
for (const key of ["seat", "project", "workspace", "tmux", "harness", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "version"]) {
|
||||
assert.deepEqual(first[key], record[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
assert.deepEqual(readRegistration(seats, "myseat", "repo"), first);
|
||||
|
||||
const second = updateTask(seats, "myseat", "by jason", { setBy: "jason", now: () => new Date("2026-09-14T11:00:00Z") });
|
||||
assert.equal(second.taskSetBy, "jason", "a later set replaces the attribution; it is not appended");
|
||||
assert.equal(second.startedAt, started.toISOString());
|
||||
|
||||
const third = updateTask(seats, "myseat", "anonymous");
|
||||
assert.equal(third.taskSetBy, SET_BY_UNKNOWN, "no setBy means unknown, never the previous name");
|
||||
|
||||
// Invalid setBy refuses with exit 4 before anything is written.
|
||||
const before = readFileSync(registrationPath(seats, "myseat", "repo"), "utf8");
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "<b>" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.equal(readFileSync(registrationPath(seats, "myseat", "repo"), "utf8"), before);
|
||||
});
|
||||
|
||||
test("CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const { configPath, dataRoot } = buildConfig(root);
|
||||
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
|
||||
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, MOSAIC_AGENT_NAME: "launcher" }) });
|
||||
assert.equal(launchResult.status, 0, launchResult.stderr);
|
||||
const launched = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal("taskSetBy" in launched, false, "launch --task does not attribute, even with the variable set");
|
||||
|
||||
const run = (args, env) => runCli(["seat", "task", "myseat", ...args, "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, ...env }) });
|
||||
const read = () => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
let r = run(["one", "--by", "darkwing"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by darkwing/);
|
||||
assert.equal(read().taskSetBy, "darkwing");
|
||||
assert.equal(read().task, "one");
|
||||
assert.equal(read().startedAt, launched.startedAt);
|
||||
|
||||
r = run(["two"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by rocko/);
|
||||
assert.equal(read().taskSetBy, "rocko");
|
||||
|
||||
r = run(["three"], {});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by unknown/);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
|
||||
r = run(["four"], { MOSAIC_AGENT_NAME: "" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
for (const key of Object.keys(launched)) {
|
||||
if (key === "task" || key === "updatedAt") continue;
|
||||
assert.deepEqual(read()[key], launched[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
|
||||
const frozen = readFileSync(path, "utf8");
|
||||
r = run(["five", "--by", "<script>alert(1)</script>"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /refused: --by/);
|
||||
assert.equal(r.stderr.includes("<script>"), false, "the refusal does not echo the value");
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid --by writes nothing");
|
||||
r = run(["five", "--by", ""], {});
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = runCli(["seat", "task", "myseat", "five", "--config", configPath, "--by"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }) });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /missing value for --by/);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = run(["five"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /MOSAIC_AGENT_NAME/);
|
||||
assert.equal(r.stderr.includes("not a name"), false);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid environment value writes nothing");
|
||||
r = run(["six", "--by", "explicit"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 0, "an explicit --by is the way past a bad environment value");
|
||||
assert.equal(read().taskSetBy, "explicit");
|
||||
assert.equal(read().task, "six");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user