mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
An accepted re-estimate can now move a claimed row's deadline without unclaiming the work: rig queue update <id> --closure-required-at <ISO>. The existing update transaction records the old deadline, the new one and the accepting actor alongside the note; the header-derived actor wins when present, and without a header the existing body-only attribution applies unchanged. The overdue query reads the revised value at once, and the next stuck sweep closes an already-open overdue finding while the work stays claimed, unless an independent pickup or stall recovery still shares custody. A deadline that passes after an earlier recovery for the row was closed is reported again, whether or not the owner is reporting activity: the renewal follows the row's own expired deadline rather than which finding kind wins, and the kind stays as today, so a row that is both stalled and overdue can report as stalled-after-claim. Open recoveries keep custody, and only a renewed expiry gets the deadline in its recovery ID. Limits: the field accepts a deadline on an in-progress row and doesn't read or backfill ETA prose; other pickup and stall findings stay independent; on 0.7.6 and 0.7.7, unclaim followed by claim remains the workaround and resets the claim. Reported in #2453 by @eisenbergjerry.
2465 lines
120 KiB
TypeScript
2465 lines
120 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import os from "node:os";
|
||
import { PassThrough } from "node:stream";
|
||
import type { QueueDeps } from "../src/commands/queue.js";
|
||
import { resolveQueueBody, previewBody, previewCutMarker, waitForDeliveryOutcome } from "../src/commands/queue.js";
|
||
import { createProgram } from "../src/index.js";
|
||
|
||
/**
|
||
* `rig queue` CLI tests — PL-004 Phase A revision (R1).
|
||
*
|
||
* Pattern mirrors compact-plan.test.ts: mock daemon-lifecycle to fake a
|
||
* running daemon, inject a clientFactory that returns a stubbed HTTP client.
|
||
* Tests assert: command parsing, HTTP request shape, non-2xx exit handling,
|
||
* hot-potato error rendering. No real daemon, no DB, no network.
|
||
*/
|
||
|
||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||
return {
|
||
...actual,
|
||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||
};
|
||
});
|
||
|
||
interface StubResponse {
|
||
status: number;
|
||
data: unknown;
|
||
}
|
||
|
||
function makeDeps(opts?: {
|
||
routes?: Record<string, StubResponse | StubResponse[]>;
|
||
}): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||
const routes = opts?.routes ?? {};
|
||
return {
|
||
calls,
|
||
deps: {
|
||
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
|
||
clientFactory: () => ({
|
||
get: vi.fn(async (path: string) => {
|
||
calls.push({ method: "GET", path });
|
||
const route = routes[`GET ${path}`];
|
||
return (Array.isArray(route) ? route.shift() : route) ?? { status: 200, data: {} };
|
||
}),
|
||
getText: vi.fn(async (path: string) => {
|
||
calls.push({ method: "GET", path });
|
||
return { status: 200, data: "" };
|
||
}),
|
||
post: vi.fn(async (path: string, body: unknown) => {
|
||
calls.push({ method: "POST", path, body });
|
||
const route = routes[`POST ${path}`];
|
||
return (Array.isArray(route) ? route.shift() : route) ?? { status: 201, data: { qitemId: "qitem-test-1" } };
|
||
}),
|
||
delete: vi.fn(async (path: string) => {
|
||
calls.push({ method: "DELETE", path });
|
||
return { status: 204, data: null };
|
||
}),
|
||
postText: vi.fn(async (path: string) => {
|
||
calls.push({ method: "POST", path });
|
||
return { status: 200, data: "" };
|
||
}),
|
||
postExpectText: vi.fn(async (path: string) => {
|
||
calls.push({ method: "POST", path });
|
||
return { status: 200, data: "" };
|
||
}),
|
||
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
|
||
},
|
||
};
|
||
}
|
||
|
||
describe("rig queue CLI", () => {
|
||
let logs: string[];
|
||
let errors: string[];
|
||
|
||
beforeEach(() => {
|
||
vi.unstubAllEnvs();
|
||
// P21 HERMETIC: the queue verbs derive source/actor from the seat env (X-OpenRig-Session), so an
|
||
// env-less harness aborts pre-POST. Stub a deterministic seat so these tests never depend on the
|
||
// AMBIENT OPENRIG_SESSION_NAME (which masked the env-less break in a managed runner). Per-test stubs
|
||
// that need a specific seat override this.
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
|
||
logs = [];
|
||
errors = [];
|
||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
|
||
process.exitCode = undefined;
|
||
});
|
||
|
||
it("queue is registered on createProgram with all R1 subcommands", async () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const queueCmd = program.commands.find((c) => c.name() === "queue");
|
||
expect(queueCmd).toBeDefined();
|
||
const subs = queueCmd!.commands.map((c) => c.name()).sort();
|
||
// R1 ratified contract: handoff-and-complete + whoami present alongside the originals.
|
||
expect(subs).toContain("create");
|
||
expect(subs).toContain("handoff");
|
||
expect(subs).toContain("handoff-and-complete");
|
||
expect(subs).toContain("whoami");
|
||
expect(subs).toContain("update");
|
||
expect(subs).toContain("inbox-drop");
|
||
expect(subs).toContain("inbox-absorb");
|
||
expect(subs).toContain("inbox-deny");
|
||
expect(subs).toContain("list");
|
||
expect(subs).toContain("show");
|
||
});
|
||
|
||
it("create sends NO body sourceSession — the source derives from the transport header (X-OpenRig-Session); an explicit --source is dropped, never forwarded as a body claim", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "alice@rig"; // the seat env == the X-OpenRig-Session the DaemonClient stamps
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-x", state: "pending" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "forged@evil", // P21: IGNORED — must not ride the body
|
||
"--destination", "bob@rig",
|
||
"--body", "do thing",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect(create).toBeDefined();
|
||
const body = create!.body as Record<string, unknown>;
|
||
// P21 I3 reconcile: no body identity claim — the daemon derives the source from the header, and
|
||
// the forged --source is DROPPED (not forwarded). The destination is the TARGET, a legit body field.
|
||
expect(body.sourceSession).toBeUndefined();
|
||
expect(body.destinationSession).toBe("bob@rig");
|
||
expect(body.body).toBe("do thing");
|
||
// R1: commander's --no-nudge sets opts.nudge to true by default.
|
||
// The CLI sends nudge: true, and the daemon treats nudge !== false as nudging.
|
||
expect(body.nudge).toBe(true);
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("create accepts an explicit source outside a managed seat as a body claim", async () => {
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--source", "external@rig",
|
||
"--destination", "bob@rig", "--body", "external work", "--summary", "External work", "--json"]);
|
||
expect(process.exitCode).toBeUndefined();
|
||
const body = calls.find(c => c.path === "/api/queue/create")?.body as Record<string, unknown>;
|
||
expect(body.sourceSession).toBe("external@rig");
|
||
expect(body.destinationSession).toBe("bob@rig");
|
||
});
|
||
|
||
it("create still requires a source when neither the managed env nor explicit claim is supplied", async () => {
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--destination", "bob@rig",
|
||
"--body", "external work", "--summary", "External work", "--json"]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(calls).toEqual([]);
|
||
expect(errors.join("\n")).toContain("--source is required");
|
||
});
|
||
|
||
it("verbs whose actor is the seat's identity name OPENRIG_SESSION_NAME outside a managed seat, never their ignored flag", async () => {
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||
for (const [flag, argv] of [["actor", ["update", "q1", "--note", "x", "--actor", "me@rig"]], ["from", ["handoff", "q1", "--to", "bob@rig", "--body", "next"]],
|
||
["destination", ["claim", "q1"]]] as const) {
|
||
errors.length = 0;
|
||
process.exitCode = undefined;
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", ...argv]);
|
||
expect(process.exitCode, argv[0]).toBe(1);
|
||
expect(calls, argv[0]).toEqual([]);
|
||
const text = errors.join("\n");
|
||
expect(text, argv[0]).toContain("OPENRIG_SESSION_NAME is not set");
|
||
expect(text, argv[0]).toContain(`--${flag} is ignored`);
|
||
expect(text, argv[0]).not.toContain("is required when OPENRIG_SESSION_NAME is not set");
|
||
}
|
||
});
|
||
|
||
it("create preserves the legacy managed source env without forwarding an explicit body claim", async () => {
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||
vi.stubEnv("RIGGED_SESSION_NAME", "legacy@rig");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--source", "ignored@rig",
|
||
"--destination", "bob@rig", "--body", "legacy work", "--summary", "Legacy work", "--json"]);
|
||
expect(process.exitCode).toBeUndefined();
|
||
const body = calls.find(c => c.path === "/api/queue/create")?.body as Record<string, unknown>;
|
||
expect(body.sourceSession).toBeUndefined();
|
||
});
|
||
|
||
it("create --human-questions-file sends the parsed questions; unreadable JSON is refused before any request", async () => {
|
||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "queue-human-questions-"));
|
||
const file = path.join(directory, "questions.json");
|
||
const questions = [{ id: "db", question: "Which database?", options: [{ id: "pg", label: "Postgres", recommended: true }, { id: "sqlite", label: "SQLite" }] }];
|
||
fs.writeFileSync(file, JSON.stringify(questions));
|
||
const broken = path.join(directory, "broken.json");
|
||
fs.writeFileSync(broken, "{ not json");
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Pick one.", "--human-intent", "decision", "--human-questions-file", file, "--json"]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")?.body).toMatchObject({ humanIntent: "decision", humanQuestions: questions });
|
||
|
||
const again = makeDeps();
|
||
await createProgram({ queueDeps: again.deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Pick one.", "--human-intent", "decision", "--human-questions-file", broken, "--json"]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(again.calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||
} finally { process.exitCode = undefined; fs.rmSync(directory, { recursive: true, force: true }); }
|
||
});
|
||
|
||
// Slice-03 Atom 6b — --body-context snapshot + provenance rule.
|
||
it("create preserves explicit human intent and authored supplemental file bytes", async () => {
|
||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "queue-human-detail-"));
|
||
const file = path.join(directory, "detail.txt");
|
||
const detail = "Supporting context.\nEmoji: 😀; symbols: < & >.\n";
|
||
fs.writeFileSync(file, detail);
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "No action needed.", "--human-intent", "update", "--human-detail-file", file, "--json"]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")?.body).toMatchObject({ humanIntent: "update", humanDetail: detail, body: "No action needed." });
|
||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||
});
|
||
|
||
// #96 — --reply-to posts an update into an earlier item's Slack thread.
|
||
it("create --reply-to without --human-intent update fails locally with a named error and never contacts the daemon", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Merged.", "--reply-to", "qitem-earlier", "--json"]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toMatch(/reply_to_requires_update/);
|
||
expect(calls.filter((c) => c.path === "/api/queue/create")).toHaveLength(0);
|
||
process.exitCode = 0;
|
||
});
|
||
|
||
it("create --reply-to with --human-intent update sends replyTo", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Merged.", "--human-intent", "update", "--reply-to", "qitem-earlier", "--json"]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")?.body).toMatchObject({ humanIntent: "update", replyTo: "qitem-earlier" });
|
||
});
|
||
|
||
it("delivery verification names a --reply-to fallback to a top-level post", async () => {
|
||
const result = await waitForDeliveryOutcome(
|
||
{ get: async <T>() => ({ status: 200, data: { deliveryOutcome: "posted", replyTo: "qitem-earlier", replyToFallback: "root-missing (qitem-earlier)" } as T }) },
|
||
"qitem-update",
|
||
);
|
||
expect(result).toMatchObject({ outcome: "posted", connectorAccepted: true, threaded: false });
|
||
expect(result.detail).toMatch(/top-level.*root-missing/);
|
||
const threaded = await waitForDeliveryOutcome(
|
||
{ get: async <T>() => ({ status: 200, data: { deliveryOutcome: "posted", replyTo: "qitem-earlier", replyToFallback: null } as T }) },
|
||
"qitem-update",
|
||
);
|
||
expect(threaded).toMatchObject({ outcome: "posted", threaded: true });
|
||
expect(threaded.detail).toBeUndefined();
|
||
});
|
||
|
||
it("create --body-context snapshots the RESOLVED content as the body + a provenance tag", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"GET /api/context-packs/library/by-ref/pieces?ref=packs%2Fbrief": { status: 200, data: { ref: "packs/brief", text: "BRIEF-BODY", bytes: 10, missingFiles: [] } },
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: "qitem-y", state: "pending" } },
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig", "--destination", "bob@rig",
|
||
"--body-context", "packs/brief", "--summary", "onboarding",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect(create, "expected the qitem to be created").toBeDefined();
|
||
const body = create!.body as { body: string; tags?: string[] };
|
||
// Snapshot: the RESOLVED content is the body (not the ref); a later library
|
||
// edit can never rewrite this handoff's history.
|
||
expect(body.body).toBe("BRIEF-BODY");
|
||
// Provenance: the ref rides as a tag so "what context was this agent given?"
|
||
// stays auditable.
|
||
expect(body.tags).toContain("body-context:packs/brief");
|
||
});
|
||
|
||
it("create --body-context ABORTS (no qitem created) when the pack has a missing member", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"GET /api/context-packs/library/by-ref/pieces?ref=packs%2Fbroken": { status: 200, data: { ref: "packs/broken", text: "X", missingFiles: [{ path: "gone.md" }] } },
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig", "--destination", "bob@rig",
|
||
"--body-context", "packs/broken", "--summary", "x",
|
||
]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create"), "no qitem created on a broken pack").toBeUndefined();
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toMatch(/gone\.md/);
|
||
});
|
||
|
||
it("create --body-context is mutually exclusive with --body", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig", "--destination", "bob@rig",
|
||
"--body", "x", "--body-context", "packs/brief", "--summary", "x",
|
||
]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toMatch(/mutually exclusive/i);
|
||
});
|
||
|
||
it("create --no-nudge passes nudge: false to the daemon (cold-queue opt-out)", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "cold",
|
||
"--no-nudge",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { nudge: boolean }).nudge).toBe(false);
|
||
});
|
||
|
||
it("create --verify waits for the existing gateway receipt and keeps persistence, connector acceptance, and readership distinct", async () => {
|
||
const id = "qitem-human-verify";
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "pending", destinationSession: "founder@external" } },
|
||
[`GET /api/queue/${id}`]: [
|
||
{ status: 200, data: { qitemId: id, deliveryOutcome: null } },
|
||
{ status: 200, data: { qitemId: id, deliveryOutcome: "posted" } },
|
||
],
|
||
},
|
||
});
|
||
deps.deliveryVerify = { timeoutMs: 50, intervalMs: 0, sleep: async () => {} };
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--destination", "founder@external",
|
||
"--body", "Please decide",
|
||
"--summary", "Founder decision",
|
||
"--evidence-ref", "proof/decision.md",
|
||
"--verify",
|
||
"--json",
|
||
]);
|
||
const out = JSON.parse(logs.at(-1)!) as Record<string, unknown>;
|
||
expect(out).toMatchObject({
|
||
qitemId: id,
|
||
persisted: true,
|
||
delivery: {
|
||
receiptSource: "gateway",
|
||
destinationKind: "human",
|
||
outcome: "posted",
|
||
connectorAccepted: true,
|
||
humanReadership: "unknown",
|
||
},
|
||
});
|
||
expect(calls.filter((call) => call.method === "POST" && call.path === "/api/queue/create")).toHaveLength(1);
|
||
expect(calls.filter((call) => call.method === "GET" && call.path === `/api/queue/${id}`)).toHaveLength(2);
|
||
});
|
||
|
||
it("create --verify times out indeterminate without retrying or weakening the durable create", async () => {
|
||
const id = "qitem-human-pending";
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "pending" } },
|
||
[`GET /api/queue/${id}`]: { status: 200, data: { qitemId: id, deliveryOutcome: null } },
|
||
},
|
||
});
|
||
let now = 0;
|
||
deps.deliveryVerify = { timeoutMs: 2, intervalMs: 0, sleep: async () => { now += 2; }, now: () => now };
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--destination", "founder@external", "--body", "Please decide", "--summary", "Founder decision", "--evidence-ref", "proof/decision.md", "--verify", "--json",
|
||
]);
|
||
const out = JSON.parse(logs.at(-1)!) as Record<string, unknown>;
|
||
expect(out).toMatchObject({
|
||
qitemId: id,
|
||
persisted: true,
|
||
delivery: {
|
||
receiptSource: "gateway",
|
||
destinationKind: "human",
|
||
outcome: "still-pending",
|
||
connectorAccepted: null,
|
||
humanReadership: "unknown",
|
||
nextAction: `rig queue show ${id} --json`,
|
||
},
|
||
});
|
||
expect(calls.filter((call) => call.method === "POST" && call.path === "/api/queue/create")).toHaveLength(1);
|
||
});
|
||
|
||
it.each(["transport-failed", "never-posted"] as const)(
|
||
"create --verify returns the terminal %s receipt without retrying",
|
||
async (outcome) => {
|
||
const id = `qitem-human-${outcome}`;
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "pending" } },
|
||
[`GET /api/queue/${id}`]: { status: 200, data: { qitemId: id, deliveryOutcome: outcome, deliveryFailureDetail: `${outcome} detail` } },
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--destination", "founder@external", "--body", "Please decide", "--summary", "Founder decision", "--evidence-ref", "proof/decision.md", "--verify", "--json",
|
||
]);
|
||
expect(JSON.parse(logs.at(-1)!)).toMatchObject({
|
||
persisted: true,
|
||
delivery: { receiptSource: "gateway", destinationKind: "human", outcome, connectorAccepted: false, humanReadership: "unknown", detail: `${outcome} detail` },
|
||
});
|
||
expect(calls.filter((call) => call.method === "POST" && call.path === "/api/queue/create")).toHaveLength(1);
|
||
},
|
||
);
|
||
|
||
it("delivery verification preserves an HTTP refusal as indeterminate", async () => {
|
||
const result = await waitForDeliveryOutcome(
|
||
{ get: async <T>() => ({ status: 503, data: { error: "projection unavailable" } as T }) },
|
||
"qitem-human-http",
|
||
{},
|
||
{ destinationSession: "founder@external" },
|
||
);
|
||
expect(result).toMatchObject({ outcome: "indeterminate", connectorAccepted: null, humanReadership: "unknown" });
|
||
expect(result.detail).toContain("HTTP 503");
|
||
});
|
||
|
||
// An ask placed on Steering without an alert owes no Slack post: verification says so at once, never "still pending".
|
||
it("create --alert sends humanAlert; delivery verification reports an unalerted ask at once, without waiting for a post", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
await createProgram({ queueDeps: deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Approve the rollback now?", "--alert", "--json"]);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")?.body).toMatchObject({ humanAlert: true });
|
||
const quiet = makeDeps();
|
||
await createProgram({ queueDeps: quiet.deps }).parseAsync(["node", "rig", "queue", "create", "--destination", "human-founder@external", "--body", "Which name?", "--json"]);
|
||
expect(quiet.calls.find((c) => c.path === "/api/queue/create")?.body).not.toHaveProperty("humanAlert");
|
||
const handoff = makeDeps();
|
||
await createProgram({ queueDeps: handoff.deps }).parseAsync(["node", "rig", "queue", "handoff", "qitem-src", "--to", "human-founder@external", "--body", "Approve now?", "--alert", "--json"]);
|
||
expect(handoff.calls.find((c) => c.path === "/api/queue/qitem-src/handoff")?.body).toMatchObject({ humanAlert: true });
|
||
const complete = makeDeps();
|
||
await createProgram({ queueDeps: complete.deps }).parseAsync(["node", "rig", "queue", "handoff-and-complete", "qitem-src", "--to", "human-founder@external", "--body", "Approve now?", "--alert", "--json"]);
|
||
expect(complete.calls.find((c) => c.path === "/api/queue/qitem-src/handoff-and-complete")?.body).toMatchObject({ humanAlert: true });
|
||
let reads = 0;
|
||
const result = await waitForDeliveryOutcome(
|
||
{ get: async <T>() => { reads += 1; return { status: 200, data: { destinationSession: "human-founder@external", lastNudgeResult: "gateway-owned:slack", ownerNotificationLevel: "RECORD", deliveryOutcome: null } as T }; } },
|
||
"qitem-steering",
|
||
{ timeoutMs: 60_000, sleep: async () => { throw new Error("waited for a post that isn't owed"); } },
|
||
{ destinationSession: "human-founder@external" },
|
||
);
|
||
expect(result).toMatchObject({ receiptSource: "gateway", outcome: "not-alerted", connectorAccepted: null, nextAction: null });
|
||
expect(result.detail).toMatch(/^created without --alert, and the person's Slack subscription is urgent-only/);
|
||
expect(reads).toBe(1);
|
||
});
|
||
|
||
it("delivery verification reports an unreadable receipt as indeterminate, not rejected", async () => {
|
||
const result = await waitForDeliveryOutcome(
|
||
{ get: async () => { throw new Error("daemon read timed out"); } } as never,
|
||
"qitem-human-indeterminate",
|
||
{},
|
||
{ destinationSession: "founder@external" },
|
||
);
|
||
expect(result).toMatchObject({
|
||
outcome: "indeterminate",
|
||
connectorAccepted: null,
|
||
humanReadership: "unknown",
|
||
});
|
||
});
|
||
|
||
// A terminal (pane-bound) destination's receipt is the row's wake result (lastNudgeResult) and
|
||
// its pickup is the claim; a registered person with a pane may also get a gateway receipt (see
|
||
// the nested describe). Keep persistence, terminal receipt, claim, Slack and human readership
|
||
// apart, and never call a saved row failed.
|
||
describe("--verify for a terminal destination", () => {
|
||
const ROW = { qitemId: "qitem-t", state: "pending", claimedAt: null, lastNudgeResult: null, deliveryOutcome: null, pickup: { state: "unclaimed" } };
|
||
// Each poll reads the next row; the last row repeats. The caller's array is not mutated.
|
||
function rowsClient(rows: Array<Record<string, unknown>>) {
|
||
let reads = 0;
|
||
return {
|
||
get reads() { return reads; },
|
||
get: async <T>() => {
|
||
const row = rows[Math.min(reads, rows.length - 1)]!;
|
||
reads += 1;
|
||
return { status: 200, data: { ...ROW, ...row } as T };
|
||
},
|
||
};
|
||
}
|
||
function boundedDeps(timeoutMs = 10) {
|
||
let now = 0;
|
||
return { timeoutMs, intervalMs: 1, sleep: async (ms: number) => { now += ms; }, now: () => now };
|
||
}
|
||
const verify = (rows: Array<Record<string, unknown>>, target: { destinationSession?: string; nudge?: boolean } = { destinationSession: "bob@rig" }) => {
|
||
const client = rowsClient(rows);
|
||
return waitForDeliveryOutcome(client, "qitem-t", boundedDeps(), target).then((result) => ({ result, reads: client.reads }));
|
||
};
|
||
|
||
it("smoke-test case through create: settles on the first delivered-ack-pending read, not a failed handoff", async () => {
|
||
const id = "qitem-agent-verify";
|
||
const rows = [
|
||
{},
|
||
{ lastNudgeResult: "delivered-ack-pending" },
|
||
{ lastNudgeResult: "delivered-ack-pending", state: "in-progress", claimedAt: "2026-10-08T10:00:10.000Z", pickup: { state: "working" } },
|
||
];
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "pending", destinationSession: "bob@rig" } },
|
||
[`GET /api/queue/${id}`]: rows.map((row) => ({ status: 200, data: { ...ROW, qitemId: id, ...row } })),
|
||
},
|
||
});
|
||
deps.deliveryVerify = boundedDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--destination", "bob@rig", "--body", "Review this", "--summary", "Review", "--verify", "--json"]);
|
||
const out = JSON.parse(logs.at(-1)!) as { persisted: boolean; delivery: Record<string, unknown> };
|
||
expect(out.persisted).toBe(true);
|
||
expect(out.delivery).toEqual({
|
||
receiptSource: "terminal",
|
||
destinationKind: "agent",
|
||
outcome: "delivered-ack-pending",
|
||
wakeResult: "delivered-ack-pending",
|
||
claim: { observed: false, claimedAt: null, pickup: "unclaimed" },
|
||
detail: "The qitem is saved. Terminal receipt unconfirmed (delivered-ack-pending): the wake was sent, but its render could not be confirmed. No claim observed yet. This is not a failed handoff.",
|
||
nextAction: `rig queue show ${id} --json`,
|
||
});
|
||
expect(process.exitCode).toBeUndefined();
|
||
expect(calls.filter((call) => call.method === "POST")).toHaveLength(1);
|
||
// The wait ends on the first recorded wake result; the later claim is not awaited.
|
||
expect(calls.filter((call) => call.method === "GET" && call.path === `/api/queue/${id}`)).toHaveLength(2);
|
||
});
|
||
|
||
it("persisted row with an unconfirmed receipt and no claim: saved, unconfirmed, not a failed handoff, at once", async () => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: "delivered-ack-pending" }]);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "agent", outcome: "delivered-ack-pending", claim: { observed: false, claimedAt: null, pickup: "unclaimed" }, nextAction: "rig queue show qitem-t --json" });
|
||
expect(result).not.toHaveProperty("humanReadership");
|
||
expect(result).not.toHaveProperty("connectorAccepted");
|
||
expect(result.detail).toBe("The qitem is saved. Terminal receipt unconfirmed (delivered-ack-pending): the wake was sent, but its render could not be confirmed. No claim observed yet. This is not a failed handoff.");
|
||
});
|
||
|
||
it("an existing claim is reported with the first wake result", async () => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: "delivered-ack-pending", state: "in-progress", claimedAt: "2026-10-08T10:00:10.000Z", pickup: { state: "working" } }]);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ outcome: "delivered-ack-pending", claim: { observed: true, claimedAt: "2026-10-08T10:00:10.000Z", pickup: "working" }, nextAction: null });
|
||
expect(result.detail).toContain("A claim shows the seat took the item; it does not prove the body was read.");
|
||
expect(result.detail).not.toContain("not a failed handoff");
|
||
});
|
||
|
||
it("a verified wake is distinct from delivered-ack-pending and settles the wait", async () => {
|
||
const { result, reads } = await verify([{}, { lastNudgeResult: "verified" }, { lastNudgeResult: "verified", claimedAt: "x" }]);
|
||
expect(result).toMatchObject({ outcome: "verified", wakeResult: "verified", claim: { observed: false } });
|
||
expect(result.detail).not.toContain("not a failed handoff");
|
||
expect(reads).toBe(2);
|
||
});
|
||
|
||
it.each([
|
||
"failed:tmux reports no session",
|
||
"unroutable: 'revieweer@rig' has no terminal transport on this daemon and names no registered human",
|
||
])("a %s wake is a failed wake on a saved row, reported at once", async (wakeResult) => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: wakeResult }]);
|
||
expect(result).toMatchObject({ outcome: "failed", wakeResult });
|
||
expect(result.detail).toMatch(/^The qitem is saved\. The wake failed/);
|
||
expect(result.detail).not.toContain("not a failed handoff");
|
||
expect(reads).toBe(1);
|
||
});
|
||
|
||
it("a claimed row's unconfirmed wake reads claimed, not indeterminate, and is never verified", async () => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: "claimed", state: "in-progress", claimedAt: "2026-10-11T20:00:00.000Z", pickup: { state: "working" } }]);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ outcome: "claimed", wakeResult: "claimed", claim: { observed: true, claimedAt: "2026-10-11T20:00:00.000Z", pickup: "working" }, nextAction: null });
|
||
expect(result.detail).toContain("Terminal receipt unconfirmed, but the seat has claimed the item (claimed).");
|
||
expect(result.detail).toContain("it does not prove the body was read");
|
||
});
|
||
|
||
it("an unrecognised wake result is indeterminate and never labelled not-failed", async () => {
|
||
const { result } = await verify([{ lastNudgeResult: "refused:new-prefix" }]);
|
||
expect(result).toMatchObject({ outcome: "indeterminate", wakeResult: "refused:new-prefix" });
|
||
expect(result.detail).not.toContain("not a failed handoff");
|
||
});
|
||
|
||
it("an indeterminate:* wake is reported unconfirmed at once", async () => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: "indeterminate:timeout" }]);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ outcome: "indeterminate", nextAction: "rig queue show qitem-t --json" });
|
||
expect(result.detail).toContain("Terminal receipt unconfirmed (indeterminate:timeout)");
|
||
expect(result.detail).toContain("This is not a failed handoff.");
|
||
});
|
||
|
||
it("a retained wake is reported at once, points at the held message and does not promise a retry", async () => {
|
||
const { result, reads } = await verify([{ lastNudgeResult: "retained:typing_guard" }]);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ outcome: "retained", nextAction: "rig seat held-messages bob@rig" });
|
||
expect(result.detail).toContain("nothing was typed into the pane");
|
||
expect(result.detail).toContain("not retried automatically");
|
||
expect(result.detail).not.toMatch(/ladder retries/);
|
||
});
|
||
|
||
it("only a missing wake result waits, up to the bound, then stays still-pending", async () => {
|
||
const { result, reads } = await verify([{}]);
|
||
expect(reads).toBeGreaterThan(1);
|
||
expect(result).toMatchObject({ outcome: "still-pending", wakeResult: null, claim: { observed: false } });
|
||
expect(result.detail).toContain("No terminal receipt was recorded within 10ms.");
|
||
});
|
||
|
||
it("a claim before any wake result ends the wait without a null duration", async () => {
|
||
const { result } = await verify([{ state: "in-progress", claimedAt: "2026-10-08T10:00:02.000Z" }]);
|
||
expect(result).toMatchObject({ outcome: "still-pending", claim: { observed: true }, nextAction: null });
|
||
expect(result.detail).toContain("No terminal receipt was recorded before the claim.");
|
||
expect(result.detail).not.toContain("null");
|
||
});
|
||
|
||
it("--no-nudge through create: one read, no wake requested, no claim that nobody was notified", async () => {
|
||
const id = "qitem-cold";
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "pending" } },
|
||
[`GET /api/queue/${id}`]: { status: 200, data: { ...ROW, qitemId: id } },
|
||
},
|
||
});
|
||
deps.deliveryVerify = boundedDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--destination", "bob@rig", "--body", "cold", "--summary", "Cold", "--no-nudge", "--verify", "--json"]);
|
||
expect((calls.find((c) => c.path === "/api/queue/create")!.body as { nudge: boolean }).nudge).toBe(false);
|
||
const out = JSON.parse(logs.at(-1)!) as { delivery: Record<string, unknown> };
|
||
expect(out.delivery).toMatchObject({ receiptSource: "terminal", outcome: "not-requested", claim: { observed: false } });
|
||
expect(out.delivery.detail).toBe("The qitem is saved. No terminal wake was requested (--no-nudge). No claim observed yet. This is not a failed handoff.");
|
||
expect(calls.filter((call) => call.method === "GET" && call.path === `/api/queue/${id}`)).toHaveLength(1);
|
||
});
|
||
|
||
it("--no-nudge to a registered person's alias keeps the bounded gateway wait (daemon destinationClass)", async () => {
|
||
const { result, reads } = await verify([{ destinationClass: "gateway-routable" }], { destinationSession: "mike@kernel", nudge: false });
|
||
expect(result).toMatchObject({ receiptSource: "gateway", destinationKind: "human", outcome: "still-pending", connectorAccepted: null, humanReadership: "unknown" });
|
||
expect(reads).toBeGreaterThan(1);
|
||
});
|
||
|
||
it("--no-nudge to a registered person's alias settles on the gateway receipt", async () => {
|
||
const { result } = await verify([{ destinationClass: "gateway-routable" }, { destinationClass: "gateway-routable", deliveryOutcome: "posted" }], { destinationSession: "mike@kernel", nudge: false });
|
||
expect(result).toMatchObject({ receiptSource: "gateway", outcome: "posted", connectorAccepted: true });
|
||
});
|
||
|
||
it("--no-nudge to a seat the daemon classes pane-bound reads once as not-requested", async () => {
|
||
const { result, reads } = await verify([{ destinationClass: "pane-bound" }], { destinationSession: "bob@rig", nudge: false });
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "agent", outcome: "not-requested" });
|
||
expect(reads).toBe(1);
|
||
});
|
||
|
||
it("the daemon's pane-bound class for a human seat wins over the address pattern", async () => {
|
||
const { result, reads } = await verify([{ destinationClass: "pane-bound" }], { destinationSession: "human-founder@kernel", nudge: false });
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "human", outcome: "not-requested", humanReadership: "unknown" });
|
||
expect(reads).toBe(1);
|
||
});
|
||
|
||
// A registered person who is also a seat with its own pane: the gateway may post the row to
|
||
// Slack too (the row's owner notification level is what its selector keys on), so --verify
|
||
// keeps a bounded wait for that receipt and reports both sides separately.
|
||
describe("a pane-bound registered person (owner notification level set)", () => {
|
||
const PANE_PERSON = { destinationClass: "pane-bound", ownerNotificationLevel: "ALERT" };
|
||
const target = { destinationSession: "dev-a@rig1" };
|
||
|
||
it("waits for the Slack receipt after the terminal receipt and reports both", async () => {
|
||
const { result, reads } = await verify([
|
||
PANE_PERSON,
|
||
{ ...PANE_PERSON, lastNudgeResult: "verified" },
|
||
{ ...PANE_PERSON, lastNudgeResult: "verified", deliveryOutcome: "posted" },
|
||
], target);
|
||
expect(reads).toBe(3);
|
||
expect(result).toMatchObject({
|
||
receiptSource: "terminal", destinationKind: "human", humanReadership: "unknown",
|
||
outcome: "verified", wakeResult: "verified",
|
||
gateway: { outcome: "posted", connectorAccepted: true },
|
||
});
|
||
expect(result.detail).toContain("Slack receipt: posted.");
|
||
});
|
||
|
||
it("keeps the bounded wait when Slack has not posted, without failing the terminal side", async () => {
|
||
const { result, reads } = await verify([{ ...PANE_PERSON, lastNudgeResult: "verified" }], target);
|
||
expect(reads).toBeGreaterThan(1);
|
||
expect(result).toMatchObject({ outcome: "verified", gateway: { outcome: "still-pending", connectorAccepted: null }, nextAction: "rig queue show qitem-t --json" });
|
||
expect(result.detail).toContain("No Slack receipt within 10ms.");
|
||
});
|
||
|
||
it("reports a Slack transport failure on its own side", async () => {
|
||
const { result } = await verify([{ ...PANE_PERSON, lastNudgeResult: "delivered-ack-pending", deliveryOutcome: "transport-failed", deliveryFailureDetail: "slack 500" }], target);
|
||
expect(result).toMatchObject({ outcome: "delivered-ack-pending", gateway: { outcome: "transport-failed", connectorAccepted: false, detail: "slack 500" } });
|
||
expect(result.detail).not.toContain("not a failed handoff");
|
||
});
|
||
|
||
it("with --no-nudge, reports no terminal wake and still waits for Slack", async () => {
|
||
const { result } = await verify([PANE_PERSON, { ...PANE_PERSON, deliveryOutcome: "posted" }], { ...target, nudge: false });
|
||
expect(result).toMatchObject({ outcome: "not-requested", gateway: { outcome: "posted", connectorAccepted: true } });
|
||
});
|
||
|
||
it("a pane-bound seat with no owner notification level is unchanged: no Slack side, settles at once", async () => {
|
||
const { result, reads } = await verify([{ destinationClass: "pane-bound", ownerNotificationLevel: null, lastNudgeResult: "verified" }], target);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "agent", outcome: "verified" });
|
||
expect(result).not.toHaveProperty("gateway");
|
||
expect(result).not.toHaveProperty("humanReadership");
|
||
});
|
||
|
||
it("a Slack receipt that lands before the wake result keeps waiting on the terminal side", async () => {
|
||
const { result, reads } = await verify([
|
||
{ ...PANE_PERSON, deliveryOutcome: "posted" },
|
||
{ ...PANE_PERSON, deliveryOutcome: "posted", lastNudgeResult: "delivered-ack-pending" },
|
||
], target);
|
||
expect(reads).toBe(2);
|
||
expect(result).toMatchObject({ outcome: "delivered-ack-pending", gateway: { outcome: "posted" } });
|
||
});
|
||
|
||
it("a claim with Slack still pending keeps the bounded Slack wait", async () => {
|
||
const { result, reads } = await verify([{ ...PANE_PERSON, state: "in-progress", claimedAt: "2026-10-08T10:00:02.000Z" }], target);
|
||
expect(reads).toBeGreaterThan(1);
|
||
expect(result).toMatchObject({ claim: { observed: true }, gateway: { outcome: "still-pending" }, nextAction: "rig queue show qitem-t --json" });
|
||
});
|
||
|
||
it("a recorded terminal wake still wins over a gateway-routable class read later (#1029 order)", async () => {
|
||
const { result, reads } = await verify([{ destinationClass: "gateway-routable", ownerNotificationLevel: "ALERT", lastNudgeResult: "verified", deliveryOutcome: "posted" }], target);
|
||
expect(reads).toBe(1);
|
||
expect(result).toMatchObject({ receiptSource: "terminal", outcome: "verified", wakeResult: "verified", gateway: { outcome: "posted" } });
|
||
});
|
||
|
||
it("an older daemon's posted receipt beside a terminal wake reports both sides", async () => {
|
||
const { result } = await verify([{ lastNudgeResult: "verified", deliveryOutcome: "posted" }], target);
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "human", outcome: "verified", gateway: { outcome: "posted" } });
|
||
});
|
||
});
|
||
|
||
it("--no-nudge to a human-seat address on a daemon without destinationClass keeps the bounded gateway wait", async () => {
|
||
const { result, reads } = await verify([{}], { destinationSession: "human-founder@kernel", nudge: false });
|
||
expect(result).toMatchObject({ receiptSource: "gateway", destinationKind: "human", outcome: "still-pending", humanReadership: "unknown" });
|
||
expect(reads).toBeGreaterThan(1);
|
||
});
|
||
|
||
it("an idempotent --id retry verifies the existing row by that id, with one POST", async () => {
|
||
const id = "qitem-agent-retry";
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": { status: 201, data: { qitemId: id, state: "in-progress" } },
|
||
[`GET /api/queue/${id}`]: { status: 200, data: { ...ROW, qitemId: id, lastNudgeResult: "verified", state: "in-progress", claimedAt: "2026-10-08T10:00:10.000Z" } },
|
||
},
|
||
});
|
||
deps.deliveryVerify = boundedDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--destination", "bob@rig", "--body", "b", "--summary", "s", "--id", id, "--verify", "--json"]);
|
||
expect((calls.find((c) => c.path === "/api/queue/create")!.body as { qitemId: string }).qitemId).toBe(id);
|
||
expect(JSON.parse(logs.at(-1)!).delivery).toMatchObject({ outcome: "verified", claim: { observed: true } });
|
||
expect(calls.filter((call) => call.method === "POST")).toHaveLength(1);
|
||
});
|
||
|
||
it("a registry alias (<entityId>@<rig>) moves to the gateway receipt once gateway-owned lands", async () => {
|
||
const { result } = await verify([{}, { lastNudgeResult: "gateway-owned: 'mike@kernel' is a virtual paneless human destination" }, { lastNudgeResult: "gateway-owned: x", deliveryOutcome: "posted" }], { destinationSession: "mike@kernel" });
|
||
expect(result).toMatchObject({ receiptSource: "gateway", destinationKind: "human", outcome: "posted", connectorAccepted: true, humanReadership: "unknown" });
|
||
});
|
||
|
||
it("a human seat with a real pane reports its terminal receipt and keeps human readership unknown", async () => {
|
||
const { result, reads } = await verify([{}, { lastNudgeResult: "verified" }], { destinationSession: "human-founder@kernel" });
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "human", outcome: "verified", humanReadership: "unknown" });
|
||
expect(result.detail).not.toContain("connector");
|
||
expect(reads).toBe(2);
|
||
});
|
||
|
||
it("an unreadable receipt for a terminal destination is indeterminate with no readership claim", async () => {
|
||
const result = await waitForDeliveryOutcome({ get: async () => { throw new Error("daemon read timed out"); } } as never, "qitem-t", boundedDeps(), { destinationSession: "bob@rig" });
|
||
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "agent", outcome: "indeterminate", wakeResult: null, claim: { observed: false }, nextAction: "rig queue show qitem-t --json" });
|
||
expect(result.detail).toContain("daemon read timed out");
|
||
expect(result).not.toHaveProperty("humanReadership");
|
||
});
|
||
});
|
||
|
||
// OPR.0.3.2.21.FR-4(a) — body input resolution kills the
|
||
// backtick-corruption class. Three accepted shapes: --body inline,
|
||
// --body-file <path>, --body / --body-file - for stdin. Exactly one
|
||
// of --body / --body-file is required; mutual exclusion validates.
|
||
describe("FR-4(a) — --body-file + stdin support", () => {
|
||
it("resolveQueueBody returns inline body when --body is passed", async () => {
|
||
const out = await resolveQueueBody({ body: "inline value" });
|
||
expect(out).toBe("inline value");
|
||
});
|
||
|
||
it("resolveQueueBody reads from a file path when --body-file is passed", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-"));
|
||
const bodyPath = path.join(tmp, "body.txt");
|
||
const content = "Multi-line body with `raw backticks` and\nliteral newlines\n— this is the corruption class --body-file kills.";
|
||
fs.writeFileSync(bodyPath, content, "utf8");
|
||
try {
|
||
const out = await resolveQueueBody({ bodyFile: bodyPath });
|
||
expect(out).toBe(content);
|
||
// Discriminator: the backtick-corruption shell class is bypassed
|
||
// entirely because no shell substitution happens on file content.
|
||
expect(out).toMatch(/`raw backticks`/);
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("resolveQueueBody throws 3-part error when both --body and --body-file are passed", async () => {
|
||
await expect(resolveQueueBody({ body: "inline", bodyFile: "/tmp/x" })).rejects.toMatchObject({
|
||
fact: expect.stringMatching(/mutually exclusive|ambiguous/i),
|
||
consequence: expect.stringMatching(/did not run/),
|
||
action: expect.stringMatching(/exactly one/),
|
||
});
|
||
});
|
||
|
||
it("resolveQueueBody throws 3-part error when neither --body nor --body-file is passed", async () => {
|
||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||
fact: expect.stringMatching(/Neither --body nor --body-file/),
|
||
consequence: expect.stringMatching(/did not run/),
|
||
action: expect.stringMatching(/--body|--body-file/),
|
||
});
|
||
});
|
||
|
||
it("resolveQueueBody throws 3-part error when --body-file path does not exist", async () => {
|
||
await expect(resolveQueueBody({ bodyFile: "/tmp/this-path-does-not-exist-fr4a-test.md" })).rejects.toMatchObject({
|
||
fact: expect.stringMatching(/does not exist/),
|
||
consequence: expect.stringMatching(/did not run/),
|
||
action: expect.stringMatching(/Check the path/),
|
||
});
|
||
});
|
||
|
||
// OPR.0.3.2.21.FR-4 cleanup (guard non-blocking note on FR-4a CLEAR): a
|
||
// directory passed to --body-file used to fall through to fs.readFileSync
|
||
// and surface a bare Error("EISDIR: illegal operation on a directory") with
|
||
// blank consequence/action. The cleanup commit emits the 3-part shape
|
||
// explicitly so the error reads consistently with the other body-resolve
|
||
// failure modes.
|
||
it("resolveQueueBody throws 3-part error when --body-file path is a directory (not a regular file)", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-isdir-"));
|
||
try {
|
||
await expect(resolveQueueBody({ bodyFile: tmp })).rejects.toMatchObject({
|
||
fact: expect.stringMatching(/not a regular file/),
|
||
consequence: expect.stringMatching(/did not run/),
|
||
action: expect.stringMatching(/Pass a path to a readable file/),
|
||
});
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("resolveQueueBody calls the injected stdin reader when --body is -", async () => {
|
||
const stdinReader = vi.fn(async () => "from stdin\n");
|
||
const out = await resolveQueueBody({ body: "-" }, stdinReader);
|
||
expect(out).toBe("from stdin\n");
|
||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("resolveQueueBody calls the injected stdin reader when --body-file is -", async () => {
|
||
const stdinReader = vi.fn(async () => "from stdin file dash\n");
|
||
const out = await resolveQueueBody({ bodyFile: "-" }, stdinReader);
|
||
expect(out).toBe("from stdin file dash\n");
|
||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("S4b RED: refuses an empty --body-file with source-aware 3-part guidance", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-empty-"));
|
||
const bodyPath = path.join(tmp, "empty.md");
|
||
fs.writeFileSync(bodyPath, "", "utf8");
|
||
try {
|
||
await expect(resolveQueueBody({ bodyFile: bodyPath })).rejects.toMatchObject({
|
||
fact: expect.stringMatching(new RegExp(`0 bytes|empty.*${path.basename(bodyPath)}`, "i")),
|
||
consequence: expect.stringMatching(/nothing was persisted|daemon was not contacted/i),
|
||
action: expect.stringMatching(/add.*content|non-empty/i),
|
||
});
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("S4b RED: refuses empty stdin and names stdin before any queue persistence", async () => {
|
||
await expect(resolveQueueBody({ bodyFile: "-" }, async () => "")).rejects.toMatchObject({
|
||
fact: expect.stringMatching(/0 bytes|empty.*stdin/i),
|
||
consequence: expect.stringMatching(/nothing was persisted|daemon was not contacted/i),
|
||
action: expect.stringMatching(/provide|pipe.*content|non-empty/i),
|
||
});
|
||
});
|
||
|
||
it("S4b RED: empty file aborts queue create before the daemon can persist a row", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-empty-"));
|
||
const bodyPath = path.join(tmp, "empty.md");
|
||
fs.writeFileSync(bodyPath, "", "utf8");
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--destination", "bob@rig",
|
||
"--body-file", bodyPath,
|
||
"--json",
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(calls.some((call) => call.method === "POST" && call.path === "/api/queue/create")).toBe(false);
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("create --body-file <file-with-backticks> POSTs the file content as body (operator-copy-paste-safe)", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-body-file-"));
|
||
const bodyPath = path.join(tmp, "body.txt");
|
||
const content = "Per-commit handoff for OPR.X.Y.Z\n\n```bash\nrig queue handoff qitem-1 --to next@rig\n```\n\nDone.";
|
||
fs.writeFileSync(bodyPath, content, "utf8");
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-fr4a-1", state: "pending" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body-file", bodyPath,
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect(create, "expected POST /api/queue/create to fire").toBeDefined();
|
||
const body = create!.body as Record<string, unknown>;
|
||
expect(body.body).toBe(content);
|
||
// Discriminator: the backtick fence survived intact, proving the
|
||
// shell-substitution class never touched the content.
|
||
expect((body.body as string)).toMatch(/```bash[\s\S]*?```/);
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("create with both --body and --body-file errors with exit 1 + 3-part error + does NOT contact the daemon", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-conflict-"));
|
||
const bodyPath = path.join(tmp, "body.txt");
|
||
fs.writeFileSync(bodyPath, "x", "utf8");
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
const prevExit = process.exitCode;
|
||
process.exitCode = undefined;
|
||
try {
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "inline",
|
||
"--body-file", bodyPath,
|
||
"--json",
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||
} finally {
|
||
process.exitCode = prevExit;
|
||
}
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("create with neither --body nor --body-file errors with exit 1 + does NOT contact the daemon", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
const prevExit = process.exitCode;
|
||
process.exitCode = undefined;
|
||
try {
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--json",
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||
} finally {
|
||
process.exitCode = prevExit;
|
||
}
|
||
});
|
||
});
|
||
|
||
// OPR.0.3.2.21.FR-4(b) — --mission / --slice first-class flags
|
||
// translate to mission:<id> / slice:<id> tags and compose with --tags.
|
||
// This is tag-formalization only — no schema change; the qitem still
|
||
// stores tags as a flat list.
|
||
describe("FR-4(b) — --mission / --slice first-class flag-formalization", () => {
|
||
it("--mission translates to a mission:<id> tag", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-1" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "x",
|
||
"--mission", "release-0.3.2",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags: string[] }).tags).toEqual(["mission:release-0.3.2"]);
|
||
});
|
||
|
||
it("--slice translates to a slice:<id> tag", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-2" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "x",
|
||
"--slice", "21-fr-4-queue-ergonomics",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags: string[] }).tags).toEqual(["slice:21-fr-4-queue-ergonomics"]);
|
||
});
|
||
|
||
it("--mission + --slice + --tags merges all three sets (mission/slice first, --tags appended) and de-duplicates", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-3" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "x",
|
||
"--mission", "release-0.3.2",
|
||
"--slice", "21-fr-4-queue-ergonomics",
|
||
"--tags", "gate:guard,handoff:per-commit",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags: string[] }).tags).toEqual([
|
||
"mission:release-0.3.2",
|
||
"slice:21-fr-4-queue-ergonomics",
|
||
"gate:guard",
|
||
"handoff:per-commit",
|
||
]);
|
||
});
|
||
|
||
it("--mission release-0.3.2 + --tags mission:release-0.3.2 de-duplicates the redundant tag (one mission:X kept)", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-4" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "x",
|
||
"--mission", "release-0.3.2",
|
||
"--tags", "mission:release-0.3.2,gate:guard",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
const tags = (create!.body as { tags: string[] }).tags;
|
||
expect(tags.filter((t) => t === "mission:release-0.3.2")).toHaveLength(1);
|
||
expect(tags).toContain("gate:guard");
|
||
});
|
||
|
||
it("no --mission/--slice/--tags → tags is undefined on the wire (legacy behavior preserved)", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-5" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@rig",
|
||
"--destination", "bob@rig",
|
||
"--body", "x",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags?: string[] }).tags).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// OPR.0.4.3.16 — --gate <role> first-class flag stamps a gate:<role> tag,
|
||
// the producer the idle-gate watchdog's centralized predicate reads.
|
||
describe("OPR.0.4.3.16 — --gate <role> gate-predicate producer", () => {
|
||
it("create --gate guard translates to a gate:guard tag", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-gate-1" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "dev@rig",
|
||
"--destination", "dev-guard@rig",
|
||
"--body", "review this diff",
|
||
"--gate", "guard",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags: string[] }).tags).toEqual(["gate:guard"]);
|
||
});
|
||
|
||
it("create --gate composes with --slice/--tags and de-duplicates", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-gate-2" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "dev@rig",
|
||
"--destination", "spec-guard@rig",
|
||
"--body", "x",
|
||
"--slice", "16",
|
||
"--gate", "spec-review",
|
||
"--tags", "gate:spec-review,handoff:per-commit",
|
||
"--json",
|
||
]);
|
||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||
expect((create!.body as { tags: string[] }).tags).toEqual([
|
||
"slice:16",
|
||
"gate:spec-review",
|
||
"handoff:per-commit",
|
||
]);
|
||
});
|
||
|
||
it("handoff --gate guard translates to a gate:guard tag on the new qitem", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/q-src/handoff": { status: 201, data: { qitemId: "q-new" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "handoff", "q-src",
|
||
"--from", "dev@rig",
|
||
"--to", "dev-guard@rig",
|
||
"--gate", "guard",
|
||
"--summary", "code review",
|
||
"--json",
|
||
]);
|
||
const handoff = calls.find((c) => c.path === "/api/queue/q-src/handoff");
|
||
expect((handoff!.body as { tags: string[] }).tags).toEqual(["gate:guard"]);
|
||
});
|
||
});
|
||
|
||
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
|
||
const { deps } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-x/update": {
|
||
status: 400,
|
||
data: {
|
||
error: "missing_closure_reason",
|
||
message: "state=done requires closure_reason; valid values: handed_off_to, denied, canceled, no-follow-on, escalation, superseded",
|
||
validReasons: ["handed_off_to", "denied", "canceled", "no-follow-on", "escalation", "superseded"],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "update", "qitem-x",
|
||
"--actor", "bob@rig",
|
||
"--state", "done",
|
||
"--json",
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
const out = logs.join("\n");
|
||
expect(out).toContain("missing_closure_reason");
|
||
expect(out).toContain("validReasons");
|
||
});
|
||
|
||
it("update accepts a note without state", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "update", "qitem-note",
|
||
"--note", "audit bytes",
|
||
"--json",
|
||
]);
|
||
|
||
expect(calls.find((c) => c.path === "/api/queue/qitem-note/update")?.body).toEqual({
|
||
state: undefined,
|
||
reopen: undefined,
|
||
closureReason: undefined,
|
||
closureTarget: undefined,
|
||
blockedOn: undefined,
|
||
summary: undefined,
|
||
evidenceRef: undefined,
|
||
transitionNote: "audit bytes",
|
||
});
|
||
});
|
||
|
||
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-estimate", "--closure-required-at", "2026-11-04T10:00:00.000Z", "--note", "Coordinator accepted the revised estimate", "--json"]);
|
||
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toMatchObject({
|
||
closureRequiredAt: "2026-11-04T10:00:00.000Z", transitionNote: "Coordinator accepted the revised estimate",
|
||
});
|
||
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toHaveProperty("state", undefined);
|
||
});
|
||
|
||
it("update forwards explicit reopen acknowledgment", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "update", "qitem-reopen",
|
||
"--state", "pending",
|
||
"--note", "repair",
|
||
"--reopen",
|
||
"--json",
|
||
]);
|
||
|
||
expect(calls.find((c) => c.path === "/api/queue/qitem-reopen/update")?.body).toMatchObject({
|
||
state: "pending",
|
||
reopen: true,
|
||
transitionNote: "repair",
|
||
});
|
||
});
|
||
|
||
it("S03: block forwards the continuation and an atomic timer wake", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "block", "qitem-park",
|
||
"--on", "external:cooldown",
|
||
"--continuation", "resume after the cooldown",
|
||
"--wake-after", "90s",
|
||
"--json",
|
||
]);
|
||
expect(calls.find((c) => c.path === "/api/queue/qitem-park/update")?.body).toMatchObject({
|
||
state: "blocked",
|
||
blockedOn: "external:cooldown",
|
||
transitionNote: "continuation: resume after the cooldown",
|
||
wakeAfterSeconds: 90,
|
||
});
|
||
});
|
||
|
||
it("S03: block help teaches all wake paths and the workspace rule", () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const block = program.commands.find((c) => c.name() === "queue")?.commands.find((c) => c.name() === "block");
|
||
let help = "";
|
||
block?.configureOutput({ writeOut: (text) => { help += text; } });
|
||
block?.outputHelp();
|
||
expect(block?.description()).toContain("Requires --on <blocker>");
|
||
expect(help).toContain("--on <blocker> is always required; a wake option does not replace it.");
|
||
expect(help).toMatch(/watchdog id/i);
|
||
expect(help).toMatch(/timer/i);
|
||
expect(help).toMatch(/live blocker/i);
|
||
expect(help).toMatch(/workspace.*not.imminent/i);
|
||
});
|
||
|
||
it("handoff-and-complete sends NO body fromSession — the handing-off seat derives from the transport header (X-OpenRig-Session); --from is dropped, --to stays the target", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "bob@rig"; // the seat env == the X-OpenRig-Session the DaemonClient stamps
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-src/handoff-and-complete": {
|
||
status: 201,
|
||
data: {
|
||
closed: { state: "done", closureReason: "handed_off_to" },
|
||
created: { state: "pending", qitemId: "qitem-new" },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "handoff-and-complete", "qitem-src",
|
||
"--from", "forged@evil", // P21: IGNORED — must not ride the body
|
||
"--to", "carol@rig",
|
||
"--body", "carol's piece",
|
||
"--json",
|
||
]);
|
||
const call = calls.find((c) => c.path === "/api/queue/qitem-src/handoff-and-complete");
|
||
expect(call).toBeDefined();
|
||
const body = call!.body as Record<string, unknown>;
|
||
// P21 I3 reconcile: no body identity claim — the daemon derives fromSession from the header, and
|
||
// the forged --from is DROPPED. toSession is the TARGET, a legit body field.
|
||
expect(body.fromSession).toBeUndefined();
|
||
expect(body.toSession).toBe("carol@rig");
|
||
expect(body.body).toBe("carol's piece");
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("whoami GETs /api/queue/whoami with session + recentLimit query params", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"GET /api/queue/whoami?session=bob%40rig&recentLimit=10": {
|
||
status: 200,
|
||
data: {
|
||
session: "bob@rig",
|
||
asDestination: { pending: 2, inProgress: 1, blocked: 0, recent: [] },
|
||
asSource: { total: 5 },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "whoami",
|
||
"--session", "bob@rig",
|
||
"--recent-limit", "10",
|
||
"--json",
|
||
]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/whoami"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("session=bob%40rig");
|
||
expect(call!.path).toContain("recentLimit=10");
|
||
});
|
||
|
||
it("plain whoami ends with one line per candidate to choose from; --json adds nothing", async () => {
|
||
const data = {
|
||
session: "bob@rig",
|
||
currentWork: null,
|
||
currentWorkBasis: "2 distinct typed work nodes — refusing to guess",
|
||
currentWorkChoice: {
|
||
instruction: "OpenRig did not choose your current work. Choose the candidate your actual work belongs to.",
|
||
candidates: [
|
||
{ qitemId: "q-1", projects: ["alpha"], mission: "release", slice: "01-work", workNodePath: "/m/release/slices/01-work", unresolved: null },
|
||
{ qitemId: "q-2", projects: [], mission: "missing", slice: "01-work", workNodePath: null, unresolved: "mission missing resolves to 0 directories on this host" },
|
||
],
|
||
unknown: [{ qitemId: "q-3", summary: null, reason: "no mission tag and no handoff ancestry" }],
|
||
},
|
||
};
|
||
const run = async (json: boolean) => {
|
||
const { deps } = makeDeps({ routes: { "GET /api/queue/whoami?session=bob%40rig&recentLimit=25": { status: 200, data } } });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
logs.length = 0; errors.length = 0;
|
||
await program.parseAsync(["node", "rig", "queue", "whoami", "--session", "bob@rig", ...(json ? ["--json"] : [])]);
|
||
return [...logs];
|
||
};
|
||
// #1806: plain stdout is the answer alone and parses as JSON; the choice lines are on stderr.
|
||
const plain = await run(false);
|
||
expect(JSON.parse(plain.join("\n"))).toEqual(data);
|
||
expect(errors.slice(-4)).toEqual([
|
||
"OpenRig did not choose your current work. Choose the candidate your actual work belongs to.",
|
||
" 1. project alpha · mission release · slice 01-work · /m/release/slices/01-work · from row q-1",
|
||
" 2. no project tag · mission missing · slice 01-work · no folder found (mission missing resolves to 0 directories on this host) · from row q-2",
|
||
" - row q-3: work not named (no mission tag and no handoff ancestry)",
|
||
]);
|
||
const json = await run(true);
|
||
expect(json).toHaveLength(1);
|
||
expect(JSON.parse(json[0]!)).toEqual(data);
|
||
});
|
||
|
||
it("whoami defaults the session from OPENRIG_SESSION_NAME when --session is omitted", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"GET /api/queue/whoami?session=bob%40rig&recentLimit=25": {
|
||
status: 200,
|
||
data: {
|
||
session: "bob@rig",
|
||
asDestination: { pending: 1, inProgress: 0, blocked: 0, recent: [] },
|
||
asSource: { total: 0 },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "whoami", "--json"]);
|
||
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/whoami"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("session=bob%40rig");
|
||
expect(call!.path).toContain("recentLimit=25");
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("claim sends NO body destinationSession — the claimant derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-x/claim": {
|
||
status: 200,
|
||
data: { qitemId: "qitem-x", destinationSession: "bob@rig", state: "in-progress" },
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "claim", "qitem-x", "--json"]);
|
||
|
||
const call = calls.find((c) => c.path === "/api/queue/qitem-x/claim");
|
||
expect(call).toBeDefined();
|
||
// P21 I3 reconcile: the claimant is NOT a body claim — the daemon derives it from the
|
||
// X-OpenRig-Session header (env present ⇒ pre-check passes ⇒ POST fires). No forgeable body field.
|
||
expect((call!.body as Record<string, unknown>).destinationSession).toBeUndefined();
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("update sends NO body actorSession — the actor derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-x/update": {
|
||
status: 200,
|
||
data: { qitemId: "qitem-x", state: "done", closureReason: "no-follow-on" },
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "update", "qitem-x",
|
||
"--state", "done",
|
||
"--closure-reason", "no-follow-on",
|
||
"--json",
|
||
]);
|
||
|
||
const call = calls.find((c) => c.path === "/api/queue/qitem-x/update");
|
||
expect(call).toBeDefined();
|
||
// P21 I3 reconcile: the actor is NOT a body claim — the daemon derives it from the header.
|
||
expect((call!.body as Record<string, unknown>).actorSession).toBeUndefined();
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("handoff sends NO body fromSession — the handing-off seat derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
|
||
const saved = process.env["OPENRIG_SESSION_NAME"];
|
||
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
|
||
try {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-x/handoff": {
|
||
status: 201,
|
||
data: { closed: { state: "handed-off" }, created: { qitemId: "qitem-new" } },
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "handoff", "qitem-x",
|
||
"--to", "carol@rig",
|
||
"--json",
|
||
]);
|
||
|
||
const call = calls.find((c) => c.path === "/api/queue/qitem-x/handoff");
|
||
expect(call).toBeDefined();
|
||
// P21 I3 reconcile: the handing-off seat is NOT a body claim — the daemon derives it from the header.
|
||
expect((call!.body as Record<string, unknown>).fromSession).toBeUndefined();
|
||
} finally {
|
||
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
|
||
else process.env["OPENRIG_SESSION_NAME"] = saved;
|
||
}
|
||
});
|
||
|
||
it("create against unknown destination rig surfaces 400 error and exits non-zero", async () => {
|
||
const { deps } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": {
|
||
status: 400,
|
||
data: {
|
||
error: "unknown_destination_rig",
|
||
message: "destination_session bob@phantom-rig references an unknown rig",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@known-rig",
|
||
"--destination", "bob@phantom-rig",
|
||
"--body", "x",
|
||
"--json",
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
const out = logs.join("\n");
|
||
expect(out).toContain("unknown_destination_rig");
|
||
});
|
||
|
||
it("queue create --host attempts the real write when the local health probe is inconclusive and surfaces the daemon response", async () => {
|
||
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7766");
|
||
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
|
||
vi.mocked(getDaemonStatus).mockResolvedValueOnce({ state: "stopped" });
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/create": {
|
||
status: 400,
|
||
data: {
|
||
error: "unknown_destination_rig",
|
||
message: "destination_session bob@phantom-rig references an unknown rig",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const clientUrls: string[] = [];
|
||
const clientFactory = deps.clientFactory;
|
||
deps.clientFactory = (baseUrl) => {
|
||
clientUrls.push(baseUrl);
|
||
return clientFactory(baseUrl);
|
||
};
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@known-rig",
|
||
"--destination", "bob@phantom-rig",
|
||
"--host", "remote-a",
|
||
"--body", "x",
|
||
"--json",
|
||
]);
|
||
|
||
expect(calls).toContainEqual({
|
||
method: "POST",
|
||
path: "/api/queue/create",
|
||
body: expect.objectContaining({ hostId: "remote-a" }),
|
||
});
|
||
expect(clientUrls).toEqual(["http://127.0.0.1:7766"]);
|
||
expect(logs.join("\n")).toContain("unknown_destination_rig");
|
||
expect(errors.join("\n")).not.toContain("Daemon not running");
|
||
expect(process.exitCode).toBe(1);
|
||
});
|
||
|
||
it("plain local queue create remains blocked when the daemon probe confirms no reachable operation target", async () => {
|
||
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
|
||
vi.mocked(getDaemonStatus).mockResolvedValueOnce({ state: "stopped" });
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "alice@known-rig",
|
||
"--destination", "bob@known-rig",
|
||
"--body", "x",
|
||
"--json",
|
||
]);
|
||
|
||
expect(calls).toEqual([]);
|
||
expect(errors.join("\n")).toContain("Daemon not running");
|
||
expect(process.exitCode).toBe(1);
|
||
});
|
||
|
||
it("handoff with --no-nudge passes nudge: false through to daemon", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/qitem-x/handoff": {
|
||
status: 201,
|
||
data: { closed: {}, created: {} },
|
||
},
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "handoff", "qitem-x",
|
||
"--from", "bob@rig",
|
||
"--to", "carol@rig",
|
||
"--no-nudge",
|
||
]);
|
||
const call = calls.find((c) => c.path === "/api/queue/qitem-x/handoff");
|
||
expect((call!.body as { nudge: boolean }).nudge).toBe(false);
|
||
});
|
||
|
||
it("list constructs /api/queue/list with filter params", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "list",
|
||
"--destination", "bob@rig",
|
||
"--state", "pending",
|
||
"--limit", "50",
|
||
"--json",
|
||
]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("destinationSession=bob%40rig");
|
||
expect(call!.path).toContain("state=pending");
|
||
expect(call!.path).toContain("limit=50");
|
||
expect(call!.path).toContain("compact=1");
|
||
expect(call!.path).not.toContain("as=");
|
||
expect(call!.path).not.toContain("rig=");
|
||
});
|
||
|
||
it("list -a includes history (no activeOnly param)", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "dev1@my-rig";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "-a", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).not.toContain("activeOnly=");
|
||
expect(call!.path).toContain("rig=my-rig");
|
||
expect(call!.path).toContain("compact=1");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
it("list -A is cross-rig (no rig param)", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "dev1@my-rig";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "-A", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).not.toContain("rig=");
|
||
expect(call!.path).toContain("activeOnly=1");
|
||
expect(call!.path).toContain("compact=1");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
it("list --full --all --all-rigs = firehose (no compact, no active, no rig)", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--full", "--all", "--all-rigs", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).not.toContain("compact=");
|
||
expect(call!.path).not.toContain("activeOnly=");
|
||
expect(call!.path).not.toContain("rig=");
|
||
expect(call!.path).not.toContain("as=");
|
||
});
|
||
|
||
it("list with --destination does not inject implicit rig scope", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "my-seat@my-rig";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--destination", "bob@rig", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("destinationSession=bob%40rig");
|
||
expect(call!.path).toContain("compact=1");
|
||
expect(call!.path).not.toContain("rig=");
|
||
expect(call!.path).not.toContain("as=");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
it("recipe command 'rig queue list --destination <seat>' parses successfully and rejects --session", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
|
||
// The generated recipe from queue-stuck-sweep uses --destination <seat>
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--destination", "next@r"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("destinationSession=next%40r");
|
||
|
||
// --session is not an option on queue list and must fail parsing
|
||
const failProgram = createProgram({ queueDeps: deps });
|
||
failProgram.exitOverride();
|
||
await expect(
|
||
failProgram.parseAsync(["node", "rig", "queue", "list", "--session", "next@r"])
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("list --mine scopes to caller session", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "dev1-driver@openrig-delivery";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--mine", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("as=dev1-driver%40openrig-delivery");
|
||
expect(call!.path).not.toContain("rig=");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
it("S4b RED: list --owned scopes only to rows whose destination is the caller", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "dev1-driver@openrig-delivery";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--owned", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("destinationSession=dev1-driver%40openrig-delivery");
|
||
expect(call!.path).not.toContain("as=");
|
||
expect(call!.path).not.toContain("sourceSession=");
|
||
expect(call!.path).not.toContain("rig=");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
it("S4b final RED: list --owned without either seat identity refuses before any GET", async () => {
|
||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--owned", "--json"]);
|
||
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toMatch(/--owned.*OPENRIG_SESSION_NAME.*RIGGED_SESSION_NAME/i);
|
||
expect(calls.some((call) => call.method === "GET" && call.path.startsWith("/api/queue/list"))).toBe(false);
|
||
});
|
||
|
||
it("S4b RED: list help distinguishes destination-owned obligations from --mine's authored union", () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const queue = program.commands.find((command) => command.name() === "queue")!;
|
||
const list = queue.commands.find((command) => command.name() === "list")!;
|
||
const help = list.helpInformation();
|
||
expect(help).toMatch(/--owned[^\n]*(destination|assigned|owe)/i);
|
||
expect(help).toMatch(/--mine[^\n]*(source or destination|authored.*do not own)/i);
|
||
});
|
||
|
||
it("list default injects rig=<rigName> + activeOnly=1 + compact=1", async () => {
|
||
const saved = process.env.OPENRIG_SESSION_NAME;
|
||
process.env.OPENRIG_SESSION_NAME = "dev1-driver@openrig-delivery";
|
||
try {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "list", "--json"]);
|
||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/queue/list"));
|
||
expect(call).toBeDefined();
|
||
expect(call!.path).toContain("rig=openrig-delivery");
|
||
expect(call!.path).toContain("activeOnly=1");
|
||
expect(call!.path).toContain("compact=1");
|
||
expect(call!.path).not.toContain("as=");
|
||
} finally {
|
||
if (saved === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||
else process.env.OPENRIG_SESSION_NAME = saved;
|
||
}
|
||
});
|
||
|
||
// OPR.0.4.3.03 — `rig queue show` body preview + `--full` compatibility.
|
||
// Bound + bodyTruncated are CODE-POINT-count based (IMPL-SPEC §2.3-2.4);
|
||
// bodyBytes is the honest TRUE total UTF-8 byte size.
|
||
describe("queue show body preview (OPR.0.4.3.03)", () => {
|
||
const PREVIEW_MAX_CODEPOINTS = 512;
|
||
|
||
function showRoute(item: unknown, id = "qitem-1") {
|
||
return makeDeps({ routes: { [`GET /api/queue/${id}`]: { status: 200, data: item } } });
|
||
}
|
||
|
||
// --- previewBody helper (unit) ---
|
||
|
||
it("previewBody: empty body → preview '', 0 bytes, not truncated", () => {
|
||
expect(previewBody("")).toEqual({ preview: "", bodyBytes: 0, bodyTruncated: false });
|
||
});
|
||
|
||
it("previewBody: small body under bound → full body, honest bytes, not truncated", () => {
|
||
const small = "hello world";
|
||
expect(previewBody(small)).toEqual({
|
||
preview: small,
|
||
bodyBytes: Buffer.byteLength(small, "utf8"),
|
||
bodyTruncated: false,
|
||
});
|
||
});
|
||
|
||
it("previewBody: body exactly at 512 code points → not truncated (boundary inclusive)", () => {
|
||
const exact = "z".repeat(512);
|
||
expect(previewBody(exact)).toEqual({ preview: exact, bodyBytes: 512, bodyTruncated: false });
|
||
});
|
||
|
||
it("previewBody: oversized body (>512 code points) → truncated, honest bodyBytes = TRUE total, preview = first 512 code points", () => {
|
||
const body = "x".repeat(1000);
|
||
const out = previewBody(body);
|
||
expect(out.bodyTruncated).toBe(true);
|
||
expect(out.bodyBytes).toBe(1000); // honest total, not the preview length
|
||
expect(Array.from(out.preview).length).toBe(PREVIEW_MAX_CODEPOINTS);
|
||
expect(out.preview).toBe("x".repeat(512));
|
||
});
|
||
|
||
it("previewBody: multibyte body >512 code points → 512-code-point slice on a clean code-point boundary (valid UTF-8, no split surrogate)", () => {
|
||
// 4-byte emoji (astral, surrogate pair in UTF-16). 600 of them = 600 code
|
||
// points / 2400 bytes → exceeds the 512-CODE-POINT bound.
|
||
const emoji = "😀"; // 4 UTF-8 bytes, 1 code point, 2 UTF-16 units
|
||
const body = emoji.repeat(600);
|
||
const out = previewBody(body);
|
||
expect(out.bodyTruncated).toBe(true); // 600 code points > 512
|
||
expect(out.bodyBytes).toBe(2400); // honest total byte size
|
||
// Preview is exactly the first 512 code points (each a whole emoji)...
|
||
expect(Array.from(out.preview).length).toBe(512);
|
||
expect([...out.preview].every((ch) => ch === emoji)).toBe(true);
|
||
// ...ending on a clean code-point boundary: re-encode round-trips, no
|
||
// lone surrogate / U+FFFD replacement char from a split sequence.
|
||
expect(Buffer.byteLength(out.preview, "utf8")).toBe(512 * 4);
|
||
const roundTrip = Buffer.from(out.preview, "utf8").toString("utf8");
|
||
expect(roundTrip).toBe(out.preview);
|
||
expect(roundTrip).not.toContain("�");
|
||
});
|
||
|
||
// --- show command (end-to-end via program) ---
|
||
|
||
it("show default: oversized body → preview + marker line + honest bodyBytes + bodyTruncated=true (human)", async () => {
|
||
const full = "A".repeat(1000);
|
||
const { deps } = showRoute({ qitemId: "qitem-1", state: "pending", body: full });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1"]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.bodyTruncated).toBe(true);
|
||
expect(printed.bodyBytes).toBe(1000);
|
||
// Keep the first 512 code points, then mark the cut inside the body.
|
||
expect(printed.body).toBe(`${"A".repeat(512)}…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]`);
|
||
// #1806: stdout is the record alone and parses as JSON; the marker line, with the honest total byte size, is on stderr
|
||
expect(JSON.parse(logs.join("\n"))).toMatchObject({ qitemId: "qitem-1", bodyTruncated: true });
|
||
expect(errors.join("\n")).toContain("bounded preview — complete body is 1000 bytes; full record");
|
||
expect(errors.join("\n")).toContain("rig queue show 'qitem-1' --full --json");
|
||
expect(errors.join("\n")).not.toMatch(/\btruncated\b/i);
|
||
});
|
||
|
||
it("show default: small body → full body, NO marker, bodyTruncated=false", async () => {
|
||
const { deps } = showRoute({ qitemId: "qitem-1", body: "short body" });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1"]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.body).toBe("short body");
|
||
expect(printed.bodyTruncated).toBe(false);
|
||
expect(printed.bodyBytes).toBe(10);
|
||
expect(logs.join("\n")).not.toContain("truncated");
|
||
});
|
||
|
||
it("show default: empty body → bodyBytes 0, bodyTruncated=false, no marker, no crash", async () => {
|
||
const { deps } = showRoute({ qitemId: "qitem-1", body: "" });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1"]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.body).toBe("");
|
||
expect(printed.bodyBytes).toBe(0);
|
||
expect(printed.bodyTruncated).toBe(false);
|
||
expect(logs.join("\n")).not.toContain("truncated");
|
||
expect(process.exitCode).not.toBe(1);
|
||
});
|
||
|
||
it("show default --json: carries preview + bodyBytes + bodyTruncated (JSON parity)", async () => {
|
||
const full = "B".repeat(1000);
|
||
const { deps } = showRoute({ qitemId: "qitem-1", body: full });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
|
||
expect(logs.length).toBe(1); // compact single-line JSON, no separate marker line
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.bodyTruncated).toBe(true);
|
||
expect(printed.bodyBytes).toBe(1000);
|
||
const marker = "…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]";
|
||
expect(printed.body.endsWith(marker)).toBe(true);
|
||
expect(Array.from(printed.body.slice(0, -marker.length)).length).toBe(PREVIEW_MAX_CODEPOINTS);
|
||
});
|
||
|
||
it("show default: a multibyte body's marker counts code points, not bytes", async () => {
|
||
const full = "😀".repeat(600); // 600 code points, 2400 bytes
|
||
const { deps } = showRoute({ qitemId: "qitem-1", body: full });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.bodyBytes).toBe(2400);
|
||
expect(printed.body).toBe(`${"😀".repeat(512)}…[cut at 512 of 600 chars; rig queue show 'qitem-1' --full]`);
|
||
});
|
||
|
||
it("previewCutMarker quotes the row id for the shell", () => {
|
||
expect(previewCutMarker("qitem-it's", 512, 3983)).toBe(`…[cut at 512 of 3983 chars; rig queue show 'qitem-it'\\''s' --full]`);
|
||
});
|
||
|
||
it("show --full --json: byte-identical COMPLETE item body (compatibility contract — no preview fields)", async () => {
|
||
const item = { qitemId: "qitem-1", state: "pending", body: "C".repeat(1000), chain_of_record: [{ a: 1 }] };
|
||
const { deps } = showRoute(item);
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--full", "--json"]);
|
||
// Byte-identical to a raw JSON.stringify of the item (today's shape).
|
||
expect(logs[0]).toBe(JSON.stringify(item));
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.body).toBe("C".repeat(1000)); // complete, untruncated
|
||
expect(printed).not.toHaveProperty("bodyBytes");
|
||
expect(printed).not.toHaveProperty("bodyTruncated");
|
||
});
|
||
|
||
it("update --resolution posts the text with the close", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", "--state", "done", "--closure-reason", "no-follow-on", "--resolution", "Fixed in #42"]);
|
||
expect(calls.find((c) => c.path === "/api/queue/qitem-x/update")?.body).toMatchObject({ state: "done", resolution: "Fixed in #42" });
|
||
});
|
||
|
||
it.each([
|
||
["canceled", []],
|
||
["denied", []],
|
||
["failed", []],
|
||
["handed-off", ["--closure-reason", "handed_off_to", "--closure-target", "next@rig"]],
|
||
])("update --state %s --resolution posts the text, kept as typed", async (state, extra) => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", "--state", state, ...extra, "--resolution", " two\nlines \u2713 "]);
|
||
expect(calls.find((c) => c.path === "/api/queue/qitem-x/update")?.body).toMatchObject({ state, resolution: " two\nlines \u2713 " });
|
||
expect(process.exitCode).toBeUndefined();
|
||
});
|
||
|
||
it.each([
|
||
["no --state", ["--resolution", "Fixed"]],
|
||
["a note only", ["--note", "n", "--resolution", "Fixed"]],
|
||
["--state in-progress", ["--state", "in-progress", "--resolution", "Fixed"]],
|
||
["--state pending", ["--state", "pending", "--resolution", "Fixed"]],
|
||
["--state blocked", ["--state", "blocked", "--blocked-on", "external:x", "--wake-after", "1h", "--resolution", "Fixed"]],
|
||
])("update --resolution with %s is a usage error before anything is sent", async (_label, args) => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", ...args]);
|
||
expect(calls).toEqual([]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toContain("--resolution");
|
||
expect(errors.join("\n")).toContain("done, canceled, denied, failed or handed-off");
|
||
});
|
||
|
||
it.each([[""], [" "], ["\n\t"]])("update --state done --resolution %j is a usage error before anything is sent", async (text) => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", "--state", "done", "--closure-reason", "no-follow-on", "--resolution", text]);
|
||
expect(calls).toEqual([]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(errors.join("\n")).toContain("needs text");
|
||
});
|
||
|
||
it("a --resolution usage error with --json is a JSON error on stderr", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", "--state", "pending", "--resolution", "x", "--json"]);
|
||
expect(calls).toEqual([]);
|
||
expect(JSON.parse(errors[0])).toMatchObject({ error: "resolution_needs_closing_state" });
|
||
});
|
||
|
||
it("update --resolution --help names only what the CLI itself checks", async () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const help = program.commands.find((c) => c.name() === "queue")!.commands.find((c) => c.name() === "update")!.helpInformation();
|
||
expect(help).toContain("--resolution <text>");
|
||
expect(help).not.toContain("resolution_not_admitted");
|
||
expect(help).not.toContain("resolution_already_closed");
|
||
});
|
||
|
||
it("update --help says how to cancel a row: --state done --closure-reason canceled", async () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const help = program.commands.find((c) => c.name() === "queue")!.commands.find((c) => c.name() === "update")!.helpInformation().replace(/\s+/g, " ");
|
||
expect(help).toContain("To cancel a row: --state done --closure-reason canceled");
|
||
expect(help).toContain("--state canceled takes no reason, except 'superseded'");
|
||
});
|
||
|
||
it("update without --resolution sends no resolution", async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "update", "qitem-x", "--note", "n"]);
|
||
expect((calls.find((c) => c.path === "/api/queue/qitem-x/update")?.body as Record<string, unknown>).resolution).toBeUndefined();
|
||
});
|
||
|
||
const closed = {
|
||
qitemId: "qitem-1", state: "done", body: "work",
|
||
closingTransition: { transitionId: 7, ts: "2026-10-14T21:00:00.000Z", state: "done", closureReason: "no-follow-on", closureTarget: null, actorSession: "b@r", transitionNote: "shipped it" },
|
||
};
|
||
|
||
it("show without --full: an open row's readView stays complete", async () => {
|
||
const { deps } = showRoute({ qitemId: "qitem-1", state: "pending", body: "work" });
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
|
||
expect(JSON.parse(logs[0]).readView).toMatchObject({ complete: true, omittedFields: [] });
|
||
});
|
||
|
||
it("show --full: a closed row's closing transition (note and reason) is printed, as JSON and as text", async () => {
|
||
for (const flags of [["--json"], []]) {
|
||
logs.length = 0;
|
||
const { deps } = showRoute(closed);
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--full", ...flags]);
|
||
expect(JSON.parse(logs[0]).closingTransition).toMatchObject({ closureReason: "no-follow-on", transitionNote: "shipped it", actorSession: "b@r" });
|
||
}
|
||
});
|
||
|
||
it("show without --full: a closed row prints as before, with no closing block", async () => {
|
||
for (const flags of [["--json"], []]) {
|
||
logs.length = 0;
|
||
const { deps } = showRoute(closed);
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", ...flags]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.state).toBe("done");
|
||
expect(printed).not.toHaveProperty("closingTransition");
|
||
expect(logs.join("\n")).not.toContain("shipped it");
|
||
// The omission is declared, so a reader that trusts readView runs --full.
|
||
expect(printed.readView.complete).toBe(false);
|
||
expect(printed.readView.omittedFields).toEqual([{ path: "closingTransition", jsonBytes: JSON.stringify(closed.closingTransition).length }]);
|
||
expect(printed.readView.fullCommand).toBe("rig queue show 'qitem-1' --full --json");
|
||
}
|
||
});
|
||
|
||
it("show --full (human): complete body, no preview fields, no marker", async () => {
|
||
const full = "D".repeat(1000);
|
||
const item = { qitemId: "qitem-1", body: full, chain_of_record: [] };
|
||
const { deps } = showRoute(item);
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--full"]);
|
||
const printed = JSON.parse(logs[0]);
|
||
expect(printed.body).toBe(full);
|
||
expect(printed).not.toHaveProperty("bodyBytes");
|
||
expect(printed).not.toHaveProperty("bodyTruncated");
|
||
expect(logs.join("\n")).not.toContain("truncated");
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// slice-08 OPR.0.4.7.8 — queue verb-surface body-input parity (TEST-ONLY RED).
|
||
// Production queue.ts is NOT edited yet; these pin the atomic-A contract so it
|
||
// fails today and passes after the four verbs route through the shipped
|
||
// resolveQueueBody. Anchors grounded at 4b05f970; locked spec sha b2db0f2b.
|
||
//
|
||
// Guard-ruled classification (honest, per-input):
|
||
// GENUINE RED (fails today): --body-file exact POST ×4; --body - stdin exact
|
||
// POST ×4; --help documents --body-file + stdin '-' ×4; command-neutral
|
||
// resolver error wording.
|
||
// PRESERVATION GREEN (must NOT regress): handoff/handoff-and-complete neither
|
||
// → POST body undefined (source-body default); inbox-drop/outbox-record
|
||
// neither → error + no POST (mechanism-neutral: Commander requiredOption
|
||
// today, resolver reject tomorrow).
|
||
// NEW-CAPABILITY GREEN: both sources → error + no POST (unknown-option today).
|
||
// Exact equality only for all 8 body transports; never length/contains.
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe("slice-08 OPR.0.4.7.8 — queue body-input parity", () => {
|
||
// Byte-discriminating body: multiline + Unicode + backticks — the exact
|
||
// corruption class file/stdin input kills.
|
||
const DISCRIMINATOR =
|
||
"line-1 `raw backticks`\nlíne-2 ünïcode ☑\n```bash\nrig queue handoff q --to x\n```\n";
|
||
|
||
// Command-level stdin harness (Guard-specified): swap process.stdin for an
|
||
// ended PassThrough carrying exact bytes; restore the ORIGINAL descriptor in
|
||
// finally. Node22 process.stdin is getter-only but configurable, so
|
||
// defineProperty is viable; a PassThrough is not a TTY so defaultStdinReader
|
||
// reads it to EOF instead of short-circuiting empty.
|
||
async function withStdin(bytes: string, fn: () => Promise<void>): Promise<void> {
|
||
const originalStdin = Object.getOwnPropertyDescriptor(process, "stdin");
|
||
const prevExit = process.exitCode;
|
||
const fake = new PassThrough();
|
||
fake.end(bytes);
|
||
Object.defineProperty(process, "stdin", { value: fake, configurable: true });
|
||
try {
|
||
await fn();
|
||
} finally {
|
||
// Restore BOTH globals this helper owns: the stdin descriptor and
|
||
// process.exitCode. If process had no own stdin descriptor originally,
|
||
// delete the temporary own property rather than leaving it installed.
|
||
if (originalStdin) Object.defineProperty(process, "stdin", originalStdin);
|
||
else delete (process as unknown as Record<string, unknown>).stdin;
|
||
process.exitCode = prevExit;
|
||
fake.destroy();
|
||
}
|
||
}
|
||
|
||
function verbSubcommand(program: ReturnType<typeof createProgram>, name: string) {
|
||
const queue = program.commands.find((c) => c.name() === "queue")!;
|
||
return queue.commands.find((c) => c.name() === name)!;
|
||
}
|
||
|
||
// Endpoints + required flags grounded at 4b05f970. `argv` is everything after
|
||
// `rig` except the body flags; `neither` is the verb's no-body contract.
|
||
const VERBS = [
|
||
{
|
||
name: "handoff",
|
||
argv: ["queue", "handoff", "q1", "--from", "a@rig", "--to", "b@rig"],
|
||
pathMatch: (p: string) => p === "/api/queue/q1/handoff",
|
||
neither: "undefined-body" as const,
|
||
},
|
||
{
|
||
name: "handoff-and-complete",
|
||
argv: ["queue", "handoff-and-complete", "q1", "--from", "a@rig", "--to", "b@rig"],
|
||
pathMatch: (p: string) => p === "/api/queue/q1/handoff-and-complete",
|
||
neither: "undefined-body" as const,
|
||
},
|
||
{
|
||
name: "inbox-drop",
|
||
argv: ["queue", "inbox-drop", "b@rig", "--sender", "a@rig"],
|
||
pathMatch: (p: string) => p === "/api/queue/inbox/drop",
|
||
neither: "reject" as const,
|
||
},
|
||
{
|
||
name: "outbox-record",
|
||
argv: ["queue", "outbox-record", "--sender", "a@rig", "--destination", "b@rig"],
|
||
pathMatch: (p: string) => p === "/api/queue/outbox/record",
|
||
neither: "reject" as const,
|
||
},
|
||
];
|
||
|
||
// ── STEP 1: harness calibration on EXISTING create --body - (GREEN today).
|
||
// Proves the process.stdin swap technique works before it carries any RED.
|
||
it("CALIBRATION (green): create --body - consumes the stdin PassThrough as the exact POST body", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-cal", state: "pending" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await withStdin(DISCRIMINATOR, async () => {
|
||
await program.parseAsync(["node", "rig", "queue", "create", "--source", "a@rig", "--destination", "b@rig", "--body", "-", "--json"]);
|
||
});
|
||
const post = calls.find((c) => c.path === "/api/queue/create");
|
||
expect(post, "create should POST after consuming stdin").toBeDefined();
|
||
expect((post!.body as Record<string, unknown>).body).toBe(DISCRIMINATOR);
|
||
});
|
||
|
||
// ── GENUINE RED ×4: --body-file exact POST body.
|
||
for (const v of VERBS) {
|
||
it(`RED: ${v.name} --body-file <path> POSTs the exact file bytes`, async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `s08-${v.name}-file-`));
|
||
const bodyPath = path.join(tmp, "body.txt");
|
||
fs.writeFileSync(bodyPath, DISCRIMINATOR, "utf8");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
try {
|
||
// Today --body-file is an unknown option on these verbs; catch the
|
||
// Commander rejection so the discriminator is the missing/incorrect
|
||
// POST body, not an uncaught parser throw.
|
||
try {
|
||
await program.parseAsync(["node", "rig", ...v.argv, "--body-file", bodyPath, "--json"]);
|
||
} catch {
|
||
/* expected Commander unknown-option today */
|
||
}
|
||
const post = calls.find((c) => v.pathMatch(c.path));
|
||
expect(post, `${v.name} should POST the file body`).toBeDefined();
|
||
expect((post!.body as Record<string, unknown>).body).toBe(DISCRIMINATOR);
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── GENUINE RED ×4: --body - stdin exact POST body.
|
||
for (const v of VERBS) {
|
||
it(`RED: ${v.name} --body - consumes stdin as the exact POST body`, async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await withStdin(DISCRIMINATOR, async () => {
|
||
try {
|
||
await program.parseAsync(["node", "rig", ...v.argv, "--body", "-", "--json"]);
|
||
} catch {
|
||
/* no throw expected today (--body exists); guarded for symmetry */
|
||
}
|
||
});
|
||
const post = calls.find((c) => v.pathMatch(c.path));
|
||
expect(post, `${v.name} should POST after consuming stdin`).toBeDefined();
|
||
// Today this is the literal "-" (bodyBytes:1) — the exact silent-dash bug.
|
||
expect((post!.body as Record<string, unknown>).body).toBe(DISCRIMINATOR);
|
||
});
|
||
}
|
||
|
||
// ── GENUINE RED ×4: help documents --body-file + stdin '-'.
|
||
for (const v of VERBS) {
|
||
it(`RED: ${v.name} --help documents --body-file and stdin '-'`, () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const help = verbSubcommand(program, v.name).helpInformation();
|
||
expect(help, `${v.name} help should teach --body-file`).toContain("--body-file");
|
||
expect(help, `${v.name} help should teach stdin '-'`).toMatch(/stdin|read from stdin|use -/i);
|
||
});
|
||
}
|
||
|
||
// ── PRESERVATION GREEN ×2: handoff pair, neither → POST body undefined.
|
||
for (const v of VERBS.filter((x) => x.neither === "undefined-body")) {
|
||
it(`PRESERVE (green): ${v.name} with no body POSTs body undefined (source-body default)`, async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
try {
|
||
await program.parseAsync(["node", "rig", ...v.argv, "--json"]);
|
||
} catch {
|
||
/* not expected */
|
||
}
|
||
const post = calls.find((c) => v.pathMatch(c.path));
|
||
expect(post, `${v.name} should still POST with the source-body default`).toBeDefined();
|
||
expect((post!.body as Record<string, unknown>).body).toBeUndefined();
|
||
});
|
||
}
|
||
|
||
// ── PRESERVATION GREEN ×2: inbox/outbox, neither → error + no POST
|
||
// (mechanism-neutral across Commander requiredOption today and resolver reject later).
|
||
for (const v of VERBS.filter((x) => x.neither === "reject")) {
|
||
it(`PRESERVE (green): ${v.name} with no body errors and does NOT contact the daemon`, async () => {
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
const prevExit = process.exitCode;
|
||
process.exitCode = undefined;
|
||
let errored = false;
|
||
try {
|
||
await program.parseAsync(["node", "rig", ...v.argv, "--json"]);
|
||
} catch {
|
||
errored = true; // Commander requiredOption throws today
|
||
}
|
||
try {
|
||
expect(errored || process.exitCode === 1, `${v.name} should signal an error`).toBe(true);
|
||
expect(calls.find((c) => v.pathMatch(c.path)), `${v.name} must not POST`).toBeUndefined();
|
||
} finally {
|
||
process.exitCode = prevExit;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── NEW-CAPABILITY GREEN ×4: both sources → error + no POST
|
||
// (unknown-option today; resolver mutual-exclusion after A — either way no daemon contact).
|
||
for (const v of VERBS) {
|
||
it(`NEW-CAP (green): ${v.name} with both --body and --body-file errors and does NOT POST`, async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `s08-${v.name}-both-`));
|
||
const bodyPath = path.join(tmp, "body.txt");
|
||
fs.writeFileSync(bodyPath, "x", "utf8");
|
||
const { deps, calls } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
const prevExit = process.exitCode;
|
||
process.exitCode = undefined;
|
||
let errored = false;
|
||
try {
|
||
await program.parseAsync(["node", "rig", ...v.argv, "--body", "inline", "--body-file", bodyPath, "--json"]);
|
||
} catch {
|
||
errored = true;
|
||
}
|
||
try {
|
||
expect(errored || process.exitCode === 1, `${v.name} should reject dual sources`).toBe(true);
|
||
expect(calls.find((c) => v.pathMatch(c.path)), `${v.name} must not POST`).toBeUndefined();
|
||
} finally {
|
||
process.exitCode = prevExit;
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── GENUINE RED: shared resolver errors must be command-NEUTRAL.
|
||
// Today all four say "rig queue create did not run" — false once handoff/
|
||
// inbox/outbox share the helper. Pin generic wording; no stale "queue create".
|
||
it("RED: resolveQueueBody 'neither' error wording is command-neutral (no 'queue create')", async () => {
|
||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||
consequence: expect.not.stringMatching(/queue create/i),
|
||
});
|
||
});
|
||
|
||
it("RED: resolveQueueBody 'both' error wording is command-neutral (no 'queue create')", async () => {
|
||
await expect(resolveQueueBody({ body: "x", bodyFile: "/tmp/y" })).rejects.toMatchObject({
|
||
consequence: expect.not.stringMatching(/queue create/i),
|
||
});
|
||
});
|
||
|
||
it("RED: resolveQueueBody missing-file error wording is command-neutral (no 'queue create')", async () => {
|
||
await expect(
|
||
resolveQueueBody({ bodyFile: "/tmp/s08-does-not-exist-neutral-wording.md" }),
|
||
).rejects.toMatchObject({
|
||
consequence: expect.not.stringMatching(/queue create/i),
|
||
});
|
||
});
|
||
|
||
// BLOCKER-1 fix: the FOURTH stale consequence — the not-a-regular-file /
|
||
// directory branch — was unguarded. A negative search found FOUR 'rig queue
|
||
// create did not run' occurrences; the packet must pin all four, not three.
|
||
it("RED: resolveQueueBody not-a-regular-file error wording is command-neutral (no 'queue create')", async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "s08-notregular-wording-"));
|
||
try {
|
||
await expect(resolveQueueBody({ bodyFile: tmp })).rejects.toMatchObject({
|
||
consequence: expect.not.stringMatching(/queue create/i),
|
||
});
|
||
} finally {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// BLOCKER-2 fix: the gate required generic BODY guidance, not only the
|
||
// consequence. The neither ACTION today is "Pass the qitem body via …",
|
||
// which is false for inbox/outbox records. It must teach --body/--body-file
|
||
// and stay generic — no 'qitem'. Phrasing-tolerant (no exact-prose lock).
|
||
it("RED: resolveQueueBody 'neither' action gives generic body guidance (no 'qitem')", async () => {
|
||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||
action: expect.stringMatching(/--body\b|--body-file/),
|
||
});
|
||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||
action: expect.not.stringMatching(/qitem/i),
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// P3 — authored-summary TEACHING LAYER. The shipped warn-then-require rail (OPR.0.4.1.18
|
||
// FR-7) tells callers a missing --summary falls back to a bounded body preview; the teaching
|
||
// layer additionally TEACHES the convention (what a good summary is + where a human reads it), so
|
||
// the needs-you rows a human skims are actually authored, not just hoped for. Rails held:
|
||
// warn → stderr (json stdout clean), warn-not-hard-break, the two handoff advisories stay
|
||
// byte-identical, facts-not-fabricated (teaches, never invents).
|
||
describe("P3 — authored-summary teaching layer (advisory + --summary hint)", () => {
|
||
let stderrOut: string[];
|
||
const TEACHES_WHERE = /needs-you view/; // teaches WHERE the summary is read
|
||
const TEACHES_WHY = /why it needs this seat/; // teaches the WHAT+WHY convention
|
||
|
||
beforeEach(() => {
|
||
stderrOut = [];
|
||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||
stderrOut.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
||
return true;
|
||
});
|
||
process.exitCode = undefined;
|
||
});
|
||
|
||
it("create without --summary teaches the same truthful bounded-preview fallback and does NOT hard-break", async () => {
|
||
const { deps, calls } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q1", state: "pending" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "a@rig", "--destination", "b@rig", "--body", "x", "--json",
|
||
]);
|
||
const warn = stderrOut.join("");
|
||
expect(warn).toMatch(TEACHES_WHERE);
|
||
expect(warn).toMatch(TEACHES_WHY);
|
||
expect(warn).toMatch(/pass --summary <text>/i);
|
||
expect(warn).toMatch(/bounded body preview/i);
|
||
expect(warn).not.toMatch(/body truncation/i);
|
||
// warn-not-require rail: the qitem is still created (no hard-break on omission).
|
||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeDefined();
|
||
});
|
||
|
||
it("handoff + handoff-and-complete without --summary carry the SAME teaching advisory (byte-identical parity)", async () => {
|
||
async function warnFor(sub: "handoff" | "handoff-and-complete"): Promise<string> {
|
||
stderrOut = [];
|
||
const { deps } = makeDeps({
|
||
routes: {
|
||
"POST /api/queue/handoff": { status: 201, data: { qitemId: "q2" } },
|
||
"POST /api/queue/handoff-and-complete": { status: 201, data: { qitemId: "q3" } },
|
||
},
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
try {
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", sub, "qitem-src-1",
|
||
"--from", "a@rig", "--to", "b@rig", "--body", "y", "--json",
|
||
]);
|
||
} catch { /* exitOverride / downstream mock — the warn fires before the daemon call */ }
|
||
return stderrOut.join("");
|
||
}
|
||
const h = await warnFor("handoff");
|
||
const hc = await warnFor("handoff-and-complete");
|
||
expect(h).toMatch(TEACHES_WHERE);
|
||
expect(h).toMatch(TEACHES_WHY);
|
||
expect(h).toMatch(/pass --summary <text>/i);
|
||
expect(h).toMatch(/bounded body preview/i);
|
||
expect(h).not.toMatch(/body truncation/i);
|
||
expect(hc).toBe(h); // parity rail: the two handoff advisories are byte-identical
|
||
});
|
||
|
||
it("the create --summary option hint teaches WHERE the summary is read (not only the cost)", () => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const queueCmd = program.commands.find((c) => c.name() === "queue")!;
|
||
const createCmd = queueCmd.commands.find((c) => c.name() === "create")!;
|
||
const summaryOpt = createCmd.options.find((o) => o.long === "--summary")!;
|
||
expect(summaryOpt.description).toMatch(TEACHES_WHERE);
|
||
});
|
||
|
||
// The summary is the row's subject on every surface that lists it, not only the needs-you
|
||
// view; an agent that reads "needs-you" alone leaves it off a row bound for an agent.
|
||
const SUBJECT_SURFACES = [/\bRECENT\b/, /\bstory\b/i, /needs-you view/];
|
||
|
||
it.each(["create", "handoff", "handoff-and-complete"])(
|
||
"%s --summary help names every surface that shows the row's subject",
|
||
(sub) => {
|
||
const { deps } = makeDeps();
|
||
const program = createProgram({ queueDeps: deps });
|
||
const queueCmd = program.commands.find((c) => c.name() === "queue")!;
|
||
const cmd = queueCmd.commands.find((c) => c.name() === sub)!;
|
||
const summaryOpt = cmd.options.find((o) => o.long === "--summary")!;
|
||
for (const surface of SUBJECT_SURFACES) expect(summaryOpt.description).toMatch(surface);
|
||
},
|
||
);
|
||
|
||
it("the missing-summary warning names every surface that shows the row's subject", async () => {
|
||
const { deps } = makeDeps({
|
||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q1", state: "pending" } } },
|
||
});
|
||
const program = createProgram({ queueDeps: deps });
|
||
program.exitOverride();
|
||
await program.parseAsync([
|
||
"node", "rig", "queue", "create",
|
||
"--source", "a@rig", "--destination", "b@rig", "--body", "x", "--json",
|
||
]);
|
||
const warn = stderrOut.join("");
|
||
for (const surface of SUBJECT_SURFACES) expect(warn).toMatch(surface);
|
||
});
|
||
});
|