mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 05:01:30 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2461c309 | ||
|
|
2a1d50a354 | ||
|
|
6aa59a3e4d |
No files matched your search
@@ -1402,8 +1402,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
|
||||
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
|
||||
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--summary <text>", "Summary persisted onto the item, with or without a state change (a blank one isn't stored)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
|
||||
@@ -1675,7 +1675,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
|
||||
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
|
||||
summary: z.string().optional().describe("Replace the one-line summary"),
|
||||
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
|
||||
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
|
||||
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
|
||||
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
|
||||
}),
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("queue writes refused for a host token the daemon doesn't know", () =>
|
||||
["a remote's refusal", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 400, detail: "HTTP 400" } }],
|
||||
["an auth failure", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "auth-failed", detail: "401" } }],
|
||||
["an internal error", { status: 500, data: { error: "internal_error", message: "boom" } }],
|
||||
["a 400 this list doesn't name", { status: 400, data: { error: "summary_evidence_not_persistable", message: "x" } }],
|
||||
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
|
||||
];
|
||||
for (const [label, reply] of UNPROVEN) {
|
||||
it(`never says it after ${label}`, async () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
|
||||
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
|
||||
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
|
||||
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
|
||||
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
|
||||
// the HTTP call, and the successful human-park control — all end to end.
|
||||
// 51-06 D2, narrowed — REAL CLI acceptance boundary. A genuine `queueCommand` invocation talks over
|
||||
// LOOPBACK HTTP to the real candidate queueRoutes + QueueRepository, backed by a migration-faithful test DB
|
||||
// (incl. 044 summary + 048 evidence_ref). No pre-baked response. A state change that carries --summary or
|
||||
// --evidence-ref is applied and stores them; a blank --summary is reported as not stored, never dropped silently.
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
@@ -107,27 +105,29 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
|
||||
const beforeTxns = txnCount(id), beforeEvents = eventCount();
|
||||
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
|
||||
expect(exit).toBe(1);
|
||||
expect(out).toContain("summary_evidence_not_persistable");
|
||||
expect(out).toContain("invalidFields");
|
||||
// unchanged item/metadata/transition/event state AFTER the real HTTP call
|
||||
const item = repo.getByIdOrThrow(id);
|
||||
expect(item.state).toBe("pending");
|
||||
expect(item.summary).toBeNull();
|
||||
expect(txnCount(id)).toBe(beforeTxns);
|
||||
expect(eventCount()).toBe(beforeEvents);
|
||||
it("normal: --summary and --evidence-ref on a state change are applied and stored (exit 0)", async () => {
|
||||
const beforeTxns = txnCount(id);
|
||||
const { exit } = await runCli(["update", id, "--state", "in-progress", "--summary", "KEEP", "--evidence-ref", "/proof/e.md"]);
|
||||
expect(exit).toBeFalsy();
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "KEEP", evidenceRef: "/proof/e.md" });
|
||||
expect(txnCount(id)).toBe(beforeTxns + 1);
|
||||
});
|
||||
|
||||
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
|
||||
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
|
||||
expect(exit).toBe(1);
|
||||
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
|
||||
expect(body.error).toBe("summary_evidence_not_persistable");
|
||||
expect(body.invalidFields).toEqual(["evidenceRef"]);
|
||||
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
|
||||
it("a finished seat's park on a typed gate with --summary and --evidence-ref parks and stores both (--json)", async () => {
|
||||
await runCli(["update", id, "--state", "in-progress"]);
|
||||
const { out, exit } = await runCli(["block", id, "--on", "external:relay-reply", "--summary", "Relayed; waiting on the reply", "--evidence-ref", "/proof/relay.md", "--json"]);
|
||||
expect(exit).toBeFalsy();
|
||||
expect(JSON.parse(out)).toMatchObject({ state: "blocked", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:relay-reply", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
|
||||
});
|
||||
|
||||
it.each([false, true])("a blank --summary is applied without it and reported as not stored (json=%s)", async (json) => {
|
||||
db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run("Original subject", id);
|
||||
const { out, exit } = await runCli(["update", id, "--state", "in-progress", "--summary", " ", ...(json ? ["--json"] : [])]);
|
||||
expect(exit).toBeFalsy();
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject" });
|
||||
if (json) expect(JSON.parse(out).advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
|
||||
else expect(out).toContain(`The summary was blank, so it wasn't stored: ${id} keeps its summary`);
|
||||
});
|
||||
|
||||
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
|
||||
|
||||
@@ -520,14 +520,13 @@ export interface QueueUpdateInput {
|
||||
wakeMessage?: string;
|
||||
/**
|
||||
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
|
||||
* updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker),
|
||||
* not create-only: `rig queue block --summary --evidence-ref` persists
|
||||
* them onto the EXISTING item so the attention query + Packet 2 read
|
||||
* them. A done close also persists evidence_ref, so a finished row carries
|
||||
* its artifact. OPR.0.5.1 slice-51-06 D2: supplying either where it can't
|
||||
* be stored is REJECTED (QueueRepositoryError "summary_evidence_not_persistable")
|
||||
* before any mutation — not silently ignored — so a caller never believes
|
||||
* unpersistable metadata was saved.
|
||||
* updatable with any state change, not create-only: `rig queue block
|
||||
* --summary --evidence-ref` persists them onto the EXISTING item so the
|
||||
* attention query + Packet 2 read them, and a done close leaves its
|
||||
* artifact on the finished row. A human-seat park still requires them.
|
||||
* A blank value is not stored (it would erase the row's subject or
|
||||
* evidence link); the update route reports any value
|
||||
* the stored row doesn't carry, so a caller never believes it was saved.
|
||||
*/
|
||||
summary?: string | null;
|
||||
evidenceRef?: string | null;
|
||||
@@ -3455,6 +3454,7 @@ export class QueueRepository {
|
||||
}
|
||||
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
|
||||
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
|
||||
const hasValidEvidenceRef = typeof input.evidenceRef === "string" && input.evidenceRef.trim().length > 0;
|
||||
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
|
||||
qitem.state as "done" | "canceled" | "handed-off",
|
||||
);
|
||||
@@ -3664,35 +3664,15 @@ export class QueueRepository {
|
||||
throw new QueueRepositoryError("invalid_human_notification", "An informational delivery cannot become a human approval park; author a separate decision request.");
|
||||
}
|
||||
// A park on a person by any address (a legacy human seat, or a registered person's own address or alias) keeps the
|
||||
// summary and evidence it names. validateHumanPark still requires them only for the legacy human-seat form.
|
||||
// summary and evidence it names, or the row's own where it names a blank one. validateHumanPark still requires them
|
||||
// only for the legacy human-seat form.
|
||||
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
|
||||
// A done close can also store the artifact the work produced, so the finished row carries its link.
|
||||
const isDoneClose = input.state === "done";
|
||||
// OPR.0.5.1 slice-51-06 D2 refused the whole transition when summary/evidence_ref came with a state it
|
||||
// didn't store them for, so finished work could stay pending over optional metadata. A state change now
|
||||
// stores them (below), except a blank value; the route reports what wasn't stored.
|
||||
|
||||
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
|
||||
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
|
||||
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
|
||||
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
|
||||
// empty string = present (a deliberate value → rejected on a non-park transition).
|
||||
if (!isHumanPark) {
|
||||
const invalidFields: Array<"summary" | "evidenceRef"> = [];
|
||||
if (input.summary != null) invalidFields.push("summary");
|
||||
if (input.evidenceRef != null && !isDoneClose) invalidFields.push("evidenceRef");
|
||||
if (invalidFields.length > 0) {
|
||||
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
|
||||
const where = invalidFields.map((f) => f === "summary"
|
||||
? "summary persists on a human-seat park, or on its own (rig queue update <id> --summary … with no --state)"
|
||||
: "evidenceRef persists on a human-seat park or a done close").join("; ");
|
||||
throw new QueueRepositoryError(
|
||||
"summary_evidence_not_persistable",
|
||||
`${where}; the '${input.state}' transition cannot store ${invalidFields.length > 1 ? "them" : "it"}. Remove ${flags}, or use one of those.`,
|
||||
{ invalidFields },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE, beside the reference
|
||||
// reject above: an incoherent field must never silently persist (worse than a drop —
|
||||
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: an incoherent
|
||||
// field must never silently persist (worse than a drop —
|
||||
// the COALESCE below would write it). Admits-map, derived from LIVE schema use:
|
||||
// closure_reason/closure_target → state "done", OR the PARK-RECORD form
|
||||
// (state "blocked" with closureReason "blocked_on" — the workflow gate/park
|
||||
@@ -3770,8 +3750,8 @@ export class QueueRepository {
|
||||
let effectiveSummary = qitem.summary;
|
||||
let effectiveEvidenceRef = qitem.evidenceRef;
|
||||
if (isHumanPark) {
|
||||
effectiveSummary = input.summary ?? qitem.summary;
|
||||
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
|
||||
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
|
||||
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
|
||||
const park = validateHumanPark({
|
||||
blockedOn: effectiveBlockedOn,
|
||||
summary: effectiveSummary,
|
||||
@@ -3899,15 +3879,13 @@ export class QueueRepository {
|
||||
input.qitemId
|
||||
);
|
||||
|
||||
// FR-6: park-time summary/evidence_ref are PERSISTED onto the existing
|
||||
// item (not merely validated-then-dropped) — visible to the attention
|
||||
// query and to Packet 2. A done close writes evidence_ref the same way.
|
||||
if (isHumanPark) {
|
||||
this.persistSummary(input.qitemId, input.summary ?? null);
|
||||
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
|
||||
} else if (isDoneClose) {
|
||||
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
|
||||
}
|
||||
// FR-6: summary/evidence_ref are PERSISTED onto the existing item (not
|
||||
// merely validated-then-dropped) — visible to the attention query, to
|
||||
// Packet 2 and to every reader of the row. Only non-blank values are
|
||||
// stored, on a park on a person too: a blank one would erase the row's
|
||||
// subject or evidence link, and the route reports it as not stored.
|
||||
if (hasValidSummary) this.persistSummary(input.qitemId, input.summary!);
|
||||
if (hasValidEvidenceRef) this.persistEvidenceRef(input.qitemId, input.evidenceRef!);
|
||||
|
||||
const transition = this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
@@ -3991,9 +3969,9 @@ export class QueueRepository {
|
||||
closureReason: validation.closureReason ?? null,
|
||||
closureTarget: validation.closureTarget ?? null,
|
||||
actorSession: input.actorSession,
|
||||
// FR-1 × FR-6: the event carries the summary as of THIS mutation
|
||||
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
|
||||
// (park-time summary included) so surfaces refresh without a fetch.
|
||||
summary: effectiveSummary ?? null,
|
||||
summary: this.getById(input.qitemId)?.summary ?? null,
|
||||
});
|
||||
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
|
||||
}
|
||||
@@ -4560,9 +4538,11 @@ export class QueueRepository {
|
||||
}
|
||||
|
||||
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
|
||||
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
|
||||
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
|
||||
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
|
||||
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions, and the evidence it
|
||||
* points to, when it has any) and its decision state (open, blocked or terminal; who it's addressed to or parked on; its
|
||||
* answers, a decision made for them, the current park, and the replies returned). A label, a wake, a claim or a note on a
|
||||
* park doesn't change it; a new park does, on any blocker, and so does a new evidence pointer. A row with no evidence
|
||||
* keeps the revision it had before evidence counted. */
|
||||
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
|
||||
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
|
||||
const excludeOp =
|
||||
@@ -4589,7 +4569,8 @@ export class QueueRepository {
|
||||
if (transitions[i]!.state === "blocked" && transitions[i - 1]?.state !== "blocked") parkEntry = transitions[i]!.transitionId;
|
||||
}
|
||||
const decision = [item.body, item.humanIntent ?? null, item.humanQuestions ?? null, phase, item.destinationSession, item.blockedOn,
|
||||
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies];
|
||||
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
|
||||
...(item.evidenceRef ? [item.evidenceRef] : [])];
|
||||
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,24 @@ export function queueRoutes(): Hono {
|
||||
message: `No wake is set for this park: nothing wakes ${item.destinationSession} on a timer while ${item.qitemId} waits on ${item.blockedOn}. To add one, park it again with --wake-after <duration> (or --wake-watchdog <job-id>). When ${item.blockedOn} clears, rig queue resolve ${item.qitemId} --decision <evidence> unparks it.`,
|
||||
};
|
||||
}
|
||||
/** A summary or evidence ref the update asked for but the stored row doesn't carry (a blank one, which isn't stored
|
||||
* because it would erase what the row has, or one the repository couldn't write): read from the stored row, so the
|
||||
* caller is told whatever the reason. */
|
||||
function unstoredAdvisories(body: { summary?: unknown; evidenceRef?: unknown }, item: QueueItem) {
|
||||
return ([["summary", "summary", "summary_not_stored"], ["evidenceRef", "evidence ref", "evidence_ref_not_stored"]] as const)
|
||||
.flatMap(([field, name, code]) => {
|
||||
const requested = body[field];
|
||||
const stored = item[field];
|
||||
if (typeof requested !== "string" || stored === requested) return [];
|
||||
const why = requested.trim().length === 0 ? `The ${name} was blank, so it wasn't stored` : `The ${name} wasn't stored`;
|
||||
return [{
|
||||
code,
|
||||
qitemId: item.qitemId,
|
||||
field,
|
||||
message: `${why}: ${item.qitemId} keeps ${stored ? `its ${name} "${stored}"` : `no ${name}`}. The rest of the update was applied.`,
|
||||
}];
|
||||
});
|
||||
}
|
||||
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
|
||||
return c.get("inboxHandler" as never) as InboxHandler;
|
||||
}
|
||||
@@ -312,9 +330,6 @@ export function queueRoutes(): Hono {
|
||||
: err.code === "reply_to_requires_update" ? 400
|
||||
: err.code === "reply_to_not_found" ? 400
|
||||
: err.code === "invalid_human_questions" ? 400
|
||||
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
|
||||
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
|
||||
: err.code === "summary_evidence_not_persistable" ? 400
|
||||
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
|
||||
// misuse of a queue verb on a live workflow packet; structured
|
||||
// 400 with the what/why/fix message, never a 500.
|
||||
@@ -922,8 +937,9 @@ export function queueRoutes(): Hono {
|
||||
evidenceRef: body.evidenceRef,
|
||||
...resolveRecordedIdentity(c, identity), // P21 §4 era-stamp, plus the MCP adapter's caller on a direct attributed request (principal:v1)
|
||||
});
|
||||
const advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
|
||||
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
|
||||
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
|
||||
.filter((advisory) => advisory !== null);
|
||||
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,29 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
|
||||
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
|
||||
});
|
||||
|
||||
it("a new evidence pointer on an open ask is a new decision: an answer read under the earlier pointer conflicts, with nothing written", async () => {
|
||||
const f = fixture();
|
||||
const id = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Approve the release notes", summary: "Notes?",
|
||||
humanIntent: "decision", evidenceRef: "/proof/notes-v1.md", nudge: false })).qitemId;
|
||||
const read = await f.revision(id);
|
||||
f.repo.update({ qitemId: id, actorSession: ASKER, state: "pending", evidenceRef: "/proof/notes-v2.md" });
|
||||
expect(f.repo.getById(id)).toMatchObject({ state: "pending", evidenceRef: "/proof/notes-v2.md" });
|
||||
expect(await f.revision(id)).not.toBe(read);
|
||||
const before = f.written(id);
|
||||
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed", currentRevision: await f.revision(id) } });
|
||||
expect(f.written(id)).toEqual(before);
|
||||
});
|
||||
|
||||
it("a row with no evidence keeps the revision it had before evidence counted", async () => {
|
||||
// A fixed id, so the revision (which names the row's park episode) is the same on every run; the value is the one
|
||||
// this ask had before evidence counted.
|
||||
const f = fixture();
|
||||
const id = (await f.repo.create({ qitemId: "qitem-revision-control", sourceSession: ASKER, destinationSession: PERSON,
|
||||
body: "Synthetic two-question ask", summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
|
||||
expect(await f.revision(id)).toBe("dr1-bed30769297c81fe419c3520");
|
||||
});
|
||||
|
||||
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
|
||||
const f = fixture();
|
||||
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
|
||||
|
||||
@@ -307,26 +307,18 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
|
||||
expect(ev!.toState).toBe("blocked");
|
||||
});
|
||||
|
||||
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
|
||||
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
|
||||
// persists on a non-park item) — now enforced loudly instead of silently.
|
||||
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
|
||||
// OPR.0.5.1 slice-51-06 D2 first replaced a silent drop with a whole-transition refusal. Narrowed: a
|
||||
// non-park state change now applies and stores them, so nothing is dropped and finished work isn't held.
|
||||
it("non-park updates STORE summary/evidence_ref inputs and apply the state change", async () => {
|
||||
const item = await inProgressItem();
|
||||
let err: unknown;
|
||||
try {
|
||||
repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "driver@rig",
|
||||
state: "in-progress",
|
||||
summary: "should NOT persist",
|
||||
evidenceRef: "should-not-persist.md",
|
||||
});
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeInstanceOf(QueueRepositoryError);
|
||||
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
|
||||
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
|
||||
const read = repo.getById(item.qitemId)!;
|
||||
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
|
||||
expect(read.evidenceRef).toBeNull();
|
||||
const res = repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "driver@rig",
|
||||
state: "in-progress",
|
||||
summary: "now persists",
|
||||
evidenceRef: "now-persists.md",
|
||||
});
|
||||
expect(res).toMatchObject({ state: "in-progress", summary: "now persists", evidenceRef: "now-persists.md" });
|
||||
expect(repo.getById(item.qitemId)).toMatchObject({ summary: "now persists", evidenceRef: "now-persists.md" });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
|
||||
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
|
||||
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
|
||||
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
|
||||
// 51-06 D2, narrowed — summary/evidence_ref given with a state change are STORED and the transition applies:
|
||||
// optional metadata never holds finished work at pending. A blank summary is the one value not stored (it would
|
||||
// erase the row's subject); the route reports it, so a caller never believes it was saved.
|
||||
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref).
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -17,11 +17,9 @@ import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_rep
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
|
||||
const REJECT_CODE = "summary_evidence_not_persistable";
|
||||
|
||||
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
|
||||
@@ -29,7 +27,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let repo: QueueRepository;
|
||||
@@ -46,52 +44,57 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
|
||||
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
|
||||
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
|
||||
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
|
||||
const setSummary = (id: string, summary: string) => db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run(summary, id);
|
||||
const setEvidence = (id: string, ref: string) => db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(ref, id);
|
||||
const post = (path: string, body: Record<string, unknown>, session: string) => app.request(path, {
|
||||
method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
|
||||
it.each([
|
||||
["summary", { summary: "KEEP" }, { summary: "KEEP", evidenceRef: null }],
|
||||
["evidence_ref", { evidenceRef: "/proof/x.md" }, { summary: null, evidenceRef: "/proof/x.md" }],
|
||||
["both", { summary: "S", evidenceRef: "/e" }, { summary: "S", evidenceRef: "/e" }],
|
||||
])("a state change carrying %s applies and stores it, with one transition", async (_label, input, stored) => {
|
||||
const id = await mkItem();
|
||||
const beforeState = repo.getByIdOrThrow(id).state;
|
||||
const beforeTxns = txnCount(id);
|
||||
const beforeEvents = eventCount();
|
||||
let err: unknown;
|
||||
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
|
||||
catch (e) { err = e; }
|
||||
expect(err).toBeInstanceOf(QueueRepositoryError);
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
|
||||
// zero mutation: no UPDATE (state), no log (transition), no event
|
||||
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
|
||||
expect(txnCount(id)).toBe(beforeTxns);
|
||||
expect(eventCount()).toBe(beforeEvents);
|
||||
}
|
||||
|
||||
it("rejects --summary on a non-park transition, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
|
||||
});
|
||||
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
|
||||
});
|
||||
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
|
||||
});
|
||||
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
|
||||
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input });
|
||||
expect(res).toMatchObject({ state: "in-progress", ...stored });
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", ...stored });
|
||||
expect(txnCount(id)).toBe(2);
|
||||
});
|
||||
|
||||
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
|
||||
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", async () => {
|
||||
const id = await mkItem();
|
||||
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
|
||||
let err: unknown;
|
||||
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
|
||||
catch (e) { err = e; }
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
|
||||
const blocker = await mkItem();
|
||||
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
|
||||
expect(res).toMatchObject({ state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
|
||||
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "blocked", summary: "Relayed; waiting on the reply" });
|
||||
});
|
||||
|
||||
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
|
||||
it.each(["done", "canceled"] as const)("a %s close stores summary and evidence_ref on the closed row", async (state) => {
|
||||
const id = await mkItem();
|
||||
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
|
||||
const close = state === "done" ? { closureReason: "no-follow-on" } : {};
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state, ...close, summary: "Finished", evidenceRef: "/proof/result.md" });
|
||||
expect(res).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
|
||||
const shown = await app.request(`/api/queue/${id}`);
|
||||
expect(shown.status).toBe(200);
|
||||
expect(await shown.json()).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
|
||||
});
|
||||
|
||||
it.each(["", " "])("a blank summary (%j) isn't stored: the state still changes and the row keeps its subject", async (blank) => {
|
||||
const id = await mkItem();
|
||||
setSummary(id, "Original subject");
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: blank, evidenceRef: "/e" });
|
||||
expect(res).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: "/e" });
|
||||
});
|
||||
|
||||
it("metadata-free update is UNCHANGED (null = absent)", async () => {
|
||||
const id = await mkItem();
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
|
||||
expect(res.state).toBe("in-progress");
|
||||
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
|
||||
});
|
||||
|
||||
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
|
||||
@@ -114,43 +117,101 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
|
||||
});
|
||||
|
||||
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
|
||||
it.each(["", " "])("a blank summary and evidence ref (%j) on a park on a registered person aren't stored: the row keeps its own", async (blank) => {
|
||||
const person = { entityId: "sarah", class: "human" as const, displayName: "Sarah", address: "sarah@external", connectorBindings: [],
|
||||
prefs: { deliveryClass: "A" as const } };
|
||||
const people = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [person] }) });
|
||||
const id = await mkItem();
|
||||
setSummary(id, "Which layout?"); setEvidence(id, "/proof/layout.md");
|
||||
expect(people.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@external", summary: blank, evidenceRef: blank }))
|
||||
.toMatchObject({ state: "blocked", blockedOn: "sarah@external", summary: "Which layout?", evidenceRef: "/proof/layout.md" });
|
||||
});
|
||||
|
||||
it("a legacy human-seat park given a blank summary and evidence ref parks on the row's own, instead of refusing", async () => {
|
||||
const id = await mkItem();
|
||||
setSummary(id, "PARK-KEEP"); setEvidence(id, "/proof/park.md");
|
||||
expect(repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " ", evidenceRef: " " }))
|
||||
.toMatchObject({ state: "blocked", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
|
||||
});
|
||||
|
||||
it("ROUTE: a blank summary on a park on a person -> 200, parked, and the advisory names the summary as not stored", async () => {
|
||||
const id = await mkItem();
|
||||
setSummary(id, "Original subject"); setEvidence(id, "/proof/kept.md");
|
||||
const res = await post(`/api/queue/${id}/update`, { actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " " }, "orch@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
|
||||
expect(body).toMatchObject({ state: "blocked", summary: "Original subject" });
|
||||
expect(body.advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
|
||||
});
|
||||
|
||||
it("a park on an unregistered name on another daemon's domain stores its summary too", async () => {
|
||||
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const id = await mkItem();
|
||||
let err: unknown;
|
||||
try { nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }); }
|
||||
catch (e) { err = e; }
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
|
||||
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
|
||||
});
|
||||
|
||||
it("a done close stores evidence_ref, and queue show returns it on the closed row", async () => {
|
||||
const id = await mkItem();
|
||||
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: "/proof/result.md" });
|
||||
expect(res).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
|
||||
const shown = await app.request(`/api/queue/${id}`);
|
||||
expect(shown.status).toBe(200);
|
||||
expect(((await shown.json()) as { state: string; evidenceRef?: string | null })).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
|
||||
});
|
||||
|
||||
it("a canceled close still refuses evidence_ref, before any mutation", async () => {
|
||||
const id = await mkItem();
|
||||
let err: unknown;
|
||||
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "canceled", evidenceRef: "/proof/x.md" }); }
|
||||
catch (e) { err = e; }
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["evidenceRef"]);
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "pending", evidenceRef: null });
|
||||
});
|
||||
|
||||
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
|
||||
// P21 I3: create/update derive the sender from the transport header; header==body claim ⇒ tolerated.
|
||||
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "orch@rig" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
|
||||
it("ROUTE: POST /:id/update with a state change + summary -> 200, stored, no advisory", async () => {
|
||||
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }, "orch@rig");
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "dev-x@rig" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; invalidFields?: string[] };
|
||||
expect(body.error).toBe(REJECT_CODE);
|
||||
expect(body.invalidFields).toEqual(["summary"]);
|
||||
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "KEEP" }, "dev-x@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
|
||||
expect(body).toMatchObject({ state: "in-progress", summary: "KEEP" });
|
||||
expect(body.advisories).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ROUTE: a blank summary with a state change -> 200, state applied, and an advisory names the summary as not stored", async () => {
|
||||
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "" }, "dev-x@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { state: string; summary: string | null; advisories?: Array<Record<string, unknown>> };
|
||||
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject" });
|
||||
expect(body.advisories).toEqual([{
|
||||
code: "summary_not_stored", qitemId: id, field: "summary",
|
||||
message: `The summary was blank, so it wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.`,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("a stored evidence pointer survives a blank one on a state change, and a done close keeps it too", async () => {
|
||||
const id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item", evidenceRef: "/proof/kept.md" })).qitemId;
|
||||
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", evidenceRef: "" })).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
|
||||
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: " " }))
|
||||
.toMatchObject({ state: "done", evidenceRef: "/proof/kept.md" });
|
||||
});
|
||||
|
||||
it("ROUTE: a blank evidence ref with a state change -> 200, the pointer kept, and an advisory names it as not stored", async () => {
|
||||
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", evidenceRef: "/proof/kept.md" }, "orch@rig");
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", evidenceRef: " " }, "dev-x@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { state: string; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
|
||||
expect(body).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
|
||||
expect(body.advisories).toEqual([{
|
||||
code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
|
||||
message: `The evidence ref was blank, so it wasn't stored: ${id} keeps its evidence ref "/proof/kept.md". The rest of the update was applied.`,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("ROUTE: when the repository can't write either field, the state still changes, the caller is told, and the event agrees with the stored row", async () => {
|
||||
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
// A column probe that read false at construction (its PRAGMA read failed), while the columns exist.
|
||||
Object.assign(repo as unknown as Record<string, boolean>, { hasSummaryColumn: false, hasEvidenceRefColumn: false });
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
|
||||
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "New subject", evidenceRef: "/proof/new.md" }, "dev-x@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { state: string; summary: string | null; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
|
||||
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
|
||||
expect(body.advisories).toEqual([
|
||||
{ code: "summary_not_stored", qitemId: id, field: "summary",
|
||||
message: `The summary wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.` },
|
||||
{ code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
|
||||
message: `The evidence ref wasn't stored: ${id} keeps no evidence ref. The rest of the update was applied.` },
|
||||
]);
|
||||
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "in-progress", summary: "Original subject" });
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user