feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -267,23 +267,62 @@ Each is a reading of the brief or a lead decision, recorded so a reviewer
|
||||
can disagree with it.
|
||||
|
||||
- **Seal.** The argv is `--mode rpc --no-extensions --no-prompt-templates
|
||||
--no-themes --session <absolute file>`, then optional `engine.extraArgs`.
|
||||
--no-themes --no-approve --session <absolute file>`, then optional
|
||||
`engine.extraArgs`. `--no-approve` keeps a project's `.pi/settings.json`,
|
||||
`SYSTEM.md`, `APPEND_SYSTEM.md` and skills out even when the operator's
|
||||
`~/.pi/agent/trust.json` trusts the project, as it trusts this checkout
|
||||
on the build host. Without it a project `SYSTEM.md` would replace the
|
||||
system prompt and a project `settings.json` could choose the binary the
|
||||
bash tool runs (`shellPath`) (Filbert F2 on #1522; smoke.test.mjs runs
|
||||
the real Pi with a trusted project, sealed and with `--approve`).
|
||||
The seal doesn't pass `--no-context-files`, so Pi still loads `AGENTS.md`
|
||||
or `CLAUDE.md` from `~/.pi/agent`, the engine's working directory and
|
||||
its parents into the system prompt (Pi's usage.md, "Context Files").
|
||||
That is instruction text, not settings or code; whoever sets
|
||||
`engine.cwd` chooses it (Darkwing's note on #1522).
|
||||
The seal is an allow-list: extraArgs may carry only `--model`,
|
||||
`--provider` and `--thinking`, each at most once with one plain value
|
||||
(not starting with `-` or `@`). Anything else refuses `unsealed-engine`
|
||||
at construction and again at bind, before spawn: an `-e`/`--extension`
|
||||
argument, a missing `--no-*` flag, a second `--mode` or `--session` (Pi
|
||||
keeps the last of each), a session or output flag (`--no-session`,
|
||||
`--fork`, `--export`, `--print`, `--continue`, ...) or a bare word, which
|
||||
Pi reads as a prompt (lead decision 31; N24, including the missing flag
|
||||
argument, a missing `--no-*` flag, `--approve`, a second `--mode` or
|
||||
`--session` (Pi keeps the last of each), a session or output flag
|
||||
(`--no-session`, `--fork`, `--export`, `--print`, `--continue`, ...) or a
|
||||
bare word, which Pi reads as a prompt (lead decision 31; N24, including the missing flag
|
||||
through `checkSeal`).
|
||||
- **Engine command.** `engine.command` and `engine.preArgs` default to
|
||||
`node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`.
|
||||
A non-default value is a test hook for the fake engine. The pin check
|
||||
reads only the lock files under `pinRoot` and the seal checks only Pi's
|
||||
arguments, so neither says what runs under an overridden command. The
|
||||
binding's `argvDigest` records the full command line. `preArgs` carrying
|
||||
`--extension` still refuses.
|
||||
- **Engine command and environment (slice 1 S5, #1522).** The controller
|
||||
always launches `node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`
|
||||
and checks that at construction and again at bind. `engine` takes only
|
||||
`extraArgs`, `cwd` and `envKeys`; any other key (`command`, `preArgs`,
|
||||
`env`), or an `engine` that isn't an object, refuses `unsealed-engine`.
|
||||
The fake engine comes in through `opts[TEST_ENGINE]`, a symbol key that
|
||||
JSON config can't carry, so no config file reaches an unsealed command.
|
||||
`preArgs` carrying `--extension` still refuses there too. The binding's
|
||||
`argvDigest` records the full command line.
|
||||
- **Engine environment.** The engine gets `ENGINE_ENV` (`pi-pin.mjs`):
|
||||
`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_ALL`,
|
||||
`LC_CTYPE`, `TZ`, `TERM`, `TMPDIR`, `PI_CODING_AGENT_DIR`, `PI_OFFLINE`,
|
||||
`PI_SKIP_VERSION_CHECK` and `PI_TELEMETRY`, plus the names in
|
||||
`engine.envKeys`, which must look like a provider credential
|
||||
(`*_API_KEY` or `*_TOKEN`). The pattern also matches founder
|
||||
credentials such as `GITEA_TOKEN`, which the S6 runner refuses; whoever
|
||||
wires a launch must not name them (Filbert N2 on #1522, DEFERRED.md).
|
||||
A name that is unset is left out. Nothing
|
||||
else is inherited, so `NODE_OPTIONS`, `LD_PRELOAD` or `PI_PACKAGE_DIR`
|
||||
in the controller's environment never reach Pi (N24b). The tradeoff:
|
||||
`HOME` passes, so Pi reads the operator's `~/.pi/agent` unless
|
||||
`PI_CODING_AGENT_DIR` is set. That is where its credentials and model
|
||||
settings live, and dropping `HOME` would break them. `ScopeLauncher`
|
||||
adds `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` so `systemd-run
|
||||
--user` reaches the user manager; the engine inherits both, and neither
|
||||
names code to load. The user bus does let the engine ask the user manager
|
||||
to run a command outside its scope; that is the same-UID limit the
|
||||
project already accepts (Filbert N3 on #1522). The engine also sees `INVOCATION_ID` from systemd,
|
||||
and `PWD` (plus `SHLVL` under bash) from the shim's `/bin/sh`; none of
|
||||
them comes from the controller's environment (K19).
|
||||
- **A force stop that throws.** If admission or the poison throws after a
|
||||
force stop sets `escalating`, the flag is cleared before the error
|
||||
propagates, so the next force stop runs instead of refusing `fenced`
|
||||
(Darkwing F2 on #1507; races.test.mjs).
|
||||
- **Session key.** The claim's session key is the Pi header ID (D1), read
|
||||
at construction. A hard link or a copy of a session under another seat
|
||||
has a different conversation ID but the same header ID, so its
|
||||
@@ -402,6 +441,17 @@ A thin view over the client library; it renders the same `Transcript` (E7).
|
||||
`not admitted: controller` and sends nothing; the buffer is kept (S5).
|
||||
- Enter takes the composer at that key: text after it in the same input
|
||||
chunk starts the next message.
|
||||
- Input after Ctrl-T or Ctrl-O waits until that action finishes, whether it
|
||||
is in the same chunk or a later one, so it is judged as if typed one key
|
||||
at a time. Ctrl-T then `hi` and Enter in one chunk sends `hi` once the
|
||||
takeover lands; `hi`, Ctrl-T and Enter sends nothing, because the
|
||||
transfer clears the composer. The held input waits for its own Ctrl-T
|
||||
or Ctrl-O, not for an earlier action such as a slow Ctrl-G (Darkwing
|
||||
and Filbert T1 on #1522). If an action throws, the keys after it, the
|
||||
input held behind it and later input still run, in order (Filbert F2 on
|
||||
#1507, N1 on #1522). The terminal command shows the error in the status
|
||||
line (`failed: <reason>`) when it happens, instead of exiting, so a later
|
||||
status such as `prompt: admitted` is not overwritten (Filbert N4).
|
||||
- A bracketed paste is inserted literally, newlines included, and never
|
||||
submits by itself. A paste marker split across input chunks, even right
|
||||
after its ESC, is still a paste marker; a lone trailing ESC waits for the
|
||||
@@ -495,8 +545,8 @@ no prompt:
|
||||
|---|---|
|
||||
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
|
||||
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
|
||||
| `turns.test.mjs` | N1–N25: the turn tracker against the fake engine's Pi behaviors |
|
||||
| `cohort.test.mjs` | K1–K18: scopes, force stop, proofs, recovery, eligibility (needs a systemd user manager) |
|
||||
| `turns.test.mjs` | N1–N25, N24b: the turn tracker against the fake engine's Pi behaviors; the engine seal and environment |
|
||||
| `cohort.test.mjs` | K1–K19: scopes, force stop, proofs, recovery, eligibility, the scope's environment (needs a systemd user manager) |
|
||||
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
|
||||
| `smoke.test.mjs` | the pinned Pi binary, as above |
|
||||
|
||||
|
||||
@@ -95,9 +95,15 @@ export class ScopeLauncher {
|
||||
this.startTimeoutMs = startTimeoutMs;
|
||||
}
|
||||
|
||||
// systemd-run --user reaches the user manager through these two; a scope's
|
||||
// command inherits systemd-run's environment, so the engine sees them too.
|
||||
// Neither loads code.
|
||||
static MANAGER_ENV = Object.freeze(["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]);
|
||||
|
||||
async launch({ unitName, socketPath, command, args, cwd, env }) {
|
||||
const manager = Object.fromEntries(ScopeLauncher.MANAGER_ENV.filter((k) => typeof process.env[k] === "string").map((k) => [k, process.env[k]]));
|
||||
const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], {
|
||||
cwd, env, stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd, env: { ...manager, ...env }, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
|
||||
const end = Date.now() + this.startTimeoutMs;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
|
||||
import { LiveSessionGuard, realPath } from "./guard.mjs";
|
||||
import { ID, fragments, safeId } from "./parts.mjs";
|
||||
import { parseSnapshot } from "./pi.mjs";
|
||||
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal } from "./pi-pin.mjs";
|
||||
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal, engineEnv } from "./pi-pin.mjs";
|
||||
import { ACTOR, conversationId, createReader, rootsFromSpecs } from "./reader.mjs";
|
||||
import { clone, equal, hash, newId, receiptAllows, record, scopeMatch, sealProof, sha256, targetOf } from "./records.mjs";
|
||||
import { ControlRefusal, Refusal } from "./safe-fs.mjs";
|
||||
@@ -66,6 +66,12 @@ export const ALL_CAPABILITIES = Object.freeze(["observe", "send", "take-control"
|
||||
// CHAT-04 or I4 and refuse `unsupported-capability`.
|
||||
export const VERIFIED_OPERATIONS = Object.freeze(["observe", "prompt", "takeover", "acquire-recovery-control", "interrupt", "force-stop", "recover", "issue-confirmation", "answer-confirmation"]);
|
||||
|
||||
// The engine a test runs instead of Pi: `{ command, preArgs, env }`. A
|
||||
// symbol key, so no JSON configuration can carry it; the plain `engine`
|
||||
// option takes only extraArgs, cwd and envKeys (I3, both reviewers on #1507).
|
||||
export const TEST_ENGINE = Symbol("conversation.test-engine");
|
||||
const ENGINE_KEYS = new Set(["extraArgs", "cwd", "envKeys"]);
|
||||
|
||||
export const TIMEOUTS = Object.freeze({ ack: 5000, state: 5000, start: 5000, clear: 5000, abort: 10000, settle: 10000, grace: 1000, write: 5000, maxRounds: 3 });
|
||||
|
||||
const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]);
|
||||
@@ -160,17 +166,24 @@ export class Controller {
|
||||
this.project = project;
|
||||
this.workspace = workspace;
|
||||
this.conversation = conversationId(this.root, basename(this.paths.sessionFile));
|
||||
if (!engine || typeof engine !== "object" || Array.isArray(engine)) throw new ControlRefusal(UNSEALED_ENGINE, "engine is not an object");
|
||||
const extra = Object.keys(engine).find((k) => !ENGINE_KEYS.has(k));
|
||||
if (extra !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${extra.slice(0, 40)} can't be configured; the controller launches the pinned Pi with its own environment`);
|
||||
const test = opts[TEST_ENGINE] ?? null;
|
||||
this.engine = {
|
||||
command: engine.command ?? process.execPath,
|
||||
preArgs: engine.preArgs ?? [join(pinRoot, PI_BIN)],
|
||||
sealed: test === null,
|
||||
command: test ? test.command : process.execPath,
|
||||
preArgs: test ? test.preArgs : [join(pinRoot, PI_BIN)],
|
||||
extraArgs: engine.extraArgs ?? [],
|
||||
cwd: engine.cwd ?? projectRoot,
|
||||
env: engine.env ?? process.env,
|
||||
env: test ? test.env : engineEnv(engine.envKeys),
|
||||
};
|
||||
for (const k of ["preArgs", "extraArgs"]) {
|
||||
if (!Array.isArray(this.engine[k])) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${k} is not a list`);
|
||||
}
|
||||
if (typeof this.engine.command !== "string" || !this.engine.command) throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not a path");
|
||||
this.piArgs = buildPiArgs({ sessionFile: this.paths.sessionFile, extraArgs: this.engine.extraArgs });
|
||||
this.pinRoot = pinRoot;
|
||||
this.#checkSeal();
|
||||
this.launcher = launcher;
|
||||
this.verifier = verifier;
|
||||
@@ -178,7 +191,6 @@ export class Controller {
|
||||
this.barrier = barrier;
|
||||
this.now = now;
|
||||
this.T = { ...TIMEOUTS, ...timeouts };
|
||||
this.pinRoot = pinRoot;
|
||||
this.policyRevision = policyRevision;
|
||||
this.sourceRootRef = sourceRootRef;
|
||||
this.approvedMappings = approvedMappings ?? [sourceRootRef];
|
||||
@@ -232,7 +244,12 @@ export class Controller {
|
||||
if (this.barrier) await this.barrier(name, detail);
|
||||
}
|
||||
|
||||
// The seal covers the command: unless a test engine was given, the launch
|
||||
// is this Node running the pinned Pi's bin, and nothing else.
|
||||
#checkSeal() {
|
||||
if (this.engine.sealed && (this.engine.command !== process.execPath || this.engine.preArgs.length !== 1 || this.engine.preArgs[0] !== join(this.pinRoot, PI_BIN))) {
|
||||
throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not the pinned Pi");
|
||||
}
|
||||
const bad = this.engine.preArgs.find((a) => typeof a !== "string" || a === "-e" || a === "--extension" || a.startsWith("--extension="));
|
||||
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine pre-arguments carry ${bad}`);
|
||||
checkSeal(this.piArgs);
|
||||
@@ -684,9 +701,17 @@ export class Controller {
|
||||
if (!this.#checkConfirmation(r, c, op)) return refused("confirmation");
|
||||
const s = this.#startStop("force-stop", { requestId: r.id, connection: c.id, target: t });
|
||||
this.escalating = s.id;
|
||||
this.closers.add("force-stop");
|
||||
this.#admission();
|
||||
this.exec?.link?.poison("force-stop");
|
||||
try {
|
||||
this.closers.add("force-stop");
|
||||
this.#admission();
|
||||
this.exec?.link?.poison("force-stop");
|
||||
} catch (err) {
|
||||
// #handle drops `after` on a throw, so #forceStop never runs to clear
|
||||
// the flag; without this every later force stop is refused `fenced`
|
||||
// until restart (Darkwing F2 on #1507).
|
||||
if (this.escalating === s.id) this.escalating = null;
|
||||
throw err;
|
||||
}
|
||||
return { outcome: "force-stop-fenced", stop: s, after: () => this.#forceStop(s, { confirmation: cmd.confirmation }) };
|
||||
}
|
||||
if (op === "recover") return this.#recover(c, r);
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
// package's bin, and the built-in llama.cpp extension ships inside it.
|
||||
//
|
||||
// Seal: the controller builds the launch argv. It always carries
|
||||
// --no-extensions, --no-prompt-templates and --no-themes, and never an
|
||||
// --extension argument (cli/args.js; usage.md 224 and 233–236). With
|
||||
// --no-extensions Pi loads only command-line extension paths
|
||||
// (resource-loader.js 316–318), so no explicit extension loads. Under the seal
|
||||
// the Mosaic prompt in the slot is the only thing that can start a run, which
|
||||
// is the basis for attributing a run to it by order.
|
||||
// --no-extensions, --no-prompt-templates, --no-themes and --no-approve, and
|
||||
// never an --extension argument (cli/args.js; usage.md 224 and 233–236).
|
||||
// --no-approve sets the project trust override to false, so a project's
|
||||
// .pi/settings.json, SYSTEM.md, APPEND_SYSTEM.md and skills don't load even
|
||||
// when trust.json under the agent dir trusts it (main.js 574–581;
|
||||
// usage.md 126; Filbert F2 on #1522). With --no-extensions Pi loads only
|
||||
// command-line extension paths (resource-loader.js 316–318), so no explicit
|
||||
// extension loads. Under the seal the Mosaic prompt in the slot is the only
|
||||
// thing that can start a run, which is the basis for attributing a run to it
|
||||
// by order.
|
||||
//
|
||||
// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode
|
||||
// and the last --session, reads a bare word as a prompt and an `@` word as a
|
||||
@@ -28,7 +32,7 @@ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
export const PI_VERSION = "0.85.1";
|
||||
export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
|
||||
export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js");
|
||||
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]);
|
||||
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes", "--no-approve"]);
|
||||
export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]);
|
||||
|
||||
export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch";
|
||||
@@ -60,7 +64,7 @@ export function buildPiArgs({ sessionFile, extraArgs = [] }) {
|
||||
return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs];
|
||||
}
|
||||
|
||||
// Refuses any argv that is not `--mode rpc`, the three --no-* flags and
|
||||
// Refuses any argv that is not `--mode rpc`, the four --no-* flags and
|
||||
// `--session <absolute path>`, in that order, followed by ENGINE_OPTIONS
|
||||
// pairs. That covers --extension in either spelling, a second --mode or
|
||||
// --session, session and output flags (--no-session, --fork, --export, ...)
|
||||
@@ -87,6 +91,24 @@ export function checkSeal(args) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The engine's environment (I3, Darkwing F3 on #1507): built from names,
|
||||
// never inherited whole. ENGINE_ENV names what Pi needs to run; a launch may
|
||||
// add provider credentials by name (`engine.envKeys`), and nothing else, so
|
||||
// NODE_OPTIONS, LD_PRELOAD and the PI_PACKAGE_DIR family can't load code
|
||||
// around the seal. Values come from the controller's own environment; an
|
||||
// unset name is left out, not set empty.
|
||||
export const ENGINE_ENV = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "PI_CODING_AGENT_DIR", "PI_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_TELEMETRY"]);
|
||||
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]{0,62}_(API_KEY|TOKEN)$/;
|
||||
|
||||
export function engineEnv(envKeys = [], source = process.env) {
|
||||
if (!Array.isArray(envKeys)) throw new ControlRefusal(UNSEALED_ENGINE, "engine.envKeys is not a list");
|
||||
const bad = envKeys.find((k) => typeof k !== "string" || !CREDENTIAL_NAME.test(k));
|
||||
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.envKeys names ${String(bad).slice(0, 80)}, which is not a provider credential (*_API_KEY or *_TOKEN)`);
|
||||
const env = {};
|
||||
for (const k of [...ENGINE_ENV, ...envKeys]) if (typeof source[k] === "string") env[k] = source[k];
|
||||
return env;
|
||||
}
|
||||
|
||||
export function argvDigest(command, args) {
|
||||
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
//
|
||||
// Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes
|
||||
// control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the
|
||||
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits.
|
||||
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits. Input after a
|
||||
// Ctrl-T or Ctrl-O waits until that finishes, in the same chunk or a later
|
||||
// one, so it is judged as if typed one key at a time.
|
||||
//
|
||||
// Engine text is shown with control characters made visible, so transcript
|
||||
// content can't drive the operator's terminal.
|
||||
@@ -26,6 +28,10 @@ import { Transcript } from "./transcript.mjs";
|
||||
export const NOT_CONTROLLER = "not admitted: controller";
|
||||
const PASTE_START = "\x1b[200~";
|
||||
const PASTE_END = "\x1b[201~";
|
||||
// Keys whose action can change who holds control. Input after one waits until
|
||||
// it finishes, so an Enter in the same chunk is judged as the keys would be
|
||||
// one at a time (Filbert F2 on #1507).
|
||||
const HOLDS = new Set(["takeover", "reload"]);
|
||||
const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" });
|
||||
|
||||
// Control characters, line and paragraph separators, bidi controls, invisible
|
||||
@@ -65,6 +71,7 @@ export class Terminal {
|
||||
this.frame = [];
|
||||
this.sent = 0;
|
||||
this.queue = Promise.resolve();
|
||||
this.held = null;
|
||||
client.on((m) => this.#onClient(m));
|
||||
this.transcript.on(() => this.render());
|
||||
}
|
||||
@@ -91,7 +98,71 @@ export class Terminal {
|
||||
}
|
||||
|
||||
// Feeds raw terminal input. Resolves when the actions it started finish.
|
||||
// While a takeover or reload is pending, input is held, not parsed.
|
||||
key(data) {
|
||||
return this.#feed(data, null);
|
||||
}
|
||||
|
||||
// Feeds terminal input like key(), but an action's error goes to the
|
||||
// status line when it happens instead of rejecting, so an unhandled
|
||||
// rejection can't end the process (Ctrl-T before the handshake throws "not
|
||||
// connected"), and a later status, such as the held Enter's "prompt:
|
||||
// admitted", is never overwritten by an earlier error (Filbert N4 on #1522).
|
||||
input(data) {
|
||||
return this.#feed(data, (err) => {
|
||||
this.status = `failed: ${err.refusal ?? err.message}`;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Held input joins the run of the takeover or reload that holds it: its
|
||||
// promise settles when that run finishes, and an error there belongs to
|
||||
// the call that started the run, not to this one.
|
||||
#feed(data, report) {
|
||||
if (this.held !== null) {
|
||||
this.held += data;
|
||||
return this.queue.catch(() => {});
|
||||
}
|
||||
const actions = this.#parse(data);
|
||||
const holds = this.held !== null;
|
||||
// A chunk runs after the one before it whether that one finished or
|
||||
// threw; the throw belongs to the call that started it, and later input
|
||||
// still runs (Filbert N1 on #1522).
|
||||
this.queue = this.queue.catch(() => {}).then(() => this.#run(actions, holds, report));
|
||||
return report ? this.queue.catch(() => {}) : this.queue;
|
||||
}
|
||||
|
||||
// Runs one chunk's actions in order. If that chunk's parse set `held`
|
||||
// (`holds`), it then parses and runs what was held behind its takeover or
|
||||
// reload. Only that run drains it: an earlier chunk's run that finishes
|
||||
// first leaves it, so the held Enter is judged after the takeover (Darkwing
|
||||
// T1 on #1522). An action that throws doesn't stop the ones after it or
|
||||
// the held input, as if each key came on its own. With `report` an error
|
||||
// is reported when it happens; without, the first one is rethrown at the
|
||||
// end.
|
||||
async #run(actions, holds, report) {
|
||||
let failure = null;
|
||||
while (actions) {
|
||||
for (const run of actions) {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
if (report) report(err);
|
||||
else failure ??= err;
|
||||
}
|
||||
}
|
||||
if (!holds) break;
|
||||
const rest = this.held;
|
||||
this.held = null;
|
||||
actions = this.#parse(rest);
|
||||
holds = this.held !== null;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
// Applies a chunk to the composer and returns the actions it starts. After
|
||||
// a HOLDS action the rest of the chunk goes to `held`.
|
||||
#parse(data) {
|
||||
const actions = [];
|
||||
let s = this.carry + data;
|
||||
this.carry = "";
|
||||
@@ -138,13 +209,17 @@ export class Terminal {
|
||||
// chunk starts the next message instead of joining this one.
|
||||
const text = this.#take();
|
||||
if (text !== null) actions.push(() => this.#send(text));
|
||||
} else if (action) actions.push(() => this.#act(action));
|
||||
else if (s[i] >= " ") this.composer += s[i];
|
||||
} else if (action) {
|
||||
actions.push(() => this.#act(action));
|
||||
if (HOLDS.has(action)) {
|
||||
this.held = s.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
} else if (s[i] >= " ") this.composer += s[i];
|
||||
i += 1;
|
||||
}
|
||||
this.render();
|
||||
for (const run of actions) this.queue = this.queue.then(run);
|
||||
return this.queue;
|
||||
return actions;
|
||||
}
|
||||
|
||||
async #act(action) {
|
||||
@@ -272,7 +347,7 @@ async function main() {
|
||||
term.rows = stdout.rows || 24;
|
||||
term.render();
|
||||
});
|
||||
stdin.on("data", (c) => void term.key(c.toString("utf8")));
|
||||
stdin.on("data", (c) => void term.input(c.toString("utf8")));
|
||||
stdin.on("end", quit);
|
||||
term.render();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K18. The scope
|
||||
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K19. The scope
|
||||
// fixtures run the fake engine as a real process under ScopeLauncher, so the
|
||||
// shim, the `engine` cgroup and systemd's invocation ID are all real; they
|
||||
// skip when systemd user scopes are unavailable. K2 runs on the process-group
|
||||
// fallback. K6–K9 and K16–K18 use the in-process fake, whose force stop is a
|
||||
// fixture stand-in (see FakeLauncher). Controllers that must die run in
|
||||
// fixture stand-in (see FakeLauncher). K19 launches /bin/sleep through
|
||||
// ScopeLauncher with no controller. Controllers that must die run in
|
||||
// ctrl-child.mjs.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
@@ -11,11 +12,11 @@ import assert from "node:assert/strict";
|
||||
import { appendFileSync, chmodSync, copyFileSync, mkdirSync, readFileSync, rmdirSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { ClaimStore, FOREIGN_HOST } from "../src/claim.mjs";
|
||||
import { ClaimStore, FOREIGN_HOST, newClaimId, unitNameFor } from "../src/claim.mjs";
|
||||
import { ConversationClient } from "../src/client.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, ScopeLauncher, scopeAvailable, shimRequest, systemctlShow, systemdUnits } from "../src/cohort.mjs";
|
||||
import { Controller, ELIGIBILITY } from "../src/controller.mjs";
|
||||
import { ENGINE_PIN_MISMATCH } from "../src/pi-pin.mjs";
|
||||
import { Controller, ELIGIBILITY, TEST_ENGINE } from "../src/controller.mjs";
|
||||
import { ENGINE_ENV, ENGINE_PIN_MISMATCH, engineEnv } from "../src/pi-pin.mjs";
|
||||
import { FixtureVerifier, newId } from "../src/records.mjs";
|
||||
import { ControlClient, FakeLauncher } from "./fake-pi.mjs";
|
||||
import { FAST, REPO, assistantEntry, claimRecords, cleanupAll, controllerFor, fixture, killChildren, noUnits, reap, receiptState, spawnController, started, tick } from "./harness.mjs";
|
||||
@@ -95,7 +96,7 @@ async function live({ kind = "scope", barrier = null, verifier = new FixtureVeri
|
||||
const ctrl = new Controller({
|
||||
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
|
||||
launcher: kind === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
|
||||
engine: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") }, cwd: fx.proj },
|
||||
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") } },
|
||||
verifier, units: kind === "scope" ? systemdUnits : noUnits, timeouts: FAST, barrier,
|
||||
});
|
||||
await ctrl.start();
|
||||
@@ -714,3 +715,28 @@ test("K18: the leaf changes after eligibility: launch refused; the reservation s
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names", NEEDS_SCOPE, async () => {
|
||||
// A real launch passes engineEnv(), which names neither variable systemd-run
|
||||
// needs; ScopeLauncher.MANAGER_ENV adds them (slice 1 S5, #1522).
|
||||
if (!ScopeLauncher.MANAGER_ENV.some((k) => typeof process.env[k] === "string")) return;
|
||||
const fx = track(fixture());
|
||||
mkdirSync(fx.socketDir, { recursive: true });
|
||||
const unitName = unitNameFor(newClaimId());
|
||||
const env = engineEnv([]);
|
||||
for (const k of ScopeLauncher.MANAGER_ENV) assert.equal(k in env, false, k);
|
||||
const socketPath = join(fx.socketDir, "k19.sock");
|
||||
const proc = await new ScopeLauncher().launch({ unitName, socketPath, command: "/bin/sleep", args: ["60"], cwd: fx.proj, env });
|
||||
try {
|
||||
const names = readFileSync(`/proc/${proc.pid}/environ`, "utf8").split("\0").filter(Boolean).map((e) => e.slice(0, e.indexOf("=")));
|
||||
// systemd-run sets INVOCATION_ID; the shim's /bin/sh sets PWD, and SHLVL
|
||||
// and _ when it is bash. None comes from the controller's environment.
|
||||
const set = ["INVOCATION_ID", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
for (const k of names) assert.ok(ENGINE_ENV.includes(k) || ScopeLauncher.MANAGER_ENV.includes(k) || set.includes(k), k);
|
||||
for (const k of ScopeLauncher.MANAGER_ENV) if (typeof process.env[k] === "string") assert.ok(names.includes(k), k);
|
||||
} finally {
|
||||
assert.equal((await shimRequest(socketPath, "kill", { timeoutMs: 5000 }, 8000)).ok, true);
|
||||
assert.equal((await shimRequest(socketPath, "release")).ok, true);
|
||||
await proc.exited;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { join } from "node:path";
|
||||
import { Controller } from "../src/controller.mjs";
|
||||
import { Controller, TEST_ENGINE } from "../src/controller.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, ScopeLauncher, systemdUnits } from "../src/cohort.mjs";
|
||||
import { FixtureVerifier } from "../src/records.mjs";
|
||||
import { defaultHost } from "../src/claim.mjs";
|
||||
@@ -75,7 +75,7 @@ try {
|
||||
ctrl = new Controller({
|
||||
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: cfg.socketDir ?? fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
|
||||
launcher: cfg.launcher === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
|
||||
engine: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) }, cwd: fx.proj },
|
||||
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) } },
|
||||
verifier: cfg.verifier === false ? null : new FixtureVerifier({ authorities: [AUTHORITY] }),
|
||||
units: cfg.units === "absent" ? { lookup: async () => ({ state: "absent" }) } : systemdUnits,
|
||||
host, barrier, timeouts: cfg.timeouts,
|
||||
|
||||
@@ -486,8 +486,8 @@ export class FakeLauncher {
|
||||
this.launches = [];
|
||||
}
|
||||
|
||||
async launch({ unitName, command, args, cwd }) {
|
||||
this.launches.push({ unitName, command, args, cwd });
|
||||
async launch({ unitName, command, args, cwd, env }) {
|
||||
this.launches.push({ unitName, command, args, cwd, env });
|
||||
const toEngine = new PassThrough();
|
||||
const fromEngine = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
|
||||
@@ -584,6 +584,196 @@ test("terminal: a paste-start marker split right after its ESC still opens the p
|
||||
assert.equal(term.composer, "x\n");
|
||||
});
|
||||
|
||||
// An observer stub whose takeover (or reconnect) waits on a gate, then makes
|
||||
// this connection the controller and pushes the binding before it resolves,
|
||||
// as the controller does.
|
||||
function takeoverStub({ grant = true, closed = false } = {}) {
|
||||
const { stub, sent } = promptStub();
|
||||
let listener = () => {};
|
||||
let open;
|
||||
const gate = new Promise((r) => (open = r));
|
||||
const become = () => {
|
||||
stub.isController = true;
|
||||
stub.binding = { state: "active", controllerConnection: "conn-1" };
|
||||
listener({ type: "push", kind: "binding" });
|
||||
};
|
||||
Object.assign(stub, {
|
||||
closed, isController: false, binding: { state: "active", controllerConnection: "conn-2" },
|
||||
on: (fn) => (listener = fn),
|
||||
takeover: async () => {
|
||||
await gate;
|
||||
if (!grant) return { outcome: "refused:controller", refusal: "controller" };
|
||||
become();
|
||||
return { outcome: "transferred", refusal: null };
|
||||
},
|
||||
connect: async () => (await gate, (stub.closed = false), become()),
|
||||
});
|
||||
return { stub, sent, open };
|
||||
}
|
||||
|
||||
test("terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507)", async () => {
|
||||
{
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], "text typed after the takeover is sent");
|
||||
assert.equal(term.status, "prompt: admitted");
|
||||
}
|
||||
{
|
||||
// §4: text typed before the takeover is cleared by the transfer, so the
|
||||
// Enter after it sends nothing.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("hi\x14\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(term.composer, "");
|
||||
}
|
||||
{
|
||||
// A refused takeover leaves an observer: the Enter is refused and the
|
||||
// text stays for the operator.
|
||||
const { stub, sent, open } = takeoverStub({ grant: false });
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(term.composer, "hi");
|
||||
assert.equal(term.status, NOT_CONTROLLER);
|
||||
}
|
||||
{
|
||||
// Input in later chunks waits behind the pending takeover and keeps its
|
||||
// order, including a paste split across the hold.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const first = term.key("\x14a");
|
||||
const second = term.key(`b${PASTE_START}c\rd`);
|
||||
const third = term.key(`${PASTE_END}e\rf`);
|
||||
assert.equal(term.composer, "", "nothing is parsed while the takeover is pending");
|
||||
open();
|
||||
await Promise.all([first, second, third]);
|
||||
assert.deepEqual(sent, ["abc\rde"]);
|
||||
assert.equal(term.composer, "f");
|
||||
}
|
||||
{
|
||||
// Ctrl-O reconnecting a closed client holds the same way.
|
||||
const { stub, sent, open } = takeoverStub({ closed: true });
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x0fhi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522)", async () => {
|
||||
// An observer presses Ctrl-G (a slow interrupt), then Ctrl-T and "hi"
|
||||
// Enter. The interrupt finishing first must not release the text held
|
||||
// behind the takeover.
|
||||
for (const chunks of [["\x07", "\x14", "hi\r"], ["\x07", "\x14hi\r"], ["\x07\x14hi\r"]]) {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const log = [];
|
||||
let openInterrupt;
|
||||
const interruptGate = new Promise((r) => (openInterrupt = r));
|
||||
stub.interrupt = async () => {
|
||||
log.push("interrupt start");
|
||||
await interruptGate;
|
||||
log.push("interrupt end");
|
||||
return { outcome: "refused:controller", refusal: "controller" };
|
||||
};
|
||||
const takeover = stub.takeover;
|
||||
stub.takeover = async () => (log.push("takeover start"), await takeover(), log.push("takeover end"), { outcome: "transferred", refusal: null });
|
||||
const prompt = stub.prompt;
|
||||
stub.prompt = async (t) => (log.push(`prompt ${t}`), prompt(t));
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = Promise.all(chunks.map((c) => term.key(c)));
|
||||
openInterrupt();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(sent, [], `${JSON.stringify(chunks)}: nothing is sent before the takeover finishes`);
|
||||
assert.notEqual(term.held, null, `${JSON.stringify(chunks)}: the text is still held`);
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
|
||||
assert.equal(term.composer, "");
|
||||
assert.equal(term.status, "prompt: admitted");
|
||||
assert.deepEqual(log, ["interrupt start", "interrupt end", "takeover start", "takeover end", "prompt hi"], JSON.stringify(chunks));
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal: an action that throws still releases the input held behind it, in order, then rethrows", async () => {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.takeover = async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14x\r");
|
||||
open();
|
||||
await assert.rejects(done, /boom/);
|
||||
assert.equal(term.held, null);
|
||||
assert.equal(term.composer, "x", "the held text was parsed; the Enter was refused as an observer");
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522)", async () => {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const takeover = stub.takeover;
|
||||
let calls = 0;
|
||||
stub.takeover = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("not connected");
|
||||
return takeover();
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
await assert.rejects(term.key("\x14"), /not connected/);
|
||||
// The queue is rejected now; the next chunk still runs its actions.
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
assert.equal(term.composer, "");
|
||||
// input() never rejects: the error lands in the status line.
|
||||
stub.takeover = async () => {
|
||||
throw new Error("not connected");
|
||||
};
|
||||
await term.input("\x14");
|
||||
assert.equal(term.status, "failed: not connected");
|
||||
});
|
||||
|
||||
test("terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522)", async () => {
|
||||
// Ctrl-G throws. The takeover after it still runs, as it would if typed on
|
||||
// its own, and the held Enter is admitted: the status ends on the
|
||||
// admission, not the old error.
|
||||
for (const chunks of [["\x07\x14hi\r"], ["\x07\x14", "hi\r"]]) {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.interrupt = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = Promise.all(chunks.map((c) => term.input(c)));
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
|
||||
assert.equal(term.status, "prompt: admitted", JSON.stringify(chunks));
|
||||
}
|
||||
// Through key() the error belongs to the call that started the run; the
|
||||
// held chunk's call resolves when that run finishes.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.interrupt = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const holder = term.key("\x07\x14");
|
||||
const held = term.key("hi\r");
|
||||
open();
|
||||
await assert.rejects(holder, /boom/);
|
||||
await held;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
});
|
||||
|
||||
test("terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line", () => {
|
||||
assert.equal(visible("a\u061cb\u200bc\u2060d\ufeffe\u{e0041}f"), "a<U+061C>b<U+200B>c<U+2060>d<U+FEFF>e<U+E0041>f");
|
||||
assert.equal(visible("\u{1F469}\u200d\u{1F4BB}"), "\u{1F469}\u200d\u{1F4BB}", "ZWJ sequences pass");
|
||||
|
||||
@@ -563,6 +563,30 @@ test("H10: a second force stop while the first escalation runs refuses fenced; o
|
||||
}
|
||||
});
|
||||
|
||||
test("a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507)", async () => {
|
||||
const h = await started();
|
||||
try {
|
||||
const link = h.ctrl.exec.link;
|
||||
const poison = link.poison;
|
||||
link.poison = () => {
|
||||
throw new Error("poison failed");
|
||||
};
|
||||
const failed = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
|
||||
assert.equal(failed.outcome, "error", JSON.stringify(failed));
|
||||
assert.equal(h.ctrl.escalating, null, "the flag is cleared on the throw");
|
||||
assert.equal(h.launcher.stops ?? 0, 0, "no escalation ran");
|
||||
link.poison = poison;
|
||||
await synced(h, h.client);
|
||||
const next = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
|
||||
assert.equal(next.outcome, "force-stop-fenced", JSON.stringify(next));
|
||||
await waitState(h, "stopped");
|
||||
assert.equal(h.launcher.stops, 1);
|
||||
assert.equal(h.ctrl.escalating, null);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("H17: a confirmation reused, answered from another connection, or used after the stop changed is refused", async () => {
|
||||
// Reused while its force stop is still running.
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -26,11 +26,11 @@ after(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
const COMMANDS = ["get_state", "get_commands", "clear_queue", "abort", "get_tree"];
|
||||
const exchanges = {};
|
||||
|
||||
function session(name, entries) {
|
||||
const dir = join(scratch, "proj", ".pi", "state", "smoke", "sessions");
|
||||
function session(name, entries, project = "proj") {
|
||||
const dir = join(scratch, project, ".pi", "state", "smoke", "sessions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, name);
|
||||
writeFileSync(file, [header(join(scratch, "proj")), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
|
||||
writeFileSync(file, [header(join(scratch, project)), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -82,13 +82,17 @@ async function converse(input, output, { onExit = null } = {}) {
|
||||
return { lines, responses };
|
||||
}
|
||||
|
||||
async function realPi(tag, sessionFile) {
|
||||
// `trusted` writes the agent dir's trust.json first, marking that project
|
||||
// trusted the way an operator's `~/.pi/agent/trust.json` can; `after` is
|
||||
// argv appended past the seal, which checkSeal would refuse.
|
||||
async function realPi(tag, sessionFile, { project = "proj", trusted = null, after = [] } = {}) {
|
||||
const { home, agent, env } = scratchEnv(tag);
|
||||
assert.equal(existsSync(join(home, ".pi", "agent", "auth.json")), false);
|
||||
assert.deepEqual(readdirSync(agent), [], "the agent dir starts empty");
|
||||
if (trusted) writeFileSync(join(agent, "trust.json"), JSON.stringify({ [realpathSync(join(scratch, trusted))]: true }));
|
||||
const args = buildPiArgs({ sessionFile });
|
||||
checkSeal(args);
|
||||
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args], { cwd: join(scratch, "proj"), env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args, ...after], { cwd: join(scratch, project), env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
pi.stderr.on("data", (c) => (stderr += c));
|
||||
const exited = new Promise((r) => pi.on("exit", (code, signal) => r({ code, signal })));
|
||||
@@ -161,6 +165,24 @@ test("pinned Pi, sealed and without credentials, answers the controller's comman
|
||||
}
|
||||
});
|
||||
|
||||
test("sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522)", async () => {
|
||||
const skill = join(scratch, "trusted", ".pi", "skills", "probe");
|
||||
mkdirSync(skill, { recursive: true });
|
||||
writeFileSync(join(skill, "SKILL.md"), "---\nname: probe\ndescription: A project skill that only a trusted load offers.\n---\nprobe\n");
|
||||
const entries = [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
|
||||
const offered = (side) => (side.responses.get_commands.data?.commands ?? []).map((c) => c.name);
|
||||
const sealed = await realPi("trust-sealed", session("trust-sealed.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted" });
|
||||
assert.deepEqual(offered(sealed), ["llama"], "the project skill is not loaded under the seal");
|
||||
// The control: the same trusted project, with --approve after the seal (Pi
|
||||
// keeps the last of --approve and --no-approve), loads the skill, so the
|
||||
// assertion above can see a load.
|
||||
const approved = await realPi("trust-approved", session("trust-approved.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted", after: ["--approve"] });
|
||||
assert.ok(offered(approved).includes("skill:probe"), `with --approve: ${offered(approved)}`);
|
||||
for (const flag of ["--approve", "-a"]) {
|
||||
assert.throws(() => checkSeal([...buildPiArgs({ sessionFile: "/s.jsonl" }), flag]), /not one of/, flag);
|
||||
}
|
||||
});
|
||||
|
||||
test("pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed)", async () => {
|
||||
const entries = [userEntry("a1b2c3d4", null, "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
|
||||
const realFile = session("bare-real.jsonl", entries);
|
||||
|
||||
@@ -6,10 +6,10 @@ import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { join } from "node:path";
|
||||
import { Controller, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
|
||||
import { Controller, TEST_ENGINE, REPO_ROOT, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
|
||||
import { AUTHORITY } from "../src/cohort.mjs";
|
||||
import { FixtureVerifier, sha256 } from "../src/records.mjs";
|
||||
import { SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
|
||||
import { ENGINE_ENV, PI_BIN, SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
|
||||
import { LineSplitter, encodeLine, parseLine } from "../src/framing.mjs";
|
||||
import { BUSY_ERROR, FakeLauncher, FakePi } from "./fake-pi.mjs";
|
||||
import { fixture, started, receiptState, outcomeUnknownPush, sessionText, cleanupAll, tick, noUnits, FAST } from "./harness.mjs";
|
||||
@@ -970,7 +970,7 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
|
||||
assert.throws(() => checkSeal(args), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(args));
|
||||
}
|
||||
assert.equal(checkSeal(["--mode", "rpc", ...SEAL_FLAGS, "--session", fx.sessionFile, "--model", "m", "--provider", "p", "--thinking", "off"]), true);
|
||||
// The argv a real bind launches carries all three and no --extension.
|
||||
// The argv a real bind launches carries every seal flag and no --extension.
|
||||
const h = await started({ fx: fixture() });
|
||||
try {
|
||||
const args = h.launcher.launches[0].args;
|
||||
@@ -981,3 +981,63 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind", async () => {
|
||||
const fx = fixture();
|
||||
const make = (extra) => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher: new FakeLauncher(), units: noUnits, timeouts: FAST, ...extra });
|
||||
// The plain engine option takes extraArgs, cwd and envKeys; the command,
|
||||
// pre-arguments and environment are the controller's (I3, #1507).
|
||||
for (const engine of [{ command: "/bin/sh" }, { preArgs: [join(REPO_ROOT, PI_BIN)] }, { command: process.execPath, preArgs: [join(REPO_ROOT, PI_BIN), "--extension", "x"] }, { env: {} }, { env: process.env }, null, [], "pi"]) {
|
||||
assert.throws(() => make({ engine }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(engine));
|
||||
}
|
||||
// A configuration is JSON, which can't carry the symbol-keyed test engine.
|
||||
const parsed = JSON.parse(JSON.stringify({ engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: "/bin/sh", preArgs: [], env: {} } }));
|
||||
assert.equal(Object.getOwnPropertySymbols(parsed).length, 0);
|
||||
// envKeys may name provider credentials only.
|
||||
for (const envKeys of [["NODE_OPTIONS"], ["LD_PRELOAD"], ["PI_PACKAGE_DIR"], ["BASH_ENV"], ["ZAI_API_KEY", "PATH"], ["zai_api_key"], ["_API_KEY"], [3], "ZAI_API_KEY"]) {
|
||||
assert.throws(() => make({ engine: { envKeys } }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(envKeys));
|
||||
}
|
||||
// The test engine is still sealed on its arguments.
|
||||
assert.throws(() => make({ [TEST_ENGINE]: { command: process.execPath, preArgs: ["fake.mjs", "-e", "x"], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.throws(() => make({ [TEST_ENGINE]: { command: "", preArgs: [], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
|
||||
|
||||
// The launch: this Node, the pinned bin, and an environment of named keys
|
||||
// only, whatever the controller's own environment holds.
|
||||
const planted = { NODE_OPTIONS: "--require /tmp/x.cjs", LD_PRELOAD: "/tmp/x.so", PI_PACKAGE_DIR: "/tmp/pkg", ZAI_API_KEY: "zai-test-value", OTHER_API_KEY: "other-test-value" };
|
||||
const saved = Object.fromEntries(Object.keys(planted).map((k) => [k, process.env[k]]));
|
||||
Object.assign(process.env, planted);
|
||||
let h;
|
||||
try {
|
||||
h = await started({ fx: fixture(), engine: { envKeys: ["ZAI_API_KEY"] } });
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
||||
}
|
||||
try {
|
||||
const l = h.launcher.launches[0];
|
||||
assert.equal(l.command, process.execPath);
|
||||
assert.equal(l.args[0], join(REPO_ROOT, PI_BIN));
|
||||
assert.equal(l.env.ZAI_API_KEY, "zai-test-value");
|
||||
for (const k of Object.keys(l.env)) assert.ok(ENGINE_ENV.includes(k) || k === "ZAI_API_KEY", k);
|
||||
for (const k of ["NODE_OPTIONS", "LD_PRELOAD", "PI_PACKAGE_DIR", "OTHER_API_KEY"]) assert.equal(k in l.env, false, k);
|
||||
if (process.env.PATH) assert.equal(l.env.PATH, process.env.PATH);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
|
||||
// A command changed after construction is refused at bind; nothing launches.
|
||||
const fx2 = fixture();
|
||||
const launcher = new FakeLauncher();
|
||||
const ctrl = new Controller({ fixtureRoot: fx2.base, claimRoot: fx2.claimRoot, socketDir: fx2.socketDir, sessionFile: fx2.sessionFile, seat: fx2.seat, launcher, units: noUnits, timeouts: FAST });
|
||||
// Closed in finally: a start that wrongly succeeds holds the socket open.
|
||||
try {
|
||||
ctrl.engine.command = "/bin/sh";
|
||||
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.equal(launcher.launches.length, 0);
|
||||
ctrl.engine.command = process.execPath;
|
||||
ctrl.engine.preArgs = [join(fx2.base, "cli.js")];
|
||||
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.equal(launcher.launches.length, 0);
|
||||
} finally {
|
||||
await ctrl.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ node packages/webui/src/cli.mjs serve
|
||||
|
||||
Open http://127.0.0.1:7330/. Optional `--port N` and
|
||||
`--board http://127.0.0.1:7331` select another port or loopback board origin.
|
||||
`--business ID` picks the business the bus views read; without it they read
|
||||
the running bus host's business (`mosaic bus start <business>`).
|
||||
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
|
||||
installation, account, authentication or deployment is added.
|
||||
|
||||
@@ -58,6 +60,40 @@ A pending send stays disabled across refresh; new text typed during a send is
|
||||
not cleared by the earlier send's success. If the proxy loses the response,
|
||||
delivery may be unknown: inspect the seat before sending again.
|
||||
|
||||
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
|
||||
|
||||
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
|
||||
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
|
||||
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>` and
|
||||
`#/trail/decision/<id>`, with `?kind=` filters on a trail. The design
|
||||
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
|
||||
- Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`,
|
||||
lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes. A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
business, as the Console does by default. From a Console started with
|
||||
`--business` for another business the copied command refuses (no bus host,
|
||||
or no such decision) rather than answering elsewhere.
|
||||
- What no Q1 verb returns is labelled where it would show, for example
|
||||
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
since the list has no trail. The task page reads the trail and tells a task
|
||||
the stack never created from one changed in Vikunja after the stack wrote it.
|
||||
A stale poll read is shown in the snapshots and never as current.
|
||||
- All bus- and agent-authored text is rendered inert: controls show as control
|
||||
pictures or `[U+XXXX]`, and Markdown stays as source.
|
||||
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
|
||||
open sections and the copy status survive a refresh.
|
||||
- A failed read keeps the last good answer for that read and says it is old,
|
||||
with the time it was read. A page never read shows the failure instead,
|
||||
titled "Bus refused the read" (403: the bus refused the Console's read, for
|
||||
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
|
||||
config, or no bus host and no `--business`) or "The read failed".
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
@@ -68,6 +104,24 @@ configured board URL for the page's error message. No scanner, registration,
|
||||
session reader or transport is implemented here. The session reader is
|
||||
`packages/conversation`, served by the board.
|
||||
|
||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and
|
||||
`/api/bus/trail?subject=<id>` are the bus reads (`src/bus.mjs`). Any other
|
||||
method is 405. The subject must match the broker's identifier rule, else 400.
|
||||
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
|
||||
a child process, so a slow read doesn't stall the server. The bus checks
|
||||
that child's ancestry, which ends at the Console process; it never sees the
|
||||
browser or any other client of the port. So while the Console runs,
|
||||
anything that can connect to its port (7330 by default) reads what a reader
|
||||
capability reads ("Human and reader paths" in `packages/bus/README.md`):
|
||||
the inbox, tasks, agents and trails. That includes a T3 seat using `curl`
|
||||
and a managed S6 session, since S6 doesn't confine the network. No route
|
||||
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
|
||||
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }`
|
||||
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
|
||||
that can't be used. The bus needs the system config
|
||||
(`~/.config/mosaic-dev/config.json`); without it the board still serves and
|
||||
the bus routes answer `not-configured`.
|
||||
|
||||
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
|
||||
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
|
||||
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
|
||||
@@ -83,6 +137,7 @@ node --test packages/webui/tests/
|
||||
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
|
||||
```
|
||||
|
||||
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
|
||||
@@ -106,6 +161,13 @@ the return flow: a send from the view, a tool call and a delayed result while a
|
||||
draft is typed, a peer message, a 4.5-million-character answer split into
|
||||
continuation parts, and a relaunch mid-turn.
|
||||
|
||||
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
|
||||
views against an in-process broker read through its reader session, so every
|
||||
row has the broker's own shape. `humanCall` runs against a stub CLI script.
|
||||
They cover each view, the gap labels, inert hostile text, Copy, the stale
|
||||
fallback and each failure title, and that no route writes. They never start
|
||||
the human CLI against a live bus.
|
||||
|
||||
No root CI workflow is configured for this package. These local tests are not a
|
||||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
|
||||
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
|
||||
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
||||
// Only the four verbs of the Q1 module (lead decision 56) are reachable:
|
||||
// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a
|
||||
// server started inside an agent run gets `human-required` from the broker.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
|
||||
export class BusReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
}
|
||||
|
||||
// The broker's identifier rule (packages/bus/src/broker.mjs `id`), checked
|
||||
// here so a bad subject is a 400 and never reaches the bus.
|
||||
export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length <= 160 && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(s);
|
||||
|
||||
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busReader({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
const call = async (verb, args) => {
|
||||
let id = business;
|
||||
if (!id) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
id = state.business;
|
||||
}
|
||||
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
|
||||
};
|
||||
return views({ call });
|
||||
}
|
||||
|
||||
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
|
||||
// bus to ask, or no answer; 502: an answer that can't be used.
|
||||
const STATUS = {
|
||||
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
|
||||
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
|
||||
'invalid-request': 400,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { startServer, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busReader } from './bus.mjs';
|
||||
import { loadSystem, socketPath } from '../../cli/src/config.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
@@ -13,10 +16,22 @@ try {
|
||||
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
|
||||
options.port = Number(value);
|
||||
} else if (flag === '--board') options.board = value;
|
||||
else if (flag === '--business') {
|
||||
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
|
||||
options.business = value;
|
||||
}
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const server = await startServer(options);
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\nCtrl-C stops this server.`);
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
let bus = null, busLine;
|
||||
try {
|
||||
const { dataRoot } = loadSystem();
|
||||
bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) { busLine = `Bus: not configured (${err.message})`; }
|
||||
const server = await startServer({ board: options.board, port: options.port, bus });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Slice 1 S5 (#1522): inbox, tasks, agents and trail (bus.js), on top of the Console and
|
||||
live styles. Nothing here changes those files. On a bus route (body.on-bus) the board's
|
||||
own parts are hidden and the inspector column is dropped; the board itself is untouched. */
|
||||
#board-tree{display:contents}
|
||||
body.on-bus #board-tree,body.on-bus #board-head,body.on-bus #error,body.on-bus #conversation,body.on-bus #board-view,body.on-bus #inspector{display:none}
|
||||
body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px minmax(0,1fr)}
|
||||
@media (min-width:1900px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:280px minmax(0,1fr)}}
|
||||
@media (min-width:2560px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:320px minmax(0,1fr)}}
|
||||
@media (max-width:959px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:minmax(0,1fr)}}
|
||||
.s1-sections .count{margin-left:auto}.s1-auth{overflow-wrap:anywhere}
|
||||
.s1-sections{display:grid;gap:4px;list-style:none;margin:0;padding:0}.s1-section{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:7px 8px;border-radius:6px;color:var(--text);text-decoration:none;font-weight:600;border:1px solid transparent;min-height:38px}
|
||||
.s1-section:hover{background:var(--raised)}.s1-section[aria-current=page]{background:var(--raised);border-color:var(--border);color:var(--action)}
|
||||
/* The h1 takes focus after navigation so screen readers land on the new page; it is not a control, so no ring. */
|
||||
.content h1{overflow-wrap:anywhere}.content h1:focus{outline:none}
|
||||
.page-head>div{min-width:0}#bus-status{overflow-wrap:anywhere}
|
||||
/* Inbox */
|
||||
.s1-decs{grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
|
||||
.s1-dec:not(.is-attn){border-left-color:var(--border);background:var(--surface)}
|
||||
.s1-dec-open{font-weight:600;color:var(--action);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.s1-badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 4px}.s1-dec .small{margin:4px 0 0;overflow-wrap:anywhere}
|
||||
.s1-q{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 12px;max-width:80ch}
|
||||
.s1-opts{display:grid;gap:10px;padding-left:0;list-style:none;margin:0 0 8px}.s1-opt{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--surface)}
|
||||
.s1-opt p{margin:0;overflow-wrap:anywhere}.s1-key{font-family:var(--mono);margin-right:6px}
|
||||
.s1-cmd{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}.s1-cmd code{flex:1 1 260px;min-width:0;overflow-wrap:anywhere;font-size:.82rem;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);user-select:all}
|
||||
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
|
||||
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
|
||||
/* Tables */
|
||||
table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
|
||||
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
|
||||
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
|
||||
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.s1-stale{margin-bottom:16px}.s1-stale p{margin:0}
|
||||
/* Trail */
|
||||
.s1-trail{list-style:none;margin:0 0 12px;padding:0;border-left:2px solid var(--line)}
|
||||
.s1-trow{display:grid;grid-template-columns:4.2em minmax(9em,13em) minmax(0,1fr);gap:4px 12px;padding:8px 0 8px 12px;border-bottom:1px solid var(--line);position:relative}
|
||||
.s1-trow::before{content:'';position:absolute;left:-6px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--muted)}
|
||||
.s1-g-request::before{background:var(--action)}.s1-g-decision::before{background:var(--warning)}.s1-g-launch::before{background:var(--accent)}.s1-g-review::before{background:var(--success)}
|
||||
.s1-twhen{color:var(--muted);font-variant-numeric:tabular-nums}.s1-tkind{font-family:var(--mono);font-size:.8rem;overflow-wrap:anywhere}.s1-ttext{min-width:0}.s1-ttext p{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
|
||||
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
|
||||
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
|
||||
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
|
||||
@@ -0,0 +1,456 @@
|
||||
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
|
||||
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
|
||||
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
|
||||
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
|
||||
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// The CHAT-02 rules: C0 controls show as control pictures, C1 and bidi controls as
|
||||
// [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
|
||||
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f--]/g;
|
||||
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
|
||||
const txt = v => esc(inert(v)); // every agent-, tracker- or bus-authored string goes through this
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
const enc = encodeURIComponent;
|
||||
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/; // the broker's identifier rule
|
||||
const TASK = /^vikunja:(\d+)\/(\d+)$/;
|
||||
const CLOSE = ['resolved', 'withdrawn', 'expired'];
|
||||
|
||||
// ---------------------------------------------------------------- reads
|
||||
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
|
||||
// can show what was read before, labelled as such.
|
||||
const last = new Map();
|
||||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||||
async function fetchRead(verb, subject) {
|
||||
let res, body;
|
||||
try {
|
||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
|
||||
body = await res.json();
|
||||
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { rows: body.rows, at: body.at };
|
||||
}
|
||||
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
||||
function reader(live) {
|
||||
const used = [];
|
||||
const get = async (verb, subject) => {
|
||||
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
||||
let r;
|
||||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||
used.push(r.at);
|
||||
return r.rows;
|
||||
};
|
||||
get.oldest = () => used.filter(Boolean).sort()[0] || null;
|
||||
return get;
|
||||
}
|
||||
const of = (rows, table) => rows.filter(r => r.table === table);
|
||||
|
||||
// One decision from its trail. An open human decision's inbox row carries the
|
||||
// authorization context; otherwise it comes from the trail's decision.raise event,
|
||||
// the rule the broker's own decision view uses.
|
||||
async function decision(get, id, row = null) {
|
||||
const tr = await get('trail', id), d = of(tr, 'decisions').find(x => x.id === id);
|
||||
if (!d) return null;
|
||||
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
|
||||
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body?.operation === 'decision.raise' && e.body?.decision === id);
|
||||
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
|
||||
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
|
||||
return {
|
||||
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
|
||||
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
|
||||
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
|
||||
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
|
||||
seen: ev.filter(x => x.op === 'seen'), closed, dm: dm ? { at: dm.at } : null, supersedes: d.supersedes, trail: tr,
|
||||
};
|
||||
}
|
||||
// Earlier versions through `supersedes`, newest first, one trail read each, at most ten.
|
||||
async function chain(get, d) {
|
||||
const out = [];
|
||||
for (let x = d, n = 0; x?.supersedes && n < 10; n++) { x = await decision(get, x.supersedes); if (x) out.push(x); }
|
||||
return out;
|
||||
}
|
||||
// A task from its task_current row; the trail adds what only events carry.
|
||||
function task(row, tr = null) {
|
||||
const ref = row.task_ref, f = row.fields || {}, [, project, id] = ref.match(TASK) || [, null, null];
|
||||
const evs = tr ? of(tr, 'events').filter(e => e.subject === ref) : [], snaps = tr ? of(tr, 'task_snapshots') : [];
|
||||
const shape = f.gone ? snaps.filter(s => !s.fields?.gone).at(-1)?.fields || {} : f;
|
||||
const created = evs.find(e => e.kind === 'task.created');
|
||||
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
|
||||
// task_current already skips stale reads, so a current row from a poll is a change the
|
||||
// stack didn't make: to a task the stack wrote ('changed') or to one it never wrote ('foreign').
|
||||
const external = !f.gone && row.source === 'poll' ? { kind: tr ? (snaps.some(s => s.source === 'self') ? 'changed' : 'foreign') : null, via: row.via, readAt: row.read_at, changed: Array.isArray(ext?.body?.changed) ? ext.body.changed : [], comments: ext?.body?.comments || 0 } : null;
|
||||
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
|
||||
return {
|
||||
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
|
||||
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
|
||||
requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
|
||||
due: shape.due_date ?? null, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
|
||||
changedAt: row.at, seq: row.seq, external,
|
||||
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
|
||||
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
|
||||
decisions: tr ? of(tr, 'decisions').filter(d => d.task_ref === ref) : [],
|
||||
// Rows after the current one are reads task_current skipped (lead decision 59).
|
||||
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, fields: s.fields || {} })),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- trail lines
|
||||
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
|
||||
const bucketText = f => f?.gone ? `gone (${f.gone})` : `bucket ${f?.bucket}`;
|
||||
const short = id => String(id ?? '').slice(0, 8);
|
||||
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
||||
function line(r, ctx) {
|
||||
const out = { at: r.at, actor: null, by: 'broker', decision: null, note: null };
|
||||
if (r.table === 'events') {
|
||||
const b = r.body && typeof r.body === 'object' ? r.body : {};
|
||||
out.kind = r.kind; out.decision = typeof b.decision === 'string' ? b.decision : null;
|
||||
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
|
||||
const map = {
|
||||
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
|
||||
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run}${b.reason ? ` (${b.reason}${b.exit !== undefined ? `, exit ${b.exit}` : ''})` : ''}`,
|
||||
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `The human chose “${b.choice}” from the CLI` : `The human ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
|
||||
: b.kind === 'instruction' ? 'Request from the human, recorded from the CLI' : b.kind === 'admin' ? 'Admin command from the human, CLI' : 'Input from the human',
|
||||
'task.created': () => `Created for ${b.requirement}`,
|
||||
'task.assigned': () => `Assigned to ${b.role}`,
|
||||
'task.state': () => `Moved ${b.previous ? `from ${b.previous} ` : ''}to ${b.state}${b.percent_done !== undefined ? `, ${b.percent_done}% done` : ''}${b.comment ? ', with a comment in Vikunja' : ''}`,
|
||||
'task.closed': () => `Closed with verdict ${b.verdict}`,
|
||||
'task.changed.external': () => {
|
||||
const changed = Array.isArray(b.changed) ? b.changed : [], comments = b.comments ? plural(b.comments, 'new comment') : '';
|
||||
if (b.previous === null) return 'Read from Vikunja for the first time: the stack did not create it';
|
||||
return changed.length ? `Changed in Vikunja: ${changed.join(', ')}${comments ? `, and ${comments}` : ''}` : comments ? `${comments[0].toUpperCase()}${comments.slice(1)} in Vikunja` : 'Read from Vikunja';
|
||||
},
|
||||
'task.conflict': () => `Write refused (${b.verb}): Vikunja changed after the stack read it (expected ${short(b.expected)}, found ${short(b.actual)})`,
|
||||
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
|
||||
'review.requested': () => 'Review requested',
|
||||
'review.verdict': () => `Review verdict: ${b.verdict ?? 'not recorded'}`,
|
||||
'action.refused': () => `Refused: ${b.code}`,
|
||||
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}`,
|
||||
'launch.revoked': () => 'Launching revoked from the CLI',
|
||||
'launch.restored': () => 'Launching restored from the CLI',
|
||||
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires ?? 'soon'}`,
|
||||
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
|
||||
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
|
||||
};
|
||||
out.text = (map[r.kind] || (() => r.kind))();
|
||||
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
|
||||
if (r.kind === 'human.input' && b.kind === 'answer') out.groupAs = 'decision'; // an answer, not a request
|
||||
} else if (r.table === 'messages') {
|
||||
out.kind = `message.${String(r.class).toLowerCase()}`; out.decision = r.decision;
|
||||
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
|
||||
out.text = `${r.from_role === 'human' ? 'The human' : r.from_role} to ${r.to_role}: ${r.body}`;
|
||||
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'deliveries') {
|
||||
const m = ctx.messages[r.message];
|
||||
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
|
||||
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
|
||||
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'decisions') {
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||||
} else if (r.table === 'task_snapshots') {
|
||||
out.kind = `snapshot.${r.source}`;
|
||||
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
|
||||
out.text = r.source === 'self' ? `Written: ${bucketText(r.fields)}` : `Read from Vikunja (${r.via} read sent ${String(r.read_at).slice(11, 19)}): ${bucketText(r.fields)}`;
|
||||
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
|
||||
} else if (r.table === 'role_claims') {
|
||||
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
|
||||
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
|
||||
} else { out.kind = String(r.table); out.text = 'A row this page does not know how to show'; }
|
||||
out.group = out.groupAs || group(out.kind);
|
||||
return out;
|
||||
}
|
||||
function lines(tr, current = null) {
|
||||
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body?.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
|
||||
return tr.map(r => line(r, ctx));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render helpers
|
||||
function ago(iso) {
|
||||
const t = Date.parse(iso);
|
||||
if (!Number.isFinite(t)) return String(iso ?? 'unknown');
|
||||
const s = Math.max(0, Math.round((Date.now() - t) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
|
||||
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
|
||||
}
|
||||
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
|
||||
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
|
||||
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||||
let readAt = null;
|
||||
const readNote = () => `Read from the bus ${when(readAt)}.`;
|
||||
// Lead decision 63: what no Q1 read returns carries this label where it would show.
|
||||
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
|
||||
// Which choice authorizes which action on which target, as the broker recorded it at
|
||||
// decision.raise. Never inferred from the recommendation or an option's position.
|
||||
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
|
||||
const first = q => String(q ?? '').split('\n')[0];
|
||||
const due = d => d ? `<time datetime="${esc(d)}" title="${esc(d)}">${esc(String(d).slice(0, 10))}</time>` : '<span class="muted">none</span>';
|
||||
const user = u => `user ${esc(u)}`;
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null;
|
||||
function sections(route) {
|
||||
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra]) => {
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||||
}).join('');
|
||||
}
|
||||
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
|
||||
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
async function inboxView(get) {
|
||||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||||
countInbox(rows);
|
||||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you.</p>';
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
}
|
||||
async function decisionView(get, id) {
|
||||
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
|
||||
if (!d) return notFound(`No decision with id ${txt(id)}.`);
|
||||
const c = d.closed;
|
||||
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
|
||||
// A command is offered only for an open decision routed to the human, and only when
|
||||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||||
const answerable = !c && d.routeTo === 'human';
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
? `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${txt(o.key)}">Copy</button></div>`
|
||||
: '<p class="small muted">This option has no command here: its key is not a plain identifier.</p>';
|
||||
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${copy}</li>`;
|
||||
}).join('')}</ol>`;
|
||||
const earlier = await chain(get, d);
|
||||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<dl class="kv"><dt>Class</dt><dd>${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status" role="status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
}
|
||||
|
||||
// Tasks
|
||||
function markers(t) {
|
||||
const out = [];
|
||||
// The list has no trail, so it can only say the current state came from a Vikunja read;
|
||||
// the task page tells a change to a task the stack wrote from one it never wrote.
|
||||
if (t.external) out.push(t.external.kind === null ? badge(`last read from Vikunja (${t.external.via})`, 'attn') : t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.changed.length ? `: ${t.external.changed.join(', ')}` : ''}`, 'attn'));
|
||||
if (t.conflicts.length) out.push(badge(plural(t.conflicts.length, 'refused write'), 'danger'));
|
||||
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
|
||||
return out.join(' ');
|
||||
}
|
||||
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
|
||||
function taskRow(t) {
|
||||
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
|
||||
}
|
||||
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
|
||||
function freshness(rows) {
|
||||
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
|
||||
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
|
||||
}
|
||||
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
|
||||
async function tasksView(get) {
|
||||
const rows = await get('tasks'), ts = rows.map(r => task(r));
|
||||
if (!ts.length) return `${head('Tasks', freshness(rows))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
|
||||
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
|
||||
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
|
||||
const groups = buckets.map(b => {
|
||||
const g = open.filter(t => t.bucket === b).sort(byChanged), id = `b-bucket-${esc(b)}`;
|
||||
return `<section aria-labelledby="${id}"><h2 id="${id}">Bucket ${esc(b)} <span class="count">${g.length}</span></h2>${taskTable(g, `Bucket ${b} tasks`)}</section>`;
|
||||
}).join('');
|
||||
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
|
||||
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
|
||||
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || '<p class="state">No open tasks.</p>'}${doneHtml}${missingHtml}`;
|
||||
}
|
||||
async function taskView(get, ref) {
|
||||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||||
const tr = await get('trail', ref), t = task(row, tr);
|
||||
const ext = t.external ? `<dt>Vikunja</dt><dd>${t.external.kind === 'foreign' ? 'Not created by the stack; first read' : 'Changed in Vikunja after the stack wrote it'}${t.external.changed.length ? `: ${txt(t.external.changed.join(', '))}` : ''}${t.external.comments ? `, ${esc(plural(t.external.comments, 'new comment'))}` : ''} <span class="source">${txt(t.external.via)} read sent ${clock(t.external.readAt)}</span></dd>` : '';
|
||||
const conflicts = t.conflicts.length ? `<dt>Refused writes</dt><dd>${t.conflicts.map(c => `${txt(c.verb)}: Vikunja changed after the stack read it (expected ${txt(short(c.expected))}, found ${txt(short(c.actual))}) ${when(c.at)}`).join('<br>')}</dd>` : '';
|
||||
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
|
||||
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
|
||||
return `${head(title(t), `${sub} ${markers(t)}`)}
|
||||
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
|
||||
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
|
||||
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
|
||||
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
|
||||
}
|
||||
|
||||
// Agents
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||||
return `${head('Agents', readNote())}
|
||||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear.</p>
|
||||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="state">No role is held right now.</p>'}</section>
|
||||
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
|
||||
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
|
||||
}
|
||||
|
||||
// Trail
|
||||
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
||||
function trailList(rows, filter) {
|
||||
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
|
||||
if (!shown.length) return '<p class="muted">Nothing of this kind in the trail.</p>';
|
||||
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
|
||||
}
|
||||
async function trailView(get, kind, ref, q) {
|
||||
let rows;
|
||||
if (kind === 'task') {
|
||||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||||
rows = lines(await get('trail', ref), row.seq);
|
||||
} else {
|
||||
const d = await decision(get, ref);
|
||||
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
|
||||
// A decision's trail with its earlier versions, merged in the broker's order.
|
||||
const all = new Map();
|
||||
for (const x of [d, ...await chain(get, d)]) for (const r of x.trail) all.set(`${r.table}:${r.seq}`, r);
|
||||
rows = lines([...all.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.table).localeCompare(String(b.table)) || a.seq - b.seq));
|
||||
}
|
||||
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
|
||||
const base = `#/trail/${kind}/${enc(ref)}`;
|
||||
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
|
||||
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
|
||||
return `${head(name, `${readNote()} Oldest first. ${back}`)}
|
||||
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
|
||||
${trailList(rows, filter)}`;
|
||||
}
|
||||
|
||||
// Not found and states
|
||||
function notFound(why) { return `${head('Not found')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
|
||||
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the bus.</p>`;
|
||||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||||
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
|
||||
function why(err) {
|
||||
const code = `<code>${txt(err.code)}</code>`;
|
||||
const say = {
|
||||
'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.',
|
||||
unauthenticated: 'The bus did not accept this Console as the human.',
|
||||
'unknown-business': 'The bus does not know this business.',
|
||||
'read-only': 'The bus refused the read.',
|
||||
'not-configured': 'This Console has no bus configured: the system config could not be read when it started. The control board still works.',
|
||||
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>. Start one with <code>mosaic bus start <business></code>.',
|
||||
'outcome-unknown': 'The bus did not answer in time.',
|
||||
'invalid-request': 'The bus refused this address as a request.',
|
||||
unreachable: 'The Console server did not answer.',
|
||||
}[err.code] || 'The bus answered with something Console cannot use.';
|
||||
return `${say} (${code})`;
|
||||
}
|
||||
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
|
||||
const failedView = err => `${head(failTitle(err))}<div class="state state-error" role="alert"><p><b>${why(err)}</b> Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>`;
|
||||
const staleBanner = err => `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
|
||||
// ---------------------------------------------------------------- router
|
||||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||||
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
|
||||
function view(get, path, q) {
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||||
if (a === 'agents' && n === 1) return agentsView(get);
|
||||
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
|
||||
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
|
||||
return notFound();
|
||||
}
|
||||
let gen = 0, shown = null, timer;
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
|
||||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||||
function keep() {
|
||||
const a = document.activeElement, view = $('bus-view');
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'),
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
function restore(k) {
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.retry && e.hasAttribute('data-retry'));
|
||||
if (el && (k.href || k.copy || k.retry)) el.focus({ preventScroll: true });
|
||||
}
|
||||
async function render(navigated) {
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||||
const g = ++gen;
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
if (board) {
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
|
||||
if (g === gen) schedule();
|
||||
return;
|
||||
}
|
||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
|
||||
let html;
|
||||
const live = reader(true);
|
||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
catch (err) {
|
||||
const kept = reader(false);
|
||||
try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
|
||||
catch { html = failedView(err); }
|
||||
}
|
||||
if (g !== gen) return;
|
||||
const k = !navigated && shown === route ? keep() : null;
|
||||
$('bus-view').innerHTML = html; shown = route;
|
||||
sections(path);
|
||||
const h1 = $('bus-view').querySelector('h1');
|
||||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||||
if (k) restore(k);
|
||||
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||||
schedule();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
|
||||
// unavailable, the command text is selected so the person can copy it themselves.
|
||||
document.addEventListener('click', async e => {
|
||||
// The skip link moves focus only. Its #main would otherwise reach the hash router.
|
||||
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
|
||||
const b = e.target.closest('#bus-view [data-copy]');
|
||||
if (b) {
|
||||
const text = b.dataset.copy, out = $('copy-status');
|
||||
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
|
||||
catch {
|
||||
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
|
||||
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#bus-view [data-retry]')) render(false);
|
||||
});
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
|
||||
})();
|
||||
@@ -1,15 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><i>M</i>Mosaic<small>Console</small></a>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option>light</option><option>dim</option><option>dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<div id="error" class="state state-error" role="alert" hidden></div>
|
||||
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
|
||||
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
|
||||
@@ -17,6 +17,7 @@
|
||||
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
|
||||
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
|
||||
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
|
||||
<div id="bus-view" hidden></div>
|
||||
</main>
|
||||
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -19,8 +20,8 @@ export function boardURL(value) {
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
...['app.js', 'brand.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'console.css', 'live.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
function json(res, status, body) {
|
||||
@@ -41,7 +42,25 @@ async function body(req) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
|
||||
return bytes;
|
||||
}
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000 } = {}) {
|
||||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let subject;
|
||||
if (verb === 'trail') {
|
||||
subject = new URLSearchParams(search).get('subject');
|
||||
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||||
}
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const rows = await bus[verb](subject);
|
||||
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -79,6 +98,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/api/bus/')) {
|
||||
const verb = path.slice('/api/bus/'.length);
|
||||
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return busRead(res, bus, verb, search);
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
|
||||
@@ -18,7 +18,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 2);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 3);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// Project filtering does not hide another project's waiting requests.
|
||||
await b.evaluate('document.querySelector("[data-project=proj]").click()');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Slice 1 S5 (#1522): the inbox, tasks, agents and trail pages in a browser,
|
||||
// against an in-process broker read through its reader session, so every row
|
||||
// the page renders has the broker's own shape. Nothing here starts the human
|
||||
// CLI or reaches a real broker; the seeding writes go straight to the broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const digest = c => c.repeat(64);
|
||||
const iso = () => new Date().toISOString();
|
||||
// A poll read counts only when it was sent after the stack's own write (task_current), and the
|
||||
// broker's clock can run a few milliseconds ahead of Date.now() when writes come fast.
|
||||
const later = () => new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
function seed(root) {
|
||||
const store = new Store(root), b = new Broker({ store, businesses });
|
||||
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
|
||||
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
|
||||
launch('cto', 'claude-code', 'cto-thread');
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
|
||||
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
|
||||
const fields = { project_id: 32, title: `Inbox ${HOSTILE}`, description: `Line one ${HOSTILE}\nline two`, done: false, due_date: '2026-10-20T00:00:00Z', priority: 3, percent_done: 40, bucket: 2, labels: [], assignees: [5] };
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('a'), fields }], events: [{ kind: 'task.created', subject: 'vikunja:32/7', body: { request: asked.request, requirement: 'REQ-SLICE-1' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('b'), fields: { ...fields, bucket: 3 }, via: 'board', read_at: later() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { via: 'board', digest: digest('b'), previous: digest('a'), changed: ['bucket'], comments: 2 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
|
||||
// A task the stack never wrote, a done task and one no longer readable.
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
|
||||
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
|
||||
const raise = args => b.request(coder, { verb: 'decision.raise', args });
|
||||
const push = { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', options: [{ key: 'yes', text: `Allow ${HOSTILE}` }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true };
|
||||
const old = raise({ ...push, question: 'Push the first candidate?' });
|
||||
const open = raise({ ...push, question: `Push the release? ${HOSTILE}\nSecond line`, supersedes: old.id });
|
||||
b.request(human, { verb: 'decision.seen', args: { id: open.id } });
|
||||
const routed = raise({ action: 'task.scope.change', question: 'Widen the scope?', options: [{ key: 'widen', text: 'Widen' }, { key: 'keep', text: 'Keep' }], recommendation: 'keep', blocking: false, approvalChoice: 'widen' });
|
||||
const resolved = raise({ ...push, question: 'Push the hotfix?', blocking: false, task_ref: undefined, target: 'hotfix' });
|
||||
b.request(human, { verb: 'decision.resolve', args: { id: resolved.id, choice: 'yes' } });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
let fail = null;
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
||||
}
|
||||
|
||||
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
|
||||
const s = seed(root), f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
const inertCheck = async where => {
|
||||
assert.equal(await b.evaluate('!!window.injected'), false, where);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view script, #bus-view img, #bus-view iframe, #bus-view object, #bus-view embed, #bus-view svg, #bus-view style, #bus-view link").length'), 0, where);
|
||||
const shown = await text('#bus-view');
|
||||
assert.doesNotMatch(shown, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/, where);
|
||||
};
|
||||
const noOverflow = async where => {
|
||||
for (const width of [320, 1440]) {
|
||||
await b.viewport(width, 1000);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${where} ${width} overflow`);
|
||||
if (out) await b.screenshot(join(out, `s5-${where}-${width}.png`));
|
||||
}
|
||||
await b.viewport(1440, 1000);
|
||||
};
|
||||
try {
|
||||
// Every request the page makes is logged with its method; the bus pages must make only GETs.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(1440, 1000);
|
||||
|
||||
// The board is untouched at the root, with the Console sections above its projects.
|
||||
await b.navigate(base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
||||
await wait('document.querySelector("#sections a[href=\\"#/inbox\\"] .count")?.textContent==="1"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
||||
|
||||
// Inbox: the one open human decision, its approval context, and the gap labels.
|
||||
await go('#/inbox', 'Inbox');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("on-bus")'), true);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
const card = await text('#bus-view .s1-dec');
|
||||
assert.match(card, /Push the release\? <script>window\.injected=1<\/script>/);
|
||||
assert.match(card, /␛\[31mred \[U\+202E\]evil/);
|
||||
assert.doesNotMatch(card, /Second line/);
|
||||
assert.match(card, /Choosing “yes” approves git\.push\.protected on refactor\./);
|
||||
assert.match(card, /blocking/);
|
||||
for (const label of ['Decisions routed to roles: not in the Q1 module', 'Closed decisions: not in the Q1 module', 'Seen and DM state per row: not in the Q1 module'])
|
||||
assert.ok((await text('#bus-view')).includes(label), label);
|
||||
assert.equal(await b.evaluate('document.title'), 'Inbox · Mosaic Console');
|
||||
await inertCheck('inbox'); await noOverflow('inbox');
|
||||
|
||||
// The open decision: options with Copy, history, the earlier version, no write.
|
||||
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()');
|
||||
await wait(`location.hash===${JSON.stringify(`#/inbox/${s.ids.open}`)}`);
|
||||
await wait('document.querySelectorAll("#bus-view [data-copy]").length===2');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view [data-copy]")].map(e=>e.dataset.copy)'), [`mosaic decide ${s.ids.open} yes`, `mosaic decide ${s.ids.open} no`]);
|
||||
const detail = await text('#bus-view');
|
||||
assert.match(detail, /approves git\.push\.protected/); assert.match(detail, /recommended/);
|
||||
assert.match(detail, /Seen by jason via cli/);
|
||||
assert.match(detail, /Second line/);
|
||||
assert.ok(detail.includes(`${s.ids.old.slice(0, 8)} withdrawn Push the first candidate?`), detail);
|
||||
assert.ok(detail.includes('A later version that replaces this one: not in the Q1 module'));
|
||||
assert.match(detail, /DMnot sent/);
|
||||
await inertCheck('decision');
|
||||
// Copy writes the command to the clipboard and nothing else; with no clipboard the command is selected.
|
||||
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("Copied")');
|
||||
assert.deepEqual(await b.evaluate('window.copied'), [`mosaic decide ${s.ids.open} yes`]);
|
||||
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("The clipboard is unavailable")');
|
||||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${s.ids.open} no`);
|
||||
// A refresh keeps the focused button and the copy status.
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].focus()');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.copy'), `mosaic decide ${s.ids.open} no`);
|
||||
assert.match(await text('#copy-status'), /clipboard is unavailable/);
|
||||
await noOverflow('decision');
|
||||
|
||||
// A decision routed to a role and a resolved one show no command.
|
||||
await go(`#/inbox/${s.ids.routed}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-view'), /routed to pm, not to you/);
|
||||
assert.match(await text('#bus-view'), /Choosing “widen” approves task\.scope\.change\./);
|
||||
await go(`#/inbox/${s.ids.resolved}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-status'), /Resolved: jason chose “yes” via cli/);
|
||||
assert.match(await text('#bus-view'), /chosen/);
|
||||
|
||||
// Tasks: buckets, markers, done, missing, the freshness line.
|
||||
await go('#/tasks', 'Tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/tasks');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view h2[id^=b-bucket]")].map(h=>h.textContent)'), ['Bucket 2 1', 'Bucket 3 1']);
|
||||
const tasks = await text('#bus-view');
|
||||
assert.match(tasks, /Inbox <script>window\.injected=1<\/script>/);
|
||||
// The list reads no trail: it says only that the current state came from a Vikunja read.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
|
||||
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
|
||||
assert.match(tasks, /#10 \(no title in the current read\)/);
|
||||
assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
|
||||
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
|
||||
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
|
||||
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
|
||||
await inertCheck('tasks'); await noOverflow('tasks');
|
||||
|
||||
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
|
||||
await go('#/tasks/vikunja%3A32%2F7', '#7');
|
||||
const t7 = await text('#bus-view');
|
||||
assert.match(t7, /RequirementREQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
|
||||
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
|
||||
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
|
||||
assert.match(t7, /Request from the human, recorded from the CLI/);
|
||||
assert.match(t7, /the request task\.created names/);
|
||||
assert.match(t7, /The human to pm: Build the inbox/);
|
||||
assert.match(t7, /Created for REQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
|
||||
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
|
||||
assert.match(t7, /Snapshots 2/);
|
||||
await inertCheck('task'); await noOverflow('task');
|
||||
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
||||
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
||||
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
|
||||
await go('#/tasks/vikunja%3A32%2F10', '#10');
|
||||
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
|
||||
|
||||
// The trail with filters.
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelector(".chips [aria-current=true]").textContent.startsWith("Task changes")'), true);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .s1-trow")].every(r=>r.classList.contains("s1-g-task"))'), true);
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=request', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-trow").length'), 3); // the message, its delivery, the human.input
|
||||
await go(`#/trail/decision/${s.ids.open}`, 'Trail for decision');
|
||||
const dt = await text('#bus-view');
|
||||
assert.match(dt, /Raised with this context: choosing “yes” approves git\.push\.protected on refactor/);
|
||||
assert.match(dt, /Withdrawn by coder/); assert.match(dt, /jason saw it via cli/);
|
||||
assert.match(dt, /Push the first candidate\?/);
|
||||
await inertCheck('trail'); await noOverflow('trail');
|
||||
|
||||
// Agents: one row per held role, with its harness and address; the gaps are labelled.
|
||||
await go('#/agents', 'Agents');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
|
||||
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
|
||||
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
|
||||
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
|
||||
await noOverflow('agents');
|
||||
|
||||
// Addresses that are not pages.
|
||||
await go('#/nope', 'Not found');
|
||||
await go('#/inbox/bad%20id', 'Not found');
|
||||
assert.match(await text('#bus-view'), /bad id is not a decision id/);
|
||||
await go('#/tasks/vikunja%3A32%2F99', 'Not found');
|
||||
|
||||
// A failed read shows what was read before, labelled; a page never read shows the refusal.
|
||||
await go('#/inbox', 'Inbox');
|
||||
s.fail('outcome-unknown');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
||||
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The bus did not answer in time\. \(outcome-unknown\)/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
await go('#/trail/task/vikunja%3A32%2F9', 'The read failed');
|
||||
s.fail('human-required');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent==="Bus refused the read"');
|
||||
assert.match(await text('#bus-view'), /started it from their own shell.*\(human-required\).*Nothing was written\./s);
|
||||
s.fail(null);
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
|
||||
await noOverflow('refused');
|
||||
|
||||
// Back to the board: it still works.
|
||||
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
|
||||
await wait('document.querySelector("#bus-view").hidden && getComputedStyle(document.querySelector("#board-view")).display!=="none"');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 3);
|
||||
assert.equal(await b.evaluate('document.title'), 'Mosaic Console');
|
||||
|
||||
// The skip link focuses main and does not route.
|
||||
await b.evaluate('location.hash="#/agents"'); await wait('document.querySelector("#bus-view h1")?.textContent==="Agents"');
|
||||
await b.evaluate('document.querySelector(".skip").click()');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/agents');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main');
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
const requests = await b.evaluate('window.requests');
|
||||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
|
||||
// A Console with no bus configured says so, and its board still works.
|
||||
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
||||
assert.match(await text('#bus-view'), /no bus configured.*The control board still works\. \(not-configured\)/s);
|
||||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); await f.close(); s.store.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// Slice 1 S5 (#1522): the /api/bus/* routes and the Console's bus transport.
|
||||
// The routes run against an in-process broker through its reader session;
|
||||
// the transport runs a fake human CLI. Nothing here starts the real human
|
||||
// CLI or reaches a real broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { hostFile, startTimeOf } from '../../cli/src/host.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError, busReader, humanCall } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.injected=true"> evil';
|
||||
const at = '2026-10-08T12:00:00.000Z';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tmp = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
|
||||
// A broker with one open decision, one claimed role and one externally changed task.
|
||||
function seeded(t) {
|
||||
const store = new Store(tmp(t, 'webui-bus-'));
|
||||
t.after(() => store.close());
|
||||
const b = new Broker({ store, businesses });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
const decision = b.request(coder, { verb: 'decision.raise', args: { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', question: `Push the release? ${HOSTILE}`, options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true } });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: at, digest: 'b'.repeat(64), fields: { title: HOSTILE, bucket: 11, done: false }, via: 'board', read_at: at }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { changed: ['bucket'] } }] });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { decision, bus, calls };
|
||||
}
|
||||
|
||||
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
|
||||
const { decision, bus, calls } = seeded(t);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
|
||||
const inbox = await read('/api/bus/inbox');
|
||||
assert.equal(inbox.status, 200); assert.equal(inbox.cache, 'no-store');
|
||||
assert.match(inbox.body.at, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.equal(inbox.body.rows.length, 1);
|
||||
assert.equal(inbox.body.rows[0].id, decision.id);
|
||||
assert.deepEqual(inbox.body.rows[0].authorization, { action: 'git.push.protected', target: 'refactor', approvalChoice: 'yes' });
|
||||
assert.ok(inbox.body.rows[0].question.endsWith(HOSTILE));
|
||||
assert.equal(inbox.body.rows[0].blocking, true);
|
||||
assert.deepEqual(inbox.body.rows[0].options.map(o => o.key), ['yes', 'no']);
|
||||
|
||||
const tasks = await read('/api/bus/tasks');
|
||||
assert.equal(tasks.body.rows.length, 1);
|
||||
assert.equal(tasks.body.rows[0].source, 'poll');
|
||||
assert.equal(tasks.body.rows[0].fields.title, HOSTILE);
|
||||
|
||||
const agents = await read('/api/bus/agents');
|
||||
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
|
||||
|
||||
const id = inbox.body.rows[0].id;
|
||||
const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));
|
||||
assert.equal(trail.status, 200);
|
||||
assert.ok(trail.body.rows.some(r => r.table === 'decisions' && r.id === id));
|
||||
const taskTrail = await read('/api/bus/trail?subject=vikunja:32/7');
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'task_snapshots'));
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'events' && r.kind === 'task.changed.external'));
|
||||
|
||||
// Bad subjects never reach the bus.
|
||||
const before = calls.length;
|
||||
for (const q of ['', '?subject=', '?subject=' + 'a'.repeat(161), '?subject=-x', '?subject=a%20b', '?subject=a%00b'])
|
||||
assert.deepEqual([q, (await read('/api/bus/trail' + q)).body.error], [q, 'invalid-request']);
|
||||
assert.equal(calls.length, before);
|
||||
assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
});
|
||||
|
||||
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
||||
const calls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
|
||||
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path);
|
||||
}
|
||||
assert.equal((await fetch(base + '/api/bus/inbox', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
assert.deepEqual(calls, []);
|
||||
for (const path of ['/bus.js', '/bus.css']) assert.equal((await fetch(base + path)).status, 200, path);
|
||||
});
|
||||
|
||||
test('bus refusals map to statuses and never carry a path or a stack', async t => {
|
||||
let fail;
|
||||
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => Promise.all([close(web), close(none)]));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
|
||||
for (const [code, status] of [['human-required', 403], ['unauthenticated', 403], ['unknown-business', 403], ['read-only', 403], ['outcome-unknown', 503], ['no-bus-host', 503], ['invalid-response', 502], ['response-too-large', 502], ['something-new', 502]]) {
|
||||
fail = new BusReadError(code);
|
||||
const [s, body] = await inbox();
|
||||
assert.deepEqual([code, s, body.error], [code, status, code]);
|
||||
}
|
||||
fail = Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { stack: 'at secret (/x.mjs:1)' });
|
||||
const [s, body] = await inbox();
|
||||
assert.equal(s, 502); assert.equal(body.error, 'invalid-response');
|
||||
assert.doesNotMatch(JSON.stringify(body), /home|mosaic-dev|secret|\.mjs/);
|
||||
const shape = await fetch(base + '/api/bus/tasks');
|
||||
assert.deepEqual([shape.status, (await shape.json()).error], [502, 'invalid-response']);
|
||||
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
|
||||
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
|
||||
});
|
||||
|
||||
// A fake human CLI: it reads the request, then acts on args.mode.
|
||||
const FAKE = `
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
const mode = request.args.mode;
|
||||
if (mode === 'refuse') { process.stderr.write('Some warning: Text\\nhuman-required\\n'); process.exit(2); }
|
||||
if (mode === 'garbage') { process.stdout.write('not json'); process.exit(0); }
|
||||
if (mode === 'silent-fail') process.exit(2);
|
||||
if (mode === 'big') { process.stdout.write('"' + 'x'.repeat(4096) + '"'); process.exit(0); }
|
||||
if (mode === 'slow') { setTimeout(() => process.stdout.write('[]'), 60000); }
|
||||
else if (mode === 'pid') { writeFileSync(request.args.pidFile, String(process.pid)); setInterval(() => {}, 60000); }
|
||||
else process.stdout.write(JSON.stringify({ request, socket: process.argv[2] }));
|
||||
`;
|
||||
function fake(t) {
|
||||
const root = tmp(t, 'webui-bus-cli-');
|
||||
const cli = join(root, 'fake-human-cli.mjs');
|
||||
writeFileSync(cli, FAKE);
|
||||
return { root, cli };
|
||||
}
|
||||
|
||||
test('humanCall speaks the human transport protocol without blocking the server', async t => {
|
||||
const { cli } = fake(t);
|
||||
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, timeoutMs: 1500, maxBytes: 1024 });
|
||||
assert.deepEqual(await call('trail', { subject: 'd-1' }), { request: { business: 'demo', verb: 'trail', args: { subject: 'd-1' } }, socket: '/run/fake.sock' });
|
||||
const code = async args => { try { await call('inbox', args); return 'resolved'; } catch (e) { assert.ok(e instanceof BusReadError); return e.code; } };
|
||||
assert.equal(await code({ mode: 'refuse' }), 'human-required');
|
||||
assert.equal(await code({ mode: 'garbage' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'silent-fail' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'big' }), 'response-too-large');
|
||||
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
|
||||
|
||||
// A slow read stays slow on its own: the server keeps answering meanwhile.
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const started = Date.now();
|
||||
const slow = fetch(base + '/api/bus/inbox');
|
||||
const health = await fetch(base + '/healthz');
|
||||
assert.equal(health.status, 200);
|
||||
assert.ok(Date.now() - started < 1000, 'healthz waited for the bus read');
|
||||
const r = await slow;
|
||||
assert.deepEqual([r.status, (await r.json()).error], [503, 'outcome-unknown']);
|
||||
assert.ok(Date.now() - started >= 1400);
|
||||
});
|
||||
|
||||
test('busReader takes --business, else the live bus host, else refuses', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const dataRoot = join(root, 'data');
|
||||
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
|
||||
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
||||
|
||||
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
||||
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
mkdirSync(join(dataRoot, 'bus-host'), { recursive: true });
|
||||
writeFileSync(hostFile(dataRoot), '{not json');
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: 'not-my-start', business: 'stale' }));
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'hosted' }));
|
||||
const reply = await reader.agents();
|
||||
assert.deepEqual([reply.request.business, reply.request.verb, reply.socket], ['hosted', 'agents', 'sock']);
|
||||
// The host is read on every request.
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'moved' }));
|
||||
assert.equal((await reader.tasks()).request.business, 'moved');
|
||||
});
|
||||
|
||||
test('humanCall kills a transport that runs past its timeout', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const pidFile = join(root, 'pid');
|
||||
const call = humanCall({ socket: 's', business: 'demo', cli, timeoutMs: 500 });
|
||||
assert.equal(await call('inbox', { mode: 'pid', pidFile }).catch(e => e.code), 'outcome-unknown');
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'));
|
||||
const gone = () => { try { process.kill(pid, 0); return false; } catch { return true; } };
|
||||
// If it survived, stop it so the failure is this assertion, not a hang.
|
||||
t.after(() => { if (!gone()) process.kill(pid, 'SIGKILL'); });
|
||||
// SIGKILL lands asynchronously; give the kernel a moment to reap it.
|
||||
for (let i = 0; i < 40 && !gone(); i++) await new Promise(r => setTimeout(r, 25));
|
||||
assert.ok(gone(), `transport ${pid} still runs after the timeout`);
|
||||
});
|
||||
|
||||
test('serve refuses a --business value that is not a business id', () => {
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
for (const business of ['../acme', '-x', 'acme corp']) {
|
||||
const result = spawnSync(process.execPath, [cli, 'serve', '--business', business, '--port', '0'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(result.status, 2, business);
|
||||
assert.match(result.stderr, /^refused: business must be a business id/, business);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user