From 3ec201ce57b6d0792b0b7d05246fb71808db9390 Mon Sep 17 00:00:00 2001 From: William Cory Date: Mon, 27 Jul 2026 05:20:59 -0700 Subject: [PATCH 1/9] --- apps/cli/docs/llms-full.txt | 7 +- docs/integrations/serve.mdx | 5 +- docs/integrations/server.mdx | 2 + docs/llms-full.txt | 7 +- docs/llms-observability.txt | 7 +- packages/server/src/approvalDecision.js | 136 ++++++++++++++++++ packages/server/src/gateway.js | 96 +------------ packages/server/src/index.js | 30 +++- packages/server/src/serve.js | 30 +++- ...eway-approval-decision-validation.test.jsx | 37 +++++ packages/server/tests/serve.test.js | 78 ++++++++++ packages/server/tests/server.test.js | 80 +++++++++++ packages/smithers/docs/llms-full.txt | 7 +- skills/smithers/llms-full.txt | 7 +- 14 files changed, 426 insertions(+), 103 deletions(-) create mode 100644 packages/server/src/approvalDecision.js diff --git a/apps/cli/docs/llms-full.txt b/apps/cli/docs/llms-full.txt index 9691d5b1d..25fb54667 100644 --- a/apps/cli/docs/llms-full.txt +++ b/apps/cli/docs/llms-full.txt @@ -16282,6 +16282,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. @@ -16473,10 +16475,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. diff --git a/docs/integrations/serve.mdx b/docs/integrations/serve.mdx index ec0c2478c..d8074f6b2 100644 --- a/docs/integrations/serve.mdx +++ b/docs/integrations/serve.mdx @@ -144,10 +144,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. diff --git a/docs/integrations/server.mdx b/docs/integrations/server.mdx index 4e8c03635..dcafa586d 100644 --- a/docs/integrations/server.mdx +++ b/docs/integrations/server.mdx @@ -69,6 +69,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry [`SmithersEvent`](/runtime/events) JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 6fab5e8b8..fd54a94e6 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -16282,6 +16282,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. @@ -16473,10 +16475,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. diff --git a/docs/llms-observability.txt b/docs/llms-observability.txt index b382c1eee..9ec12f623 100644 --- a/docs/llms-observability.txt +++ b/docs/llms-observability.txt @@ -74,6 +74,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry [`SmithersEvent`](/runtime/events) JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. @@ -266,10 +268,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. diff --git a/packages/server/src/approvalDecision.js b/packages/server/src/approvalDecision.js new file mode 100644 index 000000000..e1f4965a3 --- /dev/null +++ b/packages/server/src/approvalDecision.js @@ -0,0 +1,136 @@ +/** + * @typedef {{ + * mode: "gate" | "select" | "rank" | "decision"; + * title: string | null; + * summary: string | null; + * options: Array<{ key: string; label: string; summary?: string }>; + * allowedScopes: string[]; + * allowedUsers: string[]; + * restrictionError: string | null; + * autoApprove: Record | null; + * }} ApprovalRequestRecord + */ + +/** + * @param {unknown} value + * @returns {Record | null} + */ +function asObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} + +/** + * @param {unknown} value + * @returns {string | undefined} + */ +function asString(value) { + return typeof value === "string" ? value : undefined; +} + +/** + * @param {unknown} value + * @returns {string[]} + */ +function parseStringArray(value) { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry) => typeof entry === "string"); +} + +/** + * @param {unknown} value + * @param {"allowedScopes" | "allowedUsers"} field + * @returns {{ values: string[]; error: string | null }} + */ +function parseApprovalRestriction(value, field) { + if (value === undefined) { + return { values: [], error: null }; + } + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) { + return { + values: [], + error: `${field} must be an array of non-empty strings`, + }; + } + return { values: value, error: null }; +} + +/** + * @param {unknown} value + * @param {string | null} fallbackTitle + * @returns {ApprovalRequestRecord} + */ +function parseApprovalRequest(value, fallbackTitle) { + const record = asObject(value); + const allowedScopes = parseApprovalRestriction(record?.allowedScopes, "allowedScopes"); + const allowedUsers = parseApprovalRestriction(record?.allowedUsers, "allowedUsers"); + const options = Array.isArray(record?.options) + ? record.options + .filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) + .map((entry) => ({ + key: asString(entry.key) ?? "", + label: asString(entry.label) ?? "", + ...(asString(entry.summary) ? { summary: asString(entry.summary) } : {}), + })) + .filter((entry) => entry.key.length > 0 && entry.label.length > 0) + : []; + const autoApprove = + record?.autoApprove && typeof record.autoApprove === "object" && !Array.isArray(record.autoApprove) + ? record.autoApprove + : null; + return { + mode: record?.mode === "select" || record?.mode === "rank" || record?.mode === "decision" ? record.mode : "gate", + title: asString(record?.title) ?? fallbackTitle, + summary: asString(record?.summary) ?? null, + options, + allowedScopes: allowedScopes.values, + allowedUsers: allowedUsers.values, + restrictionError: allowedScopes.error ?? allowedUsers.error, + autoApprove, + }; +} + +/** + * @param {ApprovalRequestRecord} request + * @param {unknown} decision + */ +function validateApprovalDecision(request, decision) { + if (request.mode === "select") { + const payload = asObject(decision); + const selected = asString(payload?.selected); + if (!selected) { + return { ok: false, code: "INVALID_REQUEST", message: "select approvals require decision.selected" }; + } + if (request.options.length > 0 && !request.options.some((option) => option.key === selected)) { + return { ok: false, code: "INVALID_REQUEST", message: `Unknown selection: ${selected}` }; + } + } + if (request.mode === "rank") { + const payload = asObject(decision); + const ranked = parseStringArray(payload?.ranked); + if (ranked.length === 0) { + return { ok: false, code: "INVALID_REQUEST", message: "rank approvals require decision.ranked" }; + } + const allowed = new Set(request.options.map((option) => option.key)); + if (allowed.size > 0 && ranked.some((value) => !allowed.has(value))) { + return { ok: false, code: "INVALID_REQUEST", message: "rank approval included unknown options" }; + } + } + return { ok: true }; +} + +/** + * Shared approval request parsing and decision validation for all transports. + */ +export const approvalDecision = { + parseApprovalRequest, + validateApprovalDecision, + unwrapDecision(value) { + const decision = asObject(value); + return decision && "value" in decision ? decision.value : value; + }, +}; diff --git a/packages/server/src/gateway.js b/packages/server/src/gateway.js index a7c4e4efa..1071afd48 100644 --- a/packages/server/src/gateway.js +++ b/packages/server/src/gateway.js @@ -92,6 +92,7 @@ import { getRequiredScopeForGatewayMethod, } from "@smithers-orchestrator/gateway/rpc"; import { hasGatewayScope, isGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes"; +import { approvalDecision } from "./approvalDecision.js"; import { apiCollectionNames, serializeAccountRow, @@ -1705,98 +1706,7 @@ function parseJwtScopes(value) { } return parseStringArray(value); } -/** - * Normalized approval request stored in an approval row's requestJson. - * @typedef {{ - * mode: "gate" | "select" | "rank" | "decision"; - * title: string | null; - * summary: string | null; - * options: Array<{ key: string; label: string; summary?: string }>; - * allowedScopes: string[]; - * allowedUsers: string[]; - * restrictionError: string | null; - * autoApprove: Record | null; - * }} ApprovalRequestRecord - */ -/** - * @param {unknown} value - * @param {"allowedScopes" | "allowedUsers"} field - * @returns {{ values: string[]; error: string | null }} - */ -function parseApprovalRestriction(value, field) { - if (value === undefined) { - return { values: [], error: null }; - } - if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) { - return { - values: [], - error: `${field} must be an array of non-empty strings`, - }; - } - return { values: value, error: null }; -} -/** - * @param {unknown} value - * @param {string | null} fallbackTitle - * @returns {ApprovalRequestRecord} - */ -function parseApprovalRequest(value, fallbackTitle) { - const record = asObject(value); - const allowedScopes = parseApprovalRestriction(record?.allowedScopes, "allowedScopes"); - const allowedUsers = parseApprovalRestriction(record?.allowedUsers, "allowedUsers"); - const options = Array.isArray(record?.options) - ? record.options - .filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))) - .map((entry) => ({ - key: asString(entry.key) ?? "", - label: asString(entry.label) ?? "", - ...(asString(entry.summary) ? { summary: asString(entry.summary) } : {}), - })) - .filter((entry) => entry.key.length > 0 && entry.label.length > 0) - : []; - const autoApprove = - record?.autoApprove && typeof record.autoApprove === "object" && !Array.isArray(record.autoApprove) - ? record.autoApprove - : null; - return { - mode: record?.mode === "select" || record?.mode === "rank" || record?.mode === "decision" ? record.mode : "gate", - title: asString(record?.title) ?? fallbackTitle, - summary: asString(record?.summary) ?? null, - options, - allowedScopes: allowedScopes.values, - allowedUsers: allowedUsers.values, - restrictionError: allowedScopes.error ?? allowedUsers.error, - autoApprove, - }; -} -/** - * @param {ApprovalRequestRecord} request - * @param {unknown} decision - */ -function validateApprovalDecision(request, decision) { - if (request.mode === "select") { - const payload = asObject(decision); - const selected = asString(payload?.selected); - if (!selected) { - return { ok: false, code: "INVALID_REQUEST", message: "select approvals require decision.selected" }; - } - if (request.options.length > 0 && !request.options.some((option) => option.key === selected)) { - return { ok: false, code: "INVALID_REQUEST", message: `Unknown selection: ${selected}` }; - } - } - if (request.mode === "rank") { - const payload = asObject(decision); - const ranked = parseStringArray(payload?.ranked); - if (ranked.length === 0) { - return { ok: false, code: "INVALID_REQUEST", message: "rank approvals require decision.ranked" }; - } - const allowed = new Set(request.options.map((option) => option.key)); - if (allowed.size > 0 && ranked.some((value) => !allowed.has(value))) { - return { ok: false, code: "INVALID_REQUEST", message: "rank approval included unknown options" }; - } - } - return { ok: true }; -} +const { parseApprovalRequest, validateApprovalDecision, unwrapDecision } = approvalDecision; /** * @param {string} pattern */ @@ -9684,7 +9594,7 @@ a { color: var(--brand); } ) { return responseError(frame.id, "FORBIDDEN", "Connection is missing required approval scope"); } - const decision = stableDecision && "value" in stableDecision ? stableDecision.value : params.decision; + const decision = unwrapDecision(params.decision); const note = asString(params.note) ?? asString(stableDecision?.note); if (approved) { const validation = validateApprovalDecision(request, decision); diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 85ec74720..ea5403311 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -26,6 +26,7 @@ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson"; import { SmithersError } from "@smithers-orchestrator/errors/SmithersError"; import { assertMaxBytes, assertMaxJsonDepth } from "@smithers-orchestrator/db/input-bounds"; import { prometheusContentType, renderPrometheusMetrics } from "@smithers-orchestrator/observability"; +import { approvalDecision } from "./approvalDecision.js"; /** @typedef {import("node:http").ServerResponse} ServerResponse */ /** @typedef {import("./ServerOptions.js").ServerOptions} ServerOptions */ @@ -1349,7 +1350,22 @@ function startServerInternal(opts = {}) { return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "Run not found" }, }); - await Effect.runPromise(approveNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy)); + const iteration = body.iteration ?? 0; + let requestJson = null; + const approval = await adapter.getApproval(runId, nodeId, iteration); + try { + requestJson = approval?.requestJson ? JSON.parse(approval.requestJson) : null; + } catch {} + const decision = approvalDecision.unwrapDecision(body.decision); + const request = approvalDecision.parseApprovalRequest(requestJson, nodeId); + if (request.restrictionError) { + throw new HttpError(400, "INVALID_REQUEST", `Malformed approval request: ${request.restrictionError}`); + } + const validation = approvalDecision.validateApprovalDecision(request, decision); + if (!validation.ok) { + throw new HttpError(400, validation.code, validation.message); + } + await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, body.note, body.decidedBy, decision)); return sendJson(res, 200, { runId }); } const denyMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/nodes\/([^/]+)\/deny$/); @@ -1367,7 +1383,17 @@ function startServerInternal(opts = {}) { return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "Run not found" }, }); - await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy)); + await Effect.runPromise( + denyNode( + adapter, + runId, + nodeId, + body.iteration ?? 0, + body.note, + body.decidedBy, + approvalDecision.unwrapDecision(body.decision), + ), + ); return sendJson(res, 200, { runId }); } const signalMatch = diff --git a/packages/server/src/serve.js b/packages/server/src/serve.js index eee196924..d77229e86 100644 --- a/packages/server/src/serve.js +++ b/packages/server/src/serve.js @@ -9,6 +9,7 @@ import { logWarning } from "@smithers-orchestrator/observability/logging"; import { recoverRewindAuditsAtStartup } from "@smithers-orchestrator/time-travel/recoverRewindAuditsAtStartup"; import { runPromise } from "./smithersRuntime.js"; import { httpRequests, httpRequestDuration, trackEvent } from "@smithers-orchestrator/observability/metrics"; +import { approvalDecision } from "./approvalDecision.js"; /** @typedef {import("./ServeOptions.js").ServeOptions} ServeOptions */ // Event-poll cadence for the SSE stream. @@ -299,14 +300,39 @@ export function createServeApp(opts) { app.post("/approve/:nodeId", async (c) => { const nodeId = c.req.param("nodeId"); const body = await c.req.json().catch(() => ({})); - await Effect.runPromise(approveNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy)); + const iteration = body.iteration ?? 0; + const approval = await adapter.getApproval(runId, nodeId, iteration); + let requestJson = null; + try { + requestJson = approval?.requestJson ? JSON.parse(approval.requestJson) : null; + } catch {} + const decision = approvalDecision.unwrapDecision(body.decision); + const request = approvalDecision.parseApprovalRequest(requestJson, nodeId); + if (request.restrictionError) { + throw new HttpError(400, "INVALID_REQUEST", `Malformed approval request: ${request.restrictionError}`); + } + const validation = approvalDecision.validateApprovalDecision(request, decision); + if (!validation.ok) { + throw new HttpError(400, validation.code, validation.message); + } + await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, body.note, body.decidedBy, decision)); return c.json({ runId }); }); // POST /deny/:nodeId app.post("/deny/:nodeId", async (c) => { const nodeId = c.req.param("nodeId"); const body = await c.req.json().catch(() => ({})); - await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy)); + await Effect.runPromise( + denyNode( + adapter, + runId, + nodeId, + body.iteration ?? 0, + body.note, + body.decidedBy, + approvalDecision.unwrapDecision(body.decision), + ), + ); return c.json({ runId }); }); // POST /cancel diff --git a/packages/server/tests/gateway-approval-decision-validation.test.jsx b/packages/server/tests/gateway-approval-decision-validation.test.jsx index 1ac8e65c5..dc3cb1b3d 100644 --- a/packages/server/tests/gateway-approval-decision-validation.test.jsx +++ b/packages/server/tests/gateway-approval-decision-validation.test.jsx @@ -327,6 +327,43 @@ describe("Gateway approval decision validation", () => { await operator.close(); await approver.close(); }); + test("HTTP approval submissions persist select decisions", async () => { + const dbPath = makeDbPath("http-select-decision"); + dbPaths.push(dbPath); + const bundle = createSelectApprovalWorkflow(dbPath, []); + const port = await startGateway(bundle, "http-select-approval"); + const operator = await connectGateway(port, "operator-token"); + const runId = await createRunAndAwaitApproval(operator, "http-select-approval"); + let response; + for (let attempt = 0; attempt < 40; attempt += 1) { + const httpResponse = await fetch( + `http://127.0.0.1:${port}/v1/api/approvals/${encodeURIComponent(`${runId}:pick-plan:0`)}`, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer operator-token", + }, + body: JSON.stringify({ decision: { selected: "balanced", notes: "best fit" }, approved: true }), + }, + ); + response = { status: httpResponse.status, body: await httpResponse.json() }; + if (response.body.ok || !String(response.body.error?.message ?? "").includes("not waiting for approval")) { + break; + } + await sleep(50); + } + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + const adapter = new SmithersDb(bundle.db); + const approval = await adapter.getApproval(runId, "pick-plan", 0); + expect(approval?.status).toBe("approved"); + expect(JSON.parse(approval?.decisionJson ?? "{}")).toEqual({ + selected: "balanced", + notes: "best fit", + }); + await operator.close(); + }); test("custom allowedScopes require a literal grant instead of falling back to run:read", async () => { const dbPath = makeDbPath("custom-scope"); dbPaths.push(dbPath); diff --git a/packages/server/tests/serve.test.js b/packages/server/tests/serve.test.js index 4dec6df15..d85fd1905 100644 --- a/packages/server/tests/serve.test.js +++ b/packages/server/tests/serve.test.js @@ -161,6 +161,37 @@ export default smithers((ctx) => ( )); +`, + ); + return workflowPath; + } + function writeSelectApprovalWorkflow(name, dbPath) { + const workflowPath = resolve(testDir, `${name}.tsx`); + writeFileSync( + workflowPath, + `/** @jsxImportSource smithers-orchestrator */ +import { createSmithers } from "smithers-orchestrator"; +import { z } from "zod"; + +const { smithers, Workflow, Approval, outputs } = createSmithers( + { selection: z.object({ selected: z.string(), notes: z.string().nullable() }) }, + { dbPath: ${JSON.stringify(dbPath)} }, +); + +export default smithers(() => ( + + + +)); `, ); return workflowPath; @@ -442,6 +473,53 @@ export default smithers((ctx) => ( expect(status).toBe(200); expect(data.runId).toBe(runId); }); + test("persists a select decision", async () => { + const dbPath = resolve(testDir, "approve-select.db"); + const workflowPath = writeSelectApprovalWorkflow("approve-select", dbPath); + const { adapter, runId } = await startServeApp(workflowPath); + await waitForServeRunStatus(["waiting-approval"]); + const { status, data } = await request("/approve/task1", { + method: "POST", + body: { decision: { selected: "balanced", notes: "best fit" } }, + }); + expect(status).toBe(200); + expect(data.runId).toBe(runId); + const approval = await adapter.getApproval(runId, "task1", 0); + expect(approval?.status).toBe("approved"); + expect(JSON.parse(approval?.decisionJson ?? "null")).toEqual({ + selected: "balanced", + notes: "best fit", + }); + }); + test("rejects a select approval without a usable decision", async () => { + const dbPath = resolve(testDir, "approve-select-invalid.db"); + const workflowPath = writeSelectApprovalWorkflow("approve-select-invalid", dbPath); + const { adapter, runId } = await startServeApp(workflowPath); + await waitForServeRunStatus(["waiting-approval"]); + const { status, data } = await request("/approve/task1", { + method: "POST", + body: { decision: { selected: "unknown" } }, + }); + expect(status).toBe(400); + expect(data.error.code).toBe("INVALID_REQUEST"); + expect(data.error.message).toContain("unknown"); + expect((await adapter.getApproval(runId, "task1", 0))?.status).toBe("requested"); + }); + test("unwraps and persists a stable nested select decision", async () => { + const dbPath = resolve(testDir, "approve-select-nested.db"); + const workflowPath = writeSelectApprovalWorkflow("approve-select-nested", dbPath); + const { adapter, runId } = await startServeApp(workflowPath); + await waitForServeRunStatus(["waiting-approval"]); + const { status } = await request("/approve/task1", { + method: "POST", + body: { decision: { approved: true, value: { selected: "balanced", notes: "best fit" } } }, + }); + expect(status).toBe(200); + expect(JSON.parse((await adapter.getApproval(runId, "task1", 0))?.decisionJson ?? "null")).toEqual({ + selected: "balanced", + notes: "best fit", + }); + }); }); describe("POST /deny/:nodeId", () => { test("denies a waiting-approval task", async () => { diff --git a/packages/server/tests/server.test.js b/packages/server/tests/server.test.js index 30ed030f7..920625685 100644 --- a/packages/server/tests/server.test.js +++ b/packages/server/tests/server.test.js @@ -218,6 +218,37 @@ const fakeAgent = { ); return workflowPath; } + function writeSelectApprovalWorkflow(name, dbPath) { + const workflowPath = resolve(testDir, `${name}.tsx`); + writeFileSync( + workflowPath, + `/** @jsxImportSource smithers-orchestrator */ +import { createSmithers } from "smithers-orchestrator"; +import { z } from "zod"; + +const { smithers, Workflow, Approval, outputs } = createSmithers( + { selection: z.object({ selected: z.string(), notes: z.string().nullable() }) }, + { dbPath: ${JSON.stringify(dbPath)} }, +); + +export default smithers(() => ( + + + +)); +`, + ); + return workflowPath; + } describe("host/origin defense", () => { test("rejects a non-loopback Host without authToken", async () => { startTestServer(); @@ -654,6 +685,55 @@ const fakeAgent = { expect(status).toBe(200); expect(data.runId).toBe(startData.runId); }); + test("persists and validates a select decision", async () => { + const dbPath = resolve(testDir, "approval-select.db"); + const workflowPath = writeSelectApprovalWorkflow("approval-select", dbPath); + startTestServer(); + const { data: startData } = await request("/v1/runs", { + method: "POST", + body: { workflowPath }, + }); + await waitForRunStatus(startData.runId, ["waiting-approval"]); + const adapterDb = new Database(dbPath, { readonly: true }); + const adapter = new SmithersDb(adapterDb); + try { + const accepted = await request(`/v1/runs/${startData.runId}/nodes/task1/approve`, { + method: "POST", + body: { decision: { selected: "balanced", notes: "best fit" } }, + }); + expect(accepted.status).toBe(200); + expect(JSON.parse((await adapter.getApproval(startData.runId, "task1", 0))?.decisionJson ?? "null")).toEqual({ + selected: "balanced", + notes: "best fit", + }); + } finally { + adapterDb.close(); + } + }); + test("rejects a select approval without a usable decision", async () => { + const dbPath = resolve(testDir, "approval-select-invalid.db"); + const workflowPath = writeSelectApprovalWorkflow("approval-select-invalid", dbPath); + startTestServer(); + const { data: startData } = await request("/v1/runs", { + method: "POST", + body: { workflowPath }, + }); + await waitForRunStatus(startData.runId, ["waiting-approval"]); + const adapterDb = new Database(dbPath, { readonly: true }); + const adapter = new SmithersDb(adapterDb); + try { + const rejected = await request(`/v1/runs/${startData.runId}/nodes/task1/approve`, { + method: "POST", + body: { decision: { selected: "unknown" } }, + }); + expect(rejected.status).toBe(400); + expect(rejected.data.error.code).toBe("INVALID_REQUEST"); + expect(rejected.data.error.message).toContain("unknown"); + expect((await adapter.getApproval(startData.runId, "task1", 0))?.status).toBe("requested"); + } finally { + adapterDb.close(); + } + }); test("returns 404 for non-existent run", async () => { startTestServer(); const { status, data } = await request("/v1/runs/non-existent-run-id/nodes/some-node/approve", { diff --git a/packages/smithers/docs/llms-full.txt b/packages/smithers/docs/llms-full.txt index 9691d5b1d..25fb54667 100644 --- a/packages/smithers/docs/llms-full.txt +++ b/packages/smithers/docs/llms-full.txt @@ -16282,6 +16282,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. @@ -16473,10 +16475,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. diff --git a/skills/smithers/llms-full.txt b/skills/smithers/llms-full.txt index 6fab5e8b8..fd54a94e6 100644 --- a/skills/smithers/llms-full.txt +++ b/skills/smithers/llms-full.txt @@ -16282,6 +16282,8 @@ routes[15]{method,path,purpose,auth}: POST,/signal/:runId/:signalName,Legacy alias for signals,bearer ``` +Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state. Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`. @@ -16473,10 +16475,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1 { "iteration": 0, "note": "Looks good", - "decidedBy": "alice" + "decidedBy": "alice", + "decision": { "selected": "balanced", "notes": "best fit" } } ``` +For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted. + ### POST /deny/:nodeId Deny a pending approval gate. Same body as `/approve/:nodeId`. From 2badce55cff9bf5939744b3f0f47ba2e65a817ec Mon Sep 17 00:00:00 2001 From: William Cory Date: Thu, 30 Jul 2026 14:39:52 -0700 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=90=9B=20fix(server):=20fail=20closed?= =?UTF-8?q?=20on=20malformed=20approval=20options=20and=20mixed-type=20ran?= =?UTF-8?q?kings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - select/rank validation rejects requests whose option entries were all dropped as malformed, instead of accepting arbitrary decisions - rank validation rejects mixed-type ranked arrays outright so the persisted decisionJson honors the string[] contract - re-export approvalDecision from the package index so TypeScript consumers can import it (regenerated index.d.ts) Co-Authored-By: Claude Fable 5 --- packages/server/src/approvalDecision.js | 21 +++++- packages/server/src/index.d.ts | 67 +++++++++++++------ packages/server/src/index.js | 1 + .../server/tests/approval-decision.test.js | 60 +++++++++++++++++ 4 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 packages/server/tests/approval-decision.test.js diff --git a/packages/server/src/approvalDecision.js b/packages/server/src/approvalDecision.js index e1f4965a3..59e92cd2b 100644 --- a/packages/server/src/approvalDecision.js +++ b/packages/server/src/approvalDecision.js @@ -100,23 +100,38 @@ function parseApprovalRequest(value, fallbackTitle) { */ function validateApprovalDecision(request, decision) { if (request.mode === "select") { + // Fail closed: a select request whose options were all malformed (dropped + // by parseApprovalRequest) must not accept an arbitrary selection. + if (request.options.length === 0) { + return { ok: false, code: "INVALID_REQUEST", message: "select approval request has no valid options" }; + } const payload = asObject(decision); const selected = asString(payload?.selected); if (!selected) { return { ok: false, code: "INVALID_REQUEST", message: "select approvals require decision.selected" }; } - if (request.options.length > 0 && !request.options.some((option) => option.key === selected)) { + if (!request.options.some((option) => option.key === selected)) { return { ok: false, code: "INVALID_REQUEST", message: `Unknown selection: ${selected}` }; } } if (request.mode === "rank") { + if (request.options.length === 0) { + return { ok: false, code: "INVALID_REQUEST", message: "rank approval request has no valid options" }; + } const payload = asObject(decision); - const ranked = parseStringArray(payload?.ranked); + const rankedRaw = payload?.ranked; + // The original decision object is what gets persisted, so a mixed-type + // array must be rejected outright — sanitizing only the validation copy + // would persist a decisionJson that violates the string[] contract. + if (!Array.isArray(rankedRaw) || rankedRaw.some((entry) => typeof entry !== "string")) { + return { ok: false, code: "INVALID_REQUEST", message: "decision.ranked must be an array of strings" }; + } + const ranked = parseStringArray(rankedRaw); if (ranked.length === 0) { return { ok: false, code: "INVALID_REQUEST", message: "rank approvals require decision.ranked" }; } const allowed = new Set(request.options.map((option) => option.key)); - if (allowed.size > 0 && ranked.some((value) => !allowed.has(value))) { + if (ranked.some((value) => !allowed.has(value))) { return { ok: false, code: "INVALID_REQUEST", message: "rank approval included unknown options" }; } } diff --git a/packages/server/src/index.d.ts b/packages/server/src/index.d.ts index 2e61ae73c..3f9a88a10 100644 --- a/packages/server/src/index.d.ts +++ b/packages/server/src/index.d.ts @@ -11,6 +11,8 @@ import * as ws from 'ws'; import { WebSocketServer } from 'ws'; import * as node_stream from 'node:stream'; import * as _smithers_orchestrator_db_runState from '@smithers-orchestrator/db/runState'; +import * as effect_Record from 'effect/Record'; +import * as effect_LogLevel from 'effect/LogLevel'; import * as hono from 'hono'; import { Hono } from 'hono'; import * as hono_types from 'hono/types'; @@ -893,6 +895,11 @@ declare class Gateway { message: string; stack?: string; cause?: unknown; + readonly "~effect/Runtime/errorExitCode"?: number; + readonly "~effect/Runtime/errorReported"?: boolean; + readonly "~effect/ErrorReporter/ignore"?: boolean; + readonly "~effect/ErrorReporter/severity"?: effect_LogLevel.Severity; + readonly "~effect/ErrorReporter/attributes"?: effect_Record.ReadonlyRecord; }; isError(error: unknown): error is Error; isError(value: unknown): value is Error; @@ -2256,23 +2263,6 @@ declare class Gateway { */ cleanupExtensionSubscriptions(connection: GatewayRequestContext): Promise; } -/** - * Normalized approval request stored in an approval row's requestJson. - */ -type ApprovalRequestRecord = { - mode: "gate" | "select" | "rank" | "decision"; - title: string | null; - summary: string | null; - options: Array<{ - key: string; - label: string; - summary?: string; - }>; - allowedScopes: string[]; - allowedUsers: string[]; - restrictionError: string | null; - autoApprove: Record | null; -}; type EventFrame = EventFrame$1; type GatewayDefaults = GatewayDefaults$1; type GatewayRegisterOptions = GatewayRegisterOptions$1; @@ -2430,7 +2420,7 @@ declare function runPromise(effect: Effect.Effect, options?: { * @template A, E, R * @param {Effect.Effect} effect */ -declare function runFork(effect: Effect.Effect): effect_Fiber.RuntimeFiber; +declare function runFork(effect: Effect.Effect): effect_Fiber.Fiber; /** * @template A, E, R * @param {Effect.Effect} effect @@ -2858,6 +2848,45 @@ type SmithersDb = _smithers_orchestrator_db_adapter.SmithersDb; type DevToolsEvent = _smithers_orchestrator_protocol_devtools.DevToolsEvent; type SnapshotSerializerWarning = _smithers_orchestrator_devtools_snapshotSerializer.SnapshotSerializerWarning; +declare namespace approvalDecision { + export { parseApprovalRequest }; + export { validateApprovalDecision }; + export function unwrapDecision(value: any): any; +} +type ApprovalRequestRecord = { + mode: "gate" | "select" | "rank" | "decision"; + title: string | null; + summary: string | null; + options: Array<{ + key: string; + label: string; + summary?: string; + }>; + allowedScopes: string[]; + allowedUsers: string[]; + restrictionError: string | null; + autoApprove: Record | null; +}; +/** + * @param {unknown} value + * @param {string | null} fallbackTitle + * @returns {ApprovalRequestRecord} + */ +declare function parseApprovalRequest(value: unknown, fallbackTitle: string | null): ApprovalRequestRecord; +/** + * @param {ApprovalRequestRecord} request + * @param {unknown} decision + */ +declare function validateApprovalDecision(request: ApprovalRequestRecord, decision: unknown): { + ok: boolean; + code: string; + message: string; +} | { + ok: boolean; + code?: undefined; + message?: undefined; +}; + /** * @param {ServerOptions} [opts] */ @@ -2906,4 +2935,4 @@ declare function scheduleRunCleanup(runRegistry: Map, runId: */ declare function clearRunCleanupTimer(record: RunRecord | undefined): void; -export { type ApprovalRequestRecord, type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; +export { type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; diff --git a/packages/server/src/index.js b/packages/server/src/index.js index ea5403311..32cb500ae 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -46,6 +46,7 @@ export * from "./gatewayRoutes/getNodeOutput.js"; export * from "./gatewayRoutes/jumpToFrame.js"; export * from "./gatewayRoutes/streamDevTools.js"; export * from "./browser.js"; +export { approvalDecision } from "./approvalDecision.js"; // Type-only stubs reachable via `./*` that are NOT already transitively // re-exported through the JS modules above. export * from "./ServerOptions.js"; diff --git a/packages/server/tests/approval-decision.test.js b/packages/server/tests/approval-decision.test.js new file mode 100644 index 000000000..ff692b3da --- /dev/null +++ b/packages/server/tests/approval-decision.test.js @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { approvalDecision } from "../src/approvalDecision.js"; + +const { parseApprovalRequest, validateApprovalDecision } = approvalDecision; + +describe("validateApprovalDecision option-bearing modes", () => { + test("select request whose options were all malformed rejects any selection", () => { + // Every option entry is malformed (missing label), so parseApprovalRequest + // drops them all — validation must fail closed, not accept arbitrary keys. + const request = parseApprovalRequest({ mode: "select", options: [{ key: "safe" }] }, null); + expect(request.options).toEqual([]); + const result = validateApprovalDecision(request, { selected: "anything-goes" }); + expect(result.ok).toBe(false); + expect(result.code).toBe("INVALID_REQUEST"); + }); + + test("rank request whose options were all malformed rejects any ranking", () => { + const request = parseApprovalRequest({ mode: "rank", options: [{ key: "safe" }] }, null); + expect(request.options).toEqual([]); + const result = validateApprovalDecision(request, { ranked: ["anything-goes"] }); + expect(result.ok).toBe(false); + expect(result.code).toBe("INVALID_REQUEST"); + }); + + test("rank rejects mixed-type ranked arrays instead of silently sanitizing", () => { + const request = parseApprovalRequest( + { mode: "rank", options: [{ key: "canary", label: "Canary" }] }, + null, + ); + // The ORIGINAL decision object is what approveNode persists, so a + // non-string entry must be rejected, not stripped from a validation copy. + const result = validateApprovalDecision(request, { ranked: ["canary", 7] }); + expect(result.ok).toBe(false); + expect(result.code).toBe("INVALID_REQUEST"); + }); + + test("valid select and rank decisions still pass", () => { + const select = parseApprovalRequest( + { mode: "select", options: [{ key: "safe", label: "Safe" }] }, + null, + ); + expect(validateApprovalDecision(select, { selected: "safe" }).ok).toBe(true); + const rank = parseApprovalRequest( + { + mode: "rank", + options: [ + { key: "a", label: "A" }, + { key: "b", label: "B" }, + ], + }, + null, + ); + expect(validateApprovalDecision(rank, { ranked: ["b", "a"] }).ok).toBe(true); + }); + + test("gate mode without options is unaffected", () => { + const gate = parseApprovalRequest(null, "node-1"); + expect(validateApprovalDecision(gate, undefined).ok).toBe(true); + }); +}); From d15cbb84673832e2bf8f2d06d8ecdc418a9035ec Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 1 Aug 2026 13:07:56 -0700 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=8E=A8=20style(server):=20format=20ap?= =?UTF-8?q?proval=20decision=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex Sol --- packages/server/tests/approval-decision.test.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/server/tests/approval-decision.test.js b/packages/server/tests/approval-decision.test.js index ff692b3da..a97c3d484 100644 --- a/packages/server/tests/approval-decision.test.js +++ b/packages/server/tests/approval-decision.test.js @@ -23,10 +23,7 @@ describe("validateApprovalDecision option-bearing modes", () => { }); test("rank rejects mixed-type ranked arrays instead of silently sanitizing", () => { - const request = parseApprovalRequest( - { mode: "rank", options: [{ key: "canary", label: "Canary" }] }, - null, - ); + const request = parseApprovalRequest({ mode: "rank", options: [{ key: "canary", label: "Canary" }] }, null); // The ORIGINAL decision object is what approveNode persists, so a // non-string entry must be rejected, not stripped from a validation copy. const result = validateApprovalDecision(request, { ranked: ["canary", 7] }); @@ -35,10 +32,7 @@ describe("validateApprovalDecision option-bearing modes", () => { }); test("valid select and rank decisions still pass", () => { - const select = parseApprovalRequest( - { mode: "select", options: [{ key: "safe", label: "Safe" }] }, - null, - ); + const select = parseApprovalRequest({ mode: "select", options: [{ key: "safe", label: "Safe" }] }, null); expect(validateApprovalDecision(select, { selected: "safe" }).ok).toBe(true); const rank = parseApprovalRequest( { From 1e039f5d23528bb559c523ed6cb2a009a5a6658c Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 1 Aug 2026 13:38:53 -0700 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=8E=A8=20style(readme):=20format=20co?= =?UTF-8?q?ntract=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex Sol --- scripts/readme-contract.test.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/readme-contract.test.mjs b/scripts/readme-contract.test.mjs index 23097424a..3907481cc 100644 --- a/scripts/readme-contract.test.mjs +++ b/scripts/readme-contract.test.mjs @@ -16,9 +16,7 @@ describe("README public examples", () => { test("component primer promotes Loop instead of deprecated Ralph", () => { expect(readme).toContain("| `` | Repeat tasks until a condition is met |"); - expect(readme).toContain( - '', - ); + expect(readme).toContain(''); expect(readme).toContain(""); expect(readme).not.toContain("| `` | Loop until a condition is met |"); expect(readme).not.toContain(" ) { return responseError(frame.id, "FORBIDDEN", "Connection is missing required approval scope"); } - const decision = unwrapDecision(params.decision); - const note = asString(params.note) ?? asString(stableDecision?.note); + const { decision, note } = normalizeDecision(params.decision, params.note); if (approved) { const validation = validateApprovalDecision(request, decision); if (!validation.ok) { diff --git a/packages/server/src/index.d.ts b/packages/server/src/index.d.ts index 991be9ad3..6b880e1bd 100644 --- a/packages/server/src/index.d.ts +++ b/packages/server/src/index.d.ts @@ -2880,25 +2880,6 @@ type SmithersDb = _smithers_orchestrator_db_adapter.SmithersDb; type DevToolsEvent = _smithers_orchestrator_protocol_devtools.DevToolsEvent; type SnapshotSerializerWarning = _smithers_orchestrator_devtools_snapshotSerializer.SnapshotSerializerWarning; -declare namespace approvalDecision { - export { parseApprovalRequest }; - export { validateApprovalDecision }; - export function unwrapDecision(value: any): any; -} -type ApprovalRequestRecord = { - mode: "gate" | "select" | "rank" | "decision"; - title: string | null; - summary: string | null; - options: Array<{ - key: string; - label: string; - summary?: string; - }>; - allowedScopes: string[]; - allowedUsers: string[]; - restrictionError: string | null; - autoApprove: Record | null; -}; /** * @param {unknown} value * @param {string | null} fallbackTitle @@ -2918,6 +2899,34 @@ declare function validateApprovalDecision(request: ApprovalRequestRecord, decisi code?: undefined; message?: undefined; }; +/** + * @param {unknown} value + * @param {unknown} explicitNote + */ +declare function normalizeDecision(value: unknown, explicitNote: unknown): { + decision: unknown; + note: string | undefined; +}; +declare namespace approvalDecision { + export { parseApprovalRequest }; + export { validateApprovalDecision }; + export { normalizeDecision }; + export function unwrapDecision(value: any): unknown; +} +type ApprovalRequestRecord = { + mode: "gate" | "select" | "rank" | "decision"; + title: string | null; + summary: string | null; + options: Array<{ + key: string; + label: string; + summary?: string; + }>; + allowedScopes: string[]; + allowedUsers: string[]; + restrictionError: string | null; + autoApprove: Record | null; +}; /** * @param {ServerOptions} [opts] @@ -2967,4 +2976,4 @@ declare function scheduleRunCleanup(runRegistry: Map, runId: */ declare function clearRunCleanupTimer(record: RunRecord | undefined): void; -export { type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; +export { type ApprovalRequestRecord, type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, normalizeDecision, parseApprovalRequest, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateApprovalDecision, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 32cb500ae..b62b70ee1 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -46,7 +46,7 @@ export * from "./gatewayRoutes/getNodeOutput.js"; export * from "./gatewayRoutes/jumpToFrame.js"; export * from "./gatewayRoutes/streamDevTools.js"; export * from "./browser.js"; -export { approvalDecision } from "./approvalDecision.js"; +export * from "./approvalDecision.js"; // Type-only stubs reachable via `./*` that are NOT already transitively // re-exported through the JS modules above. export * from "./ServerOptions.js"; @@ -1357,7 +1357,7 @@ function startServerInternal(opts = {}) { try { requestJson = approval?.requestJson ? JSON.parse(approval.requestJson) : null; } catch {} - const decision = approvalDecision.unwrapDecision(body.decision); + const { decision, note } = approvalDecision.normalizeDecision(body.decision, body.note); const request = approvalDecision.parseApprovalRequest(requestJson, nodeId); if (request.restrictionError) { throw new HttpError(400, "INVALID_REQUEST", `Malformed approval request: ${request.restrictionError}`); @@ -1366,7 +1366,7 @@ function startServerInternal(opts = {}) { if (!validation.ok) { throw new HttpError(400, validation.code, validation.message); } - await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, body.note, body.decidedBy, decision)); + await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, note, body.decidedBy, decision)); return sendJson(res, 200, { runId }); } const denyMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/nodes\/([^/]+)\/deny$/); @@ -1384,17 +1384,8 @@ function startServerInternal(opts = {}) { return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "Run not found" }, }); - await Effect.runPromise( - denyNode( - adapter, - runId, - nodeId, - body.iteration ?? 0, - body.note, - body.decidedBy, - approvalDecision.unwrapDecision(body.decision), - ), - ); + const { decision, note } = approvalDecision.normalizeDecision(body.decision, body.note); + await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, note, body.decidedBy, decision)); return sendJson(res, 200, { runId }); } const signalMatch = diff --git a/packages/server/src/serve.js b/packages/server/src/serve.js index d77229e86..1c59b62cf 100644 --- a/packages/server/src/serve.js +++ b/packages/server/src/serve.js @@ -306,7 +306,7 @@ export function createServeApp(opts) { try { requestJson = approval?.requestJson ? JSON.parse(approval.requestJson) : null; } catch {} - const decision = approvalDecision.unwrapDecision(body.decision); + const { decision, note } = approvalDecision.normalizeDecision(body.decision, body.note); const request = approvalDecision.parseApprovalRequest(requestJson, nodeId); if (request.restrictionError) { throw new HttpError(400, "INVALID_REQUEST", `Malformed approval request: ${request.restrictionError}`); @@ -315,24 +315,15 @@ export function createServeApp(opts) { if (!validation.ok) { throw new HttpError(400, validation.code, validation.message); } - await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, body.note, body.decidedBy, decision)); + await Effect.runPromise(approveNode(adapter, runId, nodeId, iteration, note, body.decidedBy, decision)); return c.json({ runId }); }); // POST /deny/:nodeId app.post("/deny/:nodeId", async (c) => { const nodeId = c.req.param("nodeId"); const body = await c.req.json().catch(() => ({})); - await Effect.runPromise( - denyNode( - adapter, - runId, - nodeId, - body.iteration ?? 0, - body.note, - body.decidedBy, - approvalDecision.unwrapDecision(body.decision), - ), - ); + const { decision, note } = approvalDecision.normalizeDecision(body.decision, body.note); + await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, note, body.decidedBy, decision)); return c.json({ runId }); }); // POST /cancel diff --git a/packages/server/tests/serve.test.js b/packages/server/tests/serve.test.js index d85fd1905..a504f9948 100644 --- a/packages/server/tests/serve.test.js +++ b/packages/server/tests/serve.test.js @@ -512,13 +512,21 @@ export default smithers(() => ( await waitForServeRunStatus(["waiting-approval"]); const { status } = await request("/approve/task1", { method: "POST", - body: { decision: { approved: true, value: { selected: "balanced", notes: "best fit" } } }, + body: { + decision: { + approved: true, + value: { selected: "balanced", notes: "best fit" }, + note: "lgtm", + }, + }, }); expect(status).toBe(200); - expect(JSON.parse((await adapter.getApproval(runId, "task1", 0))?.decisionJson ?? "null")).toEqual({ + const approval = await adapter.getApproval(runId, "task1", 0); + expect(JSON.parse(approval?.decisionJson ?? "null")).toEqual({ selected: "balanced", notes: "best fit", }); + expect(approval?.note).toBe("lgtm"); }); }); describe("POST /deny/:nodeId", () => { @@ -527,7 +535,7 @@ export default smithers(() => ( const workflowPath = writeTestWorkflow("deny", dbPath, { needsApproval: true, }); - const { runId } = await startServeApp(workflowPath, { + const { adapter, runId } = await startServeApp(workflowPath, { needsApproval: true, }); await waitForServeRunStatus(["waiting-approval"]); @@ -535,12 +543,15 @@ export default smithers(() => ( method: "POST", body: { iteration: 0, - note: "denied by test", + decision: { approved: false, value: { reason: "unsafe" }, note: "denied by test" }, decidedBy: "test-user", }, }); expect(status).toBe(200); expect(data.runId).toBe(runId); + const approval = await adapter.getApproval(runId, "task1", 0); + expect(approval?.note).toBe("denied by test"); + expect(JSON.parse(approval?.decisionJson ?? "null")).toEqual({ reason: "unsafe" }); }); }); // ========================================================================= diff --git a/packages/server/tests/server.test.js b/packages/server/tests/server.test.js index 920625685..0aaedf48f 100644 --- a/packages/server/tests/server.test.js +++ b/packages/server/tests/server.test.js @@ -685,7 +685,7 @@ export default smithers(() => ( expect(status).toBe(200); expect(data.runId).toBe(startData.runId); }); - test("persists and validates a select decision", async () => { + test("persists a stable select decision and audit note", async () => { const dbPath = resolve(testDir, "approval-select.db"); const workflowPath = writeSelectApprovalWorkflow("approval-select", dbPath); startTestServer(); @@ -699,13 +699,21 @@ export default smithers(() => ( try { const accepted = await request(`/v1/runs/${startData.runId}/nodes/task1/approve`, { method: "POST", - body: { decision: { selected: "balanced", notes: "best fit" } }, + body: { + decision: { + approved: true, + value: { selected: "balanced", notes: "best fit" }, + note: "lgtm", + }, + }, }); expect(accepted.status).toBe(200); - expect(JSON.parse((await adapter.getApproval(startData.runId, "task1", 0))?.decisionJson ?? "null")).toEqual({ + const approval = await adapter.getApproval(startData.runId, "task1", 0); + expect(JSON.parse(approval?.decisionJson ?? "null")).toEqual({ selected: "balanced", notes: "best fit", }); + expect(approval?.note).toBe("lgtm"); } finally { adapterDb.close(); } @@ -766,12 +774,25 @@ export default smithers(() => ( body: { workflowPath }, }); await waitForRunStatus(startData.runId, ["waiting-approval"]); - const { status, data } = await request(`/v1/runs/${startData.runId}/nodes/task1/deny`, { - method: "POST", - body: { iteration: 0, note: "denied by test", decidedBy: "test-user" }, - }); - expect(status).toBe(200); - expect(data.runId).toBe(startData.runId); + const adapterDb = new Database(dbPath, { readonly: true }); + const adapter = new SmithersDb(adapterDb); + try { + const { status, data } = await request(`/v1/runs/${startData.runId}/nodes/task1/deny`, { + method: "POST", + body: { + iteration: 0, + decision: { approved: false, value: { reason: "unsafe" }, note: "denied by test" }, + decidedBy: "test-user", + }, + }); + expect(status).toBe(200); + expect(data.runId).toBe(startData.runId); + const approval = await adapter.getApproval(startData.runId, "task1", 0); + expect(approval?.note).toBe("denied by test"); + expect(JSON.parse(approval?.decisionJson ?? "null")).toEqual({ reason: "unsafe" }); + } finally { + adapterDb.close(); + } }); test("returns 404 for non-existent run", async () => { startTestServer(); From 586c49766a5fca83cb7006027060a9856f0fec89 Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 1 Aug 2026 15:10:36 -0700 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=85=20test(smithers):=20refresh=20REA?= =?UTF-8?q?DME=20contract=20needle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex Sol --- packages/smithers/tests/package-and-build-contract.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithers/tests/package-and-build-contract.test.js b/packages/smithers/tests/package-and-build-contract.test.js index 26ebbe2f7..a69220646 100644 --- a/packages/smithers/tests/package-and-build-contract.test.js +++ b/packages/smithers/tests/package-and-build-contract.test.js @@ -423,7 +423,7 @@ describe("PACKAGE_AND_BUILD contracts", () => { "tempo", "REDACTED_SECRET", ]); - expectText("scripts/readme-contract.test.mjs", ["Live workflow runs:", "", "Ralph"]); + expectText("scripts/readme-contract.test.mjs", ["task-fork.gif", "", "Ralph"]); }); test("gitignore excludes Smithers runtime artifacts without hiding source directories", () => { From e417baa80475538fc4528050a5b698235bdfa28a Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 1 Aug 2026 16:00:36 -0700 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20fix(server):=20narrow=20appr?= =?UTF-8?q?oval=20decision=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex Sol --- packages/server/src/approvalDecision.js | 6 ++-- packages/server/src/index.d.ts | 42 ++++++++++++------------- packages/server/src/index.js | 2 +- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/server/src/approvalDecision.js b/packages/server/src/approvalDecision.js index 97c1b67aa..ddeaba548 100644 --- a/packages/server/src/approvalDecision.js +++ b/packages/server/src/approvalDecision.js @@ -64,7 +64,7 @@ function parseApprovalRestriction(value, field) { * @param {string | null} fallbackTitle * @returns {ApprovalRequestRecord} */ -export function parseApprovalRequest(value, fallbackTitle) { +function parseApprovalRequest(value, fallbackTitle) { const record = asObject(value); const allowedScopes = parseApprovalRestriction(record?.allowedScopes, "allowedScopes"); const allowedUsers = parseApprovalRestriction(record?.allowedUsers, "allowedUsers"); @@ -98,7 +98,7 @@ export function parseApprovalRequest(value, fallbackTitle) { * @param {ApprovalRequestRecord} request * @param {unknown} decision */ -export function validateApprovalDecision(request, decision) { +function validateApprovalDecision(request, decision) { if (request.mode === "select") { // Fail closed: a select request whose options were all malformed (dropped // by parseApprovalRequest) must not accept an arbitrary selection. @@ -142,7 +142,7 @@ export function validateApprovalDecision(request, decision) { * @param {unknown} value * @param {unknown} explicitNote */ -export function normalizeDecision(value, explicitNote) { +function normalizeDecision(value, explicitNote) { const stableDecision = asObject(value); return { decision: stableDecision && "value" in stableDecision ? stableDecision.value : value, diff --git a/packages/server/src/index.d.ts b/packages/server/src/index.d.ts index 6b880e1bd..f6d2a030e 100644 --- a/packages/server/src/index.d.ts +++ b/packages/server/src/index.d.ts @@ -2880,6 +2880,26 @@ type SmithersDb = _smithers_orchestrator_db_adapter.SmithersDb; type DevToolsEvent = _smithers_orchestrator_protocol_devtools.DevToolsEvent; type SnapshotSerializerWarning = _smithers_orchestrator_devtools_snapshotSerializer.SnapshotSerializerWarning; +declare namespace approvalDecision { + export { parseApprovalRequest }; + export { validateApprovalDecision }; + export { normalizeDecision }; + export function unwrapDecision(value: any): unknown; +} +type ApprovalRequestRecord = { + mode: "gate" | "select" | "rank" | "decision"; + title: string | null; + summary: string | null; + options: Array<{ + key: string; + label: string; + summary?: string; + }>; + allowedScopes: string[]; + allowedUsers: string[]; + restrictionError: string | null; + autoApprove: Record | null; +}; /** * @param {unknown} value * @param {string | null} fallbackTitle @@ -2907,26 +2927,6 @@ declare function normalizeDecision(value: unknown, explicitNote: unknown): { decision: unknown; note: string | undefined; }; -declare namespace approvalDecision { - export { parseApprovalRequest }; - export { validateApprovalDecision }; - export { normalizeDecision }; - export function unwrapDecision(value: any): unknown; -} -type ApprovalRequestRecord = { - mode: "gate" | "select" | "rank" | "decision"; - title: string | null; - summary: string | null; - options: Array<{ - key: string; - label: string; - summary?: string; - }>; - allowedScopes: string[]; - allowedUsers: string[]; - restrictionError: string | null; - autoApprove: Record | null; -}; /** * @param {ServerOptions} [opts] @@ -2976,4 +2976,4 @@ declare function scheduleRunCleanup(runRegistry: Map, runId: */ declare function clearRunCleanupTimer(record: RunRecord | undefined): void; -export { type ApprovalRequestRecord, type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, normalizeDecision, parseApprovalRequest, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateApprovalDecision, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; +export { type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; diff --git a/packages/server/src/index.js b/packages/server/src/index.js index b62b70ee1..ea4cb6f48 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -46,7 +46,7 @@ export * from "./gatewayRoutes/getNodeOutput.js"; export * from "./gatewayRoutes/jumpToFrame.js"; export * from "./gatewayRoutes/streamDevTools.js"; export * from "./browser.js"; -export * from "./approvalDecision.js"; +export { approvalDecision } from "./approvalDecision.js"; // Type-only stubs reachable via `./*` that are NOT already transitively // re-exported through the JS modules above. export * from "./ServerOptions.js"; From a39e24b576ebd1247f7bc621470b14d9919d8c14 Mon Sep 17 00:00:00 2001 From: William Cory Date: Sat, 1 Aug 2026 16:14:52 -0700 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=90=9B=20fix(server):=20restore=20app?= =?UTF-8?q?roval=20request=20type=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex Sol --- packages/server/src/approvalDecision.js | 2 + packages/server/src/index.d.ts | 99 +++++++++++++------------ packages/server/src/index.js | 3 + 3 files changed, 55 insertions(+), 49 deletions(-) diff --git a/packages/server/src/approvalDecision.js b/packages/server/src/approvalDecision.js index ddeaba548..aa487c4ab 100644 --- a/packages/server/src/approvalDecision.js +++ b/packages/server/src/approvalDecision.js @@ -1,3 +1,4 @@ +// @smithers-type-exports-begin /** * @typedef {{ * mode: "gate" | "select" | "rank" | "decision"; @@ -10,6 +11,7 @@ * autoApprove: Record | null; * }} ApprovalRequestRecord */ +// @smithers-type-exports-end /** * @param {unknown} value diff --git a/packages/server/src/index.d.ts b/packages/server/src/index.d.ts index f6d2a030e..de5e5ea64 100644 --- a/packages/server/src/index.d.ts +++ b/packages/server/src/index.d.ts @@ -28,6 +28,54 @@ import { selectOutputRow } from '@smithers-orchestrator/db/output'; import * as _smithers_orchestrator_time_travel_jumpToFrame from '@smithers-orchestrator/time-travel/jumpToFrame'; export { JumpToFrameError } from '@smithers-orchestrator/time-travel/jumpToFrame'; +declare namespace approvalDecision { + export { parseApprovalRequest }; + export { validateApprovalDecision }; + export { normalizeDecision }; + export function unwrapDecision(value: any): unknown; +} +type ApprovalRequestRecord$1 = { + mode: "gate" | "select" | "rank" | "decision"; + title: string | null; + summary: string | null; + options: Array<{ + key: string; + label: string; + summary?: string; + }>; + allowedScopes: string[]; + allowedUsers: string[]; + restrictionError: string | null; + autoApprove: Record | null; +}; +/** + * @param {unknown} value + * @param {string | null} fallbackTitle + * @returns {ApprovalRequestRecord} + */ +declare function parseApprovalRequest(value: unknown, fallbackTitle: string | null): ApprovalRequestRecord$1; +/** + * @param {ApprovalRequestRecord} request + * @param {unknown} decision + */ +declare function validateApprovalDecision(request: ApprovalRequestRecord$1, decision: unknown): { + ok: boolean; + code: string; + message: string; +} | { + ok: boolean; + code?: undefined; + message?: undefined; +}; +/** + * @param {unknown} value + * @param {unknown} explicitNote + */ +declare function normalizeDecision(value: unknown, explicitNote: unknown): { + decision: unknown; + note: string | undefined; +}; + /** * A generic HMAC-verified webhook event source served at * `POST /v1/webhooks/:id`. Each verified delivery becomes ONE external event @@ -2880,54 +2928,6 @@ type SmithersDb = _smithers_orchestrator_db_adapter.SmithersDb; type DevToolsEvent = _smithers_orchestrator_protocol_devtools.DevToolsEvent; type SnapshotSerializerWarning = _smithers_orchestrator_devtools_snapshotSerializer.SnapshotSerializerWarning; -declare namespace approvalDecision { - export { parseApprovalRequest }; - export { validateApprovalDecision }; - export { normalizeDecision }; - export function unwrapDecision(value: any): unknown; -} -type ApprovalRequestRecord = { - mode: "gate" | "select" | "rank" | "decision"; - title: string | null; - summary: string | null; - options: Array<{ - key: string; - label: string; - summary?: string; - }>; - allowedScopes: string[]; - allowedUsers: string[]; - restrictionError: string | null; - autoApprove: Record | null; -}; -/** - * @param {unknown} value - * @param {string | null} fallbackTitle - * @returns {ApprovalRequestRecord} - */ -declare function parseApprovalRequest(value: unknown, fallbackTitle: string | null): ApprovalRequestRecord; -/** - * @param {ApprovalRequestRecord} request - * @param {unknown} decision - */ -declare function validateApprovalDecision(request: ApprovalRequestRecord, decision: unknown): { - ok: boolean; - code: string; - message: string; -} | { - ok: boolean; - code?: undefined; - message?: undefined; -}; -/** - * @param {unknown} value - * @param {unknown} explicitNote - */ -declare function normalizeDecision(value: unknown, explicitNote: unknown): { - decision: unknown; - note: string | undefined; -}; - /** * @param {ServerOptions} [opts] */ @@ -2946,6 +2946,7 @@ declare namespace __serverTestInternals { } type ServerResponse = node_http.ServerResponse; type ServerOptions = ServerOptions$1; +type ApprovalRequestRecord = ApprovalRequestRecord$1; /** * @param {SmithersDb | null} adapter @@ -2976,4 +2977,4 @@ declare function scheduleRunCleanup(runRegistry: Map, runId: */ declare function clearRunCleanupTimer(record: RunRecord | undefined): void; -export { type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; +export { type ApprovalRequestRecord, type AttemptRow, type ConnectRequest, type ConnectionEventWriterState, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TASK_PROMPT_MAX_CHARS, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsAgentRef, type DevToolsAgentSummary, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, EXTENSION_BACKPRESSURE_DISCONNECT_CODE, EXTENSION_METHOD_NOT_FOUND_CODE, EXTENSION_METHOD_PREFIX, EXTENSION_PAYLOAD_MAX_BYTES, EXTENSION_STREAM_METHOD_PREFIX, EXTENSION_STREAM_OUTBOUND_QUEUE_LIMIT, EXTENSION_WS_BUFFERED_HIGH_WATER_BYTES, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, GATEWAY_SESSION_COOKIE, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayExtensionAction, type GatewayExtensionContext, type GatewayExtensionDefinition, type GatewayExtensionResource, type GatewayExtensionStream, type GatewayExtensionStreamContext, GatewayExtensions, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayScope, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type IntegrationsConfig, type IntegrationsWebhookSourceConfig, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_DIFF_MAX_BYTES, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedExtension, type ResolvedGatewayUiConfig, type ResolvedRun, type ResolvedWorkflowTuiConfig, type ResponseFrame, type RunEventStreamState, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, type UsageReport, __serverTestInternals, approvalDecision, assertGatewayInputDepthWithinBounds, attachAgentAttemptsToDevToolsRoot, attachNodeStatesToDevToolsRoot, clampFrameStartedByPrompt, createBrowserSessionRegistry, createServeApp, emptyDevToolsRoot, extensionMethodName, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, getRunDiffRoute, isExtensionMethod, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, resolveCommitPointer, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId }; diff --git a/packages/server/src/index.js b/packages/server/src/index.js index ea4cb6f48..ce70e3e51 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -29,6 +29,9 @@ import { prometheusContentType, renderPrometheusMetrics } from "@smithers-orches import { approvalDecision } from "./approvalDecision.js"; /** @typedef {import("node:http").ServerResponse} ServerResponse */ /** @typedef {import("./ServerOptions.js").ServerOptions} ServerOptions */ +// @smithers-type-exports-begin +/** @typedef {import("./approvalDecision.js").ApprovalRequestRecord} ApprovalRequestRecord */ +// @smithers-type-exports-end // Re-export the full public surface so the tsup-bundled `src/index.d.ts` // covers every module reachable via the `./*` wildcard export. From 8446ac3ca25744f2b59566fb177e3a6245feefef Mon Sep 17 00:00:00 2001 From: William Cory Date: Fri, 14 Aug 2026 10:54:24 -0700 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=85=20test(server):=20use=20smthrs=20?= =?UTF-8?q?package=20name=20in=20approval=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval-decision test fixtures still imported the pre-rename smithers-orchestrator package, so their workflows failed to load and five tests failed after the smthrs rename landed on main. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEv75jvkqBgtRk5Mc9awfG --- packages/server/tests/serve.test.js | 4 ++-- packages/server/tests/server.test.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/tests/serve.test.js b/packages/server/tests/serve.test.js index 7c751425a..27c97e68d 100644 --- a/packages/server/tests/serve.test.js +++ b/packages/server/tests/serve.test.js @@ -169,8 +169,8 @@ export default smithers((ctx) => ( const workflowPath = resolve(testDir, `${name}.tsx`); writeFileSync( workflowPath, - `/** @jsxImportSource smithers-orchestrator */ -import { createSmithers } from "smithers-orchestrator"; + `/** @jsxImportSource smthrs */ +import { createSmithers } from "smthrs"; import { z } from "zod"; const { smithers, Workflow, Approval, outputs } = createSmithers( diff --git a/packages/server/tests/server.test.js b/packages/server/tests/server.test.js index 4b85c89ad..339f81c6b 100644 --- a/packages/server/tests/server.test.js +++ b/packages/server/tests/server.test.js @@ -222,8 +222,8 @@ const fakeAgent = { const workflowPath = resolve(testDir, `${name}.tsx`); writeFileSync( workflowPath, - `/** @jsxImportSource smithers-orchestrator */ -import { createSmithers } from "smithers-orchestrator"; + `/** @jsxImportSource smthrs */ +import { createSmithers } from "smthrs"; import { z } from "zod"; const { smithers, Workflow, Approval, outputs } = createSmithers(