mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
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:
1 parent
1c94719b11
commit
3e32b0a80c
2 files changed
+70
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user