fix(daemon): PL-004 Phase D R3 — map exit_not_allowed to HTTP 400 + route regression

Repairs guard BLOCK on R2 commit 1c94719:

R2 added the WorkflowProjectorError(exit_not_allowed) domain error but
the workflow routes errorResponse fall-through left the new code
mapping to HTTP 500 internal-server-error, dishonest at the public
surface (HTTP API + rig workflow project CLI).

R3 fix:

  - routes/workflow.ts errorResponse: add err.code === "exit_not_allowed"
    → HTTP 400. Structured details (stepId, attemptedExit, allowedExits)
    are already preserved by the existing
    `{...(err.details ?? {})}` spread.

R3 regression added in workflow-routes.test.ts:

  - "POST /project surfaces exit_not_allowed as 400 with structured
    details + no side effects": instantiates a workflow on the produce
    step (allowed_exits=[handoff]), captures pre-state via the public
    surface, attempts exit=done, asserts:
    - res.status === 400 (NOT 500)
    - body.error === "exit_not_allowed" (NOT "internal_error")
    - body.message contains "produce"
    - body.stepId === "produce"
    - body.attemptedExit === "done"
    - body.allowedExits === ["handoff"]
    - Post-rejection instance state via GET /:instance_id matches
      pre-state (currentFrontier, currentStepId, status all unchanged)
    - GET /:instance_id/trace returns trail === []

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2560 / 2560 pass (was 2559 in Phase D R2; +1
      R3 route regression).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures unchanged.

Boundaries preserved (R3):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - Smallest-honest-patch: 1 line added to error mapping + 1 test added.
    No domain change; no migration change; no other surface touched.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 08:22:19 -07:00
1 parent 1c94719b11
commit 3e32b0a80c
2 files changed
+70

No files matched your search

+5
View File
@@ -57,6 +57,11 @@ export function workflowRoutes(): Hono {
: err.code === "spec_not_cached" || err.code === "current_step_unknown" ? 409
: err.code === "no_next_step" || err.code === "next_owner_unresolved" ? 400
: err.code === "spec_invalid" || err.code === "entry_owner_unresolved" || err.code === "spec_no_steps" ? 400
// R3 fix (guard blocker): map allowed_exits projection-time
// rejection to 400 so the public surface (HTTP API + CLI) is
// honest about operator/spec misuse instead of falsifying it
// as 500 internal-server-error.
: err.code === "exit_not_allowed" ? 400
: err.code === "packet_not_found" ? 404
: 500;
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
@@ -141,6 +141,71 @@ describe("workflow routes (PL-004 Phase D)", () => {
expect(body.nextOwnerSession).toBe("reviewer@rig");
});
// R3 fix (guard blocker): exit_not_allowed must surface as HTTP 400
// (not 500 internal-server-error) with structured details preserved.
// Asserts no side effects on the public path: queue still pending,
// instance state unchanged.
it("POST /project surfaces exit_not_allowed as 400 with structured details + no side effects", async () => {
const create = await app.request("/api/workflow/instantiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
});
const created = (await create.json()) as { instance: { instanceId: string }; entryQitemId: string };
// Capture pre-rejection state via the public surface.
const beforeShow = await app.request(`/api/workflow/${created.instance.instanceId}`);
const beforeInstance = (await beforeShow.json()) as {
currentFrontier: string[];
currentStepId: string | null;
status: string;
};
// Attempt exit=done on the produce step (which only allows handoff).
const res = await app.request("/api/workflow/project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instanceId: created.instance.instanceId,
currentPacketId: created.entryQitemId,
exit: "done",
actorSession: "producer@rig",
}),
});
// R3 critical: 400 (NOT 500), structured error code + details preserved.
expect(res.status).toBe(400);
expect(res.status).not.toBe(500);
const body = (await res.json()) as {
error: string;
message: string;
stepId?: string;
attemptedExit?: string;
allowedExits?: string[];
};
expect(body.error).toBe("exit_not_allowed");
expect(body.error).not.toBe("internal_error");
expect(body.message).toContain("produce");
expect(body.stepId).toBe("produce");
expect(body.attemptedExit).toBe("done");
expect(body.allowedExits).toEqual(["handoff"]);
// No side effects through the public path: instance unchanged.
const afterShow = await app.request(`/api/workflow/${created.instance.instanceId}`);
const afterInstance = (await afterShow.json()) as {
currentFrontier: string[];
currentStepId: string | null;
status: string;
};
expect(afterInstance.currentFrontier).toEqual(beforeInstance.currentFrontier);
expect(afterInstance.currentStepId).toBe(beforeInstance.currentStepId);
expect(afterInstance.status).toBe(beforeInstance.status);
// Trail still empty (no projected step closure recorded).
const traceRes = await app.request(`/api/workflow/${created.instance.instanceId}/trace`);
const trace = (await traceRes.json()) as { trail: Array<unknown> };
expect(trace.trail).toEqual([]);
});
it("GET /list returns all instances", async () => {
await app.request("/api/workflow/instantiate", {
method: "POST",