Compare commits

..
Author SHA1 Message Date
dev-driver 05ad7335a6 perf(daemon): reuse the waiting view's prepared statements per connection
lastMeaningfulTransition, lastMeaningfulTransitionMany and WaitingStatements
prepared their fixed SQL on every call, and these run on every waiting view,
sweep and ladder read. Statements are now prepared once per connection and
reused: a WeakMap keyed by the connection, so they go with it, holding at most
32 texts from this module's fixed set.

A prepared statement still reads the current data each time it runs, and
SQLite re-prepares it itself after a schema change, so no result changes. The
schema checks stay per call, and a WaitingStatements instance still memoizes
its own table checks.

The listing-reads test now counts runs of the transition read rather than
prepares, since the statement is prepared once and reused.
2026-11-13 22:16:09 +00:00
38 changed files with 328 additions and 558 deletions

No files matched your search

+3 -5
View File
@@ -26,11 +26,9 @@ A person can also bind to a local bridge adapter using a `relay` binding:
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions. A row addressed to
`human@host` names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To reply to a person, use
their registered address:
`rig queue create --destination <their address> --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting your summary, evidence path and reply. A reply destination doesn't identify
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
+1 -7
View File
@@ -24,15 +24,9 @@ a shell prompt holds them. Keep the default for a seat that may fall back to a s
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
`--dangerously-interact --reason` remains available in every mode, `hold` included,
`--dangerously-interact --reason` remains available at every interruption level
and uses the existing-input check below. Direct human input remains available.
An ordinary `rig send` types the message inside its From/To envelope, whose first
line is `From:`, so a message that starts with `/` stays literal text rather than
running as the agent's slash command. `--raw` and `--dangerously-interact` type the
exact text, with no envelope. Claude Code (2.1.322) turns a pasted tab into four
spaces, so tabs reach a Claude Code seat as spaces.
A message sent to a busy Codex seat joins its running turn and may redirect it.
For separate work, create a queue row instead:
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
+4 -9
View File
@@ -1166,19 +1166,14 @@ Exit codes:
}
if (limited.length === 0) {
// The daemon identity rides on every empty-fleet line: with more than
// one daemon in a house (OPENRIG_URL, hosts.yaml), a targetless "No rigs"
// invites reading another daemon's answer as this one's — an empty fleet
// read from the wrong daemon reads as a dead fleet.
const daemonLabel = ` · daemon ${client.baseUrl}`;
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}${daemonLabel}`);
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else if (archivedCount > 0 && !opts.includeArchived) {
// Proven-empty with the history pointer, never a bare "No rigs"
// while archived history exists.
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)${daemonLabel}`);
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)`);
} else {
console.log(`No rigs${daemonLabel}`);
console.log("No rigs");
}
// A host can have connected assistants and no terminal rigs at all.
await printConnectedAssistants(client, opts.rig);
@@ -1197,7 +1192,7 @@ Exit codes:
// FR-1 display element 1: the host rollup line.
const rollupSeats = tableRows.reduce((n, e) => n + e.nodeCount, 0);
const rollupAttention = tableRows.reduce((n, e) => n + (e.attentionCount ?? 0), 0);
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention · daemon ${client.baseUrl}`);
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention`);
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
+2 -2
View File
@@ -1402,8 +1402,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--summary <text>", "Summary persisted onto the item, with or without a state change (a blank one isn't stored)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.addHelpText("after", `
After reading an existing remote successor, record its exact closure target, including the host:
-6
View File
@@ -78,10 +78,6 @@ export function renderShortStamp(stampISO: string): string {
return `${stampISO.slice(5, 7)}-${stampISO.slice(8, 10)} ${stampISO.slice(11, 16)}Z`;
}
/** The address OpenRig records an answer typed at this machine under (the daemon's UNATTRIBUTED_ACTOR). It names nobody,
* and a row there asks no one (it waits on Steering and in the Feed, and OpenRig reports it as undeliverable), so a reply hint never points at it. */
const UNATTRIBUTED_PERSON = "human@host";
export function wrapSendBody(
sender: string | undefined,
recipient: string,
@@ -112,8 +108,6 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: personAddress === UNATTRIBUTED_PERSON
? `↩ ${senderLabel} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: meta?.senderReachable === false
+1 -1
View File
@@ -1716,7 +1716,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
summary: z.string().optional().describe("Replace the one-line summary"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
@@ -155,6 +155,6 @@ describe("rig ps: connected assistants and their check-in status", () => {
expect(process.exitCode ?? 0).toBe(0);
}
rigs = [];
expect((await run([])).join("\n")).toContain(`No rigs · daemon http://127.0.0.1:${port}\n\nConnected assistants: check-in unavailable`);
expect((await run([])).join("\n")).toContain("No rigs\n\nConnected assistants: check-in unavailable");
});
});
-69
View File
@@ -1,69 +0,0 @@
import { afterEach, expect, it, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { psRoutes } from "../../daemon/src/routes/ps.js";
import { PsProjectionService } from "../../daemon/src/domain/ps-projection.js";
import { createFullTestDb } from "../../daemon/test/helpers/test-app.js";
let close: (() => Promise<void>) | undefined;
afterEach(async () => { await close?.(); close = undefined; vi.restoreAllMocks(); });
/** Serves /api/ps over a fresh test db. activeRigs=0 yields a daemon whose
* only rig is archived — the "No active rigs" empty-fleet shape. */
async function fixture(activeRigs = 1) {
const db = createFullTestDb();
if (activeRigs > 0) {
db.prepare("INSERT INTO rigs (id, name) VALUES ('r', 'fixture')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('n', 'r', 'builder')").run();
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s', 'n', 'fixture-builder', 'running')").run();
}
db.prepare("INSERT INTO rigs (id, name, archived_at) VALUES ('archived', 'archived-fixture', datetime('now'))").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('archived-n', 'archived', 'builder')").run();
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('archived-s', 'archived-n', 'archived-builder', 'running')").run();
const app = new Hono();
app.use("*", async (c, next) => { c.set("psProjectionService" as never, new PsProjectionService({ db }) as never); await next(); });
app.route("/api/ps", psRoutes);
const server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" });
await new Promise<void>((resolve) => server.on("listening", resolve));
const port = (server.address() as { port: number }).port;
close = () => new Promise<void>((resolve, reject) => { server.close((err) => { db.close(); err ? reject(err) : resolve(); }); });
const client = new DaemonClient(`http://127.0.0.1:${port}`);
const deps = { clientFactory: () => client, lifecycleDeps: {
exists: (p: string) => p === STATE_FILE,
readFile: () => JSON.stringify({ pid: 123, port, db: "fixture.sqlite", startedAt: new Date().toISOString() }),
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
spawn: vi.fn(), kill: vi.fn(), writeFile: vi.fn(), removeFile: vi.fn(), mkdirp: vi.fn(), openForAppend: vi.fn(),
} };
const command = new Command().addCommand(psCommand(deps as never));
return { baseUrl: `http://127.0.0.1:${port}`, command };
}
it("names the daemon on the rollup line when rigs are listed", async () => {
const { baseUrl, command } = await fixture(1);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps"]);
const rollup = log.mock.calls.flat().find((l) => /needs? attention/.test(String(l)));
expect(rollup).toBeDefined();
expect(String(rollup)).toMatch(/1 rig · 1 seat · 0 need attention · daemon http:\/\/127\.0\.0\.1:\d+$/);
expect(String(rollup)).toContain(`daemon ${baseUrl}`);
});
it("names the daemon on the empty-fleet lines — the wrong-daemon trap case", async () => {
const { baseUrl, command } = await fixture(0);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps"]);
const lines = log.mock.calls.flat().map(String).join("\n");
expect(lines).toContain(`No active rigs · 1 archived (rig ps --include-archived) · daemon ${baseUrl}`);
});
it("names the daemon on the filter-miss line", async () => {
const { baseUrl, command } = await fixture(1);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await command.parseAsync(["node", "rig", "ps", "--filter", "lifecycleState=attention_required"]);
const miss = log.mock.calls.flat().find((l) => String(l).startsWith("No rigs match"));
expect(String(miss)).toContain(`daemon ${baseUrl}`);
});
@@ -257,7 +257,7 @@ describe("PL-012 ps --filter contextUsage.* + CTX field", () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--filter", filter]);
});
expect(result.exitCode).toBeUndefined();
expect(result.logs).toEqual([`No rigs match --filter ${key}=${value} · daemon http://127.0.0.1:${port}`]);
expect(result.logs).toEqual([`No rigs match --filter ${key}=${value}`]);
expect(result.errLogs).toHaveLength(1);
expect(result.errLogs[0]).toContain(key);
expect(result.errLogs[0]).toContain("was not applied");
@@ -165,7 +165,7 @@ describe("queue writes refused for a host token the daemon doesn't know", () =>
["a remote's refusal", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 400, detail: "HTTP 400" } }],
["an auth failure", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "auth-failed", detail: "401" } }],
["an internal error", { status: 500, data: { error: "internal_error", message: "boom" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "summary_evidence_not_persistable", message: "x" } }],
];
for (const [label, reply] of UNPROVEN) {
it(`never says it after ${label}`, async () => {
@@ -1,7 +1,9 @@
// 51-06 D2, narrowed — REAL CLI acceptance boundary. A genuine `queueCommand` invocation talks over
// LOOPBACK HTTP to the real candidate queueRoutes + QueueRepository, backed by a migration-faithful test DB
// (incl. 044 summary + 048 evidence_ref). No pre-baked response. A state change that carries --summary or
// --evidence-ref is applied and stores them; a blank --summary is reported as not stored, never dropped silently.
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
// the HTTP call, and the successful human-park control — all end to end.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
@@ -105,29 +107,27 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
});
afterEach(() => { vi.unstubAllEnvs(); });
it("normal: --summary and --evidence-ref on a state change are applied and stored (exit 0)", async () => {
const beforeTxns = txnCount(id);
const { exit } = await runCli(["update", id, "--state", "in-progress", "--summary", "KEEP", "--evidence-ref", "/proof/e.md"]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "KEEP", evidenceRef: "/proof/e.md" });
expect(txnCount(id)).toBe(beforeTxns + 1);
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
const beforeTxns = txnCount(id), beforeEvents = eventCount();
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
expect(exit).toBe(1);
expect(out).toContain("summary_evidence_not_persistable");
expect(out).toContain("invalidFields");
// unchanged item/metadata/transition/event state AFTER the real HTTP call
const item = repo.getByIdOrThrow(id);
expect(item.state).toBe("pending");
expect(item.summary).toBeNull();
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
});
it("a finished seat's park on a typed gate with --summary and --evidence-ref parks and stores both (--json)", async () => {
await runCli(["update", id, "--state", "in-progress"]);
const { out, exit } = await runCli(["block", id, "--on", "external:relay-reply", "--summary", "Relayed; waiting on the reply", "--evidence-ref", "/proof/relay.md", "--json"]);
expect(exit).toBeFalsy();
expect(JSON.parse(out)).toMatchObject({ state: "blocked", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:relay-reply", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
});
it.each([false, true])("a blank --summary is applied without it and reported as not stored (json=%s)", async (json) => {
db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run("Original subject", id);
const { out, exit } = await runCli(["update", id, "--state", "in-progress", "--summary", " ", ...(json ? ["--json"] : [])]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject" });
if (json) expect(JSON.parse(out).advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
else expect(out).toContain(`The summary was blank, so it wasn't stored: ${id} keeps its summary`);
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
expect(exit).toBe(1);
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
expect(body.error).toBe("summary_evidence_not_persistable");
expect(body.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
});
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
+1 -9
View File
@@ -11,15 +11,7 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
// An answer typed at this machine is recorded as human@host, which names nobody: a reply there asks no one, waits on
// Steering and in the Feed, and OpenRig reports it as undeliverable, so its reply hint says so and points to the person's registered address.
it.each(["human@host", "human@host@host-origin"])("never points %s's reply at the unattributed address", (sender) => {
const last = wrapSendBody(sender, "driver@rig", "Answered.").split("\n").at(-1);
expect(last).toBe(`↩ ${sender} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`);
expect(last).not.toContain("rig queue create");
});
it.each(["human@kernel", "human-alice@kernel", "human-alice@host"])(
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
+1 -3
View File
@@ -1237,10 +1237,8 @@ export class TmuxAdapter {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const submit = keys.some(isSubmitKey);
const check = async () => {
// The caller's checks can wait (a conversation lookup), so they run first and the guard's input read runs
// last, as in sendText (#3076): input typed while they wait is seen, and a key is not sent onto it.
if (options?.beforeKeys) await options.beforeKeys();
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
if (options?.beforeKeys) await options.beforeKeys();
};
await check();
beforeWrite(); beforeInput?.();
+53 -34
View File
@@ -520,13 +520,14 @@ export interface QueueUpdateInput {
wakeMessage?: string;
/**
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
* updatable with any state change, not create-only: `rig queue block
* --summary --evidence-ref` persists them onto the EXISTING item so the
* attention query + Packet 2 read them, and a done close leaves its
* artifact on the finished row. A human-seat park still requires them.
* A blank value is not stored (it would erase the row's subject or
* evidence link); the update route reports any value
* the stored row doesn't carry, so a caller never believes it was saved.
* updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker),
* not create-only: `rig queue block --summary --evidence-ref` persists
* them onto the EXISTING item so the attention query + Packet 2 read
* them. A done close also persists evidence_ref, so a finished row carries
* its artifact. OPR.0.5.1 slice-51-06 D2: supplying either where it can't
* be stored is REJECTED (QueueRepositoryError "summary_evidence_not_persistable")
* before any mutation — not silently ignored — so a caller never believes
* unpersistable metadata was saved.
*/
summary?: string | null;
evidenceRef?: string | null;
@@ -2578,7 +2579,7 @@ export class QueueRepository {
}
/** The registered person an address names, so an ask back reaches them; null for anyone else, the unattributed
* terminal actor included (a person address, but a row there asks no one; it waits on Steering and in the Feed, and OpenRig reports it as undeliverable). */
* terminal actor included (it's a person address, but nothing is delivered there). */
reachablePerson(address: string | null | undefined): string | null {
if (!address || address === UNATTRIBUTED_ACTOR) return null;
const registry = this.loadHumanRegistryFn();
@@ -3454,7 +3455,6 @@ export class QueueRepository {
}
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
const hasValidEvidenceRef = typeof input.evidenceRef === "string" && input.evidenceRef.trim().length > 0;
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
qitem.state as "done" | "canceled" | "handed-off",
);
@@ -3664,15 +3664,35 @@ export class QueueRepository {
throw new QueueRepositoryError("invalid_human_notification", "An informational delivery cannot become a human approval park; author a separate decision request.");
}
// A park on a person by any address (a legacy human seat, or a registered person's own address or alias) keeps the
// summary and evidence it names, or the row's own where it names a blank one. validateHumanPark still requires them
// only for the legacy human-seat form.
// summary and evidence it names. validateHumanPark still requires them only for the legacy human-seat form.
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
// OPR.0.5.1 slice-51-06 D2 refused the whole transition when summary/evidence_ref came with a state it
// didn't store them for, so finished work could stay pending over optional metadata. A state change now
// stores them (below), except a blank value; the route reports what wasn't stored.
// A done close can also store the artifact the work produced, so the finished row carries its link.
const isDoneClose = input.state === "done";
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: an incoherent
// field must never silently persist (worse than a drop —
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
// empty string = present (a deliberate value → rejected on a non-park transition).
if (!isHumanPark) {
const invalidFields: Array<"summary" | "evidenceRef"> = [];
if (input.summary != null) invalidFields.push("summary");
if (input.evidenceRef != null && !isDoneClose) invalidFields.push("evidenceRef");
if (invalidFields.length > 0) {
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
const where = invalidFields.map((f) => f === "summary"
? "summary persists on a human-seat park, or on its own (rig queue update <id> --summary … with no --state)"
: "evidenceRef persists on a human-seat park or a done close").join("; ");
throw new QueueRepositoryError(
"summary_evidence_not_persistable",
`${where}; the '${input.state}' transition cannot store ${invalidFields.length > 1 ? "them" : "it"}. Remove ${flags}, or use one of those.`,
{ invalidFields },
);
}
}
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE, beside the reference
// reject above: an incoherent field must never silently persist (worse than a drop —
// the COALESCE below would write it). Admits-map, derived from LIVE schema use:
// closure_reason/closure_target → state "done", OR the PARK-RECORD form
// (state "blocked" with closureReason "blocked_on" — the workflow gate/park
@@ -3750,8 +3770,8 @@ export class QueueRepository {
let effectiveSummary = qitem.summary;
let effectiveEvidenceRef = qitem.evidenceRef;
if (isHumanPark) {
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
effectiveSummary = input.summary ?? qitem.summary;
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
const park = validateHumanPark({
blockedOn: effectiveBlockedOn,
summary: effectiveSummary,
@@ -3879,13 +3899,15 @@ export class QueueRepository {
input.qitemId
);
// FR-6: summary/evidence_ref are PERSISTED onto the existing item (not
// merely validated-then-dropped) — visible to the attention query, to
// Packet 2 and to every reader of the row. Only non-blank values are
// stored, on a park on a person too: a blank one would erase the row's
// subject or evidence link, and the route reports it as not stored.
if (hasValidSummary) this.persistSummary(input.qitemId, input.summary!);
if (hasValidEvidenceRef) this.persistEvidenceRef(input.qitemId, input.evidenceRef!);
// FR-6: park-time summary/evidence_ref are PERSISTED onto the existing
// item (not merely validated-then-dropped) — visible to the attention
// query and to Packet 2. A done close writes evidence_ref the same way.
if (isHumanPark) {
this.persistSummary(input.qitemId, input.summary ?? null);
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
} else if (isDoneClose) {
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
}
const transition = this.transitionLog.append({
qitemId: input.qitemId,
@@ -3969,9 +3991,9 @@ export class QueueRepository {
closureReason: validation.closureReason ?? null,
closureTarget: validation.closureTarget ?? null,
actorSession: input.actorSession,
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
// FR-1 × FR-6: the event carries the summary as of THIS mutation
// (park-time summary included) so surfaces refresh without a fetch.
summary: this.getById(input.qitemId)?.summary ?? null,
summary: effectiveSummary ?? null,
});
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
}
@@ -4538,11 +4560,9 @@ export class QueueRepository {
}
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions, and the evidence it
* points to, when it has any) and its decision state (open, blocked or terminal; who it's addressed to or parked on; its
* answers, a decision made for them, the current park, and the replies returned). A label, a wake, a claim or a note on a
* park doesn't change it; a new park does, on any blocker, and so does a new evidence pointer. A row with no evidence
* keeps the revision it had before evidence counted. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
const excludeOp =
@@ -4569,8 +4589,7 @@ export class QueueRepository {
if (transitions[i]!.state === "blocked" && transitions[i - 1]?.state !== "blocked") parkEntry = transitions[i]!.transitionId;
}
const decision = [item.body, item.humanIntent ?? null, item.humanQuestions ?? null, phase, item.destinationSession, item.blockedOn,
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
...(item.evidenceRef ? [item.evidenceRef] : [])];
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies];
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
}
+30 -16
View File
@@ -12,15 +12,35 @@ export type WaitingActivityReader = (session: string) => Pick<ArbitratedSeatStat
/** A blocked row's backstop when no live timer or watchdog is armed and its blocker isn't a row that can finish. */
export const NO_TIMED_BACKSTOP = "UNVERIFIED: no timed backstop";
/** This module's statements, prepared once per connection and reused. Preparing parses and plans the SQL every time,
* and these few fixed texts run on every waiting view, sweep and ladder read. A prepared statement still reads the
* current data each time it runs, and SQLite re-prepares it itself after a schema change, so reuse changes no result.
* Keyed by the connection, so they go with it, and by SQL text from this module's fixed set, capped as a backstop. */
const REUSED_STATEMENT_CAP = 32;
const reusedStatements = new WeakMap<Database.Database, Map<string, Database.Statement>>();
export function reusedStatement(db: Database.Database, sql: string): Database.Statement {
let byText = reusedStatements.get(db);
if (!byText) {
byText = new Map();
reusedStatements.set(db, byText);
}
let statement = byText.get(sql);
if (!statement) {
statement = db.prepare(sql);
if (byText.size < REUSED_STATEMENT_CAP) byText.set(sql, statement);
}
return statement;
}
/** A read of the existing transition log, not another progress receipt. Machine
* actors and typed wake receipts are bookkeeping, never task progress. Historical
* untyped author notes remain author testimony; their text is not classified.
* Delivery bookkeeping uses the reserved daemon actors and must not reset the
* ladder before it can join its dispatch marker to the delivery receipt. */
export function lastMeaningfulTransition(db: Database.Database, id: string): { id: number; at: string } | null {
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return null;
const hasWakes = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
const row = db.prepare(`SELECT t.transition_id, t.ts FROM (
if (!reusedStatement(db, "SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return null;
const hasWakes = reusedStatement(db, "SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
const row = reusedStatement(db, `SELECT t.transition_id, t.ts FROM (
SELECT *, LAG(state) OVER (ORDER BY transition_id) AS previous_state FROM queue_transitions WHERE qitem_id = ?
) t WHERE (t.actor_session NOT IN ('watchdog@system', 'wake-ladder@system', 'daemon@kernel', 'daemon@system') OR t.state IS NOT t.previous_state)
${hasWakes ? "AND NOT EXISTS (SELECT 1 FROM queue_transition_wakes w WHERE w.transition_id = t.transition_id AND w.phase = 'fired')" : ""}
@@ -34,9 +54,9 @@ export function lastMeaningfulTransitionMany(db: Database.Database, ids: readonl
const unique = [...new Set(ids)];
const out = new Map<string, { id: number; at: string } | null>(unique.map((id) => [id, null]));
if (unique.length === 0) return out;
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return out;
const hasWakes = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
const rows = db.prepare(`SELECT qitem_id, transition_id, ts FROM (
if (!reusedStatement(db, "SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return out;
const hasWakes = reusedStatement(db, "SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
const rows = reusedStatement(db, `SELECT qitem_id, transition_id, ts FROM (
SELECT t.qitem_id, t.transition_id, t.ts, ROW_NUMBER() OVER (PARTITION BY t.qitem_id ORDER BY t.transition_id DESC) AS newest FROM (
SELECT *, LAG(state) OVER (PARTITION BY qitem_id ORDER BY transition_id) AS previous_state FROM queue_transitions
WHERE qitem_id IN (SELECT value FROM json_each(?))
@@ -47,21 +67,15 @@ export function lastMeaningfulTransitionMany(db: Database.Database, ids: readonl
return out;
}
/** The waiting view's statements, each prepared once, and the schema checks they depend on, each made once. A listing
* holds one for all its rows; a single read uses a fresh one. A listing reads synchronously, so neither the statements
* nor the schema checks can go stale under it. */
/** The waiting view's statements (reused per connection) and the schema checks they depend on, each made once per
* instance. A listing holds one for all its rows; a single read uses a fresh one. A listing reads synchronously, so its
* schema checks can't go stale under it, and a fresh instance checks again. */
export class WaitingStatements {
private readonly prepared = new Map<string, Database.Statement>();
private readonly tables = new Map<string, boolean>();
constructor(private readonly db: Database.Database) {}
statement(sql: string): Database.Statement {
let prepared = this.prepared.get(sql);
if (!prepared) {
prepared = this.db.prepare(sql);
this.prepared.set(sql, prepared);
}
return prepared;
return reusedStatement(this.db, sql);
}
hasTable(name: string): boolean {
@@ -12,7 +12,7 @@ export function terminalAnswerMeaning(unattributed: string): string {
* which reaches them where they answer. `asked` is null when no registered person resolves: removed since the ask, an
* unreadable registry, or an ask to the unattributed actor itself. What to do then is this one sentence. */
export function askBackRoute(asked: string | null, unattributed: string, request: string): string {
const nowhere = `A row to ${unattributed} names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.`;
const nowhere = `Don't write to ${unattributed}: nothing is delivered there.`;
if (asked) {
return `If their answer is unclear or conditional, ask them back with an ask to ${asked} that names ${request}: `
+ `rig queue create --destination ${asked} --summary "<your question>" --body-file <file>. It reaches them where they answer (the Feed, Steering, and Slack when it's connected). ${nowhere}`;
-6
View File
@@ -100,10 +100,6 @@ export function appendDeliveredSegment(envelope: string, deltaMs: number): strin
return lines.join("\n") + rest;
}
/** The address OpenRig records an answer typed at this machine under (the daemon's UNATTRIBUTED_ACTOR). It names nobody,
* and a row there asks no one (it waits on Steering and in the Feed, and OpenRig reports it as undeliverable), so a reply hint never points at it. */
const UNATTRIBUTED_PERSON = "human@host";
/** Wrap a tmux-pane body with the canonical From/To envelope. The
* recipient pane sees both the sender's identity and a reply hint.
* Cross-host nudges should NOT double-wrap: the remote rig wraps
@@ -138,8 +134,6 @@ export function wrapPaneEnvelope(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: personAddress === UNATTRIBUTED_PERSON
? `↩ ${senderLabel} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: isOpenRigSender(senderLabel)
+5 -21
View File
@@ -211,24 +211,6 @@ export function queueRoutes(): Hono {
message: `No wake is set for this park: nothing wakes ${item.destinationSession} on a timer while ${item.qitemId} waits on ${item.blockedOn}. To add one, park it again with --wake-after <duration> (or --wake-watchdog <job-id>). When ${item.blockedOn} clears, rig queue resolve ${item.qitemId} --decision <evidence> unparks it.`,
};
}
/** A summary or evidence ref the update asked for but the stored row doesn't carry (a blank one, which isn't stored
* because it would erase what the row has, or one the repository couldn't write): read from the stored row, so the
* caller is told whatever the reason. */
function unstoredAdvisories(body: { summary?: unknown; evidenceRef?: unknown }, item: QueueItem) {
return ([["summary", "summary", "summary_not_stored"], ["evidenceRef", "evidence ref", "evidence_ref_not_stored"]] as const)
.flatMap(([field, name, code]) => {
const requested = body[field];
const stored = item[field];
if (typeof requested !== "string" || stored === requested) return [];
const why = requested.trim().length === 0 ? `The ${name} was blank, so it wasn't stored` : `The ${name} wasn't stored`;
return [{
code,
qitemId: item.qitemId,
field,
message: `${why}: ${item.qitemId} keeps ${stored ? `its ${name} "${stored}"` : `no ${name}`}. The rest of the update was applied.`,
}];
});
}
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
return c.get("inboxHandler" as never) as InboxHandler;
}
@@ -330,6 +312,9 @@ export function queueRoutes(): Hono {
: err.code === "reply_to_requires_update" ? 400
: err.code === "reply_to_not_found" ? 400
: err.code === "invalid_human_questions" ? 400
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
: err.code === "summary_evidence_not_persistable" ? 400
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
// misuse of a queue verb on a live workflow packet; structured
// 400 with the what/why/fix message, never a 500.
@@ -937,9 +922,8 @@ export function queueRoutes(): Hono {
evidenceRef: body.evidenceRef,
...resolveRecordedIdentity(c, identity), // P21 §4 era-stamp, plus the MCP adapter's caller on a direct attributed request (principal:v1)
});
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
.filter((advisory) => advisory !== null);
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
const advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
} catch (err) {
return errorResponse(c, err);
}
@@ -201,29 +201,6 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
});
it("a new evidence pointer on an open ask is a new decision: an answer read under the earlier pointer conflicts, with nothing written", async () => {
const f = fixture();
const id = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Approve the release notes", summary: "Notes?",
humanIntent: "decision", evidenceRef: "/proof/notes-v1.md", nudge: false })).qitemId;
const read = await f.revision(id);
f.repo.update({ qitemId: id, actorSession: ASKER, state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(f.repo.getById(id)).toMatchObject({ state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(await f.revision(id)).not.toBe(read);
const before = f.written(id);
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read }))
.toMatchObject({ status: 409, body: { error: "decision_revision_changed", currentRevision: await f.revision(id) } });
expect(f.written(id)).toEqual(before);
});
it("a row with no evidence keeps the revision it had before evidence counted", async () => {
// A fixed id, so the revision (which names the row's park episode) is the same on every run; the value is the one
// this ask had before evidence counted.
const f = fixture();
const id = (await f.repo.create({ qitemId: "qitem-revision-control", sourceSession: ASKER, destinationSession: PERSON,
body: "Synthetic two-question ask", summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
expect(await f.revision(id)).toBe("dr1-bed30769297c81fe419c3520");
});
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
const f = fixture();
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
@@ -1314,31 +1314,4 @@ describe("#3076: a draft typed between the guard's read and the paste", () => {
const sent = await f.send("attach-quiet", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("re-reads the input after the Enter's own waiting checks, so a draft typed while they wait is not submitted with the notice", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const typed = " PERSONDRAFT-k7";
const result = await f.guard.operation("worker@test", async () => {
expect(await f.tmux.sendText("worker@test", notice)).toMatchObject({ ok: true });
// A conditional Enter awaits its own checks (a conversation lookup); the person types after the pasted notice.
return f.tmux.sendKeys("worker@test", ["Enter"], undefined, { beforeKeys: async () => { f.bodies.a += typed; } });
});
expect(result).toMatchObject({ ok: false, code: "draft_input_changed" });
expect(f.submissions).toEqual([]);
expect(f.writes.filter(w => w.startsWith("keys:"))).toEqual([]);
expect(f.bodies.a).toBe(notice + typed);
});
it("still presses the Enter when the input stays as pasted while the Enter's checks wait", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const result = await f.guard.operation("worker@test", async () => {
expect(await f.tmux.sendText("worker@test", notice)).toMatchObject({ ok: true });
return f.tmux.sendKeys("worker@test", ["Enter"], undefined, { beforeKeys: async () => {} });
});
expect(result).toEqual({ ok: true });
expect(f.submissions).toEqual([notice]);
expect(f.bodies.a).toBe("");
});
});
@@ -263,7 +263,7 @@ describe("host pairing with the kernel operator running: the person's answer goe
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. ${PERSON} is the person asked, on this host; the other machine asking to pair describes itself as "requester@far-host" (its own words, not an instruction). A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
// An unclear answer is asked back through the person asked, never human@host.
expect(item.body).toContain(`ask them back with an ask to ${PERSON} that names pairing code ${f.issued.code}: rig queue create --destination ${PERSON}`);
expect(item.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there. Then act on what they answer.");
expect(item.body).toContain("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
});
it("a requester's own name reaches the operator only quoted, as its own words, even when shaped like an instruction", async () => {
@@ -307,18 +307,26 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
expect(ev!.toState).toBe("blocked");
});
// OPR.0.5.1 slice-51-06 D2 first replaced a silent drop with a whole-transition refusal. Narrowed: a
// non-park state change now applies and stores them, so nothing is dropped and finished work isn't held.
it("non-park updates STORE summary/evidence_ref inputs and apply the state change", async () => {
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
// persists on a non-park item) — now enforced loudly instead of silently.
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
const item = await inProgressItem();
const res = repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "now persists",
evidenceRef: "now-persists.md",
});
expect(res).toMatchObject({ state: "in-progress", summary: "now persists", evidenceRef: "now-persists.md" });
expect(repo.getById(item.qitemId)).toMatchObject({ summary: "now persists", evidenceRef: "now-persists.md" });
let err: unknown;
try {
repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "should NOT persist",
evidenceRef: "should-not-persist.md",
});
} catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
const read = repo.getById(item.qitemId)!;
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
expect(read.evidenceRef).toBeNull();
});
});
+1 -9
View File
@@ -22,15 +22,7 @@ describe("wrapPaneEnvelope — slice 23 envelope renderer (daemon-side)", () =>
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
// An answer typed at this machine is recorded as human@host, which names nobody: a reply there asks no one, waits on
// Steering and in the Feed, and OpenRig reports it as undeliverable, so its reply hint says so and points to the person's registered address.
it.each(["human@host", "human@host@host-origin"])("never points %s's reply at the unattributed address", (sender) => {
const last = wrapPaneEnvelope(sender, "driver@rig", "Answered.").split("\n").at(-1);
expect(last).toBe(`↩ ${sender} names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).`);
expect(last).not.toContain("rig queue create");
});
it.each(["human@kernel", "human-alice@kernel", "human-alice@host"])(
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
@@ -82,7 +82,7 @@ describe("the primary user acts on anyone's ask, unattributed, on that person's
expect(reply.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(reply.body).toContain("don't ask them to confirm it");
expect(reply.body).toContain(`ask them back with an ask to sarah@external that names ${sarahs.qitemId}: rig queue create --destination sarah@external`);
expect(reply.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
expect(reply.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(humanAnswerText(reply.body)).toBe("Ship it");
});
@@ -366,14 +366,19 @@ describe("show (getById) is the listing projection of one row", () => {
it("reads the row's last meaningful transition once, not once for the pickup and once for the waiting view", async () => {
const q = (await repo.create({ sourceSession: "orch@r", destinationSession: "worker@r", body: "work", nudge: false } as never)).qitemId;
const prepare = db.prepare.bind(db);
// Counts runs of the read, not prepares: the statement is prepared once per connection and reused.
type Run = (this: { source: string }, ...args: unknown[]) => unknown;
const proto = Object.getPrototypeOf(db.prepare("SELECT 1")) as { get: Run; all: Run };
let n = 0;
const spy = vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("actor_session NOT IN ('watchdog@system'")) n++;
return prepare(sql);
}) as typeof db.prepare);
const spies = (["get", "all"] as const).map((method) => {
const original = proto[method];
return vi.spyOn(proto, method).mockImplementation(function (this: { source: string }, ...args: unknown[]) {
if (this.source.includes("actor_session NOT IN ('watchdog@system'")) n++;
return original.apply(this, args);
});
});
repo.getById(q);
spy.mockRestore();
for (const spy of spies) spy.mockRestore();
expect(n).toBe(1);
});
});
@@ -118,20 +118,7 @@ describe("queue wake nudge carries the stored summary", () => {
} finally { await settle(); h.db.close(); }
});
it("a wake from human@host says that address names no person, and offers no reply command", async () => {
const h = makeHarness();
try {
const row = await h.repo.create({ sourceSession: "human@host", destinationSession: "dev@rig", body: "Please check the build." });
await vi.waitFor(() => expect(h.calls).toHaveLength(1));
const envelope = h.calls[0]!.text;
expect(envelope).toContain("From: human@host\nTo: dev@rig\n");
expect(envelopeBody(envelope)).toEqual([`Queue handoff: ${row.qitemId} - check your queue.`]);
expect(envelope.split("\n").at(-1)).toBe("↩ human@host names no person: a reply there asks no one. It waits on Steering and in the Feed, and OpenRig reports it as undeliverable. To ask the person back, use their registered address (rig gateway human list lists who's registered).");
expect(envelope).not.toContain("rig queue create");
} finally { await settle(); h.db.close(); }
});
it.each(["human@kernel"])("a wake from %s offers a reply accepted by the real queue writer", async (sender) => {
it.each(["human@kernel", "human@host"])("a wake from %s offers a reply accepted by the real queue writer", async (sender) => {
const h = makeHarness();
try {
const row = await h.repo.create({ sourceSession: sender, destinationSession: "dev@rig", body: "Please check the build." });
@@ -1,7 +1,7 @@
// 51-06 D2, narrowed — summary/evidence_ref given with a state change are STORED and the transition applies:
// optional metadata never holds finished work at pending. A blank summary is the one value not stored (it would
// erase the row's subject); the route reports it, so a caller never believes it was saved.
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref).
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
@@ -17,9 +17,11 @@ import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_rep
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
import { queueRoutes } from "../src/routes/queue.js";
const REJECT_CODE = "summary_evidence_not_persistable";
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
@@ -27,7 +29,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
return app;
}
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
describe("51-06 D2 — non-park queue update metadata reject", () => {
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
@@ -44,57 +46,52 @@ describe("51-06 D2 narrowed — queue update metadata on a state change", () =>
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
const setSummary = (id: string, summary: string) => db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run(summary, id);
const setEvidence = (id: string, ref: string) => db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(ref, id);
const post = (path: string, body: Record<string, unknown>, session: string) => app.request(path, {
method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
});
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
it.each([
["summary", { summary: "KEEP" }, { summary: "KEEP", evidenceRef: null }],
["evidence_ref", { evidenceRef: "/proof/x.md" }, { summary: null, evidenceRef: "/proof/x.md" }],
["both", { summary: "S", evidenceRef: "/e" }, { summary: "S", evidenceRef: "/e" }],
])("a state change carrying %s applies and stores it, with one transition", async (_label, input, stored) => {
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input });
expect(res).toMatchObject({ state: "in-progress", ...stored });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", ...stored });
expect(txnCount(id)).toBe(2);
const beforeState = repo.getByIdOrThrow(id).state;
const beforeTxns = txnCount(id);
const beforeEvents = eventCount();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
// zero mutation: no UPDATE (state), no log (transition), no event
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
}
it("rejects --summary on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
});
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
});
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
});
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
});
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", async () => {
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
const id = await mkItem();
const blocker = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(res).toMatchObject({ state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "blocked", summary: "Relayed; waiting on the reply" });
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
});
it.each(["done", "canceled"] as const)("a %s close stores summary and evidence_ref on the closed row", async (state) => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const close = state === "done" ? { closureReason: "no-follow-on" } : {};
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state, ...close, summary: "Finished", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(await shown.json()).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
});
it.each(["", " "])("a blank summary (%j) isn't stored: the state still changes and the row keeps its subject", async (blank) => {
const id = await mkItem();
setSummary(id, "Original subject");
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: blank, evidenceRef: "/e" });
expect(res).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: "/e" });
});
it("metadata-free update is UNCHANGED (null = absent)", async () => {
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
expect(res.state).toBe("in-progress");
});
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
@@ -117,101 +114,43 @@ describe("51-06 D2 narrowed — queue update metadata on a state change", () =>
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
});
it.each(["", " "])("a blank summary and evidence ref (%j) on a park on a registered person aren't stored: the row keeps its own", async (blank) => {
const person = { entityId: "sarah", class: "human" as const, displayName: "Sarah", address: "sarah@external", connectorBindings: [],
prefs: { deliveryClass: "A" as const } };
const people = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [person] }) });
const id = await mkItem();
setSummary(id, "Which layout?"); setEvidence(id, "/proof/layout.md");
expect(people.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@external", summary: blank, evidenceRef: blank }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@external", summary: "Which layout?", evidenceRef: "/proof/layout.md" });
});
it("a legacy human-seat park given a blank summary and evidence ref parks on the row's own, instead of refusing", async () => {
const id = await mkItem();
setSummary(id, "PARK-KEEP"); setEvidence(id, "/proof/park.md");
expect(repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " ", evidenceRef: " " }))
.toMatchObject({ state: "blocked", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
});
it("ROUTE: a blank summary on a park on a person -> 200, parked, and the advisory names the summary as not stored", async () => {
const id = await mkItem();
setSummary(id, "Original subject"); setEvidence(id, "/proof/kept.md");
const res = await post(`/api/queue/${id}/update`, { actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " " }, "orch@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "blocked", summary: "Original subject" });
expect(body.advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
});
it("a park on an unregistered name on another daemon's domain stores its summary too", async () => {
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
const id = await mkItem();
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
let err: unknown;
try { nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
});
it("ROUTE: POST /:id/update with a state change + summary -> 200, stored, no advisory", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }, "orch@rig");
it("a done close stores evidence_ref, and queue show returns it on the closed row", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(((await shown.json()) as { state: string; evidenceRef?: string | null })).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
});
it("a canceled close still refuses evidence_ref, before any mutation", async () => {
const id = await mkItem();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "canceled", evidenceRef: "/proof/x.md" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "pending", evidenceRef: null });
});
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
// P21 I3: create/update derive the sender from the transport header; header==body claim ⇒ tolerated.
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "orch@rig" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "KEEP" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "in-progress", summary: "KEEP" });
expect(body.advisories).toBeUndefined();
});
it("ROUTE: a blank summary with a state change -> 200, state applied, and an advisory names the summary as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject" });
expect(body.advisories).toEqual([{
code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary was blank, so it wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.`,
}]);
});
it("a stored evidence pointer survives a blank one on a state change, and a done close keeps it too", async () => {
const id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item", evidenceRef: "/proof/kept.md" })).qitemId;
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", evidenceRef: "" })).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: " " }))
.toMatchObject({ state: "done", evidenceRef: "/proof/kept.md" });
});
it("ROUTE: a blank evidence ref with a state change -> 200, the pointer kept, and an advisory names it as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", evidenceRef: "/proof/kept.md" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", evidenceRef: " " }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(body.advisories).toEqual([{
code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref was blank, so it wasn't stored: ${id} keeps its evidence ref "/proof/kept.md". The rest of the update was applied.`,
}]);
});
it("ROUTE: when the repository can't write either field, the state still changes, the caller is told, and the event agrees with the stored row", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
// A column probe that read false at construction (its PRAGMA read failed), while the columns exist.
Object.assign(repo as unknown as Record<string, boolean>, { hasSummaryColumn: false, hasEvidenceRefColumn: false });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "New subject", evidenceRef: "/proof/new.md" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
expect(body.advisories).toEqual([
{ code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.` },
{ code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref wasn't stored: ${id} keeps no evidence ref. The rest of the update was applied.` },
]);
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "in-progress", summary: "Original subject" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "dev-x@rig" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; invalidFields?: string[] };
expect(body.error).toBe(REJECT_CODE);
expect(body.invalidFields).toEqual(["summary"]);
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { WaitingStatements, lastMeaningfulTransition, lastMeaningfulTransitionMany, reusedStatement } from "../src/domain/queue-waiting.js";
// The waiting view reuses its statements per connection. These pin what that reuse must not change: every read sees
// current data, the schema checks run each call, the cached texts are bounded, and statements go with their connection.
describe("the waiting view's reused statements", () => {
const open: Database.Database[] = [];
const connection = (wakes = true) => {
const db = new Database(":memory:");
open.push(db);
db.exec(`CREATE TABLE queue_transitions (transition_id INTEGER PRIMARY KEY AUTOINCREMENT, qitem_id TEXT NOT NULL, ts TEXT NOT NULL,
state TEXT NOT NULL, actor_session TEXT NOT NULL)`);
if (wakes) db.exec("CREATE TABLE queue_transition_wakes (transition_id INTEGER NOT NULL, qitem_id TEXT NOT NULL, phase TEXT NOT NULL)");
return db;
};
const transition = (db: Database.Database, qitemId: string, state: string, actor = "a-one@rig") =>
Number(db.prepare("INSERT INTO queue_transitions (qitem_id, ts, state, actor_session) VALUES (?, ?, ?, ?)")
.run(qitemId, new Date().toISOString(), state, actor).lastInsertRowid);
afterEach(() => {
for (const db of open.splice(0)) if (db.open) db.close();
});
it("reads current data on every call through the same statement", () => {
const db = connection();
const first = transition(db, "q-1", "pending");
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(first);
const second = transition(db, "q-1", "in-progress");
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(second);
transition(db, "q-1", "in-progress", "watchdog@system"); // bookkeeping, not progress
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(second);
expect(lastMeaningfulTransitionMany(db, ["q-1", "q-2"])).toEqual(new Map([["q-1", { id: second, at: expect.any(String) }], ["q-2", null]]));
const third = transition(db, "q-2", "pending");
expect(lastMeaningfulTransitionMany(db, ["q-1", "q-2"]).get("q-2")?.id).toBe(third);
});
it("checks the schema each call, and keeps working across a schema change", () => {
const db = connection(false);
const id = transition(db, "q-1", "pending");
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(id);
// The wakes table arrives later: the next call sees it and leaves out a fired wake's transition.
db.exec("CREATE TABLE queue_transition_wakes (transition_id INTEGER NOT NULL, qitem_id TEXT NOT NULL, phase TEXT NOT NULL)");
const woken = transition(db, "q-1", "blocked", "a-one@rig");
db.prepare("INSERT INTO queue_transition_wakes (transition_id, qitem_id, phase) VALUES (?, 'q-1', 'fired')").run(woken);
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(id);
// A column added under a reused statement: SQLite re-prepares it, and the result is unchanged.
db.exec("ALTER TABLE queue_transitions ADD COLUMN note TEXT");
expect(lastMeaningfulTransition(db, "q-1")?.id).toBe(id);
// A WaitingStatements instance memoizes its table checks; a fresh one checks again.
const before = new WaitingStatements(db);
expect(before.hasTable("late_table")).toBe(false);
db.exec("CREATE TABLE late_table (x INTEGER)");
expect(before.hasTable("late_table")).toBe(false);
expect(new WaitingStatements(db).hasTable("late_table")).toBe(true);
});
it("reuses one statement per text and connection, never across connections, and caps the cached texts", () => {
const one = connection();
const two = connection();
const sql = "SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'";
expect(reusedStatement(one, sql)).toBe(reusedStatement(one, sql));
expect(new WaitingStatements(one).statement(sql)).toBe(reusedStatement(one, sql));
expect(reusedStatement(two, sql)).not.toBe(reusedStatement(one, sql));
// Past the cap a text is prepared fresh each time, never kept.
const texts = Array.from({ length: 40 }, (_, i) => `SELECT ${i} AS n`);
for (const text of texts) reusedStatement(two, text);
expect(reusedStatement(two, texts[39]!)).not.toBe(reusedStatement(two, texts[39]!));
expect(reusedStatement(two, texts[0]!)).toBe(reusedStatement(two, texts[0]!));
// A closed connection fails as a prepare on it would; the other keeps working.
one.close();
expect(() => lastMeaningfulTransition(one, "q-1")).toThrow();
expect(lastMeaningfulTransition(two, "q-1")).toBeNull();
});
});
@@ -5,7 +5,7 @@ describe("what a receiving agent reads to act on a person's answer", () => {
it("names the person to ask back, and never the unattributed terminal actor", () => {
const route = askBackRoute("sarah@external", "human@host", "request q-1");
expect(route).toContain("ask them back with an ask to sarah@external that names request q-1: rig queue create --destination sarah@external");
expect(route).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
expect(route).toContain("Don't write to human@host: nothing is delivered there.");
});
it("with no registered person to ask back: says so once, keeps the request and the answer waiting, and names the ways through", () => {
@@ -694,7 +694,7 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(handed.body).toContain(`- **Who's who:** ${SEAT} is the agent asking to run the tool; ${asked} is the person it asked; human@host answered.`);
expect(handed.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(handed.body).toContain(`ask them back with an ask to ${asked} that names request ${askId}`);
expect(handed.body).toContain("A row to human@host names no person: it asks no one, waits on Steering and in the Feed, and OpenRig reports it as undeliverable, so don't ask them back there.");
expect(handed.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(handed.body).toContain("Closing this item without deciding permits nothing");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
-41
View File
@@ -695,47 +695,6 @@ describe("TmuxAdapter", () => {
expect(tmux.cmds()).toContain("tmux delete-buffer -b 'cr'");
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
it("input typed while the carriage return's re-check awaits the caller's checks stops it (#3076)", async () => {
const tmux = readOnlyTmux();
const adapter = new TmuxAdapter(tmux.exec, tmux.files);
let composer = "notice";
const seat = guard(async () => {
if (composer !== "notice") throw new DeliveryGuardError("draft_input_changed", "The input changed; Enter was not sent.");
});
adapter.deliveryGuard = seat as unknown as SeatDeliveryGuard;
let calls = 0;
const beforeKeys = vi.fn(async () => { calls += 1; if (calls === 2) composer = "notice PERSONDRAFT-k7"; });
expect(await adapter.sendKeys("dev@rig", ["Enter"], undefined, { beforeKeys }))
.toEqual({ ok: false, code: "draft_input_changed", message: "The input changed; Enter was not sent." });
expect(tmux.cmds().some(cmd => cmd.startsWith("tmux paste-buffer"))).toBe(false);
expect(tmux.cmds()).toContain("tmux delete-buffer -b 'cr'");
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
});
describe("sendKeys reads the input after the caller's waiting checks (#3076)", () => {
const TARGET = { nodeId: "n1", session: "dev@rig", occupant: "o1", pane: "%1" };
it("a draft typed while beforeKeys awaits stops the Enter, and nothing reaches the pane", async () => {
const exec = vi.fn<ExecFn>(async (cmd: string) => cmd.startsWith("tmux list-panes") ? "%1|0|/w|80|24|1\n" : "");
const adapter = new TmuxAdapter(exec);
let composer = "notice";
const seat = {
maybeTarget: () => TARGET, target: () => TARGET, ownsLifecycle: () => false,
checkInput: vi.fn(), noteSubmitted: vi.fn(), notePasted: async () => {}, attachInputInspection: () => {},
input: (_name: string, fn: () => Promise<unknown>) => fn(), humanInput: (_name: string, fn: () => Promise<unknown>) => fn(),
needsInputCheck: () => true,
beforeInput: vi.fn(async () => {
if (composer !== "notice") throw new DeliveryGuardError("draft_input_changed", "The input changed; Enter was not sent.");
}),
};
adapter.deliveryGuard = seat as unknown as SeatDeliveryGuard;
const result = await adapter.sendKeys("dev@rig", ["Enter"], undefined, { beforeKeys: async () => { composer = "notice PERSONDRAFT-k7"; } });
expect(result).toEqual({ ok: false, code: "draft_input_changed", message: "The input changed; Enter was not sent." });
expect(exec.mock.calls.map(([cmd]) => cmd).some(cmd => cmd.startsWith("tmux send-keys"))).toBe(false);
expect(seat.noteSubmitted).not.toHaveBeenCalled();
});
});
describe("killSession", () => {
+2 -4
View File
@@ -6,8 +6,6 @@ export function daemonStartArgs(target: string): string[] {
|| url.username || url.password || url.search || url.hash || url.pathname !== "/") {
throw new Error("This target is not a local daemon endpoint. Reconnect after its owner starts it; no local daemon was started.");
}
// The daemon's default bind already listens on 127.0.0.1, with Tailscale beside it when present; naming that host would
// make it the only listener. Another loopback spelling isn't in the default bind, so it's still named.
const host = url.hostname.replace(/^\[|\]$/g, "");
return ["daemon", "start", "--no-kernel", ...(host === "127.0.0.1" ? [] : ["--host", host]), "--port", url.port || "80"];
return ["daemon", "start", "--no-kernel", "--host", url.hostname.replace(/^\[|\]$/g, ""),
"--port", url.port || "80"];
}
-2
View File
@@ -389,8 +389,6 @@ async function run(): Promise<void> {
} else {
void refreshCrashCart();
void live?.refresh();
// Daemon only: no seat was started. Start and return lists the rigs, the kernel first, to bring one back.
if (startup) view.dispatch({ type: "notice", message: "Daemon started; this start didn't start any seats. Press S to choose seats to bring back, the kernel's first." });
}
});
return;
-10
View File
@@ -163,8 +163,6 @@ export class StartupController {
const failure = s.notice;
await this.refresh();
if (!confirmed) { s.notice = failure; s.detail = failure; this.changed(); }
// refresh() moves the page on; read it again rather than through the narrowed `s.page`.
else if (this.state.page === "rigs") { s.notice = daemonOnlyNotice(s.rigs); this.changed(); }
return;
}
if (s.page === "rigs" && key === "k" && !s.rigs.some((r) => r.name === "kernel")) {
@@ -246,14 +244,6 @@ export class StartupController {
}
}
/** After a daemon-only start: this start brought back no seat, and the existing way to bring one back is the rig list
* here, where the kernel comes first. It says nothing about why any seat isn't running. */
export function daemonOnlyNotice(rigs: Array<{ name: string }>): string {
return rigs.some((r) => r.name === "kernel")
? "Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one."
: "Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.";
}
export function startupLines(s: StartupState): Array<{ text: string; action?: Action }> {
const button = (text: string, key: string) => ({ text, action: { type: "startup" as const, key } });
const lines: Array<{ text: string; action?: Action }> = [
@@ -6,16 +6,10 @@ import { resolveCrashCartKey } from "../src/crash-cart/keys.js";
describe("local startup target", () => {
it("starts only the intended local daemon, leaving occupant selection to the user", () => {
// The default bind is 127.0.0.1 plus Tailscale when present, so naming 127.0.0.1 would drop the Tailscale listener.
expect(daemonStartArgs("http://127.0.0.1:17433")).toEqual([
"daemon", "start", "--no-kernel", "--port", "17433",
"daemon", "start", "--no-kernel", "--host", "127.0.0.1", "--port", "17433",
]);
});
it.each([["http://localhost:7433", "localhost"], ["http://[::1]:7433", "::1"]])(
"names %s's host, which the default bind doesn't listen on", (target, host) => {
expect(daemonStartArgs(target)).toEqual(["daemon", "start", "--no-kernel", "--host", host, "--port", "7433"]);
},
);
it.each(["https://example.test", "http://10.0.0.2:7433", "http://user:password@localhost:7433", "http://localhost:7433/other"])(
"does not turn %s into local startup authority", (target) => {
expect(() => daemonStartArgs(target)).toThrow("no local daemon was started");
+1 -6
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { DaemonClient, launchNodeNotice } from "../src/daemon-client.js";
import { StartupController, daemonOnlyNotice, startupLines, type StartupSeat } from "../src/startup.js";
import { StartupController, startupLines, type StartupSeat } from "../src/startup.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { renderScreen } from "../src/render.js";
@@ -118,11 +118,6 @@ describe("TUI startup choices", () => {
expect(f.startDaemon).toHaveBeenCalledTimes(1);
expect(f.posts).toEqual([]);
expect(f.controller.state.page).toBe("rigs");
// It names the existing way back for the kernel, and claims nothing about why it isn't running.
expect(f.controller.state.notice).toBe("Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one.");
});
it("with no kernel rig, a daemon-only start points at choosing a rig or setting up a kernel", () => {
expect(daemonOnlyNotice([{ name: "work" }])).toBe("Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.");
});
it("decline and refresh invalidate fresh consent with no launch effect", async () => {
const f = fixture(); await chooseOperator(f);
+5 -8
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Lists lines added by a change that contain values tied to one machine, network or account, so
// the author can replace them with portable ones or keep them on purpose. A finding is a question, not a verdict: the default
// exits 0 with or without findings, --strict exits 1 when findings exist, and 2 is an operational error (bad argument, git failure).
// the author can replace them with portable ones or keep them on purpose. Findings never fail it: a finding is a question, not a verdict. It exits 0
// with or without findings and 2 on an operational error (bad argument, git failure).
//
// What it looks for:
// 1. Credentials: keys, tokens, passwords, key blocks.
@@ -17,7 +17,6 @@
// node scripts/portability-report.mjs --staged # what is staged for the next commit
// add --out report.md to also write the report to a file
// add --locations-only to list file and line without quoting the matched text (for public CI output)
// add --strict to exit 1 when findings exist (the default stays advisory: exit 0 either way)
import { execFileSync } from "node:child_process";
import { writeFileSync, realpathSync } from "node:fs";
@@ -134,12 +133,11 @@ export function renderReport(findings, label, { locationsOnly = false } = {}) {
}
function parseArguments(argv) {
const options = { staged: false, locationsOnly: false, strict: false };
const options = { staged: false, locationsOnly: false };
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (key === "--staged") options.staged = true;
else if (key === "--locations-only") options.locationsOnly = true;
else if (key === "--strict") options.strict = true;
else if (["--from", "--to", "--out", "--repo"].includes(key)) options[key.slice(2)] = argv[++index];
else throw new Error(`Unknown argument: ${key}`);
}
@@ -165,13 +163,12 @@ export function main(argv = process.argv.slice(2)) {
label = `${from.slice(0, 8)}..${to.length > 12 ? to.slice(0, 8) : to}`;
}
const diff = git(repo, ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", "--diff-filter=ACMR", ...range]);
const findings = findPortabilityIssues(addedLines(diff));
const report = renderReport(findings, label, {
const report = renderReport(findPortabilityIssues(addedLines(diff)), label, {
locationsOnly: options.locationsOnly,
});
process.stdout.write(report);
if (options.out) writeFileSync(options.out, report);
return options.strict && findings.length > 0 ? 1 : 0;
return 0;
}
function invokedDirectly() {
-29
View File
@@ -173,32 +173,3 @@ test("strips Git's separator tab and leaves C-quoted filenames quoted", () => {
}
assert.deepEqual(addedLines("+++ b/ordinary name.md\t\n@@ -0,0 +1 @@\n+see /Users/alice/project\n"), [{ file: "ordinary name.md", line: 1, text: "see /Users/alice/project" }]);
});
test("--strict exits 1 on findings, keeps the advisory default at 0, and exits 0 on a clean stage", async () => {
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const { execFileSync, spawnSync } = await import("node:child_process");
const repo = mkdtempSync(join(tmpdir(), "openrig-portability-strict-"));
const script = fileURLToPath(new URL("./portability-report.mjs", import.meta.url));
try {
execFileSync("git", ["init", "--quiet", repo]);
execFileSync("git", ["-C", repo, "config", "user.email", "[email protected]"]);
execFileSync("git", ["-C", repo, "config", "user.name", "t"]);
writeFileSync(join(repo, "a.md"), "see /Users/alice/work\n");
execFileSync("git", ["-C", repo, "add", "--", "a.md"]);
const strictWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictWithFindings.status, 1, strictWithFindings.stdout + strictWithFindings.stderr);
const advisoryWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged"], { encoding: "utf8" });
assert.equal(advisoryWithFindings.status, 0, advisoryWithFindings.stdout + advisoryWithFindings.stderr);
execFileSync("git", ["-C", repo, "commit", "--quiet", "-m", "seed"]);
writeFileSync(join(repo, "b.md"), "Nothing machine-specific here.\n");
execFileSync("git", ["-C", repo, "add", "--", "b.md"]);
const strictClean = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictClean.status, 0, strictClean.stdout + strictClean.stderr);
} finally { rmSync(repo, { recursive: true, force: true }); }
});