From 2f3d3d6258d1f41a11469e12bae4d384bef0c845 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Thu, 15 Oct 2026 02:52:37 -0400 Subject: [PATCH] fix(daemon): answer client-fixable queue park/close refusals with 400 instead of 500 Co-Authored-By: Claude Sonnet 5.5 --- packages/daemon/src/routes/queue.ts | 17 ++++ .../test/queue-client-error-status.test.ts | 78 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/daemon/test/queue-client-error-status.test.ts diff --git a/packages/daemon/src/routes/queue.ts b/packages/daemon/src/routes/queue.ts index 0582137a6..4bc435de1 100644 --- a/packages/daemon/src/routes/queue.ts +++ b/packages/daemon/src/routes/queue.ts @@ -182,6 +182,23 @@ export function queueRoutes(): Hono { // misuse of a queue verb on a live workflow packet; structured // 400 with the what/why/fix message, never a 500. : err.code === "workflow_frontier_packet" ? 400 + // Park/close input the caller can fix (wake options on a non-park transition, closure + // fields or blocked_on on a transition that cannot store them) are client errors, not + // daemon faults. wake_intent_store_unavailable, outbox_db_mismatch and + // terminal_close_without_wake_intent describe the daemon's own state and stay 500. + // blocker_not_found / blocker_not_live / blocker_malformed stay 500 on purpose: the CLI + // tests pin their exit code 2 (queue-update-metadata-reject-cli.test.ts). + : err.code === "closure_fields_not_admitted" ? 400 + : err.code === "blocked_on_not_admitted" ? 400 + : err.code === "wake_not_admitted" ? 400 + : err.code === "wake_ambiguous" ? 400 + : err.code === "wake_after_invalid" ? 400 + : err.code === "wake_max_invalid" ? 400 + : err.code === "wake_message_not_admitted" ? 400 + : err.code === "wake_message_invalid" ? 400 + : err.code === "wake_watchdog_not_live" ? 400 + : err.code === "wake_watchdog_target_mismatch" ? 400 + : err.code === "wake_self_blocker" ? 400 : 500; return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200); } diff --git a/packages/daemon/test/queue-client-error-status.test.ts b/packages/daemon/test/queue-client-error-status.test.ts new file mode 100644 index 000000000..554ef5caf --- /dev/null +++ b/packages/daemon/test/queue-client-error-status.test.ts @@ -0,0 +1,78 @@ +// Refusals the caller can fix (wake options on a non-park update, a ghost blocker, closure +// fields on a transition that cannot store them) were answered 500 because the route's +// error map did not know their codes. They are client errors: 400 with the named code. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import type Database from "better-sqlite3"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { coreSchema } from "../src/db/migrations/001_core_schema.js"; +import { eventsSchema } from "../src/db/migrations/003_events.js"; +import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js"; +import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js"; +import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js"; +import { watchdogJobsSchema } from "../src/db/migrations/031_watchdog_jobs.js"; +import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js"; +import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js"; +import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_repo.js"; +import { i3IdentityProvenanceSchema } from "../src/db/migrations/067_i3_identity_provenance.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { QueueRepository } from "../src/domain/queue-repository.js"; +import { InboxHandler } from "../src/domain/inbox-handler.js"; +import { OutboxHandler } from "../src/domain/outbox-handler.js"; +import { queueRoutes } from "../src/routes/queue.js"; + +describe("queue update refusals that are client errors answer 400", () => { + let db: Database.Database; + let repo: QueueRepository; + let app: Hono; + let qitemId: string; + + beforeEach(async () => { + db = createDb(); + migrate(db, [ + coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, + watchdogJobsSchema, inboxEntriesSchema, outboxEntriesSchema, queueTargetRepoSchema, i3IdentityProvenanceSchema, + ]); + const bus = new EventBus(db); + repo = new QueueRepository(db, bus); + const outbox = new OutboxHandler(db); + repo.attachOutbox(outbox); + const inbox = new InboxHandler(db, bus, repo); + app = new Hono(); + app.use("*", async (c, next) => { + c.set("eventBus" as never, bus); + c.set("queueRepo" as never, repo); + c.set("inboxHandler" as never, inbox); + c.set("outboxHandler" as never, outbox); + await next(); + }); + app.route("/api/queue", queueRoutes()); + const item = await repo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "work", nudge: false }); + qitemId = item.qitemId; + repo.claim({ qitemId, destinationSession: "b@r" }); + }); + + afterEach(() => db.close()); + + const update = (body: Record) => app.request(`/api/queue/${qitemId}/update`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-OpenRig-Session": "b@r" }, + body: JSON.stringify(body), + }); + + const cases: Array<[string, Record]> = [ + ["wake_not_admitted", { state: "done", closureReason: "no-follow-on", wakeAfterSeconds: 60 }], + ["wake_ambiguous", { state: "blocked", blockedOn: "gate-x", wakeWatchdogId: "wd-1", wakeAfterSeconds: 60 }], + ["wake_after_invalid", { state: "blocked", blockedOn: "gate-x", wakeAfterSeconds: 0 }], + ["blocked_on_not_admitted", { state: "done", closureReason: "no-follow-on", blockedOn: "gate-x" }], + ["closure_fields_not_admitted", { state: "in-progress", closureReason: "no-follow-on" }], + ]; + + it.each(cases)("%s", async (code, body) => { + const res = await update(body); + const payload = await res.json(); + expect(payload.error).toBe(code); + expect(res.status, JSON.stringify(payload)).toBe(400); + }); +});