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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user