mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270a28cb40 | ||
|
|
45d390d157 | ||
|
|
0da9a30622 | ||
|
|
8dcc06eb2b | ||
|
|
795dcc8538 | ||
|
|
7539373d5c |
No files matched your search
@@ -33,7 +33,7 @@ suite. Both sets are readable in the root `package.json` scripts.
|
||||
|
||||
| Lane | Command | Meaning |
|
||||
|---|---|---|
|
||||
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings never fail it (`.github/workflows/portability-report.yml`) |
|
||||
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings don't fail it by default, and `--strict` exits 1 when it finds any. The workflow (`.github/workflows/portability-report.yml`) runs it without `--strict`. |
|
||||
|
||||
## The norm (web-UI freeze at 0.5.0)
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ 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: 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -24,9 +24,15 @@ 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 at every interruption level
|
||||
`--dangerously-interact --reason` remains available in every mode, `hold` included,
|
||||
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>`.
|
||||
|
||||
@@ -1166,14 +1166,19 @@ 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}`);
|
||||
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}${daemonLabel}`);
|
||||
} 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)`);
|
||||
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)${daemonLabel}`);
|
||||
} else {
|
||||
console.log("No rigs");
|
||||
console.log(`No rigs${daemonLabel}`);
|
||||
}
|
||||
// A host can have connected assistants and no terminal rigs at all.
|
||||
await printConnectedAssistants(client, opts.rig);
|
||||
@@ -1192,7 +1197,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`);
|
||||
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention · daemon ${client.baseUrl}`);
|
||||
|
||||
// Human output: apply default truncation budget unless --full.
|
||||
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
|
||||
|
||||
@@ -347,7 +347,9 @@ async function withClient<T>(
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (isIndeterminateRemoteWrite(status, body)) {
|
||||
if (status >= 400 && body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate") {
|
||||
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
|
||||
}
|
||||
if (json) {
|
||||
@@ -461,34 +463,6 @@ function writeReceipt(full: boolean | undefined, ctx: ReceiptContext): ReceiptCo
|
||||
return !full && compactOutputEnabled() ? ctx : null;
|
||||
}
|
||||
|
||||
/** A handoff whose outcome is unknown may have committed. This names the source row to read before any retry, on the
|
||||
* daemon the command used, and where a successor would be. It never says the handoff failed, names a successor id, or
|
||||
* asks for the handoff to be repeated. */
|
||||
function handoffRecoveryHint(qitemId: string, hostId: string | undefined): string {
|
||||
const remote = remoteHostOf(hostId);
|
||||
return `Before any retry, read source row ${qitemId} at the same endpoint and OPENRIG_HOME: `
|
||||
+ `rig queue show ${shellQuote(qitemId)} --full --json. Read its recorded outcome: a handed-off row names its successor`
|
||||
+ (remote ? ` on host ${shellQuote(remote)}` : "")
|
||||
+ ", or says why it can't. A row that still reads open doesn't show the handoff failed: it may still commit.";
|
||||
}
|
||||
|
||||
/** A handoff whose answer was lost (no answer in time, a lost connection, or an unreadable response) carries the hint in
|
||||
* its error. A pre-header disconnect can follow a committed handoff, as it can a create, so the shared renderer must not
|
||||
* call it undelivered. */
|
||||
function withHandoffRecovery(error: unknown, qitemId: string, hostId: string | undefined): unknown {
|
||||
if (!(error instanceof DaemonConnectionError || error instanceof DaemonResponseError)) return error;
|
||||
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
|
||||
error.message += ` ${handoffRecoveryHint(qitemId, hostId)}`;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The selected daemon's forward to another host lost the remote's answer: the write was sent and may still commit. */
|
||||
function isIndeterminateRemoteWrite(status: number, body: unknown): boolean {
|
||||
return status >= 400 && !!body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate";
|
||||
}
|
||||
|
||||
/** A `--host` id that may name another daemon. Empty and `local` never do; one that
|
||||
* equals the selected daemon's own host id is settled by {@link settleReceiptHost}. */
|
||||
function remoteHostOf(hostId: string | undefined): string | undefined {
|
||||
@@ -1428,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 (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("--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("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
@@ -1713,13 +1687,12 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1816,13 +1789,12 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff-and-complete", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ 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,
|
||||
@@ -108,6 +112,8 @@ 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
|
||||
|
||||
@@ -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 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"),
|
||||
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"),
|
||||
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\n\nConnected assistants: check-in unavailable");
|
||||
expect((await run([])).join("\n")).toContain(`No rigs · daemon http://127.0.0.1:${port}\n\nConnected assistants: check-in unavailable`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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}`]);
|
||||
expect(result.logs).toEqual([`No rigs match --filter ${key}=${value} · daemon http://127.0.0.1:${port}`]);
|
||||
expect(result.errLogs).toHaveLength(1);
|
||||
expect(result.errLogs[0]).toContain(key);
|
||||
expect(result.errLogs[0]).toContain("was not applied");
|
||||
|
||||
@@ -6,8 +6,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "../src/client.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { DaemonTimeoutError } from "../src/client.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
@@ -45,15 +44,13 @@ function makeDeps(replies: Reply[]): { deps: QueueDeps; requests: Request[] } {
|
||||
describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
let home: string;
|
||||
let stderr: string[];
|
||||
let stdout: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "queue-xhost-wait-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "lead@rig-a");
|
||||
stderr = [];
|
||||
stdout = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { stdout.push(a.join(" ")); });
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { stderr.push(a.join(" ")); });
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { stderr.push(String(chunk)); return true; });
|
||||
process.exitCode = undefined;
|
||||
@@ -103,78 +100,4 @@ describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
expect(all).toContain("The write outcome is INDETERMINATE");
|
||||
expect(all).toMatch(/repeat the same create with --id 'qitem-\d{14}-[0-9a-f]{16}'/);
|
||||
});
|
||||
|
||||
// A handoff's lost answer leaves the source row, on the daemon the command used, as the thing to read: its successor is
|
||||
// named there once recorded. The error never names a successor id, says the handoff failed, or asks to repeat it.
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [where, to, successor] of [
|
||||
["to another host", "dev@rig-b@vps-b", "Read its recorded outcome: a handed-off row names its successor on host 'vps-b', or says why it can't."],
|
||||
["on this daemon", "dev@rig-b", "Read its recorded outcome: a handed-off row names its successor, or says why it can't."],
|
||||
] as const) {
|
||||
it(`a ${verb} ${where} that gets no answer in time names the source row to read before any retry`, async () => {
|
||||
let thrown: unknown;
|
||||
await run([verb, "qitem-src-1", "--to", to, "--summary", "s"],
|
||||
[new DaemonTimeoutError(`Request to http://localhost:7433/api/queue/qitem-src-1/${verb} timed out after 15000ms`)])
|
||||
.catch((e: unknown) => { thrown = e; });
|
||||
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
|
||||
const message = (thrown as Error).message;
|
||||
expect(message).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(message).toContain(successor);
|
||||
expect(message).toContain("doesn't show the handoff failed");
|
||||
expect(message).not.toMatch(/--id|repeat the/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered as a person sees it, through the shared program renderer: a lost connection or an unreadable response after
|
||||
// a handoff was sent is an unknown outcome, never "not delivered", and keeps the source row to read.
|
||||
async function runRendered(argv: string[], replies: Reply[], json: boolean): Promise<{ text: string; code: number }> {
|
||||
const { deps } = makeDeps(replies);
|
||||
const lines: string[] = [];
|
||||
const code = await runProgram(createProgram({ queueDeps: deps }), ["node", "rig", "queue", ...argv, ...(json ? ["--json"] : [])],
|
||||
{ out: (l) => lines.push(l), err: (l) => lines.push(l), exit: () => {} });
|
||||
return { text: [...stderr, ...lines].join("\n"), code };
|
||||
}
|
||||
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [kind, error] of [
|
||||
["a lost connection", () => new DaemonConnectionError("Cannot connect to the daemon at http://localhost:7433: fetch failed", "ECONNRESET")],
|
||||
["an unreadable response", () => new DaemonResponseError(200, "")],
|
||||
] as const) {
|
||||
for (const json of [false, true]) {
|
||||
it(`a ${verb} that meets ${kind} reads as an unknown outcome, never undelivered (${json ? "--json" : "human"})`, async () => {
|
||||
const { text, code } = await runRendered([verb, "qitem-src-1", "--to", "dev@rig-b", "--summary", "s"], [error()], json);
|
||||
expect(code).toBe(1);
|
||||
expect(text).toContain("outcome is UNKNOWN");
|
||||
expect(text).not.toMatch(/not delivered/i);
|
||||
expect(text).toContain("Before any retry, read source row qitem-src-1");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The selected daemon's forward can answer with a structured reply instead of throwing. An explicit indeterminate one
|
||||
// gets the same source-row hint, with the reply and its exit code unchanged; a refusal before any forward implies no
|
||||
// write that may still commit.
|
||||
const INDETERMINATE = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unreachable",
|
||||
outcome: "indeterminate", detail: "remote queue write timed out after 10000ms; the remote may still commit it" } };
|
||||
const REFUSED = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unsupported-transport",
|
||||
detail: "host 'vps-b' is SSH-declared" } };
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
it(`a ${verb} whose forward answers indeterminate names the source row to read, keeping the reply and exit code`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [INDETERMINATE]);
|
||||
const all = stderr.join("\n");
|
||||
expect(all).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(all).toContain("a handed-off row names its successor on host 'vps-b', or says why it can't.");
|
||||
expect(stdout).toEqual([JSON.stringify(INDETERMINATE.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it(`a ${verb} the daemon refused to forward says nothing may still commit`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [REFUSED]);
|
||||
expect(stderr.join("\n")).not.toMatch(/may still commit|INDETERMINATE|Before any retry/);
|
||||
expect(stdout).toEqual([JSON.stringify(REFUSED.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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: "summary_evidence_not_persistable", message: "x" } }],
|
||||
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
|
||||
];
|
||||
for (const [label, reply] of UNPROVEN) {
|
||||
it(`never says it after ${label}`, async () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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.
|
||||
// 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.
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
@@ -107,27 +105,29 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
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("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("--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("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("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
|
||||
|
||||
@@ -11,7 +11,15 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
|
||||
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
|
||||
});
|
||||
|
||||
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
|
||||
// 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"])(
|
||||
"gives %s a queue update reply with the existing human-route fields",
|
||||
(sender) => {
|
||||
const body = "Please check this.\nKeep the actor unchanged.";
|
||||
|
||||
@@ -1237,8 +1237,10 @@ export class TmuxAdapter {
|
||||
const identity = this.freshManaged.get(target)?.nodeId ?? target;
|
||||
const submit = keys.some(isSubmitKey);
|
||||
const check = async () => {
|
||||
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
|
||||
// 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);
|
||||
};
|
||||
await check();
|
||||
beforeWrite(); beforeInput?.();
|
||||
|
||||
@@ -520,14 +520,13 @@ export interface QueueUpdateInput {
|
||||
wakeMessage?: string;
|
||||
/**
|
||||
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
summary?: string | null;
|
||||
evidenceRef?: string | null;
|
||||
@@ -2579,7 +2578,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 (it's a person address, but nothing is delivered there). */
|
||||
* 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). */
|
||||
reachablePerson(address: string | null | undefined): string | null {
|
||||
if (!address || address === UNATTRIBUTED_ACTOR) return null;
|
||||
const registry = this.loadHumanRegistryFn();
|
||||
@@ -3455,6 +3454,7 @@ 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,35 +3664,15 @@ 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. validateHumanPark still requires them only for the legacy human-seat form.
|
||||
// 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.
|
||||
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
|
||||
// A done close can also store the artifact the work produced, so the finished row carries its link.
|
||||
const isDoneClose = input.state === "done";
|
||||
// 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.
|
||||
|
||||
// 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 —
|
||||
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: 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
|
||||
@@ -3770,8 +3750,8 @@ export class QueueRepository {
|
||||
let effectiveSummary = qitem.summary;
|
||||
let effectiveEvidenceRef = qitem.evidenceRef;
|
||||
if (isHumanPark) {
|
||||
effectiveSummary = input.summary ?? qitem.summary;
|
||||
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
|
||||
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
|
||||
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
|
||||
const park = validateHumanPark({
|
||||
blockedOn: effectiveBlockedOn,
|
||||
summary: effectiveSummary,
|
||||
@@ -3899,15 +3879,13 @@ export class QueueRepository {
|
||||
input.qitemId
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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!);
|
||||
|
||||
const transition = this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
@@ -3991,9 +3969,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 of THIS mutation
|
||||
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
|
||||
// (park-time summary included) so surfaces refresh without a fetch.
|
||||
summary: effectiveSummary ?? null,
|
||||
summary: this.getById(input.qitemId)?.summary ?? null,
|
||||
});
|
||||
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
|
||||
}
|
||||
@@ -4560,9 +4538,11 @@ 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 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. */
|
||||
/** 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. */
|
||||
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
|
||||
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
|
||||
const excludeOp =
|
||||
@@ -4589,7 +4569,8 @@ 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.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
|
||||
...(item.evidenceRef ? [item.evidenceRef] : [])];
|
||||
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `Don't write to ${unattributed}: nothing is delivered there.`;
|
||||
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.`;
|
||||
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}`;
|
||||
|
||||
@@ -100,6 +100,10 @@ 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
|
||||
@@ -134,6 +138,8 @@ 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)
|
||||
|
||||
@@ -211,6 +211,24 @@ 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;
|
||||
}
|
||||
@@ -312,9 +330,6 @@ 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.
|
||||
@@ -922,8 +937,9 @@ 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 advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
|
||||
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
|
||||
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
|
||||
.filter((advisory) => advisory !== null);
|
||||
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,29 @@ 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,4 +1314,31 @@ 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("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
|
||||
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.");
|
||||
});
|
||||
|
||||
it("a requester's own name reaches the operator only quoted, as its own words, even when shaped like an instruction", async () => {
|
||||
|
||||
@@ -307,26 +307,18 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
|
||||
expect(ev!.toState).toBe("blocked");
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// 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 () => {
|
||||
const item = await inProgressItem();
|
||||
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();
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,15 @@ describe("wrapPaneEnvelope — slice 23 envelope renderer (daemon-side)", () =>
|
||||
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
|
||||
});
|
||||
|
||||
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
|
||||
// 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"])(
|
||||
"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("Don't write to human@host: nothing is delivered there.");
|
||||
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(humanAnswerText(reply.body)).toBe("Ship it");
|
||||
});
|
||||
|
||||
|
||||
@@ -118,7 +118,20 @@ describe("queue wake nudge carries the stored summary", () => {
|
||||
} finally { await settle(); h.db.close(); }
|
||||
});
|
||||
|
||||
it.each(["human@kernel", "human@host"])("a wake from %s offers a reply accepted by the real queue writer", async (sender) => {
|
||||
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) => {
|
||||
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 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).
|
||||
// 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).
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -17,11 +17,9 @@ 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, QueueRepositoryError } from "../src/domain/queue-repository.js";
|
||||
import { QueueRepository } 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(); });
|
||||
@@ -29,7 +27,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let repo: QueueRepository;
|
||||
@@ -46,52 +44,57 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
|
||||
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 eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() 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),
|
||||
});
|
||||
|
||||
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
|
||||
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) => {
|
||||
const id = await mkItem();
|
||||
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"]);
|
||||
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);
|
||||
});
|
||||
|
||||
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
|
||||
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", async () => {
|
||||
const id = await mkItem();
|
||||
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
|
||||
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" });
|
||||
});
|
||||
|
||||
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
|
||||
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 () => {
|
||||
const id = await mkItem();
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
|
||||
expect(res.state).toBe("in-progress");
|
||||
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
|
||||
});
|
||||
|
||||
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
|
||||
@@ -114,43 +117,101 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
|
||||
});
|
||||
|
||||
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
|
||||
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 () => {
|
||||
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const id = await mkItem();
|
||||
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);
|
||||
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
|
||||
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
|
||||
});
|
||||
|
||||
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" }) });
|
||||
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");
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
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"]);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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("Don't write to human@host: nothing is delivered there.");
|
||||
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.");
|
||||
});
|
||||
|
||||
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("Don't write to human@host: nothing is delivered there.");
|
||||
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("Closing this item without deciding permits nothing");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
|
||||
@@ -695,6 +695,47 @@ 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", () => {
|
||||
|
||||
@@ -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. 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).
|
||||
// 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).
|
||||
//
|
||||
// What it looks for:
|
||||
// 1. Credentials: keys, tokens, passwords, key blocks.
|
||||
@@ -17,6 +17,7 @@
|
||||
// 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";
|
||||
@@ -133,11 +134,12 @@ export function renderReport(findings, label, { locationsOnly = false } = {}) {
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { staged: false, locationsOnly: false };
|
||||
const options = { staged: false, locationsOnly: false, strict: 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}`);
|
||||
}
|
||||
@@ -163,12 +165,13 @@ 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 report = renderReport(findPortabilityIssues(addedLines(diff)), label, {
|
||||
const findings = findPortabilityIssues(addedLines(diff));
|
||||
const report = renderReport(findings, label, {
|
||||
locationsOnly: options.locationsOnly,
|
||||
});
|
||||
process.stdout.write(report);
|
||||
if (options.out) writeFileSync(options.out, report);
|
||||
return 0;
|
||||
return options.strict && findings.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function invokedDirectly() {
|
||||
|
||||
@@ -173,3 +173,32 @@ 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 }); }
|
||||
});
|
||||
Reference in new issue
Block a user