mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(slice-21 FR-4a): rig queue create gains --body-file <path> + stdin, killing backtick-shell-corruption class (OPR.0.3.2.21.FR-4)
Closes FR-4(a) — the load-bearing fix in the queue ergonomics
slice. Operators authoring per-commit handoffs / re-verify
qitems with multi-line bodies that contain backtick fences
have had to use the temp-file pattern
(`--body "$(cat /tmp/f.txt)"`) to avoid shell substitution
mangling the body. Even with the pattern, operators
occasionally typed an inline `--body "$(...)"` and saw the
backticks vanish or the qitem reject with garbled content.
After this commit the temp-file pattern is no longer needed for
queue create: pass the path directly with `--body-file <path>`
and the file content is read as utf-8 verbatim. No shell
substitution; no backtick mangling; flag-like tokens inside
the body stay intact.
Two new input shapes on the create surface:
rig queue create ... --body-file /tmp/handoff-body.txt
rig queue create ... --body-file - # stdin
rig queue create ... --body - # stdin (legacy --body honors -)
Mutual exclusion: passing both `--body` and `--body-file`
yields a 3-part fact/consequence/action error before the
daemon is contacted. Passing neither also yields the
3-part error (previously `--body` was a requiredOption which
errored at the Commander level; the validation moves into
the body-resolver so the 3-part error shape is consistent
across both classes).
Implementation (packages/cli/src/commands/queue.ts):
- Exported resolveQueueBody(opts, stdinReader?) — pure
function returning the body string for inline / file /
stdin shapes; throws a 3-part error on conflict / missing
/ missing-file. stdinReader is dependency-injected with a
defaultStdinReader implementation so tests can swap it
without touching process.stdin.
- defaultStdinReader honors process.stdin.isTTY by resolving
to empty (caller's empty-body check converts that to the
3-part error rather than blocking forever on a TTY).
- emitBodyResolveError prints the 3-part error in JSON or
stderr-prose form and sets process.exitCode = 1.
- The create command's action handler calls resolveQueueBody
BEFORE withClient, so a missing/ambiguous/missing-file
body fails fast and locally with no daemon round-trip.
The legacy `--body <text>` flag is preserved (no
backward-compat break for the common case of small inline
bodies); the change is purely additive on the input layer.
Tests (packages/cli/test/queue.test.ts +148):
A new "FR-4(a)" describe block with 10 tests:
- resolveQueueBody returns inline body for --body case.
- resolveQueueBody reads file content for --body-file path.
Discriminator: assert raw backticks survive in the file
content — proves the corruption class is gone.
- 3-part error when both --body and --body-file are passed.
- 3-part error when neither is passed.
- 3-part error when --body-file points at a missing path.
- Injected stdin reader is called for --body -.
- Injected stdin reader is called for --body-file -.
- Integration via parseAsync: create --body-file with
backticks fences in content reaches the daemon as body;
discriminator asserts the backtick fence pattern survived
intact end-to-end.
- Integration: both --body and --body-file → exit 1 + no
daemon contact.
- Integration: neither → exit 1 + no daemon contact.
Existing 13 queue tests still pass.
Verification:
- packages/cli queue.test.ts: 23/23 PASS (13 existing + 10 new).
- packages/cli tsc EXIT=0.
- Commit body verification grep clean per banked discipline.
- git diff --cached --stat: 2 files (+241 / -3).
Out of scope for this commit (lands as subsequent FR-4
commits on this branch): --mission / --slice first-class
flags (b); lastNudgeResult wording rename (c);
closure ≠ acceptance docs (d-docs). Schema change for a
distinct "accepted" state is deferred to 0.3.3 per the
mission's split note.
Branch: slice-21-fr-4-queue-ergonomics off LOCAL main
58713e0c. LOCAL only, NO push.
This commit is contained in:
1 parent
58713e0c58
commit
2f731b8410
2 files changed
+241
-3
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
@@ -41,6 +42,84 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution. Three accepted shapes:
|
||||
// --body "<text>" inline (legacy; backtick-prone for raw
|
||||
// multiline content)
|
||||
// --body-file <path> read body content from a file path
|
||||
// (kills the backtick-corruption class)
|
||||
// --body-file - or --body - read body from stdin (pipeline-friendly)
|
||||
//
|
||||
// Exactly one of --body / --body-file must be provided; the resolver throws
|
||||
// a 3-part fact/consequence/action error otherwise.
|
||||
//
|
||||
// stdinReader is dependency-injected so tests can swap it without touching
|
||||
// process.stdin. Default reads UTF-8 from process.stdin until EOF.
|
||||
export interface ResolveBodyOpts {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
}
|
||||
|
||||
export async function defaultStdinReader(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
if (process.stdin.isTTY) {
|
||||
// Resolve immediately to empty if nothing is piped; the caller's
|
||||
// empty-body check turns this into a 3-part error.
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveQueueBody(
|
||||
opts: ResolveBodyOpts,
|
||||
stdinReader: () => Promise<string> = defaultStdinReader,
|
||||
): Promise<string> {
|
||||
const hasInline = opts.body !== undefined && opts.body !== "";
|
||||
const hasFile = opts.bodyFile !== undefined && opts.bodyFile !== "";
|
||||
if (hasInline && hasFile) {
|
||||
const err = new Error("--body and --body-file are mutually exclusive.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Both --body and --body-file were passed; the body source is ambiguous.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass exactly one of --body or --body-file.";
|
||||
throw err;
|
||||
}
|
||||
if (!hasInline && !hasFile) {
|
||||
const err = new Error("Missing required body input.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Neither --body nor --body-file was provided.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass the qitem body via --body \"<text>\" or --body-file <path> (use - for stdin).";
|
||||
throw err;
|
||||
}
|
||||
if (hasInline) {
|
||||
if (opts.body === "-") return stdinReader();
|
||||
return opts.body!;
|
||||
}
|
||||
// hasFile path
|
||||
if (opts.bodyFile === "-") return stdinReader();
|
||||
const absPath = opts.bodyFile!;
|
||||
if (!fs.existsSync(absPath)) {
|
||||
const err = new Error(`--body-file path does not exist: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path does not exist: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Check the path; pass an absolute path; or use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
return fs.readFileSync(absPath, "utf8");
|
||||
}
|
||||
|
||||
function emitBodyResolveError(err: Error & { fact?: string; consequence?: string; action?: string }, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact: err.fact ?? err.message, consequence: err.consequence ?? "", action: err.action ?? "" } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact ?? err.message}\n${err.consequence ?? ""}\n${err.action ?? ""}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
@@ -62,7 +141,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
|
||||
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
@@ -74,7 +154,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
@@ -84,6 +165,15 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
|
||||
// so a missing/ambiguous body fails fast and locally.
|
||||
let resolvedBody: string;
|
||||
try {
|
||||
resolvedBody = await resolveQueueBody({ body: opts.body, bodyFile: opts.bodyFile });
|
||||
} catch (err) {
|
||||
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -91,7 +181,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
body: resolvedBody,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { resolveQueueBody } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
/**
|
||||
@@ -134,6 +138,150 @@ describe("rig queue CLI", () => {
|
||||
expect((create!.body as { nudge: boolean }).nudge).toBe(false);
|
||||
});
|
||||
|
||||
// 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/),
|
||||
});
|
||||
});
|
||||
|
||||
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("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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: {
|
||||
|
||||
Reference in new issue
Block a user