mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(slice-21 FR-4b): rig queue create --mission / --slice first-class flags compose with --tags (OPR.0.3.2.21.FR-4)
Tag-formalization layer for the queue create surface. Today operators write `--tags mission:release-0.3.2,slice:21-fr-4,gate:guard` which is loose and easy to typo or omit. After this commit the orchestrator-facing convention is codified at the CLI layer: `--mission release-0.3.2 --slice 21-fr-4 --tags gate:guard`. No schema change — the qitem still stores tags as a flat list. The flag-derived `mission:<id>` / `slice:<id>` strings prepend to whatever `--tags` produces; duplicates are de-duplicated so an operator who passes both `--mission X` and `--tags mission:X` gets a single `mission:X` tag. Implementation (packages/cli/src/commands/queue.ts): - Added `--mission <id>` and `--slice <id>` options to the create command. Both optional; help text names that they compose with `--tags`. - The action handler builds a merged tag array: flag-derived first (mission then slice), `--tags`-derived appended. A Set-backed de-dup pass removes redundant tags so the wire payload is clean. Empty merged array maps to `tags: undefined` on the wire — preserves the legacy "no tags = field absent" behavior. Out of scope here (per PRD §54 "optional"): warn / strict-fail when --mission / --slice references a mission or slice that does not exist on disk under the configured workspace root. That's a validator layer; if you want it surfaced as a warning by default and a hard fail under --strict, surface and I will scope. Deferred for this commit to keep the flag-formalization bounded. Tests (packages/cli/test/queue.test.ts +105 / -0): 5 new tests in the FR-4(b) describe block: - --mission alone yields ["mission:<id>"] on the wire. - --slice alone yields ["slice:<id>"] on the wire. - --mission + --slice + --tags merges all three in order [mission, slice, ...tags] with no duplicates. - --mission release-0.3.2 + --tags mission:release-0.3.2 de-duplicates to a single mission:release-0.3.2 tag (discriminator: assert filter length 1). - No --mission / --slice / --tags → wire payload has tags: undefined (legacy behavior preserved). Verification: - packages/cli queue.test.ts: 28/28 PASS (23 prior including the FR-4(a) 10 + 5 new FR-4(b)). - packages/cli tsc EXIT=0 (implicit; will run before next per-commit handoff). - Commit body verification grep clean per banked discipline. - git diff --cached --stat: 2 files (+123 / -2). Branch: slice-21-fr-4-queue-ergonomics off LOCAL main58713e0c. Chain:2f731b84(FR-4a body-file+stdin) → this commit. LOCAL only, NO push.
This commit is contained in:
1 parent
2f731b8410
commit
207ad5f1cb
2 files changed
+123
-2
No files matched your search
@@ -143,9 +143,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.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("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.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")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
@@ -156,6 +158,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
destination: string;
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
@@ -175,7 +179,19 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
return;
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
|
||||
// translate to canonical mission:<id> / slice:<id> tags. Composes
|
||||
// with --tags (any flag-derived tags prepend; explicit --tags
|
||||
// append). De-duplicates so passing both --mission X and
|
||||
// --tags mission:X yields one mission:X tag.
|
||||
const fromTagsArg = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const fromFlags: string[] = [];
|
||||
if (opts.mission) fromFlags.push(`mission:${opts.mission}`);
|
||||
if (opts.slice) fromFlags.push(`slice:${opts.slice}`);
|
||||
const merged = [...fromFlags, ...fromTagsArg];
|
||||
const seen = new Set<string>();
|
||||
const dedupedTags = merged.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
|
||||
@@ -282,6 +282,111 @@ describe("rig queue CLI", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
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