diff --git a/changelog/entries/2026-07-08-order-dock-kerf-quotes.json b/changelog/entries/2026-07-08-order-dock-kerf-quotes.json new file mode 100644 index 000000000..fd417fad9 --- /dev/null +++ b/changelog/entries/2026-07-08-order-dock-kerf-quotes.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-08-order-dock-kerf-quotes", + "version": "0.9.4", + "date": "2026-07-08", + "category": "feat", + "title": "Order dock + live SendCutSend quotes via kerf", + "summary": "The viewer grows an order dock rendering the fused vcad+kerf order lifecycle with approval deep-links, and sheet-metal quotes can carry the fab's own displayed price (pricing_basis \"quoted\").", + "features": ["mcp", "fabricate", "viewer", "kerf", "sheet-metal"], + "mcpTools": ["quote_manufacturing", "get_order_feed"] +} diff --git a/changelog/entries/2026-07-08-physics-replay-inline-viewer.json b/changelog/entries/2026-07-08-physics-replay-inline-viewer.json new file mode 100644 index 000000000..be3ac624a --- /dev/null +++ b/changelog/entries/2026-07-08-physics-replay-inline-viewer.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-08-physics-replay-inline-viewer", + "version": "0.9.4", + "date": "2026-07-08", + "category": "feat", + "title": "Physics replay in the inline viewer", + "summary": "create_robot_env mounts the chat viewer with a transport bar: play/pause/scrub/speed a gym rollout with kernel-FK-accurate poses, plus live-follow while the agent is still stepping.", + "features": ["mcp", "physics", "viewer", "mcp-apps"], + "mcpTools": ["create_robot_env", "get_sim_replay", "get_sim_version", "get_preview_glb"] +} diff --git a/changelog/entries/2026-07-08-spend-approval-receipt-gates.json b/changelog/entries/2026-07-08-spend-approval-receipt-gates.json new file mode 100644 index 000000000..dbaf895b3 --- /dev/null +++ b/changelog/entries/2026-07-08-spend-approval-receipt-gates.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-08-spend-approval-receipt-gates", + "version": "0.9.4", + "date": "2026-07-08", + "category": "feat", + "title": "Protocol-native spend approval + receipt-gated ordering", + "summary": "authorize_spend carries the approval page to the human via MCP URL elicitation, and place_order fail-closed gates money on doc-hash match and re-verified clearance receipt claims.", + "features": ["mcp", "fabricate", "elicitation", "receipts"], + "mcpTools": ["authorize_spend", "place_order"] +} diff --git a/docs/agent-native-factory.md b/docs/agent-native-factory.md new file mode 100644 index 000000000..4178493e1 --- /dev/null +++ b/docs/agent-native-factory.md @@ -0,0 +1,391 @@ +# Agent-Native Factory — the purple cow spec + +**One sentence:** an agent designs a part in chat, you watch it move, tap approve, +and a verified physical object shows up at your door — with a receipt that proves +it's in spec *and* that the order provably happened. + +Nobody else can close this loop. vcad owns the design surface and the receipt; +[kerf](https://github.com/ecto/kerf) ("Stripe for metal", the reference +implementation of [ACP-CM](agentic-commerce-custom-manufacturing.md)) owns the +execution rail — supplier drivers, the payment instrument, the out-of-band human +approval, and the evidence. Four workstreams: + +1. **Play button** — live physics replay in the MCP Apps viewer (M1) +2. **Order tracking** — the fused vcad+kerf lifecycle rendered in the viewer (M2) +3. **Approval + receipts** — elicitation-native approval, receipt-gated money (M3–M4) +4. **Deprecated-surface removal** — audit result + 2026-07-28 RC prep (M0) + +UI mockups for the transport bar, order dock, and approval flow were reviewed in +the design session of 2026-07-08; the UI contracts below encode their decisions. + +> **Status: IMPLEMENTED 2026-07-08** (M0–M4 + kerf rail; M5 remains deferred). +> As-built deltas from the plan below: +> - **FK is server-side, not client-side.** `get_sim_replay` returns per-step +> `instance_transforms` computed via kernel `solveForwardKinematics` (the +> `record_simulation` pattern); the viewer applies node TRS and does zero +> joint math. `get_preview_kinematics` was therefore never built. Kernel +> stays the single FK source of truth; FK parity is exact by construction. +> - **No new flags.** The kerf rail is live whenever `KERF_URL` is set +> (degrades to `estimate` basis when unreachable); elicitation is gated on +> client capability only; the receipt + doc-hash gates in `place_order` are +> always on (fail-closed when a receipt/spec exists, `unverified` when not). +> `VCAD_FABRICATE_ORDERING=1` (pre-existing money seam) still gates +> authz/place. +> - **kerf side implemented on branch `feat/http-quote-api`** (kerf repo): +> `POST /api/quote` (+ `GET /api/jobs/:id`, `/evidence`), assertTransition- +> guarded job store, Browser-Use CDP host (live mode pending +> `BROWSER_USE_API_KEY` + first real SCS run), ScriptedHost exported for +> deterministic CI. Quote jobs terminate `STAGED → DELIVERED` (deliberate +> core transition addition; money invariants untouched). +> - Operator actions outstanding: push kerf branch + deploy, `supabase db push` +> migration 034, build the `/authorize/` web-app route (L2 lane). + +--- + +## 0. Current state (verified against source, 2026-07-08) + +**Viewer (SEP-1865, dual-host).** Single live canvas, not one-iframe-per-call. +Template/data split enforced through `viewerMetaFor()` in +`packages/mcp/src/server.ts:561` and locked by +`src/__tests__/viewer-meta.test.ts`. Milestone tools (`open_document`, +`create_cad_loon`, `place_components`, `build_receipt`, …) mount the iframe; +data tools carry only a `{document_id, document_version}` handle. The viewer +fetches geometry itself via app-only tools `get_preview_glb` / +`get_preview_version` (`tools/preview.ts`), polling adaptively +(2.5s fast / 10s idle, `viewer-app/main.ts:1354`). Hosts: Claude/Cursor via +`@modelcontextprotocol/ext-apps` 1.7.4 `App`, ChatGPT via `openai-shim.ts`. +Viewer→model channel exists (`updateModelContext` selection grounding). + +**Physics gym.** 8 tools in `tools/gym.ts` + `record_simulation` in +`tools/record.ts`. Per-step state from WASM `PhysicsSim`: +`joint_positions` (deg/mm), `joint_velocities`, `end_effector_poses` +(7-float pose per EE) — **no per-link transforms**. `record_simulation` +already proves the render path: it writes joint positions into the cloned +document's `joints[j].state` and lets kernel FK reconstruct body poses +(`record.ts:287`), then renders a GIF. Envs live in in-process `Map`s. + +**Fabricate.** Order spine in `tools/order.ts` (quote/status/list, always on) +and `tools/ordering.ts` (`authorize_spend`/`place_order`, gated behind +`VCAD_FABRICATE_ORDERING=1`). Prepaid wallet, atomic `debit_wallet` RPC, +16-state lifecycle (`fabricate/types.ts:24`). The asymmetric-capability seam: +agent proposes (`authorize_spend` → `pending_human`), **human approves +out-of-band**, agent then calls `place_order`. Persistence: in-memory or +Supabase (`fabricate/store.ts:484`). `margin_hidden: true` — fab cost and +margin never leave the server. + +**Receipts.** `DesignReceipt` (`vcad.receipt/1`, `crates/vcad-receipt`) is +fail-closed and attaches to **documents**, not orders. Ordering and receipts +are disjoint today — M4 closes that. + +**kerf (pre-alpha, Wave 0 in progress).** Contract layer + registry are real +and CI-enforced; SendCutSend quote-only playbook first. Key types: +`OrderIntent` (files pinned by content hash, `budget_cap` in minor units, +vendor-native config labels), `VendorQuote` with +`pricing_basis: estimate | quoted | binding` bound to an `intent_hash`, +`JobState` machine (17 states; `PLACING` entered at most once, ever; +`CONFIRMED` requires two independent oracles), `EvidenceBundle` — "the +per-job bundle handed back to the design surface's receipt", verdicts +`pass | fail | unverifiable` inherited from vcad-receipt. Autonomy ladder: +L0 handoff → L1 assisted (human clicks buy in live view) → L2 supervised +(single-use virtual card under mandate) → L3 standing. Agent-facing surface: +remote MCP; jobs are eve durable workflows. + +**Deprecated MCP surface.** Audit result: **zero usage.** No sampling, roots, +or logging anywhere in first-party source; capabilities never advertised them; +only handlers registered are ListTools/ListResources/ReadResource/CallTool +(`server.ts:1031–1150`). Transport is already Streamable HTTP. SDK 1.29.0, +negotiates spec 2025-11-25. + +--- + +## The kerf rail — integration architecture + +**Posture:** vcad calls kerf as a service (kerf's rule: "you integrate kerf; +kerf does not integrate you"). No vendoring. The design surface never learns a +CSS selector or touches a card; kerf never evaluates geometry. + +- `FulfillmentBroker` (`packages/mcp/src/fabricate/`) gains a **kerf driver**: + submits `ConfiguratorIntent`s built from the fab bundle (`FabArtifactRef` + files, already sha256-pinned — kerf's `kerf/upload-hash` oracle verifies the + exact bytes), receives `VendorQuote`s and job-state updates (webhook → + session event spine → `get_order_feed`). +- **Pricing-basis upgrade:** today `quote_manufacturing` returns vcad's own + cost model (`estimate`). Through kerf Wave 0, sheet-metal quotes become the + fab's own displayed price (`quoted`). `estimate` never gates money; `quoted` + may where the cart preserves price; `binding` is fab-committed. +- **Intent-hash discipline:** a quote is only meaningful with its + `intent_hash`. Geometry edit after quoting ⇒ quote is dead ⇒ re-quote. + `place_order` enforces this (see M4). + +### State mapping (vcad OrderState × kerf JobState → dock chip) + +| dock chip | vcad | kerf | +|---|---|---| +| quoted | `QUOTED` | quote job `DELIVERED` | +| approval | `AUTHORIZED` pending | `TAKEOVER_WAIT` (L1) / mandate pending (L2) | +| placing | `PAID`/`SUBMITTED` | `STAGING`,`STAGED`,`AUDIT`,`PLACING`,`CONFIRMING`,`RECONCILING`* | +| confirmed | `SUBMITTED`→ | `CONFIRMED`, `RECONCILED_PLACED` | +| production | `IN_PRODUCTION`/`SHIPPED` | `TRACKING` | +| delivered | `DELIVERED` | `DELIVERED` | +| failed (red) | `SUBMIT_FAILED` etc. | `AUDIT_FAILED`, `FAILED`, `RECONCILED_ABSENT` | + +\* `RECONCILING` renders as an explained wait ("click outcome ambiguous, +checking vendor history + inbox"), never a retry button. Raw states remain in +the per-order event log. + +### Wave gating + +| kerf wave | unlocks in vcad | +|---|---| +| Wave 0 (SCS quote-only) | `quoted` pricing basis; dock quote cards. **Integratable now.** | +| Wave 1 (L1 assisted) | approve-in-live-view button; elicitation URL → live view | +| Wave 2 (L2 + Issuing) | mandate-funded virtual cards; two-oracle confirm; `RECONCILING` | +| Wave 3 (more vendors) | vendor choice in `fab_options`; catalog parts (McMaster) | + +--- + +## M0 — Deprecated fns: remove nothing, guard everything + +There is nothing to delete. The work is a tripwire plus forward-compat prep: + +- **Tripwire test** (`src/__tests__/deprecated-surface.test.ts`): assert no + handlers for `sampling/createMessage`, `roots/list`, `logging/setLevel`, and + no `sampling`/`roots`/`logging` capability keys. Elicitation is NOT + deprecated — it's the replacement pattern and M3 builds on it. +- **2026-07-28 RC prep (notes only until the SDK ships):** `document_id` + handles already match the stateless pattern; add `ttlMs`/`cacheScope` on + list responses when supported (`private` for tools — pack-config-varying; + `public` + long TTL for the viewer HTML resource); `services/mcp` can route + on `Mcp-Method` once clients send it; error `-32002` → `-32602` at bump. + +Effort: half a day. Zero user-visible change. + +--- + +## M1 — The play button (live physics in the viewer) + +**Goal:** `create_robot_env` mounts the canvas; a ▶ button plays the rollout +the agent just ran — scrub, pause, speed — inside the chat thread, on both +Claude and ChatGPT. + +### Design decision: joint trajectories + client-side FK, not frames + +`record_simulation` proves poses reconstruct from joint states. Frames are +heavy and dead; `number[][]` trajectories are tiny and scrubbable for free. +Viewer FK: revolute = rotate child subtree about anchor axis (degrees), +prismatic = translate along axis (mm), matching `packages/engine/src/physics.ts:12`. + +### 1a. Trajectory capture (server) + +Ring buffer on the gym env record (`gym.ts` env `Map`): every +`gym_step`/`batch_step` appends `joint_positions` + reward + done (cap 600 +steps, matching `record_simulation`). `gym_reset` truncates. + +### 1b. New app-only tools (`visibility: ["app"]`, hidden from model) + +| tool | args | returns | +|---|---|---| +| `get_sim_replay` | `{ env_id }` | `{ document_id, dt, substeps, joint_trajectory: number[][], rewards: number[], target_pose?, version }` | +| `get_sim_version` | `{ env_id }` | `{ env_id, step_count, version }` — cheap change token | +| `get_preview_kinematics` | `{ document_id }` | `{ joints: [{ id, type, axis, anchor_mm, parent_instance, child_instance, state }], instances: [{ id, node_name }] }` | + +`version` = FNV-1a over `(env_id, step_count)` (pattern: `preview.ts:206`). +`target_pose` comes from the env's reward spec when one exists, so the viewer +can draw the target marker. + +### 1c. Segmented GLB + +`get_preview_glb` gains `{ segmented?: boolean }` — one named node per part +instance so the viewer can bind FK targets. **Spike first:** `buildGlb` may +already emit named per-instance nodes; if so this is a contract test, not a +feature. + +### 1d. Viewer UI contract (per reviewed mockup) + +- Transport bar at canvas bottom: ▶/⏸ toggle, scrub slider (free — the + trajectory is data), step counter `t / N`, speed select (0.25×–4×). +- Reward sparkline with progress dot, fed by `rewards[]` — the visible + "watch it learn" signal across successive rollouts. +- Joint readout line (`j1 55.0° · j2 30.0° · reward −0.12`) + `dt`/rate — + grounds it as simulation, not animation. +- End-effector trail polyline from FK. Target marker from `target_pose`. +- **Live-follow badge:** while `get_sim_version.step_count` advances, pin the + scrub head to newest and poll fast (2.5s); otherwise slow (10s). Same + adaptive loop as geometry. +- Playback wall-clock rate = `dt × substeps × speed`, interpolating rows. + +### 1e. Behavior/meta changes + +- `create_robot_env`: `behavior({ mount: true, geometry: true })`. +- `record_simulation` unchanged — GIF stays the durable transcript artifact. +- Regen `tool-surface.fixture.json` (deliberate, reviewed); extend + `viewer-meta.test.ts` for the three new app-only tools. + +Constraints: ChatGPT parity via `callServerTool` (no new host capability); +in-process envs mean replay works within a warm instance only (same caveat as +the gym itself — document it). + +Effort: ~3–5 days. Demo: *"watch the arm learn to reach, in the chat."* + +--- + +## M2 — Order tracking in the MCP UI + +**Goal:** the mounted canvas grows an order dock rendering the fused +vcad+kerf lifecycle. + +### 2a. New app-only tool + +| tool | args | returns | +|---|---|---| +| `get_order_feed` | `{ document_id }` | `{ orders: [{ order_id, state_chip, raw_state, kerf_job_state?, process, quantity, total_amount_usd, pricing_basis, vendor_display_name, lead_time_days, quote_expires_at, created_at, events: [{ at, type, note }], authorization: { status, cap_usd, expires_at, approve_url } \| null, tracking, receipt: { status, claims_pass, claims_total } \| null, evidence: { items, oracles_pass, oracles_needed } \| null }], wallet_balance_usd, version }` | + +Backed by `FabricateStore.listOrders` + `spend_authorizations` + kerf job +webhooks on the event spine; owner-scoped like the tools it mirrors. +**Margin invariant preserved:** only totals, never fab internals. + +### 2b. Order dock UI contract (per reviewed mockup) + +- Collapsible panel on the mounted canvas; renders when feed is non-empty. +- Per-order card: part name + qty + material/thickness · vendor + lead time + + quote expiry · total with **pricing-basis pill** (`estimate` gray, + `quoted` amber, `binding` green — ACP-CM colors users learn to trust). +- Six-stop timeline (mapping table above); failure states red; `RECONCILING` + = explained wait, never a retry affordance. Event log expander shows raw + vcad+kerf states. +- `pending_human` ⇒ warning banner: cap, mandate kind, TTL countdown + + **"Approve in live view"** (`openLink` → kerf live view at L1, mandate page + at L2) + **Decline**. The widget never approves — buttons leave the iframe. +- Receipt chip (design half: `holds 9/9` green / stale / violated / + unverified) **separate from** evidence chip (commerce half: item count + + `confirmation oracles n/2`). Both link to the receipt view. +- Footer: wallet balance + poll note. +- Poll cadence: slow (10s); fast while any order is transitional + (`approval`, `placing`). + +### 2c. Mount + security invariants + +- `quote_manufacturing`: `behavior({ mount: true })` — money entering the + story is a milestone. +- **The iframe is read-only for money.** `get_order_feed` is the only new + app-callable and it reads. No ordering tool is `widgetAccessible` — assert + in `viewer-meta.test.ts`. The asymmetric seam (agent proposes, human + approves out-of-band, agent places) is now double-enforced: vcad's wallet + side and kerf's card side. + +Effort: ~3–4 days after M1 (shared panel/poll plumbing) + kerf driver work. + +--- + +## M3 — Elicitation approval (URL mode) + +**Goal:** protocol-native approval that carries the human to the money moment. + +- Gate: `VCAD_FABRICATE_ELICIT=1` **and** client advertises elicitation + (check `server.getClientCapabilities()` at call time). +- **Two lanes by kerf autonomy rung, identical elicitation call, different URL:** + - **L1 (SendCutSend first):** URL = the **kerf live view**. kerf has staged + the cart, every assertion green (`STAGED`); the human's click on the + vendor's own buy button *is* the approval. vcad flips the authz + `authorized`→`consumed` when kerf reports `CONFIRMING`. + - **L2:** URL = `vcad.io/authorize/`. Approval mints the + mandate that funds kerf's single-use virtual card (amount-capped, + merchant-locked, short-expiry); kerf's auditor gates the click. +- `decline` → revoke authz → kerf job `CANCELED` pre-`PLACING`. kerf's + at-most-once `PLACING` invariant means a declined mandate can never race a + buy click. +- **URL mode is mandatory** (spec rule: form mode never carries + credentials/sensitive approvals). Fallback: capability absent or flag off ⇒ + the M2 dock button covers the same journey — the dock is the floor, + elicitation is the accelerator. +- Prereq: deep-linkable `/authorize/` route in `packages/app` (L2 lane). + +Effort: ~2 days server-side + the app route + kerf Wave 1 for the L1 lane. + +--- + +## M4 — One receipt, two halves (receipt-gated ordering) + +kerf's `EvidenceBundle` is *designed* as "the per-job bundle handed back to +the design surface's receipt" — same fail-closed verdict vocabulary. M4 is a +merge, not an invention: + +- **Commerce claims:** map kerf `OracleClaim`s 1:1 into `DesignReceipt.claims` + — `kerf/upload-hash` (exact quoted bytes were uploaded; closes the loop + with `FabArtifactRef` sha256s), price-match (charged = quoted), the two + confirmation oracles, delivery. Namespace: `commerce.*` alongside `mech.*`. +- **Gate in `place_order`:** (1) design claims re-verified at place time — + `Stale`/`Violated` ⇒ refuse with failing claims (the `export_gerber` + dirty-DRC precedent, applied to money); (2) `intent_hash` must match the + quote — geometry edited after quoting ⇒ refuse with "re-quote" (never + silent re-pricing). No receipt at all ⇒ proceed but flag **unverified** + in the feed (fail-closed only when a receipt exists and fails). +- **Persistence:** orders gain `receipt_fingerprint`, `receipt_status`, + `intent_hash` (+ fold in the `fab_artifact` column gap, `store.ts:242`). + Supabase migration. +- **Widget:** the two chip families from M2 converge post-delivery into one + receipt: *"geometry in spec, these exact bytes manufactured, this price + paid, two independent oracles saw the order, delivered."* The screenshot. +- Later (standards play): propose `io.vcad/receipt` as an MCP extension via + the extensions framework — vcad + kerf as the reference implementation for + verified agentic fabrication. + +Effort: ~2–3 days + migration review (+ kerf Wave 2 for card-settlement +oracle). + +--- + +## M5 — Tasks extension (deferred) + +kerf jobs are eve durable workflows that sleep through production lead times — +exactly the shape `io.modelcontextprotocol/tasks` wants. When the SDK ships it +(2026-07-28 spec), `place_order` returns a task handle backed by the kerf job. +Do not build against the RC; the M2 feed delivers the UX today. + +--- + +## Rollout & flags + +| flag | gates | +|---|---| +| (none) | M0 tripwire, M1 play button, M2 dock read-only + Wave-0 `quoted` basis | +| `VCAD_FABRICATE_ORDERING=1` | authz/place (existing) | +| `VCAD_KERF_RAIL=1` | kerf driver in FulfillmentBroker (per-wave) | +| `VCAD_FABRICATE_ELICIT=1` | M3 elicitation | +| `VCAD_FABRICATE_RECEIPT_GATE=1` | M4 gate (flip default after burn-in) | + +Sequencing: M0 → M1 → M2 (+ kerf Wave 0 in parallel) → M3 ∥ M4 → M5. +M1 first — no money-path risk, shares viewer plumbing with M2. + +## Test plan + +- `viewer-meta.test.ts`: new app-only tools template-less + app-visibility; + no ordering tool widget-accessible; `create_robot_env`/`quote_manufacturing` + carry the template. +- `tool-surface.fixture.json`: one deliberate regen per milestone. +- Golden trajectory: fixed-seed (`setSeed`) 2-joint arm, scripted torques → + `get_sim_replay` matches committed fixture. +- FK parity: viewer FK vs `record_simulation` kernel-FK for the same + trajectory (EE-pose numeric check against `end_effector_poses`). +- Ordering: receipt-gate verdict matrix (Holds passes; Stale/Violated refuse; + absent flags unverified); intent-hash mismatch refuses; elicitation + capability-fallback; state-mapping table exhaustively covers all 17 kerf × + 16 vcad states (no unmapped state reaches the widget). +- `scripts/test-local-mcp-apps.mjs`: mount `create_robot_env`, read replay + tools, read `get_order_feed`. + +## Open questions + +1. Does `buildGlb` already emit named per-instance nodes? (Spike before 1c.) +2. kerf transport into vcad: consume kerf's remote MCP from the server, or a + thin REST/webhook pair? (Webhooks → session event spine feels right for + the feed; MCP for submit/quote.) +3. Where does the L2 `/authorize/` route live in `packages/app`, and what + does it need from the session event spine? +4. Supabase migration numbering/ownership for `orders.receipt_fingerprint` + + `intent_hash` + `fab_artifact` (`store.ts:242` note). +5. ChatGPT shim: confirm `openLink` → `openai.openExternal` is allowed for + vcad.io/kerf live-view URLs from the skybridge sandbox. +6. kerf is pre-alpha — pin the integration to its contract layer + (`@kerf/core` types) and gate each wave behind `VCAD_KERF_RAIL`; who owns + the cross-repo contract test? diff --git a/packages/mcp/src/__tests__/deprecated-surface.test.ts b/packages/mcp/src/__tests__/deprecated-surface.test.ts new file mode 100644 index 000000000..da85c12d2 --- /dev/null +++ b/packages/mcp/src/__tests__/deprecated-surface.test.ts @@ -0,0 +1,99 @@ +/** + * Tripwire for MCP surfaces deprecated by the 2026-07-28 spec RC. + * + * Roots, Sampling, and Logging are deprecated (12-month removal window; + * replacements: tool params / direct LLM APIs / stderr + OpenTelemetry). + * vcad's server has never used any of them — these tests keep it that way + * so an SDK bump or a well-meaning contributor can't quietly adopt a + * surface that dies mid-2027. Elicitation is NOT deprecated — it is the + * blessed replacement pattern and is exercised by the ordering tools. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Engine } from "@vcad/engine"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createServer } from "../server.js"; + +let engine: Engine; + +beforeAll(async () => { + engine = await Engine.init(); +}); + +async function connect() { + const server = await createServer(engine, { user: null }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "test", version: "0.0.0" }, + { capabilities: {} }, + ); + await Promise.all([client.connect(clientT), server.connect(serverT)]); + return { client, server }; +} + +describe("deprecated MCP surface tripwire (roots / sampling / logging)", () => { + it("advertises no deprecated capabilities", async () => { + const { client, server } = await connect(); + const caps = client.getServerCapabilities() as Record; + expect(caps).toBeDefined(); + for (const key of ["sampling", "roots", "logging"]) { + expect(caps[key], `server must not advertise \`${key}\``).toBeUndefined(); + } + await client.close(); + await server.close(); + }); + + it("registers no handlers for deprecated methods", async () => { + const { client, server } = await connect(); + const handlers = ( + server as unknown as { _requestHandlers?: Map } + )._requestHandlers; + expect(handlers, "SDK internal _requestHandlers map").toBeDefined(); + for (const method of [ + "logging/setLevel", + "sampling/createMessage", + "roots/list", + ]) { + expect( + handlers?.has(method), + `no handler may be registered for \`${method}\``, + ).toBe(false); + } + await client.close(); + await server.close(); + }); + + it("first-party source never calls the deprecated SDK surface", () => { + // Static sweep of src/ (excluding generated viewer bundles, which + // legitimately contain GLSL "sampling" strings, and this test dir). + const srcRoot = fileURLToPath(new URL("..", import.meta.url)); + const offenders: string[] = []; + const banned = [ + "sendLoggingMessage", + "logging/setLevel", + "sampling/createMessage", + "listRoots", + "roots/list", + ]; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; + walk(p); + continue; + } + if (!p.endsWith(".ts") || p.endsWith(".generated.ts")) continue; + const text = readFileSync(p, "utf8"); + for (const needle of banned) { + if (text.includes(needle)) offenders.push(`${p}: ${needle}`); + } + } + }; + walk(srcRoot); + expect(offenders, offenders.join("\n")).toEqual([]); + }); +}); diff --git a/packages/mcp/src/__tests__/elicitation.test.ts b/packages/mcp/src/__tests__/elicitation.test.ts new file mode 100644 index 000000000..cd424d0aa --- /dev/null +++ b/packages/mcp/src/__tests__/elicitation.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { Engine } from "@vcad/engine"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { createServer } from "../server.js"; +import { documents } from "../tools/session.js"; +import { InMemoryFabricateStore } from "../fabricate/store.js"; +import type { Order } from "../fabricate/types.js"; + +/** + * M3 URL-mode elicitation through the REAL server pipeline: the elicit bridge + * injected by createServer detects the client's `elicitation.url` capability + * at call time and carries the spend-approval page to the human in-band. + * decline ⇒ the authorization is revoked; cancel ⇒ the proposal stands; + * no capability ⇒ the bridge reports unsupported and the out-of-band note is + * returned (the dock is the floor, elicitation is the accelerator). + * + * Anonymous connections use the in-memory fabricate store, whose state is + * module-global — seeding an order under owner "local" from the test makes it + * visible to the server's own store instance. + */ + +let engine: Engine; + +beforeAll(async () => { + engine = await Engine.init(); +}); + +const OWNER = "local"; // ownerId(null) — anonymous connection scope + +function makeOrder(orderId: string): Order { + const now = new Date().toISOString(); + return { + order_id: orderId, + document_id: "doc_elicit", + quote_id: `q_${orderId}`, + state: "QUOTED", + fab: "digitalmetal", + fab_order_ref: null, + amount_total_minor: 5000, + currency: "USD", + ship_to: null, + events: [{ state: "QUOTED", at: now, note: "quote" }], + created_at: now, + updated_at: now, + }; +} + +interface ElicitSeen { + mode?: string; + message?: string; + url?: string; + elicitationId?: string; +} + +async function connectWith( + action: "decline" | "cancel" | null, + seen: ElicitSeen[], +) { + const server = await createServer(engine, { user: null }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "test", version: "0.0.0" }, + // null = a client that never declared the elicitation capability. + { capabilities: action ? { elicitation: { url: {} } } : {} }, + ); + if (action) { + client.setRequestHandler(ElicitRequestSchema, async (req) => { + const p = req.params as ElicitSeen; + seen.push({ + mode: p.mode, + message: p.message, + url: p.url, + elicitationId: p.elicitationId, + }); + return { action }; + }); + } + await Promise.all([client.connect(clientT), server.connect(serverT)]); + return { client, server }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const body = (r: unknown): any => + JSON.parse((r as { content: Array<{ text: string }> }).content[0].text); + +describe("authorize_spend URL elicitation (capability-detected, fail-open to out-of-band)", () => { + let prev: string | undefined; + beforeEach(() => { + prev = process.env.VCAD_FABRICATE_ORDERING; + process.env.VCAD_FABRICATE_ORDERING = "1"; + documents.clear(); + }); + afterEach(() => { + if (prev === undefined) delete process.env.VCAD_FABRICATE_ORDERING; + else process.env.VCAD_FABRICATE_ORDERING = prev; + }); + + it("decline revokes the authorization (nothing can ever be charged against it)", async () => { + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_elicit_decline"), 4000, OWNER); + const seen: ElicitSeen[] = []; + const { client, server } = await connectWith("decline", seen); + + const res = await client.callTool({ + name: "authorize_spend", + arguments: { order_id: "ord_elicit_decline" }, + }); + expect(res.isError ?? false).toBe(false); + const out = body(res); + expect(out.status).toBe("revoked"); + expect(out.note).toContain("Declined"); + + // The elicitation was URL-mode with the approval deep link, keyed by the + // authorization id (the completion-notification handle). + expect(seen).toHaveLength(1); + expect(seen[0].mode).toBe("url"); + expect(seen[0].url).toBe(`https://vcad.io/authorize/${out.authorization_id}`); + expect(seen[0].elicitationId).toBe(out.authorization_id); + expect(seen[0].message).toContain("$50.00"); + + // The DB row is the truth: revoked, and place_order refuses it. + expect((await store.getAuthorization(out.authorization_id, OWNER))?.status).toBe( + "revoked", + ); + const placed = await client.callTool({ + name: "place_order", + arguments: { + order_id: "ord_elicit_decline", + authorization_id: out.authorization_id, + }, + }); + expect(placed.isError).toBe(true); + expect(body(placed).error).toContain("revoked"); + + await client.close(); + await server.close(); + }); + + it("cancel leaves the proposal pending (a dismissed prompt is not a decision)", async () => { + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_elicit_cancel"), 4000, OWNER); + const seen: ElicitSeen[] = []; + const { client, server } = await connectWith("cancel", seen); + + const res = await client.callTool({ + name: "authorize_spend", + arguments: { order_id: "ord_elicit_cancel" }, + }); + const out = body(res); + expect(seen).toHaveLength(1); // the elicitation WAS attempted + expect(out.status).toBe("pending_human"); + expect(out.note).toContain("HUMAN must approve"); + expect( + (await store.getAuthorization(out.authorization_id, OWNER))?.status, + ).toBe("pending_human"); + + await client.close(); + await server.close(); + }); + + it("without the elicitation.url capability the bridge stays quiet (out-of-band note)", async () => { + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_elicit_nocap"), 4000, OWNER); + const seen: ElicitSeen[] = []; + const { client, server } = await connectWith(null, seen); + + const res = await client.callTool({ + name: "authorize_spend", + arguments: { order_id: "ord_elicit_nocap" }, + }); + const out = body(res); + expect(seen).toHaveLength(0); // urlSupported() was false — never attempted + expect(out.status).toBe("pending_human"); + expect(out.note).toContain("HUMAN must approve"); + expect( + (await store.getAuthorization(out.authorization_id, OWNER))?.status, + ).toBe("pending_human"); + + await client.close(); + await server.close(); + }); +}); diff --git a/packages/mcp/src/__tests__/kerf-contract.test.ts b/packages/mcp/src/__tests__/kerf-contract.test.ts new file mode 100644 index 000000000..17ea8c699 --- /dev/null +++ b/packages/mcp/src/__tests__/kerf-contract.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { KERF_JOB_STATES } from "../fabricate/kerf/contract.js"; + +/** + * Cross-repo contract drift check: the kerf types mirrored into + * `src/fabricate/kerf/contract.ts` must match the source of truth in the kerf + * repo (github.com/ecto/kerf, packages/core/src). Runs only where a kerf + * clone sits at the conventional sibling-ish path (a dev machine); CI has no + * clone and skips — the mirror header documents the same discipline. + */ + +const KERF_CORE_SRC = "/Users/cam/Developer/kerf/packages/core/src"; +const hasKerfClone = existsSync(KERF_CORE_SRC); + +const read = (file: string): string => readFileSync(join(KERF_CORE_SRC, file), "utf8"); + +/** The mirrored literal vocabularies (from contract.ts — keep in sync there). */ +const PRICING_BASIS = ["estimate", "quoted", "binding"] as const; +const ORACLE_IDS = [ + "kerf/upload-hash", + "kerf/quote-extraction", + "kerf/intent-audit", + "kerf/confirmation-page", + "kerf/confirmation-email", + "kerf/card-settlement", + "kerf/tracking", + "kerf/canary", +] as const; +const VERDICTS = ["pass", "fail", "unverifiable"] as const; + +describe.skipIf(!hasKerfClone)("kerf contract mirror matches the kerf sources", () => { + it("all 17 JobState strings appear verbatim in kerf core/job.ts", () => { + const src = read("job.ts"); + expect(KERF_JOB_STATES).toHaveLength(17); + for (const state of KERF_JOB_STATES) { + expect(src, `JobState "${state}" in kerf job.ts`).toContain(`"${state}"`); + } + // And nothing extra on kerf's side: every quoted ALL-CAPS literal in the + // JobState union block is one we mirror. + const union = src.slice(src.indexOf("export type JobState"), src.indexOf("TRANSITIONS")); + const literals = [...union.matchAll(/"([A-Z_]+)"/g)].map((m) => m[1]); + for (const lit of literals) { + expect( + (KERF_JOB_STATES as readonly string[]).includes(lit), + `kerf JobState "${lit}" is mirrored in contract.ts`, + ).toBe(true); + } + }); + + it("PricingBasis members appear verbatim in kerf core/quote.ts", () => { + const src = read("quote.ts"); + for (const basis of PRICING_BASIS) { + expect(src, `PricingBasis "${basis}"`).toContain(`"${basis}"`); + } + expect(src).toContain("export type PricingBasis"); + }); + + it("OracleId and Verdict members appear verbatim in kerf core/evidence.ts", () => { + const src = read("evidence.ts"); + for (const oracle of ORACLE_IDS) { + expect(src, `OracleId "${oracle}"`).toContain(`"${oracle}"`); + } + for (const verdict of VERDICTS) { + expect(src, `Verdict "${verdict}"`).toContain(`"${verdict}"`); + } + expect(src).toContain("export type OracleId"); + expect(src).toContain("export type Verdict"); + }); +}); diff --git a/packages/mcp/src/__tests__/kerf-rail.test.ts b/packages/mcp/src/__tests__/kerf-rail.test.ts new file mode 100644 index 000000000..5dfdef751 --- /dev/null +++ b/packages/mcp/src/__tests__/kerf-rail.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createHash } from "node:crypto"; +import { Engine } from "@vcad/engine"; +import { documents } from "../tools/session.js"; +import { quoteManufacturing } from "../tools/order.js"; +import { sheetMetalCreate } from "../tools/sheet-metal.js"; +import { storeArtifact, clearArtifacts } from "../tools/artifact-store.js"; +import { InMemoryFabricateStore } from "../fabricate/store.js"; +import { kerfFetch, setKerfFetch } from "../fabricate/kerf/client.js"; +import { intentHash } from "../fabricate/kerf/intent-hash.js"; +import type { ConfiguratorIntent } from "../fabricate/kerf/contract.js"; + +/** + * The kerf rail (Wave 0, SendCutSend quote-only): with KERF_URL configured and + * a fab bundle bound, quote_manufacturing routes sheet metal through the kerf + * adapter and surfaces the fab's OWN displayed price (pricing_basis "quoted", + * suppressing the generic estimator); an unreachable rail degrades gracefully + * back to the vcad estimate. Plus the intent-hash discipline invariants the + * whole rail hangs off. + */ + +let engine: Engine; + +beforeAll(async () => { + engine = await Engine.init(); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const out = (r: { content: Array<{ text: string }> }): any => + JSON.parse(r.content[0].text); + +/** Canned kerf quote-job response (shape per fabricate/kerf/contract.ts). */ +const CANNED_JOB = { + job_id: "job_test_1", + state: "DELIVERED", + quote: { + quote_id: "vq_test_1", + vendor: "sendcutsend", + intent_hash: "cafe".repeat(16), + pricing_basis: "quoted", + unit_price: { currency: "USD", amount_minor: 279 }, + total: { currency: "USD", amount_minor: 558 }, + lead_time_days: 0, + evidence: ["ev_1", "ev_2"], + notes: ["SCS displayed price"], + }, + intent_hash: "cafe".repeat(16), + live_url: null, + evidence: { items: 2, claims: [] }, +}; + +function square(size: number) { + return [ + { x: 0, y: 0 }, + { x: size, y: 0 }, + { x: size, y: size }, + { x: 0, y: size }, + ]; +} + +/** The exact DXF bytes the fixture artifact holds — the wire contract test + * asserts these round-trip into `bytes_base64` hash-verified. */ +const FIXTURE_DXF = "0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nEOF\n"; + +/** A sheet-metal session (base flange ⇒ material/thickness derivable — an + * SCS-native aluminum gauge: 3.175 mm = .125" = ALU-125) plus a bound DXF + * fab artifact — the two preconditions of the kerf intent path. */ +function sheetMetalFixture(over: { + material?: string; + thickness?: number; + files?: Array<{ name: string; content: string }>; +} = {}): { documentId: string; artifactId: string } { + const created = out( + sheetMetalCreate( + { + outline: square(50), + thickness: over.thickness ?? 3.175, + material: over.material ?? "al-soft", + }, + engine, + ), + ); + const handle = storeArtifact( + over.files ?? [{ name: "flat-pattern.dxf", content: FIXTURE_DXF }], + ); + return { documentId: created.document_id as string, artifactId: handle.artifact_id }; +} + +/** Install a recording kerf fetch that answers CANNED_JOB. */ +function recordKerf(): Array<{ url: string; body: unknown }> { + const requests: Array<{ url: string; body: unknown }> = []; + setKerfFetch((async (url: unknown, init?: { body?: unknown }) => { + requests.push({ + url: String(url), + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return { ok: true, status: 200, json: async () => CANNED_JOB }; + }) as unknown as typeof fetch); + return requests; +} + +describe("kerf rail — quoted pricing basis + graceful degrade", () => { + const restoreFetch = kerfFetch; + beforeEach(() => { + documents.clear(); + clearArtifacts(); + process.env.KERF_URL = "http://kerf.test"; + }); + afterEach(() => { + delete process.env.KERF_URL; + delete process.env.KERF_QUOTE_MODE; + setKerfFetch(restoreFetch); + }); + + it("LIVE mode surfaces the fab's displayed price as 'quoted', with bytes + vendor-native config on the wire", async () => { + process.env.KERF_QUOTE_MODE = "live"; + const requests = recordKerf(); + + const { documentId, artifactId } = sheetMetalFixture(); + const res = await quoteManufacturing( + { + document_id: documentId, + process: "sheet_metal", + quantity: 2, + fab_artifact_id: artifactId, + }, + engine, + new InMemoryFabricateStore(), + null, + ); + expect(res.isError).toBeFalsy(); + const quote = out(res); + + // The kerf adapter quoted — the fab's own price, basis "quoted". + const scs = quote.fab_options.find((o: { fab: string }) => o.fab === "sendcutsend"); + expect(scs).toBeDefined(); + expect(scs.pricing_basis).toBe("quoted"); + expect(scs.notes).toContain("kerf job job_test_1"); + // A contracted fab quoted ⇒ the generic ballpark must not compete. + expect( + quote.fab_options.some((o: { fab: string }) => o.fab === "vcad_estimate"), + ).toBe(false); + expect(quote.pricing_basis).toBe("quoted"); + expect(quote.recommended_fab).toBe("sendcutsend"); + + // Quote-is-only-meaningful-with-its-intent-hash: persisted + surfaced. + expect(quote.kerf_intent_hash).toMatch(/^[0-9a-f]{64}$/); + expect(quote.kerf_job_id).toBe("job_test_1"); + + // The adapter sent the SAME intent order.ts hashed: one identity, no drift. + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe("http://kerf.test/api/quote"); + const sent = requests[0].body as { vendor: string; mode: string; intent: ConfiguratorIntent }; + expect(sent.vendor).toBe("sendcutsend"); + expect(sent.mode).toBe("live"); + expect(intentHash(sent.intent)).toBe(quote.kerf_intent_hash); + // Quote intents can never fund an order (kerf canary discipline). + expect(sent.intent.budget_cap.amount_minor).toBe(0); + + // Wire contract: exactly ONE file, carrying bytes_base64 that hash-match + // the manifest sha256 (kerf's posted-intent API 400s without the bytes). + expect(sent.intent.files).toHaveLength(1); + const file = sent.intent.files[0]; + expect(file.bytes_base64).toBeDefined(); + expect(Buffer.from(file.bytes_base64!, "base64").toString("utf8")).toBe(FIXTURE_DXF); + expect( + createHash("sha256").update(Buffer.from(file.bytes_base64!, "base64")).digest("hex"), + ).toBe(file.sha256); + // intentHash ignores the bytes (sha256s only): stripping bytes_base64 + // must not move the hash. + const { bytes_base64: _b, ...bare } = file; + expect( + intentHash({ ...sent.intent, files: [bare] } as ConfiguratorIntent), + ).toBe(quote.kerf_intent_hash); + + // Config vocabulary: exactly the pointers the SCS playbook dereferences, + // with vendor-native values (canary-intent fixture vocabulary). + expect(sent.intent.config).toEqual({ + units: "MM", + material_category: "Metals", + material_family: "Aluminum", + material: "5052 H32", + thickness: "ALU-125", + thickness_label: '.125" (3.2 MM)', + }); + }); + + it("scripted (default) mode downgrades to pricing_basis 'estimate' with the rehearsal note", async () => { + recordKerf(); // KERF_QUOTE_MODE unset → scripted + + const { documentId, artifactId } = sheetMetalFixture(); + const quote = out( + await quoteManufacturing( + { + document_id: documentId, + process: "sheet_metal", + quantity: 2, + fab_artifact_id: artifactId, + }, + engine, + new InMemoryFabricateStore(), + null, + ), + ); + + // The scripted run is a rehearsal of the rail (fixture price regardless + // of geometry) — it must NEVER present as the fab's own displayed price. + const scs = quote.fab_options.find((o: { fab: string }) => o.fab === "sendcutsend"); + expect(scs).toBeDefined(); + expect(scs.pricing_basis).toBe("estimate"); + expect(scs.notes).toContain("kerf scripted rehearsal — not a vendor-displayed price"); + expect(quote.pricing_basis).toBe("estimate"); + // The intent binding still records what was rehearsed… + expect(quote.kerf_intent_hash).toMatch(/^[0-9a-f]{64}$/); + // …and the CONTRACTED_FABS suppression still applies (fab-key based). + expect( + quote.fab_options.some((o: { fab: string }) => o.fab === "vcad_estimate"), + ).toBe(false); + }); + + it("fails closed when no vendor-native config derives (steel ⇒ no kerf request at all)", async () => { + const requests = recordKerf(); + + const { documentId, artifactId } = sheetMetalFixture({ + material: "steel-mild", + thickness: 2.7, + }); + const quote = out( + await quoteManufacturing( + { + document_id: documentId, + process: "sheet_metal", + quantity: 2, + fab_artifact_id: artifactId, + }, + engine, + new InMemoryFabricateStore(), + null, + ), + ); + expect(requests).toHaveLength(0); // the rail was never called + expect( + quote.fab_options.some((o: { fab: string }) => o.fab === "sendcutsend"), + ).toBe(false); + expect(quote.kerf_intent_hash).toBeUndefined(); + expect(quote.note).toContain("kerf vendor quote skipped"); + expect(quote.note).toContain("vendor-native"); + }); + + it("fails closed on multi-DXF bundles (the vendor playbook uploads a single file)", async () => { + const requests = recordKerf(); + + const { documentId, artifactId } = sheetMetalFixture({ + files: [ + { name: "part-a.dxf", content: FIXTURE_DXF }, + { name: "part-b.dxf", content: FIXTURE_DXF + "\n" }, + ], + }); + const quote = out( + await quoteManufacturing( + { + document_id: documentId, + process: "sheet_metal", + quantity: 1, + fab_artifact_id: artifactId, + }, + engine, + new InMemoryFabricateStore(), + null, + ), + ); + expect(requests).toHaveLength(0); + expect(quote.kerf_intent_hash).toBeUndefined(); + expect(quote.note).toContain("multi-DXF orders not yet kerf-quotable"); + }); + + it("degrades to the vcad estimate when the rail is unreachable (never fails the quote)", async () => { + setKerfFetch((async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch); + + const { documentId, artifactId } = sheetMetalFixture(); + const res = await quoteManufacturing( + { + document_id: documentId, + process: "sheet_metal", + quantity: 2, + fab_artifact_id: artifactId, + }, + engine, + new InMemoryFabricateStore(), + null, + ); + expect(res.isError).toBeFalsy(); + const quote = out(res); + expect( + quote.fab_options.some((o: { fab: string }) => o.fab === "sendcutsend"), + ).toBe(false); + const generic = quote.fab_options.find( + (o: { fab: string }) => o.fab === "vcad_estimate", + ); + expect(generic).toBeDefined(); + expect(generic.pricing_basis).toBe("estimate"); + // No vendor quote ⇒ no intent binding on the quote. + expect(quote.kerf_intent_hash).toBeUndefined(); + }); +}); + +describe("kerf intent-hash discipline (what would be manufactured, nothing else)", () => { + const base: ConfiguratorIntent = { + kind: "configurator", + vendor: "sendcutsend", + process: "sheet_metal", + files: [{ name: "flat.dxf", bytes: 128, sha256: "ab".repeat(32) }], + config: { material: "5052", thickness: "ALU-125", thickness_label: "3.175 mm" }, + quantity: 2, + idempotency_key: "vq_original", + ship_to: { + name: "vcad quote", + line1: "548 Market St", + city: "San Francisco", + region: "CA", + postal_code: "94104", + country: "US", + }, + budget_cap: { currency: "USD", amount_minor: 0 }, + }; + + it("is independent of object key order (canonical JSON)", () => { + const reordered: ConfiguratorIntent = { + budget_cap: base.budget_cap, + ship_to: base.ship_to, + idempotency_key: base.idempotency_key, + quantity: base.quantity, + // config keys inserted in reverse order + config: { thickness_label: "3.175 mm", thickness: "ALU-125", material: "5052" }, + files: [{ sha256: "ab".repeat(32), bytes: 128, name: "flat.dxf" }], + process: base.process, + vendor: base.vendor, + kind: "configurator", + }; + expect(intentHash(reordered)).toBe(intentHash(base)); + }); + + it("moves when quantity, config, or file bytes change (quote is dead ⇒ re-quote)", () => { + expect(intentHash({ ...base, quantity: 3 })).not.toBe(intentHash(base)); + expect( + intentHash({ ...base, config: { ...base.config, material: "6061" } }), + ).not.toBe(intentHash(base)); + expect( + intentHash({ + ...base, + files: [{ ...base.files[0], sha256: "cd".repeat(32) }], + }), + ).not.toBe(intentHash(base)); + }); + + it("ignores idempotency_key, ship_to, budget_cap, deadline, and file names", () => { + expect(intentHash({ ...base, idempotency_key: "vq_retry_99" })).toBe(intentHash(base)); + expect( + intentHash({ + ...base, + ship_to: { ...base.ship_to, line1: "1 Infinite Loop", city: "Cupertino" }, + }), + ).toBe(intentHash(base)); + expect( + intentHash({ ...base, budget_cap: { currency: "USD", amount_minor: 99_999 } }), + ).toBe(intentHash(base)); + expect(intentHash({ ...base, deadline: "2027-01-01T00:00:00Z" })).toBe(intentHash(base)); + expect( + intentHash({ ...base, files: [{ ...base.files[0], name: "renamed.dxf", bytes: 999 }] }), + ).toBe(intentHash(base)); + }); +}); diff --git a/packages/mcp/src/__tests__/order-feed.test.ts b/packages/mcp/src/__tests__/order-feed.test.ts new file mode 100644 index 000000000..6f09bf875 --- /dev/null +++ b/packages/mcp/src/__tests__/order-feed.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import { getOrderFeed } from "../tools/order-feed.js"; +import { InMemoryFabricateStore } from "../fabricate/store.js"; +import { authorizeSpend } from "../tools/ordering.js"; +import type { Order } from "../fabricate/types.js"; +import type { AuthUser } from "../oauth.js"; +import type { + SessionEvent, + SessionEventStore, + StoredSessionEvent, +} from "../session-store.js"; + +/** + * get_order_feed — the dock's single read. Two invariants under test: + * + * 1. windowing: the store limit applies BEFORE the document filter, so the + * feed must fetch wide (200) and slice to 20 AFTER filtering — newer + * orders on OTHER documents can't push this document's live approval + * out of the dock. + * 2. version token: covers authorization status, receipt status, pricing + * basis, and wallet balance — the states that change WITHOUT an order + * state transition or new event (human approval flips only the authz + * row) — so the viewer's version-dedup re-renders on approval. + */ + +class NullEventStore implements SessionEventStore { + async append(_sessionId: string, _evt: SessionEvent): Promise {} + async list(): Promise { + return []; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const out = (r: { content: Array<{ text: string }> }): any => + JSON.parse(r.content[0].text); + +function makeOrder(orderId: string, documentId: string, createdAt: string, over: Partial = {}): Order { + return { + order_id: orderId, + document_id: documentId, + quote_id: `q_${orderId}`, + state: "QUOTED", + fab: "digitalmetal", + fab_order_ref: null, + amount_total_minor: 5000, + currency: "USD", + ship_to: null, + events: [{ state: "QUOTED", at: createdAt, note: "quote" }], + authorization_id: null, + receipt_status: null, + kerf_intent_hash: null, + created_at: createdAt, + updated_at: createdAt, + ...over, + }; +} + +const iso = (i: number): string => new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(); + +describe("get_order_feed windowing (limit applied AFTER the document filter)", () => { + it("keeps an older document's order visible past 20 newer orders elsewhere", async () => { + const user: AuthUser = { sub: "u-feed-window", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + // Oldest row: the order the dock must not lose (doc A). + await store.saveOrder(makeOrder("ord_a", "doc_a", iso(0)), 4000, user.sub); + // 25 newer orders on doc B — with a pre-filter limit of 20 these would + // evict ord_a from the window entirely. + for (let i = 1; i <= 25; i++) { + await store.saveOrder(makeOrder(`ord_b_${i}`, "doc_b", iso(i)), 4000, user.sub); + } + + const feed = out(await getOrderFeed({ document_id: "doc_a" }, store, user)); + expect(feed.orders).toHaveLength(1); + expect(feed.orders[0].order_id).toBe("ord_a"); + + // And the busy document still caps at the dock's 20. + const feedB = out(await getOrderFeed({ document_id: "doc_b" }, store, user)); + expect(feedB.orders).toHaveLength(20); + }); +}); + +describe("get_order_feed version token (approval/wallet changes re-render the dock)", () => { + it("changes when the human approves the authorization (no order state/event change)", async () => { + const prev = process.env.VCAD_FABRICATE_ORDERING; + process.env.VCAD_FABRICATE_ORDERING = "1"; + try { + const user: AuthUser = { sub: "u-feed-authz", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_v", "doc_v", iso(0)), 4000, user.sub); + const a = out( + await authorizeSpend({ order_id: "ord_v" }, store, new NullEventStore(), user), + ); + + const pending = out(await getOrderFeed({ document_id: "doc_v" }, store, user)); + expect(pending.orders[0].state_chip).toBe("approval"); + expect(pending.orders[0].authorization.status).toBe("pending_human"); + + // Human approval on vcad.io flips ONLY the spend_authorizations row. + const orderBefore = await store.getOrder("ord_v", user.sub); + expect(store.approveAuthorizationForTest(a.authorization_id, user.sub)).toBe(true); + const orderAfter = await store.getOrder("ord_v", user.sub); + expect(orderAfter?.state).toBe(orderBefore?.state); + expect(orderAfter?.events.length).toBe(orderBefore?.events.length); + + const approved = out(await getOrderFeed({ document_id: "doc_v" }, store, user)); + expect(approved.orders[0].state_chip).toBe("quoted"); + expect(approved.orders[0].authorization.status).toBe("authorized"); + // The whole point: the viewer dedups on version, so it MUST move. + expect(approved.version).not.toBe(pending.version); + } finally { + if (prev === undefined) delete process.env.VCAD_FABRICATE_ORDERING; + else process.env.VCAD_FABRICATE_ORDERING = prev; + } + }); + + it("changes when the wallet balance changes", async () => { + const user: AuthUser = { sub: "u-feed-wallet", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_w", "doc_w", iso(0)), 4000, user.sub); + + const before = out(await getOrderFeed({ document_id: "doc_w" }, store, user)); + store.creditWalletForTest(user.sub, 12345); + const after = out(await getOrderFeed({ document_id: "doc_w" }, store, user)); + expect(after.wallet_balance_usd).toBe(123.45); + expect(after.version).not.toBe(before.version); + }); + + it("changes when the persisted receipt status changes (state stays QUOTED)", async () => { + const user: AuthUser = { sub: "u-feed-receipt", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + await store.saveOrder(makeOrder("ord_r", "doc_r", iso(0)), 4000, user.sub); + + const before = out(await getOrderFeed({ document_id: "doc_r" }, store, user)); + // place_order's receipt gate persists "violated" with the state unchanged; + // strip the appended event's contribution by comparing against a second + // no-op read to make sure the receipt itself participates. + await store.setOrderState("ord_r", user.sub, "QUOTED", "receipt violated at place time", { + receipt_status: "violated", + }); + const after = out(await getOrderFeed({ document_id: "doc_r" }, store, user)); + expect(after.orders[0].receipt.status).toBe("violated"); + expect(after.version).not.toBe(before.version); + }); +}); diff --git a/packages/mcp/src/__tests__/ordering.test.ts b/packages/mcp/src/__tests__/ordering.test.ts index c14c319c5..afc2d0cb8 100644 --- a/packages/mcp/src/__tests__/ordering.test.ts +++ b/packages/mcp/src/__tests__/ordering.test.ts @@ -109,6 +109,67 @@ describe("Fabricate ordering — enabled (test-mode)", () => { expect( (placed!.payload as { fab_artifact?: { artifact_id?: string } }).fab_artifact?.artifact_id, ).toBe(handle.artifact_id); + + // No bundle was bound at quote time, so this was a LATE binding — the + // order timeline records it (provenance gap made visible). + const orderRow = await store.getOrder("ord_1", user.sub); + expect( + orderRow?.events.some((e) => e.note?.includes("late-bound at place time")), + ).toBe(true); + }); + + it("refuses to SWAP the fab bundle after approval — quoted artifact is pinned", async () => { + const user: AuthUser = { sub: "u-swap", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const quoted = storeArtifact([{ name: "bracket-v1.dxf", content: "v1 flat pattern" }]); + const swapped = storeArtifact([{ name: "bracket-v2.dxf", content: "v2 BIGGER part" }]); + await store.saveOrder( + makeOrder({ + fab_artifact: { + artifact_id: quoted.artifact_id, + artifact_url: quoted.artifact_url, + bytes: quoted.bytes, + manifest: quoted.manifest, + }, + }), + 4000, + user.sub, + ); + + const a = json(await authorizeSpend({ order_id: "ord_1" }, store, es, user)); + store.approveAuthorizationForTest(a.authorization_id, user.sub); + store.creditWalletForTest(user.sub, 10000); + + const res = await placeOrder( + { + order_id: "ord_1", + authorization_id: a.authorization_id, + fab_artifact_id: swapped.artifact_id, + }, + store, + es, + user, + ); + expect(res.isError).toBe(true); + expect(text(res)).toContain("re-quote to change files"); + // Refused BEFORE any money movement: order still QUOTED, no spine event. + expect((await store.getOrder("ord_1", user.sub))?.state).toBe("QUOTED"); + expect(es.events.some((e) => e.type === "order_placed")).toBe(false); + + // Re-passing the SAME artifact the quote bound is fine (not a swap). + const same = await placeOrder( + { + order_id: "ord_1", + authorization_id: a.authorization_id, + fab_artifact_id: quoted.artifact_id, + }, + store, + es, + user, + ); + expect(same.isError).toBeFalsy(); + expect(json(same).fab_artifact.artifact_id).toBe(quoted.artifact_id); }); it("rejects a place_order with an unknown fab artifact handle before any debit", async () => { diff --git a/packages/mcp/src/__tests__/place-order-gates.test.ts b/packages/mcp/src/__tests__/place-order-gates.test.ts new file mode 100644 index 000000000..02e97bebb --- /dev/null +++ b/packages/mcp/src/__tests__/place-order-gates.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { Engine } from "@vcad/engine"; +import type { Document } from "@vcad/ir"; +import { documents, getSession, openDocument } from "../tools/session.js"; +import { quoteManufacturing } from "../tools/order.js"; +import { authorizeSpend, placeOrder } from "../tools/ordering.js"; +import { checkClearance } from "../tools/clearance.js"; +import { InMemoryFabricateStore } from "../fabricate/store.js"; +import type { AuthUser } from "../oauth.js"; +import type { + SessionEvent, + SessionEventStore, + SessionStore, + StoredSessionEvent, +} from "../session-store.js"; + +/** + * The M4 fail-closed money gates in place_order, driven end-to-end through a + * REAL session (quotes from inline IR get `document_id: "inline:"` and + * deliberately SKIP both gates — so every case here quotes by document_id): + * + * gate 1 — geometry: doc_hash re-hashed at place time must match the quote. + * gate 2 — receipt: persisted clearance specs re-verified; fail refuses; + * no specs proceeds flagged "unverified"; passing specs → "holds". + */ + +let engine: Engine; + +beforeAll(async () => { + engine = await Engine.init(); +}); + +/** Records control events so we can assert the spine got them. */ +class RecordingEventStore implements SessionEventStore { + public events: Array = []; + async append(sessionId: string, evt: SessionEvent): Promise { + this.events.push({ sessionId, ...evt }); + } + async list(): Promise { + return []; + } + types(): string[] { + return this.events.map((e) => e.type); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const out = (r: { content: Array<{ text: string }> }): any => + JSON.parse(r.content[0].text); +const text = (r: { content: Array<{ text: string }> }) => r.content[0].text; + +/** Rotor/stator fixture (the clearance.test.ts pattern): radius 5.0 leaves a + * clean 1.0 mm air gap; radius 7.0 pierces the stator (negative distance). */ +function rotorStatorDocument(rotorRadius: number): Document { + const nodes: Record = {}; + let id = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const add = (name: string, op: any): number => { + id += 1; + nodes[String(id)] = { id, name, op }; + return id; + }; + const rotorCyl = add("rotor-solid", { + type: "Cylinder", + radius: rotorRadius, + height: 8, + segments: 128, + }); + const rotor = add("rotor", { + type: "Translate", + child: rotorCyl, + offset: { x: 0, y: 0, z: 1 }, + }); + const statorOuter = add("stator-outer", { + type: "Cylinder", + radius: 10, + height: 10, + segments: 128, + }); + const statorBoreCyl = add("stator-bore-solid", { + type: "Cylinder", + radius: 6, + height: 12, + segments: 128, + }); + const statorBore = add("stator-bore", { + type: "Translate", + child: statorBoreCyl, + offset: { x: 0, y: 0, z: -1 }, + }); + const stator = add("stator", { type: "Difference", left: statorOuter, right: statorBore }); + return { + version: "0.1", + nodes, + materials: {}, + part_materials: {}, + roots: [ + { root: rotor, material: "steel" }, + { root: stator, material: "aluminum" }, + ], + } as unknown as Document; +} + +/** Quote the session, propose + human-approve a spend, fund the wallet — + * everything up to the place_order gates. */ +async function quotedAndApproved( + docId: string, + store: InMemoryFabricateStore, + es: RecordingEventStore, + user: AuthUser, +): Promise<{ orderId: string; authorizationId: string }> { + const quote = out( + await quoteManufacturing( + { document_id: docId, process: "cast_metal", quantity: 1, material: "stainless" }, + engine, + store, + user, + ), + ); + expect(quote.order_id).toBeTruthy(); + const a = out(await authorizeSpend({ order_id: quote.order_id }, store, es, user)); + expect(store.approveAuthorizationForTest(a.authorization_id, user.sub)).toBe(true); + store.creditWalletForTest(user.sub, 10_000_000); + return { orderId: quote.order_id, authorizationId: a.authorization_id }; +} + +describe("place_order money gates (doc-hash + receipt, fail-closed)", () => { + let prev: string | undefined; + beforeEach(() => { + prev = process.env.VCAD_FABRICATE_ORDERING; + process.env.VCAD_FABRICATE_ORDERING = "1"; + documents.clear(); + }); + afterEach(() => { + if (prev === undefined) delete process.env.VCAD_FABRICATE_ORDERING; + else process.env.VCAD_FABRICATE_ORDERING = prev; + }); + + it("refuses on doc_hash mismatch: geometry edited after quoting kills the quote", async () => { + const user: AuthUser = { sub: "u-gate-hash", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const docId = out(openDocument({ initial: rotorStatorDocument(5.0) })).document_id; + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + + // Edit the design AFTER quoting — the quote priced different geometry. + const doc = getSession(docId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Object.values(doc.nodes).find((n) => n.name === "rotor-solid")!.op as any).radius = 5.5; + + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(res.isError).toBe(true); + expect(text(res)).toContain("doc_hash mismatch"); + expect(text(res)).toContain("re-quote"); + // No money moved — and the refusal is DURABLE: the order is expired so a + // retry on an instance where the session isn't resident still refuses. + expect((await store.getOrder(orderId, user.sub))?.state).toBe("EXPIRED"); + const blocked = es.events.find((e) => e.type === "order_blocked"); + expect(blocked).toBeTruthy(); + expect((blocked!.payload as { reason?: string }).reason).toBe("doc_hash_mismatch"); + + // Simulate instance churn / close_document: the session is gone, but the + // EXPIRED order refuses at the entry check — the gate can't be bypassed + // by making the document non-resident. + documents.clear(); + const retry = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(retry.isError).toBe(true); + expect(text(retry)).toContain("not placeable"); + }); + + it("refuses receipt_violated: a failing clearance claim blocks the debit", async () => { + const user: AuthUser = { sub: "u-gate-recv", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + // Rotor radius 7.0 pierces the stator — measured distance is NEGATIVE, so + // any positive min_mm makes the persisted claim fail at place time. + const docId = out(openDocument({ initial: rotorStatorDocument(7.0) })).document_id; + const clearance = await checkClearance( + { document_id: docId, group_a: ["rotor"], group_b: ["stator"], min_mm: 0.1, label: "air-gap" }, + engine, + ); + expect(clearance.isError).toBeUndefined(); + expect(out(clearance).pass).toBe(false); + + // Spec persisted BEFORE quoting, so gate 1 (doc hash) still holds and the + // refusal is attributable to gate 2 alone. + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(res.isError).toBe(true); + expect(text(res)).toContain("receipt violated"); + expect(text(res)).toContain("mech.clearance.air-gap"); + const after = await store.getOrder(orderId, user.sub); + expect(after?.state).toBe("QUOTED"); + // The verdict is persisted (state stays QUOTED) so the refusal is durable. + expect(after?.receipt_status).toBe("violated"); + const blocked = es.events.find((e) => e.type === "order_blocked"); + expect(blocked).toBeTruthy(); + expect((blocked!.payload as { reason?: string }).reason).toBe("receipt_violated"); + + // close_document / instance churn must NOT bypass the known-failing + // receipt: with the session gone the entry check still refuses (before + // the gates would have degraded to "unverified — proceeding"). + documents.clear(); + const retry = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(retry.isError).toBe(true); + expect(text(retry)).toContain("receipt violated"); + expect((await store.getOrder(orderId, user.sub))?.state).toBe("QUOTED"); + }); + + it("proceeds flagged 'unverified' when the document carries no clearance specs", async () => { + const user: AuthUser = { sub: "u-gate-unv", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const docId = out(openDocument({ initial: rotorStatorDocument(5.0) })).document_id; + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(res.isError).toBeFalsy(); + const body = out(res); + expect(body.state).toBe("PAID"); + expect(body.receipt.status).toBe("unverified"); + expect((await store.getOrder(orderId, user.sub))?.state).toBe("PAID"); + expect((await store.getOrder(orderId, user.sub))?.receipt_status).toBe("unverified"); + }); + + it("re-verifies a passing spec at place time and records receipt 'holds'", async () => { + const user: AuthUser = { sub: "u-gate-holds", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const docId = out(openDocument({ initial: rotorStatorDocument(5.0) })).document_id; + const clearance = await checkClearance( + { document_id: docId, group_a: ["rotor"], group_b: ["stator"], min_mm: 0.9, label: "air-gap" }, + engine, + ); + expect(out(clearance).pass).toBe(true); + + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(res.isError).toBeFalsy(); + const body = out(res); + expect(body.state).toBe("PAID"); + expect(body.receipt.status).toBe("holds"); + expect(body.receipt.note).toContain("re-verified"); + expect((await store.getOrder(orderId, user.sub))?.receipt_status).toBe("holds"); + // The placement event carries the verdict for the feed. + const placed = es.events.find((e) => e.type === "order_placed"); + expect((placed!.payload as { receipt_status?: string }).receipt_status).toBe("holds"); + }); + + it("consumed-authz replay skips the gates: a committed debit finalizes even after drift", async () => { + const user: AuthUser = { sub: "u-gate-replay", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const docId = out(openDocument({ initial: rotorStatorDocument(5.0) })).document_id; + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + + // Simulate the crash window: the debit COMMITS (authz consumed) but the + // PAID state write never lands — the order is still QUOTED. + const debit = await store.debit({ + userId: user.sub, + amountMinor: (await store.getOrder(orderId, user.sub))!.amount_total_minor, + orderId, + authorizationId, + idempotencyKey: `${orderId}:debit`, // place_order's default key + }); + expect(debit.ok).toBe(true); + expect((await store.getAuthorization(authorizationId, user.sub))?.status).toBe("consumed"); + + // The design drifts AFTER the debit — historically this stranded the + // order at QUOTED forever (gate 1 refused every replay attempt). + const doc = getSession(docId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Object.values(doc.nodes).find((n) => n.name === "rotor-solid")!.op as any).radius = 5.5; + + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + ); + expect(res.isError).toBeFalsy(); + const body = out(res); + expect(body.state).toBe("PAID"); + expect(body.idempotent).toBe(true); // replay-matched, no second debit + expect(body.receipt.note).toContain("idempotent replay"); + expect((await store.getOrder(orderId, user.sub))?.state).toBe("PAID"); + // The gates never fired for the replay — no order_blocked on the spine. + expect(es.events.some((e) => e.type === "order_blocked")).toBe(false); + }); + + it("hydrates the order's session before the gates (signed-in path has no args.document_id)", async () => { + const user: AuthUser = { sub: "u-gate-hydrate", email: "x@y.z" }; + const store = new InMemoryFabricateStore(); + const es = new RecordingEventStore(); + const docId = out(openDocument({ initial: rotorStatorDocument(5.0) })).document_id; + const { orderId, authorizationId } = await quotedAndApproved(docId, store, es, user); + + // The durable store holds a DRIFTED version of the design; the warm cache + // is empty (fresh per-request scope / cold instance). Without hydration + // the doc-hash gate silently skipped and the wallet was debited. + const drifted = JSON.parse(JSON.stringify(getSession(docId))) as Document; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Object.values(drifted.nodes).find((n) => n.name === "rotor-solid")!.op as any).radius = 6.2; + const durable = new Map([[docId, drifted]]); + const sessionStore: SessionStore = { + scope: "user", + load: async (id: string) => durable.get(id) ?? null, + save: async () => {}, + drop: async () => {}, + }; + documents.clear(); // not resident — only the durable store has it + + const res = await placeOrder( + { order_id: orderId, authorization_id: authorizationId }, + store, + es, + user, + engine, + sessionStore, + ); + expect(res.isError).toBe(true); + expect(text(res)).toContain("doc_hash mismatch"); + expect((await store.getOrder(orderId, user.sub))?.state).toBe("EXPIRED"); + }); +}); diff --git a/packages/mcp/src/__tests__/sim-replay.test.ts b/packages/mcp/src/__tests__/sim-replay.test.ts new file mode 100644 index 000000000..e7d4b8b41 --- /dev/null +++ b/packages/mcp/src/__tests__/sim-replay.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { createRobotEnv, gymStep, gymReset, gymClose } from "../tools/gym.js"; +import { getSimReplay, getSimVersion } from "../tools/sim-replay.js"; +import { documents } from "../tools/session.js"; + +/** + * Golden-trajectory coverage for the inline viewer's replay tools (M1): the + * gym ring buffer records every step, get_sim_replay serves the trajectory + * plus per-step kernel-FK instance transforms, get_sim_version is the cheap + * change token, and gym_reset truncates. Physics-gated like the existing gym + * tests — the WASM build without `--features physics` skips cleanly. + */ + +const robotDoc = { + version: "0.1", + nodes: { + "1": { id: 1, name: "base", op: { type: "Cube", size: { x: 100, y: 100, z: 50 } } }, + "2": { id: 2, name: "link1", op: { type: "Cube", size: { x: 20, y: 20, z: 100 } } }, + }, + materials: {}, + roots: [{ root: 1, material: "default" }], + part_materials: {}, + partDefs: { + base: { id: "base", name: "Base", root: 1, defaultMaterial: null }, + link1: { id: "link1", name: "Link 1", root: 2, defaultMaterial: null }, + }, + instances: [ + { id: "base_inst", partDefId: "base", name: "Base", transform: null, material: null }, + { id: "link1_inst", partDefId: "link1", name: "Link 1", transform: null, material: null }, + ], + joints: [ + { + id: "joint1", + name: "Joint 1", + parentInstanceId: "base_inst", + childInstanceId: "link1_inst", + parentAnchor: { x: 0, y: 0, z: 25 }, + childAnchor: { x: 0, y: 0, z: -50 }, + kind: { type: "Revolute", axis: { x: 0, y: 1, z: 0 }, limits: [-90, 90] }, + state: 0, + }, + ], + groundInstanceId: "base_inst", +}; + +const json = (r: { content: Array<{ text: string }> }) => JSON.parse(r.content[0].text); + +/** Physics availability gate (same pattern as the gym tests in tools.test.ts). */ +async function createEnvOrSkip(): Promise<{ envId: string; numJoints: number } | null> { + const result = await createRobotEnv({ + document: robotDoc, + end_effector_ids: ["link1_inst"], + }); + const info = json(result); + if (info.error) return null; + return { envId: info.env_id, numJoints: info.num_joints }; +} + +describe("sim replay (gym ring buffer → viewer playback)", () => { + it("records a 5-step torque rollout with per-step FK transforms for every instance", async () => { + documents.clear(); + const env = await createEnvOrSkip(); + if (!env) return; // physics unavailable in this WASM build + + for (let i = 0; i < 5; i++) { + const step = gymStep({ env_id: env.envId, action_type: "torque", values: [0.5] }); + expect(step.isError ?? false).toBe(false); + } + + const replay = json(await getSimReplay({ env_id: env.envId })); + expect(replay.steps).toBe(5); + expect(replay.total_steps).toBe(5); + expect(replay.joint_trajectory).toHaveLength(5); + for (const row of replay.joint_trajectory) { + expect(row).toHaveLength(env.numJoints); + } + expect(replay.rewards).toHaveLength(5); + expect(replay.dones).toHaveLength(5); + expect(typeof replay.document_id).toBe("string"); + expect(replay.dt).toBeGreaterThan(0); + expect(replay.substeps).toBeGreaterThan(0); + + // Per-step kernel FK: one transform record per step, one entry per instance. + expect(replay.instance_transforms).toHaveLength(5); + for (const row of replay.instance_transforms) { + for (const instanceId of ["base_inst", "link1_inst"]) { + expect(row[instanceId], `FK row carries ${instanceId}`).toBeDefined(); + // The viewer contract is plain [x, y, z] ARRAYS for every component — + // the kernel's Vec3 {x,y,z} objects (or serde Maps) must have been + // normalized server-side, or applySimFrame reads pa[0] === undefined + // and every pose goes NaN. Regression-pinned here. + for (const key of ["translation", "rotation", "scale"] as const) { + const v = row[instanceId][key]; + expect(Array.isArray(v), `${instanceId}.${key} is an array`).toBe(true); + expect(v).toHaveLength(3); + for (const c of v) expect(typeof c).toBe("number"); + } + } + } + + gymClose({ env_id: env.envId }); + }); + + it("version token advances with steps and gym_reset clears the rollout", async () => { + documents.clear(); + const env = await createEnvOrSkip(); + if (!env) return; + + gymStep({ env_id: env.envId, action_type: "torque", values: [0.2] }); + const v1 = json(getSimVersion({ env_id: env.envId })); + expect(v1.step_count).toBe(1); + expect(typeof v1.version).toBe("string"); + + gymStep({ env_id: env.envId, action_type: "torque", values: [0.2] }); + const v2 = json(getSimVersion({ env_id: env.envId })); + expect(v2.step_count).toBe(2); + expect(v2.version).not.toBe(v1.version); + + // A reset starts a fresh episode: the recorded rollout drops to zero. + const reset = gymReset({ env_id: env.envId }); + expect(reset.isError ?? false).toBe(false); + const replay = json(await getSimReplay({ env_id: env.envId })); + expect(replay.steps).toBe(0); + expect(replay.joint_trajectory).toHaveLength(0); + expect(replay.instance_transforms).toHaveLength(0); + const v3 = json(getSimVersion({ env_id: env.envId })); + expect(v3.step_count).toBe(0); + + // Reset epoch keeps the token moving: an equal-length rollout AFTER the + // reset must not collide with the pre-reset token at the same count — + // otherwise the viewer would never re-fetch the new episode. + expect(v3.reset_epoch).toBe(1); + gymStep({ env_id: env.envId, action_type: "torque", values: [0.2] }); + gymStep({ env_id: env.envId, action_type: "torque", values: [0.2] }); + const v4 = json(getSimVersion({ env_id: env.envId })); + expect(v4.step_count).toBe(2); // same count as v2, different episode + expect(v4.version).not.toBe(v2.version); + const replay2 = json(await getSimReplay({ env_id: env.envId })); + expect(replay2.version).toBe(v4.version); + expect(replay2.reset_epoch).toBe(1); + + gymClose({ env_id: env.envId }); + }); + + it("errors on an unknown env_id (fail-closed, isError set)", async () => { + const replay = await getSimReplay({ env_id: "sim_nope" }); + expect(replay.isError).toBe(true); + expect(json(replay).error).toContain("Unknown env_id"); + + const version = getSimVersion({ env_id: "sim_nope" }); + expect(version.isError).toBe(true); + expect(json(version).error).toContain("Unknown env_id"); + }); +}); diff --git a/packages/mcp/src/__tests__/state-chip.test.ts b/packages/mcp/src/__tests__/state-chip.test.ts new file mode 100644 index 000000000..908f44665 --- /dev/null +++ b/packages/mcp/src/__tests__/state-chip.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { orderStateChip, kerfJobChip, type OrderChip } from "../fabricate/state-chip.js"; +import { KERF_JOB_STATES } from "../fabricate/kerf/contract.js"; +import type { OrderState } from "../fabricate/types.js"; + +/** + * Exhaustive chip-mapping coverage (M2): every vcad OrderState and every one + * of the 17 kerf JobStates maps to a dock chip — no state ever reaches the + * widget unmapped. The mapping functions carry compile-time never-checks; + * this test adds the runtime sweep so a state addition that bypasses the + * switch (e.g. a cast) still fails loudly. + */ + +const CHIPS: readonly OrderChip[] = [ + "quoted", + "approval", + "placing", + "confirmed", + "production", + "delivered", + "failed", +]; + +/** All 16 vcad order states (mirrors the orders.state check constraint in + * fabricate/types.ts — typed as OrderState[] so drift fails the build). */ +const ORDER_STATES: readonly OrderState[] = [ + "DRAFT", + "QUOTED", + "EXPIRED", + "AUTHORIZED", + "PENDING_PAYMENT", + "PAYMENT_FAILED", + "PAID", + "SUBMITTED", + "SUBMIT_FAILED", + "RECONCILING", + "IN_PRODUCTION", + "SHIPPED", + "DELIVERED", + "CANCELED", + "CANCELED_BY_FAB", + "REFUNDED", +]; + +describe("order-dock state chips (exhaustive, no unmapped state)", () => { + it("maps every vcad OrderState to a chip", () => { + expect(ORDER_STATES).toHaveLength(16); + for (const state of ORDER_STATES) { + const chip = orderStateChip(state); + expect(CHIPS, `${state} maps to a known chip`).toContain(chip); + } + }); + + it("pins the expected chip per vcad state (the spec's mapping table)", () => { + expect(orderStateChip("DRAFT")).toBe("quoted"); + expect(orderStateChip("QUOTED")).toBe("quoted"); + expect(orderStateChip("AUTHORIZED")).toBe("approval"); + expect(orderStateChip("PENDING_PAYMENT")).toBe("approval"); + expect(orderStateChip("PAID")).toBe("placing"); + expect(orderStateChip("SUBMITTED")).toBe("placing"); + expect(orderStateChip("RECONCILING")).toBe("placing"); + expect(orderStateChip("IN_PRODUCTION")).toBe("production"); + expect(orderStateChip("SHIPPED")).toBe("production"); + expect(orderStateChip("DELIVERED")).toBe("delivered"); + for (const failed of [ + "EXPIRED", + "PAYMENT_FAILED", + "SUBMIT_FAILED", + "CANCELED", + "CANCELED_BY_FAB", + "REFUNDED", + ] as const) { + expect(orderStateChip(failed), `${failed} is failed`).toBe("failed"); + } + }); + + it("foregrounds the pending_human overlay: QUOTED + pending authz = approval", () => { + expect(orderStateChip("QUOTED", "pending_human")).toBe("approval"); + // Any other authz status leaves the order's own chip in charge. + expect(orderStateChip("QUOTED", "authorized")).toBe("quoted"); + expect(orderStateChip("QUOTED", undefined)).toBe("quoted"); + // The overlay is specific to QUOTED — a terminal state stays terminal. + expect(orderStateChip("DELIVERED", "pending_human")).toBe("delivered"); + }); + + it("maps every one of the 17 kerf JobStates to a chip — no misses", () => { + expect(KERF_JOB_STATES).toHaveLength(17); + for (const state of KERF_JOB_STATES) { + const chip = kerfJobChip(state); + expect(CHIPS, `${state} maps to a known chip`).toContain(chip); + } + }); + + it("pins the kerf mapping table (RECONCILING = explained wait, never retry)", () => { + for (const placing of [ + "QUEUED", + "SESSION_OPEN", + "STAGING", + "STAGED", + "AUDIT", + "PLACING", + "CONFIRMING", + "RECONCILING", + "RECONCILED_PLACED", + ] as const) { + expect(kerfJobChip(placing), `${placing} is placing`).toBe("placing"); + } + expect(kerfJobChip("TAKEOVER_WAIT")).toBe("approval"); + expect(kerfJobChip("CONFIRMED")).toBe("confirmed"); + expect(kerfJobChip("TRACKING")).toBe("production"); + expect(kerfJobChip("DELIVERED")).toBe("delivered"); + for (const failed of ["AUDIT_FAILED", "FAILED", "RECONCILED_ABSENT", "CANCELED"] as const) { + expect(kerfJobChip(failed), `${failed} is failed`).toBe("failed"); + } + }); +}); diff --git a/packages/mcp/src/__tests__/tool-surface.fixture.json b/packages/mcp/src/__tests__/tool-surface.fixture.json index 1baa45116..9e5fb0318 100644 --- a/packages/mcp/src/__tests__/tool-surface.fixture.json +++ b/packages/mcp/src/__tests__/tool-surface.fixture.json @@ -268,7 +268,7 @@ { "name": "quote_manufacturing", "title": "Quote Manufacturing", - "description": "Quote manufacturing a part: measures the design, runs light DFM, and returns margin-inclusive price options per fab (pcb/cnc/3dprint/sheet_metal/cast_metal). Pass `ir` (inline Document — stateless, no open_document needed, serverless-safe, parallel-safe) OR a `document_id` from an open session. Persists a quote + a QUOTED order. Phase 0 is quote-only — prices are estimates and ordering/payment ship next; no money moves. For sheet_metal the result includes `fab_handoff`: curated US instant-quote shops (SendCutSend/OSH Cut/Fabworks), the exact file recipe (DXF via sheet_metal_unfold or folded STEP via export_cad), and what to enter at upload — everything needed to finish the order on the fab's site today.", + "description": "Quote manufacturing a part: measures the design, runs light DFM, and returns margin-inclusive price options per fab (pcb/cnc/3dprint/sheet_metal/cast_metal). Pass `ir` (inline Document — stateless, no open_document needed, serverless-safe, parallel-safe) OR a `document_id` from an open session. Persists a quote + a QUOTED order. Phase 0 is quote-only — prices are estimates and ordering/payment ship next; no money moves. For sheet_metal the result includes `fab_handoff`: curated US instant-quote shops (SendCutSend/OSH Cut/Fabworks), the exact file recipe (DXF via sheet_metal_unfold or folded STEP via export_cad), and what to enter at upload — everything needed to finish the order on the fab's site today. Mounts the inline viewer's order dock, so the quote and its order lifecycle render alongside the model.", "inputSchema": { "type": "object", "properties": { @@ -330,6 +330,15 @@ "annotations": { "readOnlyHint": false, "openWorldHint": true + }, + "_meta": { + "ui": { + "resourceUri": "ui://vcad/viewer" + }, + "ui/resourceUri": "ui://vcad/viewer", + "openai/outputTemplate": "ui://vcad/viewer-openai.html", + "openai/toolInvocation/invoking": "Modeling geometry…", + "openai/toolInvocation/invoked": "Model updated" } }, { @@ -381,7 +390,7 @@ { "name": "authorize_spend", "title": "Authorize Spend", - "description": "Propose a spend authorization for a QUOTED order. Creates a DB-backed, revocable authorization (status pending_human) and records the proposal on the session's event log. A HUMAN must approve it in the vcad app before place_order can charge — the agent cannot approve its own spend. Flag-gated (test-mode); no money moves here.", + "description": "Propose a spend authorization for a QUOTED order. Creates a DB-backed, revocable authorization (status pending_human) and records the proposal on the session's event log. A HUMAN must approve it in the vcad app before place_order can charge — the agent cannot approve its own spend; when the client supports URL elicitation the approval page is offered to the human in-band. Flag-gated (test-mode); no money moves here.", "inputSchema": { "type": "object", "properties": { @@ -407,7 +416,7 @@ { "name": "place_order", "title": "Place Order", - "description": "Place a QUOTED order once its authorization has been human-approved: performs one atomic wallet debit and moves the order to PAID (fab submission follows in a later step). Refuses if the authorization is still pending approval. Flag-gated (test-mode).", + "description": "Place a QUOTED order once its authorization has been human-approved: performs one atomic wallet debit and moves the order to PAID (fab submission follows in a later step). Refuses if the authorization is still pending approval, if the geometry changed since the quote (doc_hash mismatch — re-quote), or if the design's persisted clearance claims fail or cannot be re-verified (fail-closed receipt gate). Flag-gated (test-mode).", "inputSchema": { "type": "object", "properties": { @@ -439,6 +448,35 @@ "openWorldHint": true } }, + { + "name": "get_order_feed", + "title": "Get Order Feed", + "description": "App-only: the order-dock feed for a document — orders with fused lifecycle chips, joined quote details, authorization status (+ approve URL while pending), receipt status, and wallet balance. Polled by the inline viewer; read-only.", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Session/document id whose orders to feed." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": true + }, + "_meta": { + "openai/widgetAccessible": true, + "ui": { + "visibility": [ + "app" + ] + } + } + }, { "name": "bom_create", "title": "Create BOM", @@ -1094,6 +1132,10 @@ "document_id": { "type": "string", "description": "Session id of the document to preview." + }, + "instances": { + "type": "boolean", + "description": "Return one named node per assembly instance (part-local geometry + node transforms) so the replay viewer can bind FK targets. Falls back to the merged parts preview when the document has no instances." } }, "required": [ @@ -1140,6 +1182,62 @@ } } }, + { + "name": "get_sim_replay", + "title": "Get Sim Replay", + "description": "Return the recorded joint trajectory and per-step instance transforms for a physics env. Internal to the inline viewer's replay UI — agents should use gym_observe or record_simulation instead.", + "inputSchema": { + "type": "object", + "properties": { + "env_id": { + "type": "string", + "description": "Environment ID returned by create_robot_env" + } + }, + "required": [ + "env_id" + ] + }, + "annotations": { + "readOnlyHint": true + }, + "_meta": { + "openai/widgetAccessible": true, + "ui": { + "visibility": [ + "app" + ] + } + } + }, + { + "name": "get_sim_version", + "title": "Get Sim Version", + "description": "Return a cheap {env_id, step_count, version} change token for a physics env (no FK eval). Internal to the inline viewer's replay poll — agents should ignore it.", + "inputSchema": { + "type": "object", + "properties": { + "env_id": { + "type": "string", + "description": "Environment ID returned by create_robot_env" + } + }, + "required": [ + "env_id" + ] + }, + "annotations": { + "readOnlyHint": true + }, + "_meta": { + "openai/widgetAccessible": true, + "ui": { + "visibility": [ + "app" + ] + } + } + }, { "name": "apply_edits", "title": "Apply Edits", @@ -2406,7 +2504,7 @@ { "name": "create_robot_env", "title": "Create Robot Env", - "description": "Create a physics simulation environment from a vcad assembly. Returns an environment ID that can be used with gym_step, gym_reset, and gym_observe. The environment provides a gym-style interface for RL training.", + "description": "Create a physics simulation environment from a vcad assembly. Returns an environment ID that can be used with gym_step, gym_reset, and gym_observe. The environment provides a gym-style interface for RL training. Mounts the inline 3D viewer with a play button — gym_step rollouts replay right in the chat.", "inputSchema": { "type": "object", "properties": { @@ -2441,6 +2539,15 @@ }, "annotations": { "readOnlyHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://vcad/viewer" + }, + "ui/resourceUri": "ui://vcad/viewer", + "openai/outputTemplate": "ui://vcad/viewer-openai.html", + "openai/toolInvocation/invoking": "Modeling geometry…", + "openai/toolInvocation/invoked": "Model updated" } }, { diff --git a/packages/mcp/src/__tests__/viewer-meta.test.ts b/packages/mcp/src/__tests__/viewer-meta.test.ts index 9d7593a30..d42607e0c 100644 --- a/packages/mcp/src/__tests__/viewer-meta.test.ts +++ b/packages/mcp/src/__tests__/viewer-meta.test.ts @@ -50,7 +50,13 @@ describe("viewer _meta split (one canvas, not one iframe per call)", () => { const by = (n: string) => tools.find((t) => t.name === n); // Mount tools: carry the UI template in both dialects. - for (const name of ["open_document", "place_components", "build_receipt"]) { + for (const name of [ + "open_document", + "place_components", + "build_receipt", + "create_robot_env", + "quote_manufacturing", + ]) { const m = by(name)?._meta; expect(m?.ui?.resourceUri, `${name} ui.resourceUri`).toBe(VIEWER_URI); expect(m?.["openai/outputTemplate"], `${name} outputTemplate`).toBeTruthy(); @@ -71,7 +77,13 @@ describe("viewer _meta split (one canvas, not one iframe per call)", () => { const { tools } = (await client.listTools()) as { tools: ToolDesc[] }; const by = (n: string) => tools.find((t) => t.name === n); - for (const name of ["get_preview_glb", "get_preview_version"]) { + for (const name of [ + "get_preview_glb", + "get_preview_version", + "get_sim_replay", + "get_sim_version", + "get_order_feed", + ]) { const m = by(name)?._meta; expect(by(name), `${name} exists`).toBeDefined(); expect(m?.ui?.visibility, `${name} app-only`).toEqual(["app"]); @@ -88,6 +100,29 @@ describe("viewer _meta split (one canvas, not one iframe per call)", () => { await server.close(); }); + it("money tools are never widget-callable (the iframe is read-only for money)", async () => { + documents.clear(); + const { client, server } = await connect(engine); + const { tools } = (await client.listTools()) as { tools: ToolDesc[] }; + const by = (n: string) => tools.find((t) => t.name === n); + + // The asymmetric seam: the agent proposes, the human approves out-of-band, + // the agent places. The widget must not be able to call either money tool — + // no template, no openai/widgetAccessible. + for (const name of ["authorize_spend", "place_order"]) { + const m = by(name)?._meta; + expect(by(name), `${name} exists`).toBeDefined(); + expect(hasTemplate(m), `${name} must not mount`).toBe(false); + expect( + m?.["openai/widgetAccessible"], + `${name} must not be widget-callable`, + ).not.toBe(true); + } + + await client.close(); + await server.close(); + }); + it("data-tool results carry a document_version token for self-refresh", async () => { documents.clear(); const { client, server } = await connect(engine); diff --git a/packages/mcp/src/export/glb.ts b/packages/mcp/src/export/glb.ts index 3fa0fa846..d43693ff0 100644 --- a/packages/mcp/src/export/glb.ts +++ b/packages/mcp/src/export/glb.ts @@ -5,7 +5,7 @@ */ import type { EvaluatedScene, TriangleMesh } from "@vcad/engine"; -import type { Document } from "@vcad/ir"; +import type { Document, Vec3 } from "@vcad/ir"; /** * Build `":"` labels for every visible root, index-aligned @@ -31,6 +31,17 @@ export const DEFAULT_MATERIAL = { roughness: 0.5, }; +/** + * A glTF node transform for {@link GlbMesh}: translation in mm, rotation as a + * glTF quaternion `[x, y, z, w]` (see {@link eulerXyzDegToQuat}), per-axis + * scale. Geometry stays part-local; the viewer applies the node TRS. + */ +export interface GlbNodeTransform { + translation: [number, number, number]; + rotationQuat: [number, number, number, number]; + scale: [number, number, number]; +} + /** * One renderable mesh for {@link buildGlb}: geometry plus an explicit PBR * material. Positions/indices/normals accept either typed arrays (the scene @@ -54,6 +65,14 @@ export interface GlbMesh { clearcoat?: number; /** Clearcoat roughness 0..1. */ clearcoatRoughness?: number; + /** Node TRS applied to part-local geometry (assembly instances). Identity + * components are omitted from the emitted node, so an identity transform + * produces byte-identical output to no transform. */ + transform?: GlbNodeTransform; + /** Geometry-dedup key (e.g. a partDefId): inputs sharing a `meshKey` emit + * ONE glTF mesh referenced by multiple nodes. The first input carrying a + * key supplies the geometry and material for all of them. */ + meshKey?: string; } /** A PBR material resolved from a {@link GlbMesh}, deduped across meshes. */ @@ -74,6 +93,41 @@ const isEmissive = (e: [number, number, number]): boolean => const f32 = (a: Float32Array | number[]): Float32Array => a instanceof Float32Array ? a : new Float32Array(a); +/** + * Convert a `Transform3D` Euler rotation in degrees (`rotation: Vec3`, Euler + * XYZ deg) to the glTF node quaternion `[x, y, z, w]`. + * + * Composition AUTHORITY: the kernel applies Transform3D rotations as + * `R = Rz·Ry·Rx` on column vectors — rotate about world X first, then world + * Y, then world Z (extrinsic XYZ). See crates/vcad-eval/src/kinematics.rs + * `euler_to_matrix` ("// Rz * Ry * Rx"), the identical matrix in + * packages/engine/src/evaluate.ts `transformMesh`, and evaluate.rs's + * `rx.then(ry).then(rz)`. In three.js Euler-order terms this is "ZYX" + * (three's "XYZ" is the intrinsic Rx·Ry·Rz — the OPPOSITE order), so the + * quaternion below is q = qz ⊗ qy ⊗ qx. + */ +export function eulerXyzDegToQuat( + rotation: Vec3, +): [number, number, number, number] { + const rad = Math.PI / 180; + const hx = (rotation.x * rad) / 2; + const hy = (rotation.y * rad) / 2; + const hz = (rotation.z * rad) / 2; + const c1 = Math.cos(hx); + const s1 = Math.sin(hx); + const c2 = Math.cos(hy); + const s2 = Math.sin(hy); + const c3 = Math.cos(hz); + const s3 = Math.sin(hz); + // q = qz ⊗ qy ⊗ qx (X applied first) — the "ZYX" quaternion. + return [ + s1 * c2 * c3 - c1 * s2 * s3, + c1 * s2 * c3 + s1 * c2 * s3, + c1 * c2 * s3 - s1 * s2 * c3, + c1 * c2 * c3 + s1 * s2 * s3, + ]; +} + /** Build binary GLB bytes from an explicit list of meshes + PBR materials. * * Writes POSITION, NORMAL (when present), and u32 indices per mesh, and @@ -126,8 +180,20 @@ export function buildGlb(inputMeshes: GlbMesh[], name: string): Uint8Array { let bufferOffset = 0; - for (let meshIdx = 0; meshIdx < inputMeshes.length; meshIdx++) { - const input = inputMeshes[meshIdx]; + // Geometry dedup: inputs sharing a `meshKey` (assembly instances of one + // partDef) emit one glTF mesh, referenced by one node per input. + const meshKeyToIdx = new Map(); + + for (let inputIdx = 0; inputIdx < inputMeshes.length; inputIdx++) { + const input = inputMeshes[inputIdx]; + + const dedupIdx = + input.meshKey !== undefined ? meshKeyToIdx.get(input.meshKey) : undefined; + if (dedupIdx !== undefined) { + nodes.push(makeNode(input, dedupIdx)); + continue; + } + const positions = f32(input.positions); const normals = input.normals && input.normals.length === positions.length @@ -254,6 +320,7 @@ export function buildGlb(inputMeshes: GlbMesh[], name: string): Uint8Array { } // Mesh + const meshIdx = meshes.length; meshes.push({ name: `mesh_${meshIdx}`, primitives: [ @@ -264,12 +331,10 @@ export function buildGlb(inputMeshes: GlbMesh[], name: string): Uint8Array { }, ], }); + if (input.meshKey !== undefined) meshKeyToIdx.set(input.meshKey, meshIdx); // Node — named with part identity when the caller provides it. - nodes.push({ - mesh: meshIdx, - name: input.name, - }); + nodes.push(makeNode(input, meshIdx)); } // Build JSON. Materials may carry KHR extensions (clearcoat for glossy @@ -370,6 +435,24 @@ export function buildGlb(inputMeshes: GlbMesh[], name: string): Uint8Array { return glb; } +/** + * Build a glTF node for one input mesh: `{mesh, name}` plus TRS fields when a + * transform is present. Identity components are omitted (glTF defaults), so + * an identity transform emits byte-identical JSON to no transform at all. + */ +function makeNode(input: GlbMesh, meshIdx: number): GltfNode { + const node: GltfNode = { mesh: meshIdx, name: input.name }; + const t = input.transform; + if (!t) return node; + const [tx, ty, tz] = t.translation; + if (tx !== 0 || ty !== 0 || tz !== 0) node.translation = t.translation; + const [qx, qy, qz, qw] = t.rotationQuat; + if (qx !== 0 || qy !== 0 || qz !== 0 || qw !== 1) node.rotation = t.rotationQuat; + const [sx, sy, sz] = t.scale; + if (sx !== 1 || sy !== 1 || sz !== 1) node.scale = t.scale; + return node; +} + /** Convert an evaluated scene to binary GLB bytes. * * `partLabels` (index-aligned with `scene.parts`) become glTF node names — @@ -437,4 +520,10 @@ interface Mesh { interface GltfNode { mesh: number; name: string; + /** Node translation (mm), omitted at identity. */ + translation?: [number, number, number]; + /** Node rotation quaternion [x, y, z, w], omitted at identity. */ + rotation?: [number, number, number, number]; + /** Node per-axis scale, omitted at identity. */ + scale?: [number, number, number]; } diff --git a/packages/mcp/src/fabricate/adapters/kerf.ts b/packages/mcp/src/fabricate/adapters/kerf.ts new file mode 100644 index 000000000..52f38fe35 --- /dev/null +++ b/packages/mcp/src/fabricate/adapters/kerf.ts @@ -0,0 +1,145 @@ +/** + * SendCutSend-via-kerf adapter — the first live kerf-rail driver (Wave 0, + * quote-only). + * + * vcad calls kerf as a service: order.ts builds the ConfiguratorIntent (files + * pinned by sha256 from the bound fab artifact + vendor-native config) and + * threads it through QuoteRequest.kerfIntent; this adapter forwards it whole + * to kerf's quote job and maps the VendorQuote back. In LIVE mode the price + * that returns is the fab's OWN displayed price (pricing_basis "quoted"); + * scripted mode is a rehearsal against kerf's recorded fixture and is + * downgraded to "estimate" (never money-gating). The broker's margin/landed + * layers apply on top exactly as for any other adapter. + * + * Degradation is always null, never an error: no KERF_URL, no intent, no + * files, an unreachable rail, or a quote job that didn't price — all return + * null so the generic estimator covers and a quote fan-out never breaks. + * + * Ordering through kerf is Wave 1/2; the quote intent's budget_cap is pinned + * to 0 (kerf canary discipline — a quote job can never fund an order). + */ + +import { randomUUID } from "node:crypto"; +import { KerfClient, KerfUnreachableError } from "../kerf/client.js"; +import type { ConfiguratorIntent, FileRef, ShipTo } from "../kerf/contract.js"; +import type { AdapterQuote, ManufacturerAdapter, QuoteRequest } from "../types.js"; + +/** The kerf registry vendor id this adapter drives. */ +export const KERF_VENDOR = "sendcutsend"; + +/** Quote mode: "scripted" (kerf's recorded fixture flow — offline, + * deterministic, the default) or "live" (real cloud-browser run, opt-in via + * KERF_QUOTE_MODE=live). */ +export function kerfQuoteMode(): "scripted" | "live" { + return process.env.KERF_QUOTE_MODE === "live" ? "live" : "scripted"; +} + +/** + * Quote-time ship-to. SendCutSend's configurator prices before any address is + * entered, so at quote time ship_to affects NOTHING in the SCS flow — it + * exists because kerf's intent schema requires one. Override with KERF_SHIP_TO + * (JSON ShipTo) for rails where it matters; the default is a US placeholder. + */ +function quoteShipTo(): ShipTo { + const raw = process.env.KERF_SHIP_TO; + if (raw) { + try { + const parsed = JSON.parse(raw) as ShipTo; + if (parsed && typeof parsed.country === "string") return parsed; + } catch { + // fall through to the placeholder + } + } + return { + name: "vcad quote", + line1: "548 Market St", + city: "San Francisco", + region: "CA", + postal_code: "94104", + country: "US", + }; +} + +/** + * Build the sheet-metal ConfiguratorIntent for a SendCutSend quote. Called by + * quote_manufacturing (order.ts) so the SAME object is both hashed for + * kerf_intent_hash persistence and sent by this adapter — one identity, no + * drift. budget_cap is 0: this intent can quote, never buy. + */ +export function buildKerfSheetMetalIntent(p: { + files: FileRef[]; + config: Record; + quantity: number; +}): ConfiguratorIntent { + return { + kind: "configurator", + vendor: KERF_VENDOR, + process: "sheet_metal", + files: p.files, + config: p.config, + quantity: p.quantity, + idempotency_key: `vq_${randomUUID()}`, + ship_to: quoteShipTo(), + budget_cap: { currency: "USD", amount_minor: 0 }, + }; +} + +// Log the unreachable-rail degradation once per process, not once per quote. +let loggedUnreachable = false; + +export const kerfAdapter: ManufacturerAdapter = { + key: KERF_VENDOR, + label: "SendCutSend (via kerf)", + region: "US", + processes: ["sheet_metal"], + supportsDdp: true, + async quote(req: QuoteRequest): Promise { + const intent = req.kerfIntent?.intent; + const client = new KerfClient(); + // Can't serve without the rail, an intent, or files — the generic + // estimator covers (returning null is the adapter contract for "not us"). + if (!client.available || !intent || intent.files.length === 0) return null; + + const mode = kerfQuoteMode(); + let job; + try { + job = await client.quote(KERF_VENDOR, intent, { mode }); + } catch (err) { + if (err instanceof KerfUnreachableError) { + if (!loggedUnreachable) { + loggedUnreachable = true; + console.error("[kerf-adapter] rail unreachable — degrading to estimates:", err.message); + } + return null; + } + throw err; + } + + const quote = job.quote; + if (!quote) return null; + + // Scripted mode replays kerf's recorded fixture (a fixed price regardless + // of the posted geometry/config) — that's a REHEARSAL of the rail, not a + // vendor-displayed price, so its basis is downgraded to "estimate" (which + // never gates money). Only a live run may carry "quoted". + const scripted = mode !== "live"; + const pricingBasis = scripted ? "estimate" : quote.pricing_basis; + + return { + // The vendor's displayed total IS the fab cost; broker margin + landed + // cost stack on top like every adapter. + fab_cost_minor: quote.total.amount_minor, + lead_time_days: quote.lead_time_days, + in_spec: true, + pricing_basis: pricingBasis, + notes: [ + ...quote.notes, + `kerf job ${job.job_id}`, + `intent ${quote.intent_hash.slice(0, 16)}`, + ...(scripted + ? ["kerf scripted rehearsal — not a vendor-displayed price"] + : []), + ], + }; + }, +}; diff --git a/packages/mcp/src/fabricate/broker.ts b/packages/mcp/src/fabricate/broker.ts index 969a97c40..3eefb9716 100644 --- a/packages/mcp/src/fabricate/broker.ts +++ b/packages/mcp/src/fabricate/broker.ts @@ -16,6 +16,7 @@ import { jlcpcbAdapter } from "./adapters/jlcpcb.js"; import { digitalMetalAdapter } from "./adapters/digitalmetal.js"; import { genericEstimateAdapter } from "./adapters/generic.js"; +import { kerfAdapter } from "./adapters/kerf.js"; import { applyMargin, estimateLandedCost, marginOf } from "./pricing.js"; import type { FabOption, @@ -26,12 +27,16 @@ import type { /** Fabs with a real (eventual) contract — eligible to be orderable. The * generic estimator never is. Still requires a BINDING quote to flip - * orderable true, which Phase 0 never produces. */ -const CONTRACTED_FABS = new Set(["jlcpcb", "digitalmetal"]); + * orderable true, which no adapter produces yet: the kerf rail's + * "sendcutsend" quotes at basis "quoted" (the fab's own displayed price — + * it suppresses the generic estimator for sheet metal when it actually + * quoted, but stays non-orderable until kerf order execution, Wave 1/2). */ +const CONTRACTED_FABS = new Set(["jlcpcb", "digitalmetal", "sendcutsend"]); export const DEFAULT_ADAPTERS: readonly ManufacturerAdapter[] = [ jlcpcbAdapter, digitalMetalAdapter, + kerfAdapter, genericEstimateAdapter, ]; diff --git a/packages/mcp/src/fabricate/kerf/client.ts b/packages/mcp/src/fabricate/kerf/client.ts new file mode 100644 index 000000000..c9cd189fd --- /dev/null +++ b/packages/mcp/src/fabricate/kerf/client.ts @@ -0,0 +1,249 @@ +/** + * KerfClient — thin HTTP client for the kerf execution rail's job API. + * + * Configuration is env-only: KERF_URL (no default — absent means the rail is + * off and `available` is false) and optional KERF_API_TOKEN (sent as + * `Authorization: Bearer`). Every failure mode a caller can't act on — + * network error, timeout, non-2xx, malformed body — throws + * {@link KerfUnreachableError} so the adapter can degrade to null cleanly + * (the generic estimator then covers) instead of poisoning a quote fan-out. + */ + +import type { + ConfiguratorIntent, + EvidenceBundle, + JobState, + OracleClaim, + VendorQuote, +} from "./contract.js"; +import { KERF_JOB_STATES } from "./contract.js"; + +/** kerf could not be reached or answered unusably — degrade, don't fail. */ +export class KerfUnreachableError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "KerfUnreachableError"; + } +} + +/** Injectable fetch seam (mirrors store.ts's setFabricateFetch) for tests. */ +export let kerfFetch: typeof fetch = (...args) => fetch(...args); +export function setKerfFetch(fn: typeof fetch): void { + kerfFetch = fn; +} + +/** A kerf job snapshot as returned by POST /api/quote and GET /api/jobs/:id. */ +export interface KerfJob { + job_id: string; + state: JobState; + quote: VendorQuote | null; + live_url: string | null; + evidence: { items: number; claims: OracleClaim[] } | null; +} + +/** Scripted quote runs replay an in-memory recording — fast and offline. */ +const DEFAULT_QUOTE_TIMEOUT_MS = 45_000; +/** + * Live quote runs drive a REAL cloud browser through the vendor's + * configurator (kerf's route sets maxDuration 300: "minutes, not + * milliseconds"). Aborting at the scripted 45s would orphan a paid Browser + * Use session mid-run and discard a real vendor price nobody can retrieve, + * so live mode waits far longer by default. + */ +const DEFAULT_LIVE_QUOTE_TIMEOUT_MS = 120_000; +const DEFAULT_READ_TIMEOUT_MS = 15_000; // job/evidence reads + +// ── structural guards (hand-rolled — no new deps) ────────────────────────── + +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function isMoney(v: unknown): boolean { + return ( + isRecord(v) && + typeof v.currency === "string" && + typeof v.amount_minor === "number" + ); +} + +function isVendorQuote(v: unknown): v is VendorQuote { + if (!isRecord(v)) return false; + return ( + typeof v.quote_id === "string" && + typeof v.vendor === "string" && + typeof v.intent_hash === "string" && + (v.pricing_basis === "estimate" || + v.pricing_basis === "quoted" || + v.pricing_basis === "binding") && + isMoney(v.unit_price) && + isMoney(v.total) && + typeof v.lead_time_days === "number" && + Array.isArray(v.evidence) && + Array.isArray(v.notes) + ); +} + +function isOracleClaim(v: unknown): v is OracleClaim { + if (!isRecord(v)) return false; + return ( + typeof v.oracle === "string" && + (v.verdict === "pass" || v.verdict === "fail" || v.verdict === "unverifiable") && + Array.isArray(v.evidence) + ); +} + +function parseJob(body: unknown): KerfJob { + if (!isRecord(body)) throw new KerfUnreachableError("kerf: non-object response body"); + const { job_id, state, quote, live_url, evidence } = body; + if (typeof job_id !== "string" || !job_id) { + throw new KerfUnreachableError("kerf: response missing job_id"); + } + if (typeof state !== "string" || !(KERF_JOB_STATES as readonly string[]).includes(state)) { + throw new KerfUnreachableError(`kerf: response has unknown job state "${String(state)}"`); + } + if (quote != null && !isVendorQuote(quote)) { + throw new KerfUnreachableError("kerf: response quote is malformed"); + } + if (live_url != null && typeof live_url !== "string") { + throw new KerfUnreachableError("kerf: response live_url is malformed"); + } + let ev: KerfJob["evidence"] = null; + if (evidence != null) { + if ( + !isRecord(evidence) || + typeof evidence.items !== "number" || + !Array.isArray(evidence.claims) || + !evidence.claims.every(isOracleClaim) + ) { + throw new KerfUnreachableError("kerf: response evidence is malformed"); + } + ev = { items: evidence.items, claims: evidence.claims }; + } + return { + job_id, + state: state as JobState, + quote: (quote as VendorQuote | null) ?? null, + live_url: (live_url as string | null) ?? null, + evidence: ev, + }; +} + +function parseEvidenceBundle(body: unknown): EvidenceBundle { + if ( + !isRecord(body) || + typeof body.job_id !== "string" || + typeof body.created_at !== "string" || + !Array.isArray(body.items) || + !Array.isArray(body.claims) || + !body.claims.every(isOracleClaim) + ) { + throw new KerfUnreachableError("kerf: evidence bundle is malformed"); + } + return body as unknown as EvidenceBundle; +} + +// ── the client ────────────────────────────────────────────────────────────── + +export class KerfClient { + /** True when KERF_URL is configured — the rail's on/off switch. */ + readonly available: boolean; + private readonly baseUrl: string; + private readonly token: string | undefined; + + constructor() { + this.baseUrl = (process.env.KERF_URL ?? "").trim().replace(/\/+$/, ""); + this.available = this.baseUrl.length > 0; + this.token = process.env.KERF_API_TOKEN || undefined; + } + + /** + * Run a vendor quote job: POST /api/quote with `{vendor, intent, mode}`. + * "scripted" replays kerf's recorded fixture flow (offline, deterministic); + * "live" drives the vendor's real configurator in a cloud browser — its + * default timeout is minutes-scale (see DEFAULT_LIVE_QUOTE_TIMEOUT_MS). + * + * The intent is serialized VERBATIM — including each file's wire-only + * `bytes_base64` (kerf's posted-intent API requires the bytes inline, + * hash-checked against the FileRef sha256 at the door). + */ + async quote( + vendor: string, + intent: ConfiguratorIntent, + opts?: { mode?: "scripted" | "live"; timeoutMs?: number }, + ): Promise { + const mode = opts?.mode ?? "scripted"; + const body = JSON.stringify({ vendor, intent, mode }); + const json = await this.request( + "POST", + "/api/quote", + body, + opts?.timeoutMs ?? + (mode === "live" ? DEFAULT_LIVE_QUOTE_TIMEOUT_MS : DEFAULT_QUOTE_TIMEOUT_MS), + ); + return parseJob(json); + } + + /** Read a job snapshot: GET /api/jobs/:id. */ + async getJob(jobId: string, opts?: { timeoutMs?: number }): Promise { + const json = await this.request( + "GET", + `/api/jobs/${encodeURIComponent(jobId)}`, + undefined, + opts?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, + ); + return parseJob(json); + } + + /** Read a job's evidence bundle: GET /api/jobs/:id/evidence. */ + async getEvidence(jobId: string, opts?: { timeoutMs?: number }): Promise { + const json = await this.request( + "GET", + `/api/jobs/${encodeURIComponent(jobId)}/evidence`, + undefined, + opts?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, + ); + return parseEvidenceBundle(json); + } + + private async request( + method: "GET" | "POST", + path: string, + body: string | undefined, + timeoutMs: number, + ): Promise { + if (!this.available) { + throw new KerfUnreachableError("kerf: KERF_URL is not configured"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (this.token) headers.Authorization = `Bearer ${this.token}`; + const res = await kerfFetch(`${this.baseUrl}${path}`, { + method, + headers, + body, + signal: controller.signal, + }); + if (!res.ok) { + throw new KerfUnreachableError(`kerf: ${method} ${path} → HTTP ${res.status}`); + } + try { + return await res.json(); + } catch (err) { + throw new KerfUnreachableError(`kerf: ${method} ${path} → non-JSON body`, { + cause: err, + }); + } + } catch (err) { + if (err instanceof KerfUnreachableError) throw err; + throw new KerfUnreachableError( + `kerf: ${method} ${path} failed (${err instanceof Error ? err.message : String(err)})`, + { cause: err }, + ); + } finally { + clearTimeout(timer); + } + } +} diff --git a/packages/mcp/src/fabricate/kerf/contract.ts b/packages/mcp/src/fabricate/kerf/contract.ts new file mode 100644 index 000000000..ae2381c20 --- /dev/null +++ b/packages/mcp/src/fabricate/kerf/contract.ts @@ -0,0 +1,166 @@ +/** + * kerf contract types — the ACP-CM execution rail's wire vocabulary. + * + * Mirrored from github.com/ecto/kerf packages/core — keep in sync; drift is + * checked by kerf-contract.test. vcad integrates kerf as a service (quotes, + * jobs, evidence over HTTP) and never vendors its engine; these types are the + * whole surface area we depend on. Money is integer MINOR units, never floats. + */ + +/** Integer minor-unit money (USD cents). Mirrors kerf core/intent.ts. */ +export interface Money { + currency: string; + amount_minor: number; +} + +/** A content-hash-pinned file reference. kerf's `kerf/upload-hash` oracle + * verifies the exact bytes named here were uploaded. */ +export interface FileRef { + name: string; + bytes: number; + sha256: string; + media_type?: string; + /** + * WIRE-ONLY (vcad→kerf POST /api/quote): posted intents must inline the + * file bytes as base64 — kerf's validatePostedIntent requires it, hash- + * checks sha256(bytes) === `sha256` at the door, and STRIPS the field from + * the FileRef it keeps. Not part of kerf core's FileRef, and NEVER part of + * `intentHash` (which hashes file sha256s only — see intent-hash.ts). + */ + bytes_base64?: string; +} + +/** Shipping address (vendor-native field granularity). */ +export interface ShipTo { + name: string; + line1: string; + line2?: string; + city: string; + region: string; + postal_code: string; + country: string; +} + +/** Common intent fields. `idempotency_key` is unique per order attempt and + * never reused after PLACING; `budget_cap` is the hard mandate ceiling. */ +export interface IntentBase { + idempotency_key: string; + /** Registry vendor id, e.g. "sendcutsend". */ + vendor: string; + ship_to: ShipTo; + budget_cap: Money; + /** ISO-8601, advisory. */ + deadline?: string; +} + +/** An intent against a vendor's web configurator (upload + options + qty). + * `config` uses VENDOR-NATIVE labels per the vendor manifest's config_schema. */ +export interface ConfiguratorIntent extends IntentBase { + kind: "configurator"; + files: FileRef[]; + /** e.g. "sheet_metal". */ + process: string; + config: Record; + quantity: number; +} + +/** How firm a price is. kerf's browser rail always emits "quoted" (the fab's + * own displayed price); "binding" is fab-committed; "estimate" never gates + * money. */ +export type PricingBasis = "estimate" | "quoted" | "binding"; + +/** A vendor quote bound to the `intent_hash` of the producing intent — + * geometry (or config/quantity) edit ⇒ new hash ⇒ this quote is dead. */ +export interface VendorQuote { + quote_id: string; + vendor: string; + /** sha256 of canonical JSON of the producing OrderIntent (see intent-hash). */ + intent_hash: string; + pricing_basis: PricingBasis; + unit_price: Money; + total: Money; + shipping?: Money; + /** Currently 0 from the browser rail; raw lead text lives in notes. */ + lead_time_days: number; + /** Missing ⇒ kerf treats as 24h. */ + expires_at?: string; + /** Evidence item ids. */ + evidence: string[]; + notes: string[]; +} + +/** All 17 kerf job states (core/job.ts). PLACING is entered at most once per + * job, ever; CONFIRMED requires two independent oracles. */ +export const KERF_JOB_STATES = [ + "QUEUED", + "SESSION_OPEN", + "STAGING", + "TAKEOVER_WAIT", + "STAGED", + "AUDIT", + "AUDIT_FAILED", + "PLACING", + "CONFIRMING", + "CONFIRMED", + "RECONCILING", + "RECONCILED_PLACED", + "RECONCILED_ABSENT", + "TRACKING", + "DELIVERED", + "FAILED", + "CANCELED", +] as const; + +/** kerf job lifecycle state. */ +export type JobState = (typeof KERF_JOB_STATES)[number]; + +/** The oracles kerf's evidence layer can attest with. */ +export type OracleId = + | "kerf/upload-hash" + | "kerf/quote-extraction" + | "kerf/intent-audit" + | "kerf/confirmation-page" + | "kerf/confirmation-email" + | "kerf/card-settlement" + | "kerf/tracking" + | "kerf/canary"; + +/** Fail-closed verdict vocabulary (shared with vcad-receipt): unverifiable is + * NEVER a pass. */ +export type Verdict = "pass" | "fail" | "unverifiable"; + +/** One captured evidence artifact (hash-pinned, PII-redacted). */ +export interface EvidenceItem { + id: string; + kind: + | "screenshot" + | "dom_snapshot" + | "email" + | "settlement" + | "upload_hash" + | "tracking_event" + | "trace"; + sha256: string; + bytes: number; + captured_at: string; + step_ref?: string; + redactions?: Array<"pan" | "cvc" | "address">; +} + +/** One oracle's attestation over evidence items. */ +export interface OracleClaim { + oracle: OracleId; + verdict: Verdict; + observed?: string; + reason?: string; + /** Evidence item ids backing the claim. */ + evidence: string[]; +} + +/** The per-job bundle handed back to the design surface's receipt. */ +export interface EvidenceBundle { + job_id: string; + created_at: string; + items: EvidenceItem[]; + claims: OracleClaim[]; +} diff --git a/packages/mcp/src/fabricate/kerf/intent-hash.ts b/packages/mcp/src/fabricate/kerf/intent-hash.ts new file mode 100644 index 000000000..a4b34f21c --- /dev/null +++ b/packages/mcp/src/fabricate/kerf/intent-hash.ts @@ -0,0 +1,54 @@ +/** + * kerf `intentHash` — reproduced EXACTLY from kerf packages/engine/src/hash.ts + * (keep byte-identical; drift is checked by kerf-contract.test). + * + * The hash is the identity a VendorQuote and a spend mandate bind to: the same + * design + config + quantity at the same vendor always collides to the same + * hash, and any change to what would actually be manufactured produces a new + * one (quote is dead ⇒ re-quote — never silent re-pricing). + */ + +import { createHash } from "node:crypto"; +import type { ConfiguratorIntent } from "./contract.js"; + +/** Recursively sort object keys (default String sort); arrays keep order. */ +function sortKeys(v: unknown): unknown { + if (Array.isArray(v)) return v.map(sortKeys); + if (v !== null && typeof v === "object") { + const rec = v as Record; + const out: Record = {}; + for (const k of Object.keys(rec).sort()) out[k] = sortKeys(rec[k]); + return out; + } + return v; +} + +/** Canonical JSON: recursively key-sorted objects, arrays in order. */ +export function canonicalJson(v: unknown): string { + return JSON.stringify(sortKeys(v)); +} + +/** Lowercase-hex SHA-256 over the UTF-8 canonical JSON of `v`. */ +export function sha256Hex(v: unknown): string { + return createHash("sha256").update(canonicalJson(v), "utf8").digest("hex"); +} + +/** + * Hash a ConfiguratorIntent the way kerf does. + * + * Deliberately EXCLUDES `idempotency_key`, `ship_to`, `budget_cap`, + * `deadline`, `kind`, and file names/sizes — a re-quote of the same design + * collides intentionally; only what would actually be manufactured (vendor, + * process, exact file bytes via sha256, vendor-native config, quantity) + * participates. After canonicalization the hashed JSON has top-level key + * order `config, files, process, quantity, vendor`. + */ +export function intentHash(intent: ConfiguratorIntent): string { + return sha256Hex({ + vendor: intent.vendor, + process: intent.process ?? null, + files: (intent.files ?? []).map((f) => f.sha256), + config: intent.config ?? {}, + quantity: intent.quantity ?? null, + }); +} diff --git a/packages/mcp/src/fabricate/state-chip.ts b/packages/mcp/src/fabricate/state-chip.ts new file mode 100644 index 000000000..90748a41a --- /dev/null +++ b/packages/mcp/src/fabricate/state-chip.ts @@ -0,0 +1,96 @@ +/** + * Order-dock state chips — the fused vcad OrderState × kerf JobState → chip + * mapping from docs/agent-native-factory.md (M2). Both maps are exhaustive + * switches with never-checks so a new state on either side breaks the build + * here instead of reaching the widget unmapped. + */ + +import type { JobState } from "./kerf/contract.js"; +import type { AuthorizationStatus, OrderState } from "./types.js"; + +/** The six-stop dock timeline (+ failed). */ +export type OrderChip = + | "quoted" + | "approval" + | "placing" + | "confirmed" + | "production" + | "delivered" + | "failed"; + +/** + * Chip for a vcad order state. A QUOTED order with a pending_human spend + * authorization renders as "approval" — the human's next move, not the + * order's last one, is what the dock foregrounds. + */ +export function orderStateChip( + state: OrderState, + authzStatus?: AuthorizationStatus, +): OrderChip { + switch (state) { + case "DRAFT": + return "quoted"; + case "QUOTED": + return authzStatus === "pending_human" ? "approval" : "quoted"; + case "AUTHORIZED": + case "PENDING_PAYMENT": + return "approval"; + case "PAID": + case "SUBMITTED": + case "RECONCILING": + return "placing"; + case "IN_PRODUCTION": + case "SHIPPED": + return "production"; + case "DELIVERED": + return "delivered"; + case "EXPIRED": + case "PAYMENT_FAILED": + case "SUBMIT_FAILED": + case "CANCELED": + case "CANCELED_BY_FAB": + case "REFUNDED": + return "failed"; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +} + +/** + * Chip for a kerf job state (all 17). RECONCILING renders as "placing" — the + * dock explains it as a wait ("click outcome ambiguous, checking vendor + * history"), never a retry affordance; raw states stay in the event log. + */ +export function kerfJobChip(state: JobState): OrderChip { + switch (state) { + case "QUEUED": + case "SESSION_OPEN": + case "STAGING": + case "STAGED": + case "AUDIT": + case "PLACING": + case "CONFIRMING": + case "RECONCILING": + case "RECONCILED_PLACED": + return "placing"; + case "TAKEOVER_WAIT": + return "approval"; + case "CONFIRMED": + return "confirmed"; + case "TRACKING": + return "production"; + case "DELIVERED": + return "delivered"; + case "AUDIT_FAILED": + case "FAILED": + case "RECONCILED_ABSENT": + case "CANCELED": + return "failed"; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +} diff --git a/packages/mcp/src/fabricate/store.ts b/packages/mcp/src/fabricate/store.ts index 46ccd7b12..62b33dad3 100644 --- a/packages/mcp/src/fabricate/store.ts +++ b/packages/mcp/src/fabricate/store.ts @@ -10,8 +10,10 @@ import type { AuthUser } from "../oauth.js"; import type { + AuthorizationStatus, DebitResult, Order, + OrderReceiptStatus, OrderState, Quote, QuoteEconomics, @@ -32,6 +34,14 @@ export interface DebitParams { idempotencyKey: string; } +/** Enrichment columns a state transition may carry alongside state+events + * (place_order records its receipt verdict; authorize_spend links the + * proposed authorization). Optional so plain transitions stay one-argument. */ +export interface OrderStatePatch { + receipt_status?: OrderReceiptStatus; + authorization_id?: string | null; +} + export interface FabricateStore { saveQuote(quote: Quote, econ: QuoteEconomics, userId: string): Promise; /** Read a persisted quote, ownership-scoped (BOM lines link quotes by id). */ @@ -44,10 +54,25 @@ export interface FabricateStore { createAuthorization(authz: SpendAuthorization, userId: string): Promise; /** Read an authorization, ownership-scoped. */ getAuthorization(id: string, userId: string): Promise; + /** Flip an authorization's status (e.g. elicitation decline → revoked). + * Never a substitute for the human approval flow — approval stays + * web-app/out-of-band; this records lifecycle outcomes. */ + setAuthorizationStatus(id: string, userId: string, status: AuthorizationStatus): Promise; /** Atomic, balance-floored, idempotent debit. The ONLY way credits leave. */ debit(p: DebitParams): Promise; - /** Transition an order's state and append a lifecycle event. */ - setOrderState(orderId: string, userId: string, state: OrderState, note: string): Promise; + /** Transition an order's state, append a lifecycle event, and optionally + * patch enrichment fields (receipt_status / authorization_id). */ + setOrderState( + orderId: string, + userId: string, + state: OrderState, + note: string, + patch?: OrderStatePatch, + ): Promise; + /** Read the caller's prepaid wallet balance (minor units), or null when it + * can't be read (no wallet row, store outage) — callers must treat null as + * "unknown", never as zero. */ + getWalletBalance(userId: string): Promise; } // ── In-memory (module-global so it survives across calls in one process) ── @@ -95,6 +120,15 @@ export class InMemoryFabricateStore implements FabricateStore { async getAuthorization(id: string, userId: string): Promise { return memAuthz.get(memKey(userId, id)) ?? null; } + async setAuthorizationStatus( + id: string, + userId: string, + status: AuthorizationStatus, + ): Promise { + const a = memAuthz.get(memKey(userId, id)); + if (!a) return; + memAuthz.set(memKey(userId, id), { ...a, status }); + } /** * Local mirror of the debit_wallet RPC contract — enough to exercise the @@ -149,14 +183,26 @@ export class InMemoryFabricateStore implements FabricateStore { return result; } - async setOrderState(orderId: string, userId: string, state: OrderState, note: string): Promise { + async setOrderState( + orderId: string, + userId: string, + state: OrderState, + note: string, + patch?: OrderStatePatch, + ): Promise { const rec = memOrders.get(memKey(userId, orderId)); if (!rec) return; rec.order.state = state; rec.order.events.push({ state, at: new Date().toISOString(), note }); + if (patch?.receipt_status !== undefined) rec.order.receipt_status = patch.receipt_status; + if (patch?.authorization_id !== undefined) rec.order.authorization_id = patch.authorization_id; rec.order.updated_at = new Date().toISOString(); } + async getWalletBalance(userId: string): Promise { + return memWallets.get(userId) ?? null; + } + // ── test-only seams (NOT on the FabricateStore interface) ── /** Seed wallet credit for tests (production top-ups go via credit_wallet). */ creditWalletForTest(userId: string, minor: number): void { @@ -217,8 +263,11 @@ export class SupabaseFabricateStore implements FabricateStore { total_amount_minor: quote.total_amount_minor, currency: quote.currency, expires_at: quote.expires_at, + // Migration-034 columns — stripped on retry if the DB predates them. + kerf_intent_hash: quote.kerf_intent_hash ?? null, + kerf_job_id: quote.kerf_job_id ?? null, }; - await this.insert("quotes", row); + await this.insertTolerant("quotes", row, ["kerf_intent_hash", "kerf_job_id"]); } async getQuote(quoteId: string, userId: string): Promise { @@ -239,11 +288,6 @@ export class SupabaseFabricateStore implements FabricateStore { } async saveOrder(order: Order, fabCostMinor: number, userId: string): Promise { - // `fab_artifact` is intentionally NOT written here — there is no orders - // column for it yet, and including an unknown key would fail the whole - // PostgREST insert. The durable cloud record of the fab bundle is the - // `order_placed` session-event (place_order); the handle is also re-suppliable - // at place_order time. A dedicated orders.fab_artifact column is the follow-up. const row = { id: order.order_id, user_id: userId, @@ -257,8 +301,20 @@ export class SupabaseFabricateStore implements FabricateStore { currency: order.currency, ship_to: order.ship_to, events: order.events, + authorization_id: order.authorization_id, // column since migration 027 + // Migration-034 columns — stripped on retry if the DB predates them, so + // a pre-migration deploy degrades to the 024/027 row instead of losing + // the whole write. fab_artifact is the handle only, never bytes. + fab_artifact: order.fab_artifact ?? null, + receipt_status: order.receipt_status, + kerf_intent_hash: order.kerf_intent_hash, }; - await this.insert("orders", row, "id"); + await this.insertTolerant( + "orders", + row, + ["fab_artifact", "receipt_status", "kerf_intent_hash"], + "id", + ); } async getOrder(orderId: string, userId: string): Promise { @@ -352,14 +408,38 @@ export class SupabaseFabricateStore implements FabricateStore { } } - async setOrderState(orderId: string, userId: string, state: OrderState, note: string): Promise { + async setOrderState( + orderId: string, + userId: string, + state: OrderState, + note: string, + patch?: OrderStatePatch, + ): Promise { const order = await this.getOrder(orderId, userId); - const events = [ - ...(order?.events ?? []), - { state, at: new Date().toISOString(), note }, - ]; - try { - const res = await fabricateFetch( + const base: Record = { state }; + if (order) { + base.events = [...order.events, { state, at: new Date().toISOString(), note }]; + } else { + // The pre-read failed (transient network / non-2xx). Appending would + // replace the whole events jsonb with a singleton — wiping the money + // audit trail — so PATCH state (+patch fields) WITHOUT the events key + // and keep whatever history the row already holds. + console.error( + `[fabricate-store] setOrderState ${orderId}: pre-read failed — patching without events to preserve history (dropped event: ${state} "${note}")`, + ); + } + if (patch?.authorization_id !== undefined) { + base.authorization_id = patch.authorization_id; // column since 027 + } + // receipt_status is a migration-034 column — patched tolerantly: if the + // full PATCH is rejected specifically for column skew on a pre-migration + // DB, retry without it so the state transition itself never fails there. + const full = + patch?.receipt_status !== undefined + ? { ...base, receipt_status: patch.receipt_status } + : base; + const patchOnce = async (body: Record) => + fabricateFetch( this.url( "orders", `?id=eq.${encodeURIComponent(orderId)}&user_id=eq.${encodeURIComponent(userId)}`, @@ -367,9 +447,23 @@ export class SupabaseFabricateStore implements FabricateStore { { method: "PATCH", headers: this.headers({ Prefer: "return=minimal" }), - body: JSON.stringify({ state, events }), + body: JSON.stringify(body), }, ); + try { + let res = await patchOnce(full); + if (!res.ok && full !== base) { + // Only strip the 034 column when the failure IS column skew — any + // other failure (transient 5xx, timeout, rate limit) must not + // silently drop the money-audit field from a then-successful retry. + const bodyText = await res.text().catch(() => ""); + if (isColumnSkew(res.status, bodyText)) { + res = await patchOnce(base); + } else { + console.error("[fabricate-store] setOrderState failed:", res.status, bodyText); + return; + } + } if (!res.ok) { console.error( "[fabricate-store] setOrderState failed:", @@ -382,11 +476,119 @@ export class SupabaseFabricateStore implements FabricateStore { } } + async setAuthorizationStatus( + id: string, + userId: string, + status: AuthorizationStatus, + ): Promise { + const now = new Date().toISOString(); + const body: Record = { status }; + if (status === "revoked") body.revoked_at = now; + if (status === "consumed") body.consumed_at = now; + try { + const res = await fabricateFetch( + this.url( + "spend_authorizations", + `?id=eq.${encodeURIComponent(id)}&user_id=eq.${encodeURIComponent(userId)}`, + ), + { + method: "PATCH", + headers: this.headers({ Prefer: "return=minimal" }), + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + console.error( + "[fabricate-store] setAuthorizationStatus failed:", + res.status, + await res.text().catch(() => ""), + ); + } + } catch (err) { + console.error("[fabricate-store] setAuthorizationStatus failed:", err); + } + } + + async getWalletBalance(userId: string): Promise { + try { + const res = await fabricateFetch( + this.url( + "wallets", + `?user_id=eq.${encodeURIComponent(userId)}&select=credit_balance_minor&limit=1`, + ), + { method: "GET", headers: this.headers({ Accept: "application/vnd.pgrst.object+json" }) }, + ); + if (!res.ok) return null; + const row = (await res.json()) as Record; + const minor = Number(row?.credit_balance_minor); + return Number.isFinite(minor) ? minor : null; + } catch (err) { + console.error("[fabricate-store] getWalletBalance failed:", err); + return null; + } + } + private async insert( table: string, row: Record, onConflict?: string, ): Promise { + const res = await this.postRow(table, row, onConflict); + if (!res.ok) { + console.error(`[fabricate-store] insert ${table} failed:`, res.status, res.text); + } + } + + /** + * Insert that tolerates column skew: if the full row is rejected BECAUSE the + * migration-034 columns aren't deployed yet (PostgREST fails the WHOLE + * insert on one unknown key), retry once WITHOUT `newerKeys` so a + * pre-migration database degrades to the older row shape instead of losing + * the write entirely. The stripped retry ONLY fires when the first failure + * is actually column skew (see {@link isColumnSkew}) — any other failure + * (transient 5xx, timeout, rate limit) keeps the original error path so a + * flaky-then-successful retry can never silently drop money-audit fields. + * Best-effort like every store write — never throws. + */ + private async insertTolerant( + table: string, + row: Record, + newerKeys: readonly string[], + onConflict?: string, + ): Promise { + const first = await this.postRow(table, row, onConflict); + if (first.ok) return; + if (!isColumnSkew(first.status, first.text)) { + console.error(`[fabricate-store] insert ${table} failed:`, first.status, first.text); + return; + } + const stripped: Record = { ...row }; + for (const k of newerKeys) delete stripped[k]; + const second = await this.postRow(table, stripped, onConflict); + if (second.ok) { + console.error( + `[fabricate-store] insert ${table}: wrote without [${newerKeys.join(", ")}] ` + + `(pre-migration schema? first attempt: ${first.status} ${first.text})`, + ); + return; + } + console.error( + `[fabricate-store] insert ${table} failed:`, + first.status, + first.text, + "| retry without newer keys:", + second.status, + second.text, + ); + } + + /** POST one row; reports the outcome instead of logging so tolerant callers + * can retry before deciding what to say. Never throws. */ + private async postRow( + table: string, + row: Record, + onConflict?: string, + ): Promise<{ ok: boolean; status: number; text: string }> { try { const q = onConflict ? `?on_conflict=${onConflict}` : ""; const res = await fabricateFetch(this.url(table, q), { @@ -398,19 +600,34 @@ export class SupabaseFabricateStore implements FabricateStore { }), body: JSON.stringify([row]), }); - if (!res.ok) { - console.error( - `[fabricate-store] insert ${table} failed:`, - res.status, - await res.text().catch(() => ""), - ); - } + if (res.ok) return { ok: true, status: res.status, text: "" }; + return { + ok: false, + status: res.status, + text: await res.text().catch(() => ""), + }; } catch (err) { - console.error(`[fabricate-store] insert ${table} failed:`, err); + return { ok: false, status: 0, text: err instanceof Error ? err.message : String(err) }; } } } +/** + * True when a PostgREST failure is an unknown-column rejection (schema skew: + * the DB predates a migration that added the column). PostgREST reports this + * as HTTP 400 with code PGRST204 ("Could not find the 'x' column of 'y' in + * the schema cache") or a raw Postgres `column ... does not exist`. ONLY this + * shape may trigger the stripped retry in the tolerant writers — anything + * else keeps the original error path. + */ +function isColumnSkew(status: number, bodyText: string): boolean { + return ( + status === 400 && + (bodyText.includes("PGRST204") || + /column .* does not exist|Could not find/i.test(bodyText)) + ); +} + /** Map a PostgREST quotes row to a Quote (inverse of saveQuote's row shape). * Server-only economics columns (fab_cost_minor, margin_minor) are never * copied onto the returned Quote. */ @@ -435,6 +652,12 @@ function rowToQuote(row: unknown): Quote { margin_hidden: true, expires_at: String(r.expires_at ?? ""), created_at: String(r.created_at ?? ""), + kerf_intent_hash: (r.kerf_intent_hash as string) ?? null, + kerf_job_id: (r.kerf_job_id as string) ?? null, + // Not a column — the recommended option leads the sorted fab_options. + pricing_basis_best: Array.isArray(r.fab_options) + ? (r.fab_options as Quote["fab_options"])[0]?.pricing_basis + : undefined, }; } @@ -452,11 +675,19 @@ function rowToOrder(row: unknown): Order { currency: String(r.currency ?? "USD"), ship_to: r.ship_to ?? null, events: Array.isArray(r.events) ? (r.events as Order["events"]) : [], + fab_artifact: (r.fab_artifact as Order["fab_artifact"]) ?? null, + authorization_id: (r.authorization_id as string) ?? null, + receipt_status: isReceiptStatus(r.receipt_status) ? r.receipt_status : null, + kerf_intent_hash: (r.kerf_intent_hash as string) ?? null, created_at: String(r.created_at ?? ""), updated_at: String(r.updated_at ?? ""), }; } +function isReceiptStatus(v: unknown): v is OrderReceiptStatus { + return v === "holds" || v === "stale" || v === "violated" || v === "unverified"; +} + /** Map a PostgREST spend_authorizations row to a SpendAuthorization. */ function rowToAuthorization(row: unknown): SpendAuthorization { const r = (row ?? {}) as Record; diff --git a/packages/mcp/src/fabricate/types.ts b/packages/mcp/src/fabricate/types.ts index c039a9d9c..b9128f285 100644 --- a/packages/mcp/src/fabricate/types.ts +++ b/packages/mcp/src/fabricate/types.ts @@ -9,6 +9,8 @@ * sees only the margin-inclusive total. */ +import type { ConfiguratorIntent } from "./kerf/contract.js"; + /** Manufacturing processes vcad can quote. */ export type Process = "pcb" | "cnc" | "3dprint" | "sheet_metal" | "cast_metal"; @@ -39,8 +41,13 @@ export type OrderState = | "CANCELED_BY_FAB" | "REFUNDED"; -/** Whether a price is a vcad local estimate or a binding fab quote. */ -export type PricingBasis = "estimate" | "binding"; +/** + * How firm a price is (aligned with kerf's ACP-CM vocabulary): + * "estimate" = vcad's own cost model, never gates money; "quoted" = the fab's + * own displayed price via the kerf rail (may gate money where the cart + * preserves price); "binding" = fab-committed. + */ +export type PricingBasis = "estimate" | "quoted" | "binding"; /** Normalized request a broker hands to each adapter. */ export interface QuoteRequest { @@ -70,6 +77,14 @@ export interface QuoteRequest { baseCostModel?: "kernel" | "sheet_metal_laser"; /** Resolved kernel-cost catalog material name (e.g. "Aluminum 6061"). */ materialCatalog?: string; + /** + * kerf rail (sheet metal, Wave 0): the fully-built ConfiguratorIntent for a + * vendor quote. Constructed by quote_manufacturing — order.ts owns intent + * construction so the persisted kerf_intent_hash is computed over the exact + * object the adapter sends. The kerf adapter forwards it whole; absent ⇒ + * the adapter returns null and the generic estimator covers. + */ + kerfIntent?: { intent: ConfiguratorIntent }; } /** Lean geometry summary used by the cost models. */ @@ -158,6 +173,14 @@ export interface Quote { margin_hidden: true; expires_at: string; created_at: string; + /** kerf intent hash the recommended vendor quote is bound to (kerf rail). + * Geometry/config/quantity edit ⇒ new hash ⇒ the vendor quote is dead. */ + kerf_intent_hash?: string | null; + /** kerf quote-job id — the handle for job-state and evidence lookups. */ + kerf_job_id?: string | null; + /** Pricing basis of the recommended option (agents read this to know + * whether the price is an estimate, a fab-displayed quote, or binding). */ + pricing_basis_best?: PricingBasis; } /** Server-only economics persisted alongside a quote, never returned. */ @@ -216,6 +239,16 @@ export interface FabArtifactRef { manifest: Array<{ file: string; bytes: number; sha256: string }>; } +/** + * Design-receipt verdict recorded on an order by place_order's receipt gate + * (M4). "holds" = every clearance claim re-verified at place time; "stale" / + * "violated" never reach a PLACED order (the gate refuses; the refusal also + * persists "violated" on the still-QUOTED order so the block survives + * session non-residency — only a re-quote resets it); "unverified" = + * the document carried no claims (or wasn't resident) — flagged, not blocked. + */ +export type OrderReceiptStatus = "holds" | "stale" | "violated" | "unverified"; + /** The persisted order lifecycle row. */ export interface Order { order_id: string; @@ -231,6 +264,13 @@ export interface Order { /** Fab files bound to this order, kept out of context (set when an artifact * handle is passed to quote_manufacturing / place_order). */ fab_artifact?: FabArtifactRef | null; + /** Spend authorization proposed/consumed for this order (money plane). */ + authorization_id: string | null; + /** Receipt-gate verdict recorded at place time (see OrderReceiptStatus). */ + receipt_status: OrderReceiptStatus | null; + /** kerf intent hash the order's quote was bound to — the geometry-edit + * tripwire place_order names when it refuses on a doc_hash mismatch. */ + kerf_intent_hash: string | null; created_at: string; updated_at: string; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 9a039b251..23757f1c6 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -129,6 +129,8 @@ import { toolDefs as recordToolDefs } from "./tools/record.js"; import { toolDefs as changelogToolDefs } from "./tools/changelog.js"; import { toolDefs as ecadToolDefs } from "./tools/ecad.js"; import { toolDefs as enclosureToolDefs } from "./tools/enclosure.js"; +import { toolDefs as simReplayToolDefs } from "./tools/sim-replay.js"; +import { toolDefs as orderFeedToolDefs } from "./tools/order-feed.js"; // Re-exported so the Vercel transport entry can drain in-flight PostHog // captures before a serverless instance freezes (see services/mcp/entry.ts). @@ -301,6 +303,8 @@ const STATIC_TOOL_DEFS: readonly ToolDef[] = [ ...changelogToolDefs, ...ecadToolDefs, ...enclosureToolDefs, + ...simReplayToolDefs, + ...orderFeedToolDefs, ]; /** @@ -329,6 +333,7 @@ const LIST_TOOL_ORDER: readonly string[] = [ "list_orders", "authorize_spend", "place_order", + "get_order_feed", // ── Project BOM ──────────────────────────────────────────── "bom_create", "bom_add_line", @@ -344,6 +349,8 @@ const LIST_TOOL_ORDER: readonly string[] = [ // ── MCP Apps: app-only preview fetch + version poll ──────── "get_preview_glb", "get_preview_version", + "get_sim_replay", + "get_sim_version", // ── Atomic multi-op editing ──────────────────────────────── "apply_edits", // ── Loon DSL one-shot + core see/measure/export ──────────── @@ -641,6 +648,16 @@ export async function createServer( const shareStore = createShareStore(); // Everything a tool handler may need, threaded per connection. + // + // `elicit` is the URL-mode elicitation bridge (M3). Its closures reference + // the `server` const declared LATER in this function — the same late-closure + // pattern as setToolPacksDef: handlers only run after connect, so the + // binding is live by the time either function is invoked. No env flag — + // capability detection only: `urlSupported` re-reads the client's declared + // `elicitation.url` capability at call time (capabilities land only after + // initialize). `requestUrl` never throws: the SDK's capability assertion + // (and any transport failure) degrades to `{action:"cancel"}`, which callers + // treat as a dismissed prompt. const ctx: ToolContext = { engine, user: context.user, @@ -648,6 +665,27 @@ export async function createServer( eventStore, fabricateStore, shareStore, + elicit: { + urlSupported: () => { + const caps = server.getClientCapabilities() as + | { elicitation?: { url?: object } } + | undefined; + return Boolean(caps?.elicitation?.url); + }, + requestUrl: async (p) => { + try { + const res = await server.elicitInput({ + mode: "url", + message: p.message, + url: p.url, + elicitationId: p.elicitationId, + }); + return { action: res.action }; + } catch { + return { action: "cancel" as const }; + } + }, + }, }; // Wire the kernel WASM's chat helpers into the shared commandRegistry so diff --git a/packages/mcp/src/tools/gym.ts b/packages/mcp/src/tools/gym.ts index 25e3aa93d..4ead920ed 100644 --- a/packages/mcp/src/tools/gym.ts +++ b/packages/mcp/src/tools/gym.ts @@ -16,6 +16,7 @@ import { type PhysicsStepResult, type PhysicsActionType, } from "@vcad/engine"; +import { registerSession } from "./session.js"; import { behavior, type ToolDef } from "./tool-def.js"; /** Observation from the robot environment (re-export for API compatibility) */ @@ -26,8 +27,14 @@ export type StepResult = PhysicsStepResult; /** MCP tool result for the gym tools. Error paths set `isError: true` so hosts * (and the central next_actions enrichment) treat them as failures rather than - * reading a `{"error": ...}` body as a successful result. */ -type GymResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean }; + * reading a `{"error": ...}` body as a successful result. `structuredContent` + * carries the env/document handles for hosts (ChatGPT shim) that deliver ONLY + * structuredContent to the widget. */ +type GymResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; + structuredContent?: Record; +}; /** In-memory storage for active simulations */ const simulations = new Map(); @@ -39,6 +46,49 @@ export function getSimulation(envId: string): PhysicsEnv | null { return simulations.get(envId) ?? null; } +/** Max trajectory entries retained per env — matches record_simulation's + * MAX_STEPS so a replay never exceeds what the GIF path would render. */ +const MAX_TRAJECTORY = 600; + +/** Replay record for a single env: the source assembly plus the rolling + * joint-trajectory ring buffer the inline viewer's playback UI reads via + * get_sim_replay / get_sim_version. */ +export interface EnvRecord { + /** The assembly the env was created from — replay FK re-poses a clone. */ + document: Document; + /** Session id the assembly was registered under, so the viewer can fetch + * its geometry via get_preview_glb. */ + documentId: string; + /** Per-step joint positions (degrees/mm), oldest first, capped at 600. */ + trajectory: number[][]; + /** Per-step rewards, index-aligned with `trajectory`. */ + rewards: number[]; + /** Per-step done flags, index-aligned with `trajectory`. */ + dones: boolean[]; + /** Total steps since the last reset. Keeps increasing after the ring buffer + * starts dropping old entries, so it doubles as a cheap change token. */ + stepCount: number; + /** Monotonically increasing reset counter (gym_reset). Folded into the + * replay version token so an equal-length rollout AFTER a reset still + * changes the token — stepCount alone rewinds to 0 on reset and would + * collide with the previous episode at the same length. */ + resetEpoch: number; + /** Simulation timestep in seconds. */ + dt: number; + /** Physics substeps per step. */ + substeps: number; +} + +/** Replay records keyed by env_id, populated by create_robot_env. */ +const envRecords = new Map(); + +/** Look up an env's replay record. Returns null if not found. + * Exposed for the sim-replay tools (get_sim_replay / get_sim_version) that + * serve the inline viewer's playback UI. */ +export function getEnvRecord(envId: string): EnvRecord | null { + return envRecords.get(envId) ?? null; +} + /** In-memory storage for batch simulation groups */ interface BatchGroup { envs: PhysicsEnv[]; @@ -179,8 +229,24 @@ export async function createRobotEnv(input: unknown): Promise { simulations.set(envId, env); + // Register the assembly as a session so the inline viewer can render it, + // and start the replay record its playback UI reads via get_sim_replay. + const documentId = registerSession(args.document); + envRecords.set(envId, { + document: args.document, + documentId, + trajectory: [], + rewards: [], + dones: [], + stepCount: 0, + resetEpoch: 0, + dt: args.dt ?? 1 / 240, + substeps: args.substeps ?? 4, + }); + const info = { env_id: envId, + document_id: documentId, num_joints: env.numJoints, action_dim: env.actionDim, observation_dim: env.observationDim, @@ -192,6 +258,10 @@ export async function createRobotEnv(input: unknown): Promise { return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }], + // The ChatGPT shim delivers ONLY structuredContent to the widget — the + // viewer's sim mode keys off env_id here (attachPreviewHandle merges + // document_version on top). + structuredContent: { env_id: envId, document_id: documentId }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -222,6 +292,22 @@ export function gymStep(input: unknown): GymResult { try { const result = env.step(args.action_type, args.values); + + // Append to the replay ring buffer. stepCount keeps the monotonic total + // so the viewer's change token still advances once old entries drop. + const record = envRecords.get(args.env_id); + if (record) { + record.trajectory.push([...result.observation.joint_positions]); + record.rewards.push(result.reward); + record.dones.push(result.done); + record.stepCount += 1; + if (record.trajectory.length > MAX_TRAJECTORY) { + record.trajectory.shift(); + record.rewards.shift(); + record.dones.shift(); + } + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; @@ -250,6 +336,19 @@ export function gymReset(input: unknown): GymResult { try { const observation = env.reset(); + + // A reset starts a fresh episode — drop the recorded rollout and bump + // the epoch so the replay version token can't collide with an + // equal-length rollout from the previous episode. + const record = envRecords.get(args.env_id); + if (record) { + record.trajectory.length = 0; + record.rewards.length = 0; + record.dones.length = 0; + record.stepCount = 0; + record.resetEpoch += 1; + } + return { content: [{ type: "text", text: JSON.stringify(observation, null, 2) }], }; @@ -298,6 +397,7 @@ export function gymClose(input: unknown): GymResult { if (env) { env.close(); simulations.delete(args.env_id); + envRecords.delete(args.env_id); return { content: [{ type: "text", text: JSON.stringify({ success: true }) }], }; @@ -544,10 +644,14 @@ export const toolDefs: ToolDef[] = [ description: "Create a physics simulation environment from a vcad assembly. " + "Returns an environment ID that can be used with gym_step, gym_reset, and gym_observe. " + - "The environment provides a gym-style interface for RL training.", + "The environment provides a gym-style interface for RL training. " + + "Mounts the inline 3D viewer with a play button — gym_step rollouts replay right in the chat.", inputSchema: createRobotEnvSchema, handler: (a) => createRobotEnv(a), - behavior: behavior({}), + // writesDoc: the registered session must persist durably (signed-in hosted + // users) so the mounted viewer can fetch geometry across instances — the + // dispatch pipeline persists via effectiveDocId → structuredContent.document_id. + behavior: behavior({ mount: true, geometry: true, writesDoc: true }), }, { name: "gym_step", diff --git a/packages/mcp/src/tools/order-feed.ts b/packages/mcp/src/tools/order-feed.ts new file mode 100644 index 000000000..8524ddf6d --- /dev/null +++ b/packages/mcp/src/tools/order-feed.ts @@ -0,0 +1,138 @@ +/** + * get_order_feed — app-only feed behind the viewer's order dock (M2). + * + * The single read the mounted canvas polls to render the fused vcad+kerf + * order lifecycle: per-order state chips, the joined quote's process/qty/ + * lead/pricing-basis, authorization status (+ the approve URL while a human + * decision is pending), receipt status, and the wallet balance footer. + * + * Security posture: the iframe is READ-ONLY for money. This is the only + * ordering-adjacent tool the widget can call, and it only reads — the + * asymmetric seam (agent proposes, human approves out-of-band, agent places) + * stays intact. Margin invariant preserved: totals only, never fab internals. + */ + +import type { AuthUser } from "../oauth.js"; +import { ownerId, type FabricateStore } from "../fabricate/store.js"; +import { orderStateChip } from "../fabricate/state-chip.js"; +import { behavior, type ToolDef } from "./tool-def.js"; +import { ok, err, type ToolResult } from "./tool-result.js"; + +const toUsd = (minor: number): number => Math.round(minor) / 100; + +/** FNV-1a change token over the feed's identity (order ids + states + event + * counts) — the dock polls this-shaped payloads and re-renders on change + * (pattern: preview.ts's previewVersion). */ +function feedVersion(parts: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < parts.length; i++) { + h ^= parts.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +export const getOrderFeedSchema = { + type: "object" as const, + properties: { + document_id: { + type: "string" as const, + description: "Session/document id whose orders to feed.", + }, + }, + required: ["document_id"], +}; + +export async function getOrderFeed( + input: unknown, + store: FabricateStore, + user: AuthUser | null, +): Promise { + const args = (input ?? {}) as Record; + const documentId = String(args.document_id ?? ""); + if (!documentId) return err("document_id is required."); + const owner = ownerId(user); + + // Owner-scoped, newest first; quote-only orders (state QUOTED, no money + // moved) belong in the dock too — the filter is by document, not by state. + // The store limit applies BEFORE the document filter, so fetch a wide + // window (200) and slice to the dock's 20 AFTER filtering — otherwise 20 + // newer orders on other documents push this document's live approvals out + // of the feed entirely. + const all = await store.listOrders(owner, { limit: 200 }); + const mine = all.filter((o) => o.document_id === documentId).slice(0, 20); + + const orders: Array> = []; + const versionParts: string[] = []; + for (const o of mine) { + const quote = o.quote_id ? await store.getQuote(o.quote_id, owner) : null; + const authz = o.authorization_id + ? await store.getAuthorization(o.authorization_id, owner) + : null; + // The recommended option leads the sorted fab_options; prefer the one the + // order actually routed to. + const option = + quote?.fab_options.find((f) => f.fab === o.fab) ?? quote?.fab_options[0] ?? null; + + orders.push({ + order_id: o.order_id, + state_chip: orderStateChip(o.state, authz?.status), + raw_state: o.state, + ...(quote ? { process: quote.process, quantity: quote.quantity } : {}), + total_amount_usd: toUsd(o.amount_total_minor), + ...(option ? { pricing_basis: option.pricing_basis } : {}), + vendor: o.fab, + ...(option ? { lead_time_days: option.lead_time_days } : {}), + ...(quote?.expires_at ? { quote_expires_at: quote.expires_at } : {}), + created_at: o.created_at, + events: o.events, + authorization: authz + ? { + status: authz.status, + max_amount_usd: toUsd(authz.max_amount_minor), + expires_at: authz.expires_at, + // The dock's approve button leaves the iframe — the widget never + // approves; only surface the URL while a human decision is open. + ...(authz.status === "pending_human" + ? { approve_url: `https://vcad.io/authorize/${authz.id}` } + : {}), + } + : null, + tracking: null, + receipt: { status: o.receipt_status ?? "unverified" }, + ...(o.kerf_intent_hash ? { kerf_intent_hash: o.kerf_intent_hash } : {}), + }); + // Version input covers everything the rendered card is derived from: + // authorization status (human approval flips ONLY the authz row — no + // order state change, no new event), receipt verdict, and pricing basis, + // so the dock re-renders the moment any of them move. + versionParts.push( + `${o.order_id}:${o.state}:${o.events.length}:${authz?.status ?? "-"}:${o.receipt_status ?? "-"}:${option?.pricing_basis ?? "-"}`, + ); + } + + const walletMinor = await store.getWalletBalance(owner); + // Wallet balance is part of the identity too — a top-up or debit must + // refresh the footer even when no order changed. + const version = feedVersion( + `${versionParts.join("|")}|w:${walletMinor ?? "-"}`, + ); + + return ok({ + orders, + wallet_balance_usd: walletMinor == null ? null : toUsd(walletMinor), + version, + }); +} + +export const toolDefs: ToolDef[] = [ + { + name: "get_order_feed", + pack: "fabricate", + description: + "App-only: the order-dock feed for a document — orders with fused lifecycle chips, joined quote details, authorization status (+ approve URL while pending), receipt status, and wallet balance. Polled by the inline viewer; read-only.", + inputSchema: getOrderFeedSchema, + handler: (a, c) => getOrderFeed(a, c.fabricateStore, c.user), + behavior: behavior({ appOnly: true }), + }, +]; diff --git a/packages/mcp/src/tools/order.ts b/packages/mcp/src/tools/order.ts index 87e23bdea..df4744f71 100644 --- a/packages/mcp/src/tools/order.ts +++ b/packages/mcp/src/tools/order.ts @@ -22,8 +22,11 @@ import { FulfillmentBroker } from "../fabricate/broker.js"; import { toDfmProcess, catalogMaterial } from "../fabricate/process-map.js"; import { ownerId, type FabricateStore } from "../fabricate/store.js"; import { buildFabHandoff } from "../fabricate/handoff.js"; +import { buildKerfSheetMetalIntent, KERF_VENDOR } from "../fabricate/adapters/kerf.js"; +import { intentHash } from "../fabricate/kerf/intent-hash.js"; +import type { ConfiguratorIntent, FileRef } from "../fabricate/kerf/contract.js"; import { captureEvent } from "../telemetry.js"; -import { resolveArtifactRefAsync } from "./artifact-store.js"; +import { getArtifactFileAsync, resolveArtifactRefAsync } from "./artifact-store.js"; import { behavior, type ToolDef } from "./tool-def.js"; import { PROCESSES, @@ -95,10 +98,111 @@ function quoteDfm(process: Process, metrics: GeometryMetrics): DfmSummary { return { checked: true, passed: violations.length === 0, violations }; } -function docHash(ir: unknown): string { +/** sha256(JSON)[0..16] — the design fingerprint persisted on quotes and + * re-checked by place_order's geometry gate (exported so the gate hashes + * with the exact same function, never a near-copy). */ +export function docHash(ir: unknown): string { return createHash("sha256").update(JSON.stringify(ir)).digest("hex").slice(0, 16); } +/** Pull material + thickness (mm) off the document's sheet-metal base flange, + * mirroring what sheet_metal_create writes (tools/sheet-metal.ts). Null when + * the document has no sheet-metal chain. */ +function sheetMetalParams(ir: Document): { material: string; thicknessMm: number } | null { + for (const node of Object.values(ir.nodes ?? {})) { + const op = node.op; + if ( + op.type === "SheetMetalBaseFlangeRect" || + op.type === "SheetMetalBaseFlangePolygon" + ) { + if (op.thickness > 0) return { material: op.material, thicknessMm: op.thickness }; + } + } + return null; +} + +/** + * vcad/registry material names → SendCutSend's vendor-native alloy labels + * (the exact link text the SCS configurator's alloy step shows — recorded + * 2026-07-07 in kerf's sendcutsend manifest: 2024 T3, 5052 H32, 6061 T6, + * 7075 T6, MIC-6). Only aluminum maps today; anything unmapped fails closed + * (no kerfIntent) rather than guessing a label the playbook can't select. + */ +const SCS_ALLOY_LABELS: Record = { + // Soft/bendable aluminum → 5052 H32 (SCS's default bendable sheet alloy). + "5052": "5052 H32", + "5052 h32": "5052 H32", + "5052-h32": "5052 H32", + "al-soft": "5052 H32", + aluminum: "5052 H32", + aluminium: "5052 H32", + al: "5052 H32", + // Hard aluminum → 6061 T6 (flat-only at SCS — no bends). + "6061": "6061 T6", + "6061 t6": "6061 T6", + "6061-t6": "6061 T6", + "al-hard": "6061 T6", + "2024": "2024 T3", + "2024 t3": "2024 T3", + "2024-t3": "2024 T3", + "7075": "7075 T6", + "7075 t6": "7075 T6", + "7075-t6": "7075 T6", +}; + +/** + * SendCutSend aluminum thickness → the (radio value code, display label) + * pair the kerf SCS quote playbook dereferences: `/config/thickness` must + * equal the checked radio's stable VALUE code ("ALU-125") and + * `/config/thickness_label` is the visible tile label the select step + * clicks (`.125" (3.2 MM)`). Derivation is confident only for inch-native + * mils in the recorded ALU-040..ALU-500 range (label format verified + * against kerf's recorded pairs: ALU-040 '.040" (1.0 MM)', ALU-125 + * '.125" (3.2 MM)', ALU-250 '.250" (6.3 MM)'); anything else returns null + * and the whole kerfIntent is omitted — fail-closed, never a silently + * mis-selected thickness. + */ +function scsAluminumThickness( + thicknessMm: number, +): { code: string; label: string } | null { + const mils = (thicknessMm / 25.4) * 1000; + const rounded = Math.round(mils); + if (Math.abs(mils - rounded) > 0.25) return null; + if (rounded < 40 || rounded > 500) return null; // recorded SCS ALU range + const mmLabel = (Math.round(thicknessMm * 10) / 10).toFixed(1); + return { + code: `ALU-${rounded}`, + label: `.${String(rounded).padStart(3, "0")}" (${mmLabel} MM)`, + }; +} + +/** + * Full vendor-native SendCutSend sheet-metal config — EXACTLY the pointers + * kerf's SCS quote playbook dereferences (playbooks/quote.json): + * `/config/units`, `/config/material_category`, `/config/material_family`, + * `/config/material`, `/config/thickness`, `/config/thickness_label` (the + * playbook's resolveValueRef THROWS on a missing pointer, so a partial + * config kills the run). Returns null when any value can't be derived + * vendor-natively — the caller then omits the kerfIntent entirely. + */ +export function scsSheetConfig( + material: string, + thicknessMm: number, +): Record | null { + const alloy = SCS_ALLOY_LABELS[material.trim().toLowerCase()]; + if (!alloy) return null; + const th = scsAluminumThickness(thicknessMm); + if (!th) return null; + return { + units: "MM", // vcad documents are always millimeters + material_category: "Metals", + material_family: "Aluminum", + material: alloy, + thickness: th.code, + thickness_label: th.label, + }; +} + const toUsd = (minor: number): number => Math.round(minor) / 100; export async function quoteManufacturing( @@ -212,6 +316,78 @@ export async function quoteManufacturing( } } + // ── kerf rail (Wave 0, sheet metal): with a fab bundle bound, build the + // ConfiguratorIntent HERE — order.ts owns intent construction so the + // persisted kerf_intent_hash is computed over the exact object the adapter + // sends — and thread it through the broker to the SendCutSend-via-kerf + // adapter. kerf's posted-intent API requires the actual DXF bytes inline + // (`bytes_base64` per file, hash-checked at the door), so the single DXF's + // bytes are read from the artifact store, re-hashed against the manifest + // sha256, and attached as a WIRE-ONLY field — the intent hash stays over + // sha256s alone (see intent-hash.ts). Every underivable input fails closed: + // no intent, a note, and the generic estimator covers. + let kerfIntent: ConfiguratorIntent | null = null; + let kerfSkipNote: string | null = null; + if (process === "sheet_metal" && fabArtifact) { + const dxfEntries = fabArtifact.manifest.filter((m) => + m.file.toLowerCase().endsWith(".dxf"), + ); + const sheet = sheetMetalParams(ir); + if (dxfEntries.length === 0) { + kerfSkipNote = + "kerf vendor quote skipped: the bound fab artifact has no .dxf files (export the flat pattern via sheet_metal_unfold)."; + } else if (dxfEntries.length > 1) { + // The SCS playbook uploads only /files/0 — pricing one file of a + // multi-part bundle and presenting it as the whole order would + // misprice, so multi-DXF intents are refused outright. + kerfSkipNote = + "kerf vendor quote skipped: multi-DXF orders not yet kerf-quotable (the vendor playbook uploads a single file; quoting only the first DXF would misprice the bundle)."; + } else if (!sheet) { + kerfSkipNote = + "kerf vendor quote skipped: no sheet-metal base flange in the document to derive material/thickness config from."; + } else { + const material = requestedMaterial ?? sheet.material ?? "5052"; + const config = scsSheetConfig(material, sheet.thicknessMm); + if (!config) { + kerfSkipNote = + `kerf vendor quote skipped: no vendor-native SendCutSend config for material "${material}" at ${sheet.thicknessMm} mm — ` + + "fail-closed rather than guessing a configurator selection (aluminum at inch-native gauges derives today)."; + } else { + const entry = dxfEntries[0]; + const stored = await getArtifactFileAsync(fabArtifact.artifact_id, entry.file); + const actualSha = stored + ? createHash("sha256").update(stored.buf).digest("hex") + : null; + if (!stored || actualSha !== entry.sha256) { + // Never send bytes that don't hash to the manifest's pin — kerf's + // upload-hash oracle would (rightly) refuse, and a mismatch here + // means the artifact store no longer holds what was quoted. + console.error( + `[quote_manufacturing] kerf intent skipped: artifact ${fabArtifact.artifact_id} file "${entry.file}" ` + + (stored + ? `sha256 mismatch (manifest ${entry.sha256}, bytes ${actualSha})` + : "bytes unavailable in the artifact store"), + ); + kerfSkipNote = + "kerf vendor quote skipped: the fab artifact's DXF bytes are unavailable or do not match the manifest sha256 — re-run the export and re-quote."; + } else { + const files: FileRef[] = [ + { + name: entry.file, + bytes: entry.bytes, + sha256: entry.sha256, + media_type: "image/vnd.dxf", + // Wire-only: kerf strips this at the door after hash-checking; + // it never participates in intentHash. + bytes_base64: stored.buf.toString("base64"), + }, + ]; + kerfIntent = buildKerfSheetMetalIntent({ files, config, quantity }); + } + } + } + } + const broker = new FulfillmentBroker(); const result = await broker.quote({ process, @@ -224,12 +400,25 @@ export async function quoteManufacturing( baseCostMinor, baseCostModel, materialCatalog, + ...(kerfIntent ? { kerfIntent: { intent: kerfIntent } } : {}), }); if (!result.recommended) { return err(`No fab adapter could quote process "${process}".`); } + // kerf provenance: when the recommendation came from the kerf rail, bind + // the quote to its intent hash (kerf discipline: geometry/config/quantity + // edit ⇒ new hash ⇒ the vendor quote is dead) and record the quote-job id + // for job-state/evidence lookups. + let kerfIntentHash: string | null = null; + let kerfJobId: string | null = null; + if (kerfIntent && result.recommended.fab === KERF_VENDOR) { + kerfIntentHash = intentHash(kerfIntent); + const jobNote = result.recommended.notes.find((n) => n.startsWith("kerf job ")); + kerfJobId = jobNote ? jobNote.slice("kerf job ".length) : null; + } + const now = new Date(); const expiresAt = new Date(now.getTime() + QUOTE_TTL_MS).toISOString(); const quoteId = randomUUID(); @@ -251,6 +440,9 @@ export async function quoteManufacturing( margin_hidden: true, expires_at: expiresAt, created_at: now.toISOString(), + kerf_intent_hash: kerfIntentHash, + kerf_job_id: kerfJobId, + pricing_basis_best: result.recommended.pricing_basis, }; const order: Order = { @@ -265,6 +457,9 @@ export async function quoteManufacturing( ship_to: (args.ship_to as unknown) ?? null, events: [{ state: "QUOTED", at: now.toISOString(), note: "quote_manufacturing" }], fab_artifact: fabArtifact, + authorization_id: null, + receipt_status: null, + kerf_intent_hash: kerfIntentHash, created_at: now.toISOString(), updated_at: now.toISOString(), }; @@ -294,7 +489,7 @@ export async function quoteManufacturing( } // Agent-facing payload: margin-inclusive prices only; fab cost / margin never appear. - return ok({ + const quoteResult = ok({ quote_id: quoteId, order_id: orderId, process, @@ -329,6 +524,10 @@ export async function quoteManufacturing( notes: o.notes, })), recommended_fab: result.recommended.fab, + // Pricing basis of the recommended option — agents branch on this: + // "estimate" never gates money; "quoted" is the fab's own displayed price + // (kerf rail); "binding" is fab-committed. + pricing_basis: result.recommended.pricing_basis, total_amount_usd: toUsd(quote.total_amount_minor), total_amount_minor: quote.total_amount_minor, currency: "USD", @@ -349,13 +548,28 @@ export async function quoteManufacturing( files: fabArtifact.manifest.length, } : null, + ...(kerfIntentHash + ? { kerf_intent_hash: kerfIntentHash, ...(kerfJobId ? { kerf_job_id: kerfJobId } : {}) } + : {}), note: - "Phase 0: quote-only. Prices are local ESTIMATES (no binding fab quote yet) and ordering/payment ships in Phase 1. " + + (result.recommended.pricing_basis === "quoted" + ? "The recommended price is the fab's OWN displayed quote (via the kerf rail), bound to kerf_intent_hash — any geometry/config/quantity edit kills it (re-quote). Ordering/payment still ships separately. " + : "Phase 0: quote-only. Prices are local ESTIMATES (no binding fab quote yet) and ordering/payment ships in Phase 1. ") + "An order row was created at state QUOTED — see it with get_order_status / list_orders." + (fabArtifact ? " Fab files are bound by reference (artifact_id) — they stay in the artifact store and never transit model context." - : ""), + : "") + + (kerfSkipNote ? ` ${kerfSkipNote}` : ""), }); + // quote_manufacturing mounts the viewer (behavior.mount) but isn't a + // geometry tool, so the dispatch never attaches a preview handle — surface + // the session id in structuredContent so the mounted order dock knows which + // document to render and poll. Inline-IR quotes have no live session and + // stay handle-less (the dock can't bind a session that doesn't exist). + if (documentId) { + quoteResult.structuredContent = { document_id: documentId }; + } + return quoteResult; } // ── get_order_status ───────────────────────────────────────────────────────── @@ -435,10 +649,10 @@ export const toolDefs: ToolDef[] = [ name: "quote_manufacturing", pack: "fabricate", description: - "Quote manufacturing a part: measures the design, runs light DFM, and returns margin-inclusive price options per fab (pcb/cnc/3dprint/sheet_metal/cast_metal). Pass `ir` (inline Document — stateless, no open_document needed, serverless-safe, parallel-safe) OR a `document_id` from an open session. Persists a quote + a QUOTED order. Phase 0 is quote-only — prices are estimates and ordering/payment ship next; no money moves. For sheet_metal the result includes `fab_handoff`: curated US instant-quote shops (SendCutSend/OSH Cut/Fabworks), the exact file recipe (DXF via sheet_metal_unfold or folded STEP via export_cad), and what to enter at upload — everything needed to finish the order on the fab's site today.", + "Quote manufacturing a part: measures the design, runs light DFM, and returns margin-inclusive price options per fab (pcb/cnc/3dprint/sheet_metal/cast_metal). Pass `ir` (inline Document — stateless, no open_document needed, serverless-safe, parallel-safe) OR a `document_id` from an open session. Persists a quote + a QUOTED order. Phase 0 is quote-only — prices are estimates and ordering/payment ship next; no money moves. For sheet_metal the result includes `fab_handoff`: curated US instant-quote shops (SendCutSend/OSH Cut/Fabworks), the exact file recipe (DXF via sheet_metal_unfold or folded STEP via export_cad), and what to enter at upload — everything needed to finish the order on the fab's site today. Mounts the inline viewer's order dock, so the quote and its order lifecycle render alongside the model.", inputSchema: quoteManufacturingSchema, handler: (a, c) => quoteManufacturing(a, c.engine, c.fabricateStore, c.user), - behavior: behavior({}), + behavior: behavior({ mount: true }), }, { name: "get_order_status", diff --git a/packages/mcp/src/tools/ordering.ts b/packages/mcp/src/tools/ordering.ts index c0f49fef2..e40e49bb3 100644 --- a/packages/mcp/src/tools/ordering.ts +++ b/packages/mcp/src/tools/ordering.ts @@ -5,11 +5,21 @@ * authorize_spend — the AGENT proposes a spend authorization for a QUOTED * order (status pending_human) and emits a `propose_order` * control event on the session spine. No money moves. + * When the client supports URL-mode elicitation (M3) the + * approval page is carried to the human in-band — the + * elicitation is an accelerator only; approval itself + * stays out-of-band on vcad.io, never an MCP action. * place_order — the agent places the order ONLY after a HUMAN approved * the authorization (out of band, in the web app — never an - * MCP tool). On approval it performs one atomic debit via - * the debit_wallet RPC, moves the order to PAID, and emits - * an `order_placed` control event. + * MCP tool). Two fail-closed gates run BEFORE the debit + * (M4): the geometry gate (doc_hash must still match the + * quote — the kerf intent-hash discipline applied to the + * design surface) and the receipt gate (persisted + * clearance claims re-verified; fail/unverifiable ⇒ + * refuse; no claims ⇒ proceed flagged "unverified"). On + * approval it performs one atomic debit via the + * debit_wallet RPC, moves the order to PAID, and emits an + * `order_placed` control event. * * Ordering is OFF unless VCAD_FABRICATE_ORDERING=1 — and even then the live * debit path (Supabase RPC) must be staging-verified before use. Fab submission @@ -18,12 +28,21 @@ */ import { randomUUID } from "node:crypto"; +import type { Engine } from "@vcad/engine"; +import type { Document } from "@vcad/ir"; import type { AuthUser } from "../oauth.js"; import { ownerId, type FabricateStore } from "../fabricate/store.js"; import { resolveArtifactRefAsync } from "./artifact-store.js"; -import type { SessionEventStore } from "../session-store.js"; -import type { FabArtifactRef, SpendAuthorization } from "../fabricate/types.js"; -import { behavior, type ToolDef } from "./tool-def.js"; +import { getSession, hydrateSession } from "./session.js"; +import { docHash } from "./order.js"; +import { clearanceReceiptClaims } from "./clearance.js"; +import type { SessionEventStore, SessionStore } from "../session-store.js"; +import type { + FabArtifactRef, + OrderReceiptStatus, + SpendAuthorization, +} from "../fabricate/types.js"; +import { behavior, type ToolContext, type ToolDef } from "./tool-def.js"; import { okPretty as ok, err, type ToolResult } from "./tool-result.js"; /** Ordering is disabled unless explicitly enabled — test-mode, flag-gated. */ @@ -38,6 +57,9 @@ const AUTHZ_TTL_MS = 24 * 60 * 60 * 1000; // 24h const toUsd = (minor: number): number => Math.round(minor) / 100; +/** Human-facing dollars for elicitation copy ("12.30", never "12.3"). */ +const usd = (minor: number): string => (Math.round(minor) / 100).toFixed(2); + /** Best-effort spine control event; never fails the tool (mirrors persist). */ async function emitControl( eventStore: SessionEventStore, @@ -79,6 +101,7 @@ export async function authorizeSpend( store: FabricateStore, eventStore: SessionEventStore, user: AuthUser | null, + elicit?: ToolContext["elicit"], ): Promise { if (!orderingEnabled()) return err(DISABLED_MSG); @@ -118,6 +141,15 @@ export async function authorizeSpend( created_at: now.toISOString(), }; await store.createAuthorization(authz, owner); + // Link the proposal onto the order row (the order feed joins through it) + // and append a QUOTED timeline event so the dock sees the proposal. + await store.setOrderState( + orderId, + owner, + order.state, + `spend authorization ${authz.id} proposed (pending human approval)`, + { authorization_id: authz.id }, + ); await emitControl(eventStore, order.document_id, user, "propose_order", { order_id: orderId, authorization_id: authz.id, @@ -125,15 +157,84 @@ export async function authorizeSpend( fab: order.fab, }); - return ok({ + const base = { authorization_id: authz.id, order_id: orderId, - status: authz.status, max_amount_usd: toUsd(max), expires_at: authz.expires_at, - note: - "Proposed. A HUMAN must approve this authorization in the vcad app before place_order can charge — the agent cannot approve its own spend. Once approved, call place_order with this authorization_id.", - }); + }; + const pendingNote = + "Proposed. A HUMAN must approve this authorization in the vcad app before place_order can charge — the agent cannot approve its own spend. Once approved, call place_order with this authorization_id."; + + // M3: URL-mode elicitation carries the human straight to the approval page. + // Strictly an accelerator: any failure here must never lose the created + // authorization, so the whole exchange is fenced and falls through to the + // out-of-band note. Approval itself always happens on vcad.io, never here. + if (elicit?.urlSupported()) { + try { + const approveUrl = `https://vcad.io/authorize/${authz.id}`; + const res = await elicit.requestUrl({ + message: + `Approve $${usd(order.amount_total_minor)} fabrication spend for order ${orderId} ` + + `(cap $${usd(max)}, expires in 24h)`, + url: approveUrl, + elicitationId: authz.id, + }); + if (res.action === "decline") { + // Compare-and-set: the elicitation blocks for the whole human decision + // window, during which the human may have approved on vcad.io and a + // concurrent place_order may have consumed the authz. A late decline + // must never stomp 'authorized'/'consumed' → 'revoked' — re-read the + // DB row and only revoke while the decision is still pending. + const fresh = await store.getAuthorization(authz.id, owner); + if (fresh && fresh.status !== "pending_human") { + return ok({ + ...base, + status: fresh.status, + note: + `Declined in chat, but the authorization is no longer pending (status: ${fresh.status}) — left untouched. ` + + "The DB row is the truth; a stale chat decline never rewrites a decision that already happened.", + }); + } + await store.setAuthorizationStatus(authz.id, owner, "revoked"); + await emitControl(eventStore, order.document_id, user, "authorization_declined", { + order_id: orderId, + authorization_id: authz.id, + }); + return ok({ + ...base, + status: "revoked", + note: + "Declined by human — the authorization was revoked; nothing can be charged against it. Re-run authorize_spend to propose again.", + }); + } + if (res.action === "accept") { + // "accept" means the human engaged the approval page — re-read the + // truth (the DB row), never infer approval from the elicitation. + const fresh = await store.getAuthorization(authz.id, owner); + if (fresh?.status === "authorized") { + return ok({ + ...base, + status: "authorized", + note: + "Approved by human — ready to place. Call place_order with this authorization_id.", + }); + } + return ok({ + ...base, + status: fresh?.status ?? "pending_human", + note: + "Approval page opened — complete the approval there, then call place_order with this authorization_id.", + }); + } + // "cancel" → the human dismissed the prompt; the proposal stands. + } catch { + // Elicitation transport failure — the authorization already exists; + // fall through to the standard pending note. + } + } + + return ok({ ...base, status: authz.status, note: pendingNote }); } // ── place_order ────────────────────────────────────────────────────────────── @@ -168,6 +269,8 @@ export async function placeOrder( store: FabricateStore, eventStore: SessionEventStore, user: AuthUser | null, + engine?: Engine, + sessionStore?: SessionStore, ): Promise { if (!orderingEnabled()) return err(DISABLED_MSG); @@ -213,11 +316,155 @@ export async function placeOrder( // through: the idempotent debit below is the single chokepoint against double // spend. A 'consumed' authz with no matching prior debit is rejected there // (authz_not_authorized), so a stale authz can't place a fresh charge. + // + // A 'consumed' authz means the debit ALREADY COMMITTED (crash between debit + // and setOrderState) — this call is a replay finalizing a paid order, so the + // pre-debit gates below are skipped: blocking the replay on post-debit drift + // would strand a debited order at QUOTED forever with money already gone. + const isConsumedReplay = authz.status === "consumed"; + + // Durable receipt refusal: a receipt-gate failure persists receipt_status + // "violated" on the order (see gate 2), so the refusal survives session + // non-residency — close_document / serverless instance churn can't turn a + // known-failing receipt into an "unverified" pass-through. + if (!isConsumedReplay && order.receipt_status === "violated") { + return err( + "receipt violated — a previous place attempt found failing clearance claims on this design. " + + "Fix the design and re-quote (a fresh quote resets the receipt) before money moves.", + ); + } + + // Rehydrate the order's session BEFORE the gates: the dispatch layer only + // hydrates sessions named by args.document_id, which place_order doesn't + // carry — without this, a signed-in call (fresh per-request cache) NEVER + // had a resident session and both gates silently skipped. Best-effort: a + // failed hydrate degrades to the non-resident path, no worse than before. + if ( + sessionStore && + order.document_id && + !order.document_id.startsWith("inline:") + ) { + try { + await hydrateSession(sessionStore, order.document_id); + } catch { + // Durable load failed — the gates fall back to the non-resident note. + } + } + + // ── M4 gate 1: geometry must still match the quote ──────────────────────── + // The export_gerber dirty-DRC precedent applied to money: a quote is only + // meaningful for the design it priced. When the quote carries a doc_hash and + // the order's session is resident, re-hash and refuse on drift — the kerf + // intent-hash discipline (geometry edit ⇒ quote dead ⇒ re-quote), enforced + // on the design surface. Inline-IR quotes and non-resident sessions can't be + // re-verified; they skip the gate with a note (fail-closed only when a + // verifiable claim exists and fails). + const quote = await store.getQuote(order.quote_id, owner); + let sessionDoc: Document | null = null; + let docUnavailable: string | null = null; + if (!order.document_id || order.document_id.startsWith("inline:")) { + docUnavailable = "quoted from inline IR — no live session to re-verify against"; + } else { + try { + sessionDoc = getSession(order.document_id); + } catch { + docUnavailable = `session ${order.document_id} is not resident on this instance`; + } + } + if (!isConsumedReplay && quote?.doc_hash && sessionDoc) { + const currentHash = docHash(sessionDoc); + if (currentHash !== quote.doc_hash) { + // Durable refusal: expire the order so the refusal holds even when a + // later attempt lands on an instance where the session isn't resident + // (EXPIRED already refuses at the non-QUOTED entry check above). + await store.setOrderState( + orderId, + owner, + "EXPIRED", + `doc_hash mismatch — quote invalidated (quoted ${quote.doc_hash}, current ${currentHash})`, + ); + await emitControl(eventStore, order.document_id, user, "order_blocked", { + order_id: orderId, + authorization_id: authorizationId, + reason: "doc_hash_mismatch", + quoted_doc_hash: quote.doc_hash, + current_doc_hash: currentHash, + }); + return err( + `geometry changed since quote (doc_hash mismatch: quoted ${quote.doc_hash}, current ${currentHash}) — re-quote before placing.` + + (quote.kerf_intent_hash + ? ` The vendor quote is bound to kerf intent ${quote.kerf_intent_hash} — an edited design voids it (intent changed ⇒ quote dead).` + : ""), + ); + } + } + + // ── M4 gate 2: the design receipt must hold ────────────────────────────── + // Re-verify every persisted clearance spec at place time. Any failing claim + // refuses; any unverifiable claim refuses too (fail-closed — a claim that + // can't verify never passes). A document with no claims proceeds flagged + // "unverified" in the feed; an unavailable document likewise (noted). + let receiptStatus: OrderReceiptStatus = "unverified"; + let receiptNote: string; + if (isConsumedReplay) { + receiptStatus = order.receipt_status ?? "unverified"; + receiptNote = + "consumed-authz idempotent replay — gates skipped (debit already committed); finalizing the paid order"; + } else if (!sessionDoc) { + receiptNote = `receipt not re-verified (${docUnavailable ?? "document unavailable"}) — proceeding as unverified`; + } else if (!sessionDoc.clearance_specs?.length) { + receiptNote = "document carries no clearance specs — receipt status: unverified"; + } else { + const claims = clearanceReceiptClaims(sessionDoc, engine); + const failing = claims.filter((c) => c.verdict === "fail").map((c) => c.id); + const unverifiable = claims + .filter((c) => c.verdict === "unverifiable") + .map((c) => c.id); + if (failing.length > 0) { + // Durable refusal: persist the verdict (state stays QUOTED) so the + // violated-receipt entry check refuses future attempts even when the + // session is no longer resident. Only a re-quote resets it. + await store.setOrderState( + orderId, + owner, + order.state, + `receipt violated at place time: ${failing.join(", ")}`, + { receipt_status: "violated" }, + ); + await emitControl(eventStore, order.document_id, user, "order_blocked", { + order_id: orderId, + authorization_id: authorizationId, + reason: "receipt_violated", + claims: failing, + }); + return err( + `receipt violated — clearance claims fail at place time: ${failing.join(", ")}. Fix the design (or re-quote) before money moves.`, + ); + } + if (unverifiable.length > 0) { + await emitControl(eventStore, order.document_id, user, "order_blocked", { + order_id: orderId, + authorization_id: authorizationId, + reason: "receipt_unverifiable", + claims: unverifiable, + }); + return err( + `receipt unverifiable — clearance claims could not be re-checked: ${unverifiable.join(", ")}. Fail-closed: an unverifiable claim never passes; re-verify with check_clearance / verify_receipt before placing.`, + ); + } + receiptStatus = "holds"; + receiptNote = `receipt holds — ${claims.length} clearance claim(s) re-verified at place time`; + } // Bind the fab bundle by reference (provided handle, else whatever the quote - // bound). A provided-but-unresolvable handle fails BEFORE any money moves. + // bound). A provided-but-unresolvable handle fails BEFORE any money moves, + // and a handle that would SWAP an already-bound bundle refuses outright: the + // human approved a spend against the files the quote priced (the kerf + // intent_hash pins exactly those sha256s) — substituting different files + // after approval is a re-quote, never a place-time override. const fabHandle = typeof args.fab_artifact_id === "string" ? args.fab_artifact_id : ""; let fabArtifact: FabArtifactRef | null = order.fab_artifact ?? null; + let lateBinding = false; if (fabHandle) { const ref = await resolveArtifactRefAsync(fabHandle); if (!ref) { @@ -225,7 +472,25 @@ export async function placeOrder( `Unknown or expired fab artifact "${fabHandle}". Re-run export_gerber / export_cad and pass the artifact_id it returns.`, ); } + if (order.fab_artifact && order.fab_artifact.artifact_id !== ref.artifact_id) { + return err( + `fab artifact was bound at authorization time (${order.fab_artifact.artifact_id}) — ` + + `re-quote to change files. The approved spend covers the quoted bundle; ` + + `passing a different artifact (${ref.artifact_id}) at place time is refused before any money moves.`, + ); + } fabArtifact = ref; + lateBinding = order.fab_artifact == null; + } + if (lateBinding && fabArtifact) { + // No bundle was bound at quote time — allow, but record the late binding + // on the order's timeline so the provenance gap is visible in the feed. + await store.setOrderState( + orderId, + owner, + order.state, + `fab artifact ${fabArtifact.artifact_id} late-bound at place time (no bundle was bound at quote time)`, + ); } const debit = await store.debit({ @@ -244,13 +509,20 @@ export async function placeOrder( return err(`Payment failed: ${debit.reason ?? "unknown"}. The order remains QUOTED; resolve and retry.`); } - await store.setOrderState(orderId, owner, "PAID", `place_order debit ok${debit.idempotent ? " (idempotent replay)" : ""}`); + await store.setOrderState( + orderId, + owner, + "PAID", + `place_order debit ok${debit.idempotent ? " (idempotent replay)" : ""}; receipt ${receiptStatus}`, + { receipt_status: receiptStatus, authorization_id: authorizationId }, + ); await emitControl(eventStore, order.document_id, user, "order_placed", { order_id: orderId, authorization_id: authorizationId, amount_minor: order.amount_total_minor, fab: order.fab, idempotent: debit.idempotent ?? false, + receipt_status: receiptStatus, // The handle, never the bytes — the fab-submission worker fetches the files // from the artifact store; the manifest's sha256 verifies what it sends. fab_artifact: fabArtifact, @@ -262,6 +534,7 @@ export async function placeOrder( amount_usd: toUsd(order.amount_total_minor), fab: order.fab, idempotent: debit.idempotent ?? false, + receipt: { status: receiptStatus, note: receiptNote }, fab_artifact: fabArtifact ? { artifact_id: fabArtifact.artifact_id, @@ -283,18 +556,19 @@ export const toolDefs: ToolDef[] = [ name: "authorize_spend", pack: "fabricate", description: - "Propose a spend authorization for a QUOTED order. Creates a DB-backed, revocable authorization (status pending_human) and records the proposal on the session's event log. A HUMAN must approve it in the vcad app before place_order can charge — the agent cannot approve its own spend. Flag-gated (test-mode); no money moves here.", + "Propose a spend authorization for a QUOTED order. Creates a DB-backed, revocable authorization (status pending_human) and records the proposal on the session's event log. A HUMAN must approve it in the vcad app before place_order can charge — the agent cannot approve its own spend; when the client supports URL elicitation the approval page is offered to the human in-band. Flag-gated (test-mode); no money moves here.", inputSchema: authorizeSpendSchema, - handler: (a, c) => authorizeSpend(a, c.fabricateStore, c.eventStore, c.user), + handler: (a, c) => authorizeSpend(a, c.fabricateStore, c.eventStore, c.user, c.elicit), behavior: behavior({}), }, { name: "place_order", pack: "fabricate", description: - "Place a QUOTED order once its authorization has been human-approved: performs one atomic wallet debit and moves the order to PAID (fab submission follows in a later step). Refuses if the authorization is still pending approval. Flag-gated (test-mode).", + "Place a QUOTED order once its authorization has been human-approved: performs one atomic wallet debit and moves the order to PAID (fab submission follows in a later step). Refuses if the authorization is still pending approval, if the geometry changed since the quote (doc_hash mismatch — re-quote), or if the design's persisted clearance claims fail or cannot be re-verified (fail-closed receipt gate). Flag-gated (test-mode).", inputSchema: placeOrderSchema, - handler: (a, c) => placeOrder(a, c.fabricateStore, c.eventStore, c.user), + handler: (a, c) => + placeOrder(a, c.fabricateStore, c.eventStore, c.user, c.engine, c.sessionStore), behavior: behavior({}), }, ]; diff --git a/packages/mcp/src/tools/preview.ts b/packages/mcp/src/tools/preview.ts index f13016b6e..e11ff8edc 100644 --- a/packages/mcp/src/tools/preview.ts +++ b/packages/mcp/src/tools/preview.ts @@ -18,6 +18,7 @@ import { getNodePcb } from "@vcad/core"; import { buildGlb, buildPartLabels, + eulerXyzDegToQuat, DEFAULT_MATERIAL, type GlbMesh, } from "../export/glb.js"; @@ -107,6 +108,61 @@ export async function generateGlbPreview( return uint8ArrayToBase64(buildGlb(meshes, "preview")); } +/** + * Generate a base64 GLB preview with one named node PER ASSEMBLY INSTANCE, + * for the replay viewer's FK playback. Node names follow + * `":"` (mirroring the `":"` root + * convention) so the viewer can bind per-step transforms from + * `get_sim_replay` back to nodes. Geometry stays part-local — the FK-solved + * world pose rides on the glTF node TRS — and instances of one partDef share + * a single glTF mesh via `meshKey`. + * + * Returns null when the scene has no instances (or evaluation fails), so the + * caller can fall back to the parts path and the flag is safe on any doc. + */ +export function generateInstancesGlbPreview( + doc: Document, + engine: Engine, +): string | null { + try { + const scene = engine.evaluate(doc); + const instances = scene?.instances; + if (!instances || instances.length === 0) return null; + + const meshes: GlbMesh[] = instances.map((inst) => ({ + name: `${inst.instanceId}:${inst.name ?? ""}`, + positions: inst.mesh.positions, + indices: inst.mesh.indices, + normals: inst.mesh.normals, + color: DEFAULT_MATERIAL.color, + metallic: DEFAULT_MATERIAL.metallic, + roughness: DEFAULT_MATERIAL.roughness, + meshKey: inst.partDefId, + transform: inst.transform + ? { + translation: [ + inst.transform.translation.x, + inst.transform.translation.y, + inst.transform.translation.z, + ], + rotationQuat: eulerXyzDegToQuat(inst.transform.rotation), + scale: [ + inst.transform.scale.x, + inst.transform.scale.y, + inst.transform.scale.z, + ], + } + : undefined, + })); + + return uint8ArrayToBase64(buildGlb(meshes, "preview")); + } catch { + // Evaluation failures fall back to the parts path (or its own + // empty-geometry signal) rather than erroring the viewer poll. + return null; + } +} + /** * Push a board's layered preview meshes onto `meshes`, all carrying the * board's part-identity `name` so a click anywhere on the board resolves to @@ -160,6 +216,13 @@ export const getPreviewGlbSchema = { type: "string" as const, description: "Session id of the document to preview.", }, + instances: { + type: "boolean" as const, + description: + "Return one named node per assembly instance (part-local geometry + " + + "node transforms) so the replay viewer can bind FK targets. Falls " + + "back to the merged parts preview when the document has no instances.", + }, }, required: ["document_id"], }; @@ -171,11 +234,34 @@ export const getPreviewGlbSchema = { * This tool exists for the MCP Apps viewer (`visibility: ["app"]`) — it * keeps multi-hundred-KB geometry payloads out of model-visible tool * results. Agents wanting geometry should use `export_cad` instead. + * + * With `instances: true` the GLB carries one node per assembly instance + * (see {@link generateInstancesGlbPreview}) and the envelope adds + * `mode: "instances"`; documents without instances fall back to the + * normal parts preview, so the flag is safe on any doc. */ export async function getPreviewGlb( doc: Document, engine: Engine, + instances = false, ): Promise<{ content: Array<{ type: "text"; text: string }> }> { + if (instances) { + const instGlb = generateInstancesGlbPreview(doc, engine); + if (instGlb) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + _vcad_glb: instGlb, + version: previewVersion(doc), + mode: "instances", + }), + }, + ], + }; + } + } const glbBase64 = await generateGlbPreview(doc, engine); if (!glbBase64) { // No previewable geometry yet (e.g. a freshly opened empty document the @@ -255,7 +341,11 @@ export const toolDefs: ToolDef[] = [ "Return a base64 GLB preview of an open session document. Internal to the inline 3D viewer — agents should use `export_cad` for geometry exports.", inputSchema: getPreviewGlbSchema, handler: async (a, c) => - getPreviewGlb(getSession(String(a.document_id ?? "")), c.engine), + getPreviewGlb( + getSession(String(a.document_id ?? "")), + c.engine, + a.instances === true, + ), behavior: behavior({ appOnly: true }), }, { diff --git a/packages/mcp/src/tools/record.ts b/packages/mcp/src/tools/record.ts index f8412cbc4..732b8ec51 100644 --- a/packages/mcp/src/tools/record.ts +++ b/packages/mcp/src/tools/record.ts @@ -284,6 +284,9 @@ export async function recordSimulation( const result = env.step(actionType, perStepActions.values[s]!); const obs = result.observation; + // Joint-order contract: obs.joint_positions[j] → docClone.joints[j] — + // positional, the same assumption get_sim_replay makes (see the caveat + // there: the kernel emits joint_positions in RobotEnv's joint_ids order). for (let j = 0; j < docClone.joints!.length; j++) { const pos = obs.joint_positions[j]; if (typeof pos === "number") docClone.joints![j]!.state = pos; diff --git a/packages/mcp/src/tools/sim-replay.ts b/packages/mcp/src/tools/sim-replay.ts new file mode 100644 index 000000000..b1069209a --- /dev/null +++ b/packages/mcp/src/tools/sim-replay.ts @@ -0,0 +1,281 @@ +/** + * Physics replay tools for the MCP Apps viewer. + * + * The gym tools record every step's joint positions into a ring buffer on the + * env record (tools/gym.ts). These two app-only tools serve that rollout to + * the inline viewer's playback UI: `get_sim_replay` returns the trajectory + * plus per-step FK-solved instance transforms so the viewer can re-pose the + * assembly, and `get_sim_version` is the cheap change token its poll watches. + * + * Like the preview tools, envs are in-process — replay works within a warm + * server instance only (the same caveat as the gym itself). + */ + +import type { Document } from "@vcad/ir"; +import { getKernelWasm, resetKernelWasm } from "@vcad/engine"; +import { getEnvRecord } from "./gym.js"; +import { behavior, type ToolDef } from "./tool-def.js"; + +/** MCP tool result for the replay tools. Error paths set `isError: true` so + * hosts treat them as failures rather than reading a `{"error": ...}` body + * as a successful result. */ +type ReplayResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +/** + * FNV-1a change token over `(env_id, reset_epoch, step_count)` — same hash + * the preview poll uses (`previewVersion` in tools/preview.ts), but keyed on + * the env's counters instead of document bytes: no FK, no serialization, so + * the viewer can poll it every tick. `resetEpoch` participates so an + * equal-length rollout AFTER a gym_reset still changes the token (stepCount + * alone rewinds to 0 and would collide with the prior episode). + */ +function replayVersion(envId: string, resetEpoch: number, stepCount: number): string { + const s = `${envId}:${resetEpoch}:${stepCount}`; + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +/** One replay pose on the wire: plain `[x, y, z]` ARRAYS for every component + * — the viewer contract (`SimTrsLike` in viewer-app/main.ts indexes + * `translation[0]` etc.), NOT the IR's `{x, y, z}` Vec3 objects. */ +interface SimTrs { + translation: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +} + +/** A Map (serde_wasm_bindgen HashMap) or plain object → plain record. */ +function toRecord(value: unknown): Record { + if (value instanceof Map) { + return Object.fromEntries(value as Map); + } + if (value && typeof value === "object") { + return value as Record; + } + return {}; +} + +/** Normalize one Vec3-ish value ({x,y,z} object, Map, or an already-array + * triple) to a plain `[x, y, z]` array; anything unusable → `fallback`. */ +function toVec3Array( + value: unknown, + fallback: [number, number, number], +): [number, number, number] { + if (Array.isArray(value) && value.length >= 3) { + const [x, y, z] = value as unknown[]; + if (typeof x === "number" && typeof y === "number" && typeof z === "number") { + return [x, y, z]; + } + return fallback; + } + const rec = toRecord(value); + const { x, y, z } = rec as { x?: unknown; y?: unknown; z?: unknown }; + if (typeof x === "number" && typeof y === "number" && typeof z === "number") { + return [x, y, z]; + } + return fallback; +} + +/** + * Normalize the kernel's FK return into the viewer wire shape. + * `serde_wasm_bindgen` emits a JS `Map` for the Rust HashMap and Vec3 fields + * as `{x, y, z}` structs — but the viewer's `SimTrsLike` contract is plain + * `[x, y, z]` ARRAYS (it reads `translation[0]`, `rotation[0]`…; object + * fields would index to `undefined` and NaN every pose). Every Transform3D + * is therefore normalized server-side to array triples here. + * + * The kernel is called directly (via `getKernelWasm`, the record_simulation + * pattern) rather than through `@vcad/engine`'s `solveForwardKinematics` + * wrapper, which mis-handles the Map return and comes back empty. + */ +function toTransformRecord(value: unknown): Record { + const raw = toRecord(value); + const out: Record = {}; + for (const [id, t] of Object.entries(raw)) { + const rec = toRecord(t); + out[id] = { + translation: toVec3Array(rec.translation, [0, 0, 0]), + rotation: toVec3Array(rec.rotation, [0, 0, 0]), + scale: toVec3Array(rec.scale, [1, 1, 1]), + }; + } + return out; +} + +/** JSON Schema for get_sim_replay input */ +export const getSimReplaySchema = { + type: "object" as const, + properties: { + env_id: { + type: "string" as const, + description: "Environment ID returned by create_robot_env", + }, + }, + required: ["env_id"], +}; + +/** JSON Schema for get_sim_version input */ +export const getSimVersionSchema = { + type: "object" as const, + properties: { + env_id: { + type: "string" as const, + description: "Environment ID returned by create_robot_env", + }, + }, + required: ["env_id"], +}; + +/** + * Return the recorded rollout for an env: the joint trajectory, rewards, + * done flags, and per-step FK-solved instance transforms. + * + * FK runs on a single clone of the stored assembly — each trajectory row is + * written into the clone's `joints[j].state` (the record_simulation pattern, + * tools/record.ts) and the kernel solver reconstructs world poses. Documents + * without joints or instances still return the trajectory with + * `instance_transforms: []`; the viewer falls back to static geometry. + */ +export async function getSimReplay(input: unknown): Promise { + const args = input as { env_id: string }; + + const record = getEnvRecord(args.env_id); + if (!record) { + return { + content: [ + { type: "text", text: JSON.stringify({ error: `Unknown env_id: ${args.env_id}` }) }, + ], + isError: true, + }; + } + + // Per-step FK: clone the stored assembly ONCE, write each trajectory row + // into the clone's joints[j].state, and let the kernel solver reconstruct + // world poses (the record_simulation pattern, tools/record.ts). The + // per-row serialization happens at the WASM boundary, so the single clone + // never aliases across rows. + // + // Joint-order contract: trajectory row[j] is written into doc.joints[j] — + // the SAME positional assumption record_simulation ships on (record.ts, + // its obs.joint_positions[j] → docClone.joints[j].state loop). The order + // is the kernel's: RobotEnv captures PhysicsWorld::joint_ids() at + // construction and emits observation.joint_positions in that fixed order + // (crates/vcad-kernel-physics gym.rs observe()). NOTE joint_ids() today + // iterates the joint_to_index HashMap (world.rs), so for multi-joint + // documents the emitted order is not guaranteed to equal doc.joints order + // — if the kernel ever exposes its joint id list, both this loop and + // record.ts should map by id instead of index. + const instanceTransforms: Array> = []; + const jointCount = record.document.joints?.length ?? 0; + const instanceCount = record.document.instances?.length ?? 0; + if (jointCount > 0 && instanceCount > 0 && record.trajectory.length > 0) { + try { + const wasm = (await getKernelWasm()) as unknown as { + solveForwardKinematics?: (docJson: string) => unknown; + }; + if (typeof wasm.solveForwardKinematics === "function") { + const docClone: Document = JSON.parse(JSON.stringify(record.document)); + for (const row of record.trajectory) { + for (let j = 0; j < docClone.joints!.length; j++) { + const pos = row[j]; + if (typeof pos === "number") docClone.joints![j]!.state = pos; + } + instanceTransforms.push( + toTransformRecord( + wasm.solveForwardKinematics(JSON.stringify(docClone)), + ), + ); + } + } + } catch (err) { + // A kernel trap poisons the shared WASM instance — recover it, then + // degrade to a transform-less replay (the viewer falls back to static + // geometry) rather than erroring the playback poll. + if (err instanceof WebAssembly.RuntimeError) { + resetKernelWasm(`get_sim_replay FK trap: ${err.message}`); + } + instanceTransforms.length = 0; + } + } + + const body = { + env_id: args.env_id, + document_id: record.documentId, + dt: record.dt, + substeps: record.substeps, + steps: record.trajectory.length, + total_steps: record.stepCount, + joint_trajectory: record.trajectory, + rewards: record.rewards, + dones: record.dones, + instance_transforms: instanceTransforms, + reset_epoch: record.resetEpoch, + version: replayVersion(args.env_id, record.resetEpoch, record.stepCount), + }; + + return { + content: [{ type: "text", text: JSON.stringify(body) }], + }; +} + +/** + * Return a cheap `{env_id, document_id, step_count, version}` change token + * for an env — no FK, no geometry. The viewer polls this to learn "did the + * rollout advance?" and only re-fetches the heavy replay when it did. + */ +export function getSimVersion(input: unknown): ReplayResult { + const args = input as { env_id: string }; + + const record = getEnvRecord(args.env_id); + if (!record) { + return { + content: [ + { type: "text", text: JSON.stringify({ error: `Unknown env_id: ${args.env_id}` }) }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + env_id: args.env_id, + document_id: record.documentId, + step_count: record.stepCount, + reset_epoch: record.resetEpoch, + version: replayVersion(args.env_id, record.resetEpoch, record.stepCount), + }), + }, + ], + }; +} + +export const toolDefs: ToolDef[] = [ + { + name: "get_sim_replay", + pack: null, + description: + "Return the recorded joint trajectory and per-step instance transforms for a physics env. Internal to the inline viewer's replay UI — agents should use gym_observe or record_simulation instead.", + inputSchema: getSimReplaySchema, + handler: (a) => getSimReplay(a), + behavior: behavior({ appOnly: true }), + }, + { + name: "get_sim_version", + pack: null, + description: + "Return a cheap {env_id, step_count, version} change token for a physics env (no FK eval). Internal to the inline viewer's replay poll — agents should ignore it.", + inputSchema: getSimVersionSchema, + handler: (a) => getSimVersion(a), + behavior: behavior({ appOnly: true }), + }, +]; diff --git a/packages/mcp/src/tools/tool-def.ts b/packages/mcp/src/tools/tool-def.ts index c0f1bbb79..57ca80265 100644 --- a/packages/mcp/src/tools/tool-def.ts +++ b/packages/mcp/src/tools/tool-def.ts @@ -52,6 +52,25 @@ export interface ToolContext { eventStore: SessionEventStore; fabricateStore: FabricateStore; shareStore: ShareStore; + /** + * Server-injected elicitation bridge (MCP URL-mode elicitation, SDK ≥1.29). + * Injected by `createServer` when the transport can carry an in-band + * elicitation round-trip; absent on stdio/legacy clients. `urlSupported()` + * re-checks the client's `elicitation.url` capability at CALL time + * (capabilities land only after initialize); `requestUrl` sends a + * `mode:"url"` elicitation and resolves with the human's action. Tools must + * treat this as an accelerator, never a dependency: every flow it fronts + * (e.g. spend approval) keeps its out-of-band fallback, and a thrown + * elicitation must never lose work already persisted. + */ + elicit?: { + urlSupported(): boolean; + requestUrl(p: { + message: string; + url: string; + elicitationId: string; + }): Promise<{ action: "accept" | "decline" | "cancel" }>; + }; } /** A tool's implementation. `args` is the raw MCP argument object. */ diff --git a/packages/mcp/src/tools/tool-metadata.ts b/packages/mcp/src/tools/tool-metadata.ts index d4b2f9be5..56af94a43 100644 --- a/packages/mcp/src/tools/tool-metadata.ts +++ b/packages/mcp/src/tools/tool-metadata.ts @@ -95,6 +95,7 @@ export const TOOL_METADATA: Record = { title: "Place Order", annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, }, + get_order_feed: { title: "Get Order Feed", annotations: RO_NET }, // ── Project BOM ──────────────────────────────────────────────────────── bom_create: { title: "Create BOM", annotations: RW }, @@ -122,6 +123,8 @@ export const TOOL_METADATA: Record = { // ── MCP Apps: app-only preview fetchers ──────────────────────────────── get_preview_glb: { title: "Get Preview GLB", annotations: RO }, get_preview_version: { title: "Get Preview Version", annotations: RO }, + get_sim_replay: { title: "Get Sim Replay", annotations: RO }, + get_sim_version: { title: "Get Sim Version", annotations: RO }, // ── Atomic multi-op editing ──────────────────────────────────────────── apply_edits: { title: "Apply Edits", annotations: RW }, diff --git a/packages/mcp/viewer-app/index.html b/packages/mcp/viewer-app/index.html index a53f1692f..dd8f50f40 100644 --- a/packages/mcp/viewer-app/index.html +++ b/packages/mcp/viewer-app/index.html @@ -185,6 +185,68 @@ #ask-input::placeholder { color: var(--text-tert); } #ask-send { display: inline-flex; } + /* ── Sim transport bar (create_robot_env replay) ───────────── */ + #transport { + position: absolute; left: 10px; right: 10px; bottom: 10px; + display: none; flex-direction: column; gap: 3px; + padding: 6px 10px; + background: var(--surface); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + user-select: none; + } + #transport.visible { display: flex; } + /* Bars stack: the ask composer rides above the transport when both show. */ + #transport.visible ~ #ask-bar { bottom: 76px; } + #tp-main { display: flex; align-items: center; gap: 8px; min-width: 0; } + .tp-btn { + flex: none; display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 22px; + background: var(--fill); color: var(--text-2); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + font: 500 11px var(--font-ui); + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + .tp-btn:hover { background: var(--hover); color: var(--text); } + #tp-scrub { flex: 1; min-width: 40px; height: 4px; accent-color: var(--brand); cursor: pointer; } + #tp-step { + flex: none; min-width: 52px; text-align: center; white-space: nowrap; + font: 10px var(--font-mono); color: var(--text-muted); + } + #tp-speed { + flex: none; height: 22px; padding: 0 4px; + background: var(--fill); color: var(--text-2); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + font: 500 10px var(--font-ui); + cursor: pointer; outline: none; + } + #tp-spark { flex: none; width: 88px; height: 18px; overflow: visible; } + #tp-spark-line { fill: none; stroke: var(--text-muted); stroke-width: 1.2; vector-effect: non-scaling-stroke; } + #tp-spark-dot { fill: var(--brand); } + #tp-live { + flex: none; display: inline-flex; align-items: center; + height: 16px; padding: 0 7px; border-radius: 999px; + background: var(--fill); color: var(--text-tert); + border: var(--hairline) solid var(--border); + font: 600 9px var(--font-ui); letter-spacing: 0.06em; text-transform: uppercase; + cursor: pointer; + } + #tp-live.on { + background: color-mix(in srgb, #98c379 18%, transparent); + color: #98c379; border-color: transparent; + } + #tp-live.ping { animation: pulse 0.6s ease-in-out 1; } + #tp-readout { + display: flex; align-items: center; gap: 8px; min-width: 0; + font: 10px var(--font-mono); color: var(--text-muted); + } + #tp-joints { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #tp-note { flex: none; font: italic 10px var(--font-ui); color: var(--text-tert); white-space: nowrap; } + /* ── Receipt ledger (build_receipt) ────────────────────────── */ /* Opaque on --surface by design: a proof you can't read is not a proof, so monospaced measured numbers never bleed onto the stage. */ @@ -241,6 +303,114 @@ .rcpt-viol { cursor: pointer; } .rcpt-viol:hover .k, .rcpt-viol:hover .v { color: var(--brand); } .rcpt-muted .k, .rcpt-muted .v { color: var(--text-tert); } + + /* ── Order dock (get_order_feed) ───────────────────────────── */ + /* Same opaque-ledger rationale as the receipt: money states must be + legible, never blended into the stage. Read-only by design — the + only affordance that leaves the iframe is the approve deep link. */ + #orders { + position: absolute; top: 8px; right: 8px; bottom: 8px; + width: 290px; max-width: 62%; + display: none; flex-direction: column; + background: var(--surface); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.30); + overflow: hidden; + } + #orders.visible { display: flex; } + #orders.collapsed { bottom: auto; } + #orders.collapsed #orders-body, #orders.collapsed #orders-foot { display: none; } + /* Yield to the receipt ledger / transport bar when they're open. */ + #receipt.visible ~ #orders { right: 306px; } + #transport.visible ~ #orders { bottom: 76px; } + #transport.visible ~ #orders.collapsed { bottom: auto; } + #orders-head { + flex: none; display: flex; align-items: center; gap: 8px; + padding: 8px 8px 8px 10px; + border-bottom: var(--hairline) solid var(--border-soft); + } + #orders-title { flex: 1; font: 600 12px var(--font-ui); color: var(--text); } + #orders-count { flex: none; font: 10px var(--font-mono); color: var(--text-tert); } + #orders-body { flex: 1; overflow-y: auto; padding: 8px 10px; } + #orders-foot { + flex: none; padding: 6px 10px; + border-top: var(--hairline) solid var(--border-soft); + font: 10px var(--font-mono); color: var(--text-muted); + } + .ord-card { + padding: 8px 0 9px; + border-bottom: var(--hairline) solid var(--border-soft); + } + .ord-card:first-child { padding-top: 2px; } + .ord-card:last-child { border-bottom: none; } + .ord-name { font: 600 12px var(--font-ui); color: var(--text); } + .ord-sub { + margin-top: 2px; + font: 10px var(--font-mono); color: var(--text-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .ord-total { display: flex; align-items: center; gap: 6px; margin-top: 5px; } + .ord-total b { font: 600 13px var(--font-ui); color: var(--text); } + .pill { + display: inline-flex; align-items: center; + height: 15px; padding: 0 7px; border-radius: 999px; + font: 600 9px var(--font-ui); letter-spacing: 0.05em; text-transform: uppercase; + } + .pill-estimate { background: color-mix(in srgb, var(--text-muted) 16%, transparent); color: var(--text-muted); } + .pill-quoted { background: color-mix(in srgb, #e5c07b 20%, transparent); color: #e5c07b; } + .pill-binding { background: color-mix(in srgb, #98c379 18%, transparent); color: #98c379; } + .ord-chips { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 6px; } + .ord-chip { + display: inline-flex; align-items: center; + height: 14px; padding: 0 6px; border-radius: 999px; + font: 500 9px var(--font-ui); + background: var(--fill); color: var(--text-tert); + border: var(--hairline) solid var(--border-soft); + } + .ord-chip.done { color: var(--text-muted); border-color: var(--border); } + .ord-chip.now { + background: color-mix(in srgb, var(--brand) 14%, transparent); + border-color: color-mix(in srgb, var(--brand) 45%, transparent); + color: var(--text); font-weight: 600; + } + .ord-chip.fail { + background: color-mix(in srgb, var(--brand) 16%, transparent); + border-color: transparent; color: var(--brand); font-weight: 600; + } + .ord-wait { margin-top: 5px; font: italic 10px var(--font-ui); color: var(--text-muted); } + .ord-banner { + margin-top: 6px; padding: 6px 8px; + background: color-mix(in srgb, #e5c07b 10%, transparent); + border: var(--hairline) solid color-mix(in srgb, #e5c07b 40%, transparent); + border-radius: var(--radius-sm); + } + .ord-banner-text { font: 10px var(--font-mono); color: var(--text-2); } + .ord-banner .btn { display: inline-flex; margin-top: 5px; } + .ord-rcpt { margin-top: 6px; } + .rcpt-chip { + display: inline-flex; align-items: center; + height: 15px; padding: 0 7px; border-radius: 999px; + font: 600 9px var(--font-ui); + } + .rcpt-chip.holds { background: color-mix(in srgb, #98c379 18%, transparent); color: #98c379; } + .rcpt-chip.bad { background: color-mix(in srgb, var(--brand) 16%, transparent); color: var(--brand); } + .rcpt-chip.unverified { background: color-mix(in srgb, var(--text-muted) 14%, transparent); color: var(--text-tert); } + .ord-evt-toggle { + margin-top: 6px; padding: 0; + background: transparent; border: none; + font: 500 10px var(--font-ui); color: var(--text-muted); + cursor: pointer; + } + .ord-evt-toggle:hover { color: var(--text); } + .ord-events { margin-top: 4px; } + .ord-evt { + display: flex; gap: 6px; padding: 2px 0; + font: 10px var(--font-mono); color: var(--text-muted); + } + .ord-evt .t { flex: none; color: var(--text-tert); } + .ord-evt .s { flex: none; color: var(--text-2); } + .ord-evt .n { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -257,6 +427,29 @@
+
+
+ + + 0 / 0 + + + +
+
+ + +
+
@@ -272,6 +465,15 @@
+
+
+ Orders + + +
+
+
+
diff --git a/packages/mcp/viewer-app/main.ts b/packages/mcp/viewer-app/main.ts index 659586d2d..4f4b238a8 100644 --- a/packages/mcp/viewer-app/main.ts +++ b/packages/mcp/viewer-app/main.ts @@ -46,6 +46,21 @@ const receiptHashEl = document.getElementById("receipt-hash")!; const receiptBodyEl = document.getElementById("receipt-body")!; const receiptRerunBtn = document.getElementById("receipt-rerun") as HTMLButtonElement; const receiptCloseBtn = document.getElementById("receipt-close") as HTMLButtonElement; +const transportEl = document.getElementById("transport")!; +const tpPlayBtn = document.getElementById("tp-play") as HTMLButtonElement; +const tpScrubEl = document.getElementById("tp-scrub") as HTMLInputElement; +const tpStepEl = document.getElementById("tp-step")!; +const tpSpeedEl = document.getElementById("tp-speed") as HTMLSelectElement; +const tpSparkLineEl = document.getElementById("tp-spark-line")!; +const tpSparkDotEl = document.getElementById("tp-spark-dot")!; +const tpLiveEl = document.getElementById("tp-live") as HTMLButtonElement; +const tpJointsEl = document.getElementById("tp-joints")!; +const tpNoteEl = document.getElementById("tp-note")!; +const ordersEl = document.getElementById("orders")!; +const ordersBodyEl = document.getElementById("orders-body")!; +const ordersCountEl = document.getElementById("orders-count")!; +const ordersFootEl = document.getElementById("orders-foot")!; +const ordersToggleBtn = document.getElementById("orders-toggle") as HTMLButtonElement; // Hostless dev harness (`#dev*`): selection affordances stay active but // protocol calls are logged instead of sent. @@ -316,8 +331,18 @@ new ResizeObserver(resize).observe(stageEl); resize(); // ── Animation loop ─────────────────────────────────────────── +// Sim replay playback hook — assigned by the transport section below. +// Declared here (before animate's first synchronous call) so the rAF +// loop can guard-call it without a TDZ trap. +let simTick: ((deltaSeconds: number) => void) | null = null; +let lastFrameMs = performance.now(); + function animate(): void { requestAnimationFrame(animate); + const now = performance.now(); + const deltaSeconds = (now - lastFrameMs) / 1000; + lastFrameMs = now; + if (simTick) simTick(deltaSeconds); controls.update(); renderer.render(scene, camera); } @@ -845,6 +870,43 @@ function findPreviewVersion(result: ToolResultLike): string | null { return null; } +/** Generic payload finder: structuredContent first (the only carrier + * ChatGPT's widget bridge exposes), then any JSON text block that + * satisfies the predicate (Cursor / stdio fallback). */ +function findPayload( + result: ToolResultLike, + matches: (o: Record) => boolean, +): T | null { + const sc = result.structuredContent; + if (sc && matches(sc)) return sc as unknown as T; + for (const block of result.content ?? []) { + if (block?.type !== "text" || !block.text) continue; + try { + const parsed = JSON.parse(block.text) as unknown; + if ( + parsed && + typeof parsed === "object" && + matches(parsed as Record) + ) { + return parsed as T; + } + } catch { + // Not JSON — skip + } + } + return null; +} + +/** The GLB mode reported by get_preview_glb ("instances" when the scene + * carries one named node per assembly instance). */ +function findPreviewMode(result: ToolResultLike): string | null { + const hit = findPayload<{ mode?: string }>( + result, + (o) => typeof o.mode === "string", + ); + return hit?.mode ?? null; +} + /** * Fetch a document's GLB via the app-only preview tool and render it in * place. Records the version token so the self-refresh poll knows the @@ -855,9 +917,15 @@ async function fetchAndRenderGlb( docId: string, changed: PartsChanged | null, ): Promise { + // In an instance-driven sim session, geometry refreshes must keep the + // per-instance node layout or FK playback would lose its bind targets. + const wantInstances = simEnvId != null && simUseInstances; const previewResult = (await app.callServerTool({ name: "get_preview_glb", - arguments: { document_id: docId }, + arguments: { + document_id: docId, + ...(wantInstances ? { instances: true } : {}), + }, })) as ToolResultLike; const glb = findInlineGlb(previewResult); if (!glb) { @@ -866,7 +934,10 @@ async function fetchAndRenderGlb( } const ver = findPreviewVersion(previewResult); if (ver) lastPreviewVersion = ver; - renderGlbForDoc(glb, docId, changed); + // FK stays enabled only when the server actually served instances mode. + const isInstancesGlb = + wantInstances && findPreviewMode(previewResult) === "instances"; + renderGlbForDoc(glb, docId, changed, isInstancesGlb); } /** Capture VCode IR text for the "Open in vcad.io" button. */ @@ -962,11 +1033,16 @@ function flashChanged(changed: PartsChanged): void { } /** Render a freshly-fetched GLB for a document, holding the camera and - * re-binding the selection when it's the document already on screen. */ + * re-binding the selection when it's the document already on screen. + * `simInstancesGlb` marks the load as the sim session's INSTANCES-mode GLB + * — only then are FK bind targets (re)built and pose application enabled; + * any other GLB (flat/inline/part-segmented) disables FK until the + * instances GLB reloads, so replay poses can't land on part-id nodes. */ function renderGlbForDoc( glb: string, docId: string | null, changed: PartsChanged | null, + simInstancesGlb = false, ): void { const preserveCamera = docId != null && docId === renderedDocId; const keepPartId = preserveCamera ? selected?.partId ?? null : null; @@ -976,6 +1052,16 @@ function renderGlbForDoc( if (docId != null) renderedDocId = docId; if (keepPartId != null) reselectById(keepPartId); if (changed) flashChanged(changed); + // Re-bind FK targets after a reload — node objects are new — but ONLY + // for the instances-mode sim GLB; other GLBs of the same document use + // ":" node names that would collide with instance ids. + if (simEnvId && simInstancesGlb) { + buildSimInstanceIndex(); + simGlbActive = true; + } else { + simNodeIndex.clear(); + simGlbActive = false; + } }, }); } @@ -1177,6 +1263,886 @@ async function rerunReceipt(): Promise { receiptRerunBtn.addEventListener("click", () => void rerunReceipt()); receiptCloseBtn.addEventListener("click", () => receiptEl.classList.remove("visible")); +// ── Sim replay — the play button (create_robot_env) ───────── +// create_robot_env mounts this canvas; its result text carries +// { env_id, document_id }. We fetch the instance-segmented GLB (one named +// node per assembly instance), pull the recorded rollout via the app-only +// get_sim_replay tool, and play it back client-side: linear interpolation +// between per-step instance transforms (slerp for rotation), wall-clock +// step rate = dt × substeps / speed. The trajectory is data, so scrubbing +// is free; a live-follow badge pins the playhead to the newest step while +// the agent keeps stepping the env (get_sim_version is the change token). + +interface SimTrsLike { + translation?: [number, number, number]; + rotation?: [number, number, number]; // Euler XYZ, degrees + scale?: [number, number, number]; +} + +interface SimReplayLike { + env_id?: string; + document_id?: string; + dt?: number; + substeps?: number; + steps?: number; + total_steps?: number; + reset_epoch?: number; + joint_trajectory?: number[][]; + rewards?: number[]; + dones?: boolean[]; + instance_transforms?: Array>; + version?: string; +} + +let simEnvId: string | null = null; +let simDocId: string | null = null; +let simUseInstances = false; +let simReplay: SimReplayLike | null = null; +let simPlayhead = 0; // fractional step position +let simPlaying = false; +let simSpeed = 1; +let simFollow = true; // pin the playhead to the newest step +let simKnownStepCount = -1; // step counter of the last ADOPTED replay +let simKnownVersion: string | null = null; // version token of the last ADOPTED replay +let simLastReadoutStep = -1; +let lastPushedSimContext = ""; +const simNodeIndex = new Map(); +// FK poses may ONLY be applied while the rendered GLB is the sim session's +// instances-mode GLB. A flat pattern or a plain/inline (part-segmented) GLB +// of the SAME document shares node-name shapes (":") — applying +// instance transforms to those nodes would visibly mispose parts. +let simGlbActive = false; + +// Scratch objects — reused every frame so playback never churns the GC. +const _simEuler = new THREE.Euler(); +const _simQa = new THREE.Quaternion(); +const _simQb = new THREE.Quaternion(); +const DEG_TO_RAD = Math.PI / 180; + +function simStepCount(): number { + const r = simReplay; + if (!r) return 0; + return Math.max( + r.joint_trajectory?.length ?? 0, + r.rewards?.length ?? 0, + r.instance_transforms?.length ?? 0, + ); +} + +/** First ABSOLUTE step of the current replay window. The server keeps a + * ring buffer: once total_steps exceeds the window length, index k in the + * window is absolute step `base + k`. The scrub stays window-relative; + * only the readout and the model-context pushes speak absolute steps. */ +function simStepBase(): number { + const r = simReplay; + if (!r) return 0; + const total = r.total_steps ?? 0; + return Math.max(total - simStepCount(), 0); +} + +/** create_robot_env result → { env_id, document_id }. structuredContent + * first, then any JSON text block (the documented carrier). */ +function findSimEnv(result: ToolResultLike): { envId: string; docId: string } | null { + const hit = findPayload<{ env_id?: unknown; document_id?: unknown }>( + result, + (o) => + (typeof o.env_id === "string" || typeof o.env_id === "number") && + typeof o.document_id === "string", + ); + if (!hit) return null; + return { envId: String(hit.env_id), docId: String(hit.document_id) }; +} + +/** Index ":" nodes of the loaded scene for FK binding. + * First match wins so a nested duplicate never shadows the root node. */ +function buildSimInstanceIndex(): void { + simNodeIndex.clear(); + if (!currentModel) return; + currentModel.traverse((o) => { + const idx = o.name.indexOf(":"); + if (idx <= 0) return; + const id = o.name.slice(0, idx); + if (!simNodeIndex.has(id)) simNodeIndex.set(id, o); + }); +} + +function updateLiveBadge(): void { + tpLiveEl.classList.toggle("on", simFollow); + tpLiveEl.title = simFollow + ? "following the newest step" + : "click to jump to the newest step"; +} + +/** Brief pulse on the live badge when fresh steps arrive. */ +function flashLiveBadge(): void { + tpLiveEl.classList.remove("ping"); + // Force a reflow so re-adding restarts the animation. + void tpLiveEl.offsetWidth; + tpLiveEl.classList.add("ping"); + window.setTimeout(() => tpLiveEl.classList.remove("ping"), 700); +} + +// Reward sparkline: polyline over rewards[], with a progress dot that +// tracks the playhead. Min/max are cached at rebuild for the dot math. +let sparkMin = 0; +let sparkMax = 0; + +function rebuildSparkline(): void { + const rewards = simReplay?.rewards ?? []; + if (rewards.length < 2) { + tpSparkLineEl.setAttribute("points", ""); + tpSparkDotEl.setAttribute("cx", "-10"); + return; + } + sparkMin = Infinity; + sparkMax = -Infinity; + for (const r of rewards) { + if (r < sparkMin) sparkMin = r; + if (r > sparkMax) sparkMax = r; + } + const span = sparkMax - sparkMin || 1; + const pts: string[] = []; + for (let i = 0; i < rewards.length; i++) { + const x = (i / (rewards.length - 1)) * 100; + const y = 14 - ((rewards[i] - sparkMin) / span) * 12; + pts.push(`${x.toFixed(2)},${y.toFixed(2)}`); + } + tpSparkLineEl.setAttribute("points", pts.join(" ")); +} + +function updateSparkDot(step: number): void { + const rewards = simReplay?.rewards ?? []; + if (rewards.length === 0) { + tpSparkDotEl.setAttribute("cx", "-10"); + return; + } + const i = Math.min(step, rewards.length - 1); + const span = sparkMax - sparkMin || 1; + const x = rewards.length > 1 ? (i / (rewards.length - 1)) * 100 : 0; + const y = 14 - ((rewards[i] - sparkMin) / span) * 12; + tpSparkDotEl.setAttribute("cx", x.toFixed(2)); + tpSparkDotEl.setAttribute("cy", y.toFixed(2)); +} + +/** Step counter, scrub position, joint readout, spark dot — everything + * keyed to the integer step under the playhead. */ +function updateSimReadout(step: number, n: number): void { + const last = Math.max(n - 1, 0); + // Absolute step numbers in the readout: after ring-buffer wraparound the + // window slides, so window index k is episode step base + k. + const base = simStepBase(); + tpStepEl.textContent = `${base + step} / ${base + last}`; + tpScrubEl.max = String(last); + tpScrubEl.value = String(step); + const rep = simReplay; + const jt = rep?.joint_trajectory ?? []; + const joints = jt.length > 0 ? jt[Math.min(step, jt.length - 1)] ?? [] : []; + const bits: string[] = []; + for (let i = 0; i < Math.min(joints.length, 6); i++) { + bits.push(`j${i + 1} ${joints[i].toFixed(1)}°`); + } + if (joints.length > 6) bits.push(`+${joints.length - 6} joints`); + const rewards = rep?.rewards ?? []; + const reward = rewards.length > 0 ? rewards[Math.min(step, rewards.length - 1)] : undefined; + if (reward !== undefined) bits.push(`reward ${reward.toFixed(2)}`); + tpJointsEl.textContent = bits.join(" · "); + if (rep?.dt != null) { + tpStepEl.title = `dt ${(rep.dt * 1000).toFixed(0)} ms × ${rep.substeps ?? 1} substeps per step`; + } + updateSparkDot(step); +} + +/** Apply the interpolated frame at the current playhead to the named + * instance nodes. Transforms are in kernel Z-up space — nodes live under + * modelGroup, which owns the display rotation, so they apply directly. */ +function applySimFrame(forceReadout = false): void { + const rep = simReplay; + if (!rep) return; + const n = simStepCount(); + if (n === 0) return; + const p = Math.min(Math.max(simPlayhead, 0), n - 1); + const k = Math.floor(p); + const frac = p - k; + + // FK only while the rendered GLB is the instances-mode sim GLB — a flat + // pattern or plain part-segmented GLB must never be re-posed (its nodes + // are indexed by part id, not instance id). + const tf = rep.instance_transforms ?? []; + if (simGlbActive && tf.length > 0 && simNodeIndex.size > 0) { + const a = tf[Math.min(k, tf.length - 1)]; + const b = tf[Math.min(k + 1, tf.length - 1)] ?? a; + if (a) { + for (const id of Object.keys(a)) { + const node = simNodeIndex.get(id); + const ta = a[id]; + if (!node || !ta) continue; + const tb = b?.[id] ?? ta; + const pa = ta.translation; + const pb = tb.translation ?? pa; + if (pa && pb) { + node.position.set( + pa[0] + (pb[0] - pa[0]) * frac, + pa[1] + (pb[1] - pa[1]) * frac, + pa[2] + (pb[2] - pa[2]) * frac, + ); + } + const sa = ta.scale; + const sb = tb.scale ?? sa; + if (sa && sb) { + node.scale.set( + sa[0] + (sb[0] - sa[0]) * frac, + sa[1] + (sb[1] - sa[1]) * frac, + sa[2] + (sb[2] - sa[2]) * frac, + ); + } + const ra = ta.rotation; + const rb = tb.rotation ?? ra; + if (ra && rb) { + // Euler order "ZYX": the kernel's Transform3D convention is + // R = Rz·Ry·Rx (rotate about world X, then Y, then Z — see + // crates/vcad-eval/src/kinematics.rs euler_to_matrix and + // packages/engine/src/evaluate.ts transformMesh, the authority), + // which three.js spells "ZYX". Matches eulerXyzDegToQuat in + // src/export/glb.ts, so replay poses agree with the GLB's own + // node rotations. + _simEuler.set(ra[0] * DEG_TO_RAD, ra[1] * DEG_TO_RAD, ra[2] * DEG_TO_RAD, "ZYX"); + _simQa.setFromEuler(_simEuler); + _simEuler.set(rb[0] * DEG_TO_RAD, rb[1] * DEG_TO_RAD, rb[2] * DEG_TO_RAD, "ZYX"); + _simQb.setFromEuler(_simEuler); + _simQa.slerp(_simQb, frac); + node.quaternion.copy(_simQa); + } + } + } + } + + const step = Math.floor(p); + if (forceReadout || step !== simLastReadoutStep) { + simLastReadoutStep = step; + updateSimReadout(step, n); + } +} + +function setSimPlaying(on: boolean): void { + if (simPlaying === on) return; + simPlaying = on; + tpPlayBtn.textContent = on ? "⏸" : "▶"; + tpPlayBtn.title = on ? "Pause" : "Play"; +} + +/** Short context note on pause so "why did it stop there" typed in chat + * is grounded. Capability-guarded like the selection push; text-only. */ +async function pushSimContext(): Promise { + if (!simEnvId || !simReplay) return; + const n = simStepCount(); + if (n === 0) return; + const t = Math.min(Math.floor(simPlayhead), n - 1); + const rewards = simReplay.rewards ?? []; + const r = rewards.length > 0 ? rewards[Math.min(t, rewards.length - 1)] : undefined; + // Absolute step in the context note — after ring-buffer wraparound the + // window index t is episode step base + t (the model correlates this with + // its own action sequence, so window-relative numbers would mislead it). + const text = `viewer_sim: paused at step t=${simStepBase() + t}, reward=${r != null ? r.toFixed(3) : "n/a"}`; + if (text === lastPushedSimContext) return; + lastPushedSimContext = text; + if (devMode) { + console.log("[vcad-viewer:dev] updateModelContext:", text); + return; + } + const caps = app.getHostCapabilities(); + if (!caps?.updateModelContext) return; + try { + await app.updateModelContext({ content: [{ type: "text", text }] }); + } catch (e) { + console.warn("[vcad-viewer] updateModelContext failed:", e); + } +} + +/** Install a replay (fresh fetch or dev-synthesized) and refresh the + * transport chrome. Following → snap the playhead to the newest step. + * No-op after exitSimMode: a replay landing from an in-flight fetch must + * never resurrect the transport bar over a different document. */ +function adoptSimReplay(rep: SimReplayLike, jumpToNewest: boolean): void { + if (!simEnvId) return; // sim mode exited while the fetch was in flight + simReplay = rep; + // Commit the change tokens HERE — on successful adoption — so a failed + // replay fetch leaves them stale and the next poll retries (see + // pollSimVersion). + simKnownStepCount = rep.total_steps ?? rep.steps ?? simStepCount(); + simKnownVersion = typeof rep.version === "string" ? rep.version : null; + rebuildSparkline(); + const n = simStepCount(); + const hasFk = Boolean( + rep.instance_transforms?.some((row) => row && Object.keys(row).length > 0), + ); + tpNoteEl.textContent = hasFk + ? "" + : "no articulated assembly — showing trajectory only"; + if (jumpToNewest && simFollow) simPlayhead = Math.max(n - 1, 0); + else simPlayhead = Math.min(simPlayhead, Math.max(n - 1, 0)); + simLastReadoutStep = -1; + applySimFrame(true); + transportEl.classList.add("visible"); + updateLiveBadge(); +} + +/** Fetch + adopt the replay. Returns true only when a replay was ADOPTED — + * callers (pollSimVersion) treat false as "retry next poll". Guards both + * ends of the round trip against sim mode exiting / switching env while + * the call was in flight (the zombie-transport hazard). */ +async function refreshSimReplay(jumpToNewest: boolean): Promise { + const envId = simEnvId; + if (!envId) return false; + const res = (await app.callServerTool({ + name: "get_sim_replay", + arguments: { env_id: envId }, + })) as ToolResultLike; + if (simEnvId !== envId) return false; // exited/switched during the fetch + const rep = findPayload( + res, + (o) => Array.isArray(o.joint_trajectory) || Array.isArray(o.instance_transforms), + ); + if (!rep) return false; + adoptSimReplay(rep, jumpToNewest); + return true; +} + +/** Fetch the instance-segmented GLB; fall back to the plain preview when + * the server returns nothing or doesn't speak instances mode. */ +async function loadSimGlb(docId: string): Promise { + let res = (await app.callServerTool({ + name: "get_preview_glb", + arguments: { document_id: docId, instances: true }, + })) as ToolResultLike; + let glb = findInlineGlb(res); + simUseInstances = Boolean(glb) && findPreviewMode(res) === "instances"; + if (!glb) { + res = (await app.callServerTool({ + name: "get_preview_glb", + arguments: { document_id: docId }, + })) as ToolResultLike; + glb = findInlineGlb(res); + simUseInstances = false; + } + if (!glb) { + setStatus("no geometry to preview", "idle"); + return; + } + const ver = findPreviewVersion(res); + if (ver) lastPreviewVersion = ver; + // afterLoad rebuilds the FK node index (instances mode only). + renderGlbForDoc(glb, docId, null, simUseInstances); +} + +/** Enter (or re-enter) sim mode for a create_robot_env result. */ +async function enterSimMode(envId: string, docId: string): Promise { + const isNewEnv = envId !== simEnvId; + simEnvId = envId; + simDocId = docId; + lastDocumentId = docId; + docLabelEl.textContent = docId; + openBtn.style.display = "inline-flex"; + if (isNewEnv) { + setSimPlaying(false); + simReplay = null; + simPlayhead = 0; + simFollow = true; + simKnownStepCount = -1; + simKnownVersion = null; + simLastReadoutStep = -1; + lastPushedSimContext = ""; + } + setStatus(docId === renderedDocId ? "updating…" : "loading simulation…"); + try { + await loadSimGlb(docId); + } catch (e) { + console.error("[vcad-viewer] sim preview fetch failed:", e); + setStatus("preview unavailable", "error"); + errEl.textContent = e instanceof Error ? e.message : String(e); + } + try { + // If the replay isn't up yet, the sim-version poll below self-heals. + await refreshSimReplay(true); + } catch (e) { + console.warn("[vcad-viewer] get_sim_replay failed:", e); + setTicker("replay unavailable", "error"); + } + updateLiveBadge(); +} + +/** A different document mounting over a sim session ends the replay. */ +function exitSimMode(): void { + simEnvId = null; + simDocId = null; + simReplay = null; + simUseInstances = false; + simGlbActive = false; + simKnownVersion = null; + setSimPlaying(false); + simNodeIndex.clear(); + transportEl.classList.remove("visible"); +} + +/** Piggybacks on the adaptive preview poll: cheap step_count token, full + * replay re-fetch only on change. Returns true when new steps arrived so + * the shared loop stays on the fast cadence while the env is stepping. */ +async function pollSimVersion(): Promise { + if (!simEnvId) return false; + if (typeof document !== "undefined" && document.hidden) return false; + let count: number | null = null; + let version: string | null = null; + try { + const res = (await app.callServerTool({ + name: "get_sim_version", + arguments: { env_id: simEnvId }, + })) as ToolResultLike; + const v = findPayload<{ step_count?: number; version?: string }>( + res, + (o) => typeof o.step_count === "number", + ); + count = v?.step_count ?? null; + if (typeof v?.version === "string") version = v.version; + } catch { + return false; // transient failure — next tick retries + } + if (count == null) return false; + // Prefer the version token when both sides have one — it also folds in the + // server's reset epoch, so an equal-length rollout after gym_reset still + // reads as changed. Fall back to the raw step counter for older servers. + const unchanged = + version != null && simKnownVersion != null + ? version === simKnownVersion + : count === simKnownStepCount; + if (unchanged) return false; + // The known tokens are committed by adoptSimReplay ONLY after the replay + // re-fetch succeeds — a transient fetch failure leaves them stale so the + // next poll retries instead of freezing playback at the old rollout. + let refreshed = false; + try { + refreshed = await refreshSimReplay(true); + } catch { + return false; + } + if (!refreshed) return false; + flashLiveBadge(); + return true; +} + +// Playback engine: wall-clock rate = dt × substeps / speed seconds per +// step, linear interpolation between rows (slerp for rotation). +function simFrameTick(deltaSeconds: number): void { + if (!simPlaying || !simReplay) return; + const n = simStepCount(); + if (n <= 1) return; + const stepSeconds = + ((simReplay.dt ?? 0.01) * (simReplay.substeps ?? 1)) / simSpeed; + if (stepSeconds <= 0) return; + // Clamp delta spikes so a backgrounded tab doesn't teleport the playhead. + simPlayhead += Math.min(deltaSeconds, 0.25) / stepSeconds; + if (simPlayhead >= n - 1) { + simPlayhead = n - 1; + setSimPlaying(false); + void pushSimContext(); + } + applySimFrame(); +} +simTick = simFrameTick; + +tpPlayBtn.addEventListener("click", () => { + if (!simReplay) return; + if (simPlaying) { + setSimPlaying(false); + void pushSimContext(); + return; + } + const n = simStepCount(); + if (n > 1 && simPlayhead >= n - 1) { + // Hitting play at the end re-arms live-follow and replays from the top. + simFollow = true; + updateLiveBadge(); + simPlayhead = 0; + } + setSimPlaying(true); +}); + +tpScrubEl.addEventListener("input", () => { + if (!simReplay) return; + setSimPlaying(false); // pause on scrub drag + const n = simStepCount(); + const v = Math.min(Number(tpScrubEl.value) || 0, Math.max(n - 1, 0)); + simPlayhead = v; + if (v < n - 1) { + simFollow = false; // scrubbed back — stop chasing the newest step + updateLiveBadge(); + } + applySimFrame(true); +}); +// Context push on drag end only, so scrubbing doesn't spam the host. +tpScrubEl.addEventListener("change", () => void pushSimContext()); + +tpSpeedEl.addEventListener("change", () => { + simSpeed = Number(tpSpeedEl.value) || 1; +}); + +tpLiveEl.addEventListener("click", () => { + if (!simReplay) return; + simFollow = true; + updateLiveBadge(); + simPlayhead = Math.max(simStepCount() - 1, 0); + applySimFrame(true); +}); + +// ── Order dock — fused vcad+kerf order lifecycle ───────────── +// Read-only by contract: get_order_feed is the ONLY tool this dock calls; +// approval happens on vcad.io via openLink and decline goes through the +// agent — no money action ever originates in this iframe. Polls slow +// (10s) normally, fast (2.5s) while any order is transitional +// (approval/placing), and backs way off after repeated failures (the +// feed tool may not be deployed on every server yet). + +interface OrderAuthorizationLike { + status?: string; + max_amount_usd?: number; + cap_usd?: number; // spec-doc name — guarded alongside the wire name + expires_at?: string; + approve_url?: string; +} + +interface OrderEventLike { + state?: string; + type?: string; // spec-doc name for the same field + at?: string; + note?: string; +} + +interface OrderLike { + order_id: string; + state_chip?: string; + raw_state?: string; + process?: string; + quantity?: number; + total_amount_usd?: number; + pricing_basis?: string; + vendor?: string; + vendor_display_name?: string; // spec-doc name — guarded + lead_time_days?: number; + quote_expires_at?: string; + created_at?: string; + events?: OrderEventLike[]; + authorization?: OrderAuthorizationLike | null; + tracking?: unknown; + receipt?: { status?: string } | null; + kerf_intent_hash?: string; +} + +interface OrderFeedLike { + orders?: OrderLike[]; + wallet_balance_usd?: number | null; + version?: string; +} + +const ORDER_STOPS = [ + "quoted", + "approval", + "placing", + "confirmed", + "production", + "delivered", +]; + +const ORDER_POLL_FAST_MS = 2500; +const ORDER_POLL_SLOW_MS = 10000; +const ORDER_POLL_BACKOFF_MS = 60000; +const ORDER_POLL_FAILURE_LIMIT = 4; + +let orderPollStarted = false; +let orderPollHandle: ReturnType | undefined; +let orderPollFailures = 0; +let lastOrderFeed: OrderFeedLike | null = null; +let lastOrderFeedVersion: string | null = null; +let orderDockArmed = false; // first non-empty feed arms the dock for good +let ordersCollapsed = false; +const expandedOrders = new Set(); + +function usd(n: number): string { + return ( + "$" + + n.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ); +} + +function fmtWhen(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function orderCard(o: OrderLike): HTMLElement { + const card = document.createElement("div"); + card.className = "ord-card"; + + // Name line: process · qty. + const name = document.createElement("div"); + name.className = "ord-name"; + name.textContent = `${o.process ?? "order"}${o.quantity != null ? ` · ×${o.quantity}` : ""}`; + card.append(name); + + // Vendor · lead · quote expiry. + const subBits: string[] = []; + const vendor = o.vendor ?? o.vendor_display_name; + if (vendor) subBits.push(vendor); + if (o.lead_time_days != null) subBits.push(`${o.lead_time_days}d lead`); + if (o.quote_expires_at) subBits.push(`quote expires ${fmtWhen(o.quote_expires_at)}`); + if (subBits.length > 0) { + const sub = document.createElement("div"); + sub.className = "ord-sub"; + sub.textContent = subBits.join(" · "); + card.append(sub); + } + + // Total + pricing-basis pill (ACP-CM colors users learn to trust). + const total = document.createElement("div"); + total.className = "ord-total"; + const amount = document.createElement("b"); + amount.textContent = o.total_amount_usd != null ? usd(o.total_amount_usd) : "—"; + total.append(amount); + const basis = o.pricing_basis; + if (basis === "estimate" || basis === "quoted" || basis === "binding") { + const pill = document.createElement("span"); + pill.className = `pill pill-${basis}`; + pill.textContent = basis; + total.append(pill); + } + card.append(total); + + // Six-stop timeline; a failed order shows all stops muted + a red chip. + const chips = document.createElement("div"); + chips.className = "ord-chips"; + const failed = o.state_chip === "failed"; + const cur = failed ? -1 : ORDER_STOPS.indexOf(o.state_chip ?? ""); + ORDER_STOPS.forEach((stop, i) => { + const c = document.createElement("span"); + c.className = + "ord-chip" + + (cur >= 0 && i < cur ? " done" : "") + + (i === cur ? " now" : ""); + c.textContent = stop; + chips.append(c); + }); + if (failed) { + const c = document.createElement("span"); + c.className = "ord-chip fail"; + c.textContent = "failed"; + chips.append(c); + } + card.append(chips); + + // RECONCILING = an explained wait, never a retry affordance. + if ((o.raw_state ?? "").toUpperCase() === "RECONCILING") { + const wait = document.createElement("div"); + wait.className = "ord-wait"; + wait.textContent = + "reconciling with the vendor — verifying order state, no action needed"; + card.append(wait); + } + + // Approval banner: the human approves on vcad.io — the widget never + // approves and offers no decline (that goes through the agent). + const auth = o.authorization; + if (auth?.status === "pending_human") { + const banner = document.createElement("div"); + banner.className = "ord-banner"; + const text = document.createElement("div"); + text.className = "ord-banner-text"; + const cap = auth.max_amount_usd ?? auth.cap_usd; + const bits = ["needs your approval"]; + if (cap != null) bits.push(`cap ${usd(cap)}`); + if (auth.expires_at) bits.push(`expires ${fmtWhen(auth.expires_at)}`); + text.textContent = bits.join(" · "); + banner.append(text); + if (auth.approve_url) { + const url = auth.approve_url; + const btn = document.createElement("button"); + btn.className = "btn btn-brand"; + btn.textContent = "Approve in vcad.io"; + btn.addEventListener("click", () => { + app.openLink({ url }).catch(() => window.open(url, "_blank")); + }); + banner.append(btn); + } + card.append(banner); + } + + // Receipt chip — the design-half verification verdict. + const rs = o.receipt?.status ?? "unverified"; + const rcptRowEl = document.createElement("div"); + rcptRowEl.className = "ord-rcpt"; + const rcptChip = document.createElement("span"); + rcptChip.className = + "rcpt-chip " + + (rs === "holds" ? "holds" : rs === "stale" || rs === "violated" ? "bad" : "unverified"); + rcptChip.textContent = `receipt ${rs}`; + rcptRowEl.append(rcptChip); + card.append(rcptRowEl); + + // Event log expander (collapsed by default) — raw vcad+kerf states. + const events = o.events ?? []; + if (events.length > 0) { + const expanded = expandedOrders.has(o.order_id); + const label = (open: boolean): string => + `${events.length} ${events.length === 1 ? "event" : "events"} ${open ? "▾" : "▸"}`; + const toggle = document.createElement("button"); + toggle.className = "ord-evt-toggle"; + toggle.textContent = label(expanded); + const list = document.createElement("div"); + list.className = "ord-events"; + list.style.display = expanded ? "block" : "none"; + for (const ev of events) { + const row = document.createElement("div"); + row.className = "ord-evt"; + const t = document.createElement("span"); + t.className = "t"; + t.textContent = ev.at ? fmtWhen(ev.at) : ""; + const s = document.createElement("span"); + s.className = "s"; + s.textContent = ev.state ?? ev.type ?? "event"; + row.append(t, s); + if (ev.note) { + const noteEl = document.createElement("span"); + noteEl.className = "n"; + noteEl.textContent = ev.note; + noteEl.title = ev.note; + row.append(noteEl); + } + list.append(row); + } + toggle.addEventListener("click", () => { + const open = list.style.display === "none"; + list.style.display = open ? "block" : "none"; + if (open) expandedOrders.add(o.order_id); + else expandedOrders.delete(o.order_id); + toggle.textContent = label(open); + }); + card.append(toggle, list); + } + + return card; +} + +function renderOrderFeed(feed: OrderFeedLike): void { + const orders = feed.orders ?? []; + ordersCountEl.textContent = String(orders.length); + ordersBodyEl.innerHTML = ""; + if (orders.length === 0) { + const empty = document.createElement("div"); + empty.className = "ord-sub"; + empty.textContent = "no orders yet"; + ordersBodyEl.append(empty); + } else { + for (const o of orders) ordersBodyEl.append(orderCard(o)); + } + if (feed.wallet_balance_usd != null) { + ordersFootEl.textContent = `Wallet ${usd(feed.wallet_balance_usd)}`; + ordersFootEl.style.display = "block"; + } else { + ordersFootEl.textContent = ""; + ordersFootEl.style.display = "none"; + } +} + +function applyOrderFeed(feed: OrderFeedLike): void { + const version = typeof feed.version === "string" ? feed.version : null; + const unchanged = + version != null && version === lastOrderFeedVersion && lastOrderFeed != null; + lastOrderFeed = feed; + lastOrderFeedVersion = version; + if ((feed.orders?.length ?? 0) > 0) orderDockArmed = true; + if (!orderDockArmed) return; // stay hidden until the first non-empty feed + if (!unchanged) renderOrderFeed(feed); + ordersEl.classList.add("visible"); +} + +function orderFeedTransitional(): boolean { + return Boolean( + lastOrderFeed?.orders?.some( + (o) => o.state_chip === "approval" || o.state_chip === "placing", + ), + ); +} + +async function pollOrderFeed(): Promise { + if (!lastDocumentId) return; // nothing mounted yet — stay lazy + if (typeof document !== "undefined" && document.hidden) return; + try { + const res = (await app.callServerTool({ + name: "get_order_feed", + arguments: { document_id: lastDocumentId }, + })) as ToolResultLike; + orderPollFailures = 0; + const feed = findPayload(res, (o) => Array.isArray(o.orders)); + if (feed) applyOrderFeed(feed); + } catch { + // Tool may not be deployed on this server — count and back off. + orderPollFailures++; + } +} + +function scheduleNextOrderPoll(): void { + const delay = + orderPollFailures >= ORDER_POLL_FAILURE_LIMIT + ? ORDER_POLL_BACKOFF_MS + : orderFeedTransitional() + ? ORDER_POLL_FAST_MS + : ORDER_POLL_SLOW_MS; + if (orderPollHandle) clearTimeout(orderPollHandle); + orderPollHandle = setTimeout(() => void runOrderPoll(), delay); +} + +// In-flight guard: visibilitychange fires runOrderPoll directly, and while a +// poll is awaiting the server its timer id is already consumed — without the +// guard each hide/show during an in-flight poll would fork a SECOND +// setTimeout chain that then polls forever in parallel. +let orderPollInFlight = false; + +async function runOrderPoll(): Promise { + if (orderPollInFlight) return; // the live chain reschedules on completion + orderPollInFlight = true; + try { + await pollOrderFeed(); + } finally { + orderPollInFlight = false; + scheduleNextOrderPoll(); + } +} + +function startOrderPolling(): void { + if (orderPollStarted) return; + orderPollStarted = true; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.hidden) return; + if (orderPollHandle) clearTimeout(orderPollHandle); + void runOrderPoll(); + }); + } + scheduleNextOrderPoll(); +} + +ordersToggleBtn.addEventListener("click", () => { + ordersCollapsed = !ordersCollapsed; + ordersEl.classList.toggle("collapsed", ordersCollapsed); + ordersToggleBtn.textContent = ordersCollapsed ? "▸" : "▾"; + ordersToggleBtn.title = ordersCollapsed ? "Expand" : "Collapse"; +}); + // ── Host protocol ──────────────────────────────────────────── // MCP Apps hosts (Claude, Cursor) speak the SEP-1865 postMessage // protocol via the App class; ChatGPT injects `window.openai` instead — @@ -1209,7 +2175,7 @@ function applyHostContext(ctx: McpUiHostContext | undefined): void { } app.onhostcontextchanged = (params) => { - applyHostContext(params.hostContext); + applyHostContext(params.hostContext as McpUiHostContext | undefined); // Hosts may announce availableDisplayModes only after connect — retry the // one-shot dock when the context (finally) says pip is supported. void maybeAutoDock(); @@ -1244,14 +2210,30 @@ async function handleToolResult(result: ToolResultLike): Promise { if (flat) { const docId = result.structuredContent?.document_id ?? findDocumentId(result); if (typeof docId === "string") { + // Sim mode is keyed to the sim document — a different document's + // flat pattern mounting over it ends the replay. + if (simEnvId && docId !== simDocId) exitSimMode(); lastDocumentId = docId; docLabelEl.textContent = docId; openBtn.style.display = "inline-flex"; } + // The 2D drawing replaces the 3D model — drop the FK bind targets so a + // still-live replay (same document) can't re-pose flat geometry. + simNodeIndex.clear(); + simGlbActive = false; renderFlatPattern(flat); return; } + // Robot sim session (create_robot_env): env_id + document_id ride in the + // result text. Mount the instance GLB + replay transport instead of the + // plain preview path. + const simEnv = findSimEnv(result); + if (simEnv) { + await enterSimMode(simEnv.envId, simEnv.docId); + return; + } + // The parts this call changed (for the in-place flash). const changed = findChanged(result); @@ -1259,6 +2241,13 @@ async function handleToolResult(result: ToolResultLike): Promise { const inline = findInlineGlb(result); if (inline) { const inlineDoc = result.structuredContent?.document_id ?? findDocumentId(result); + // A different document's inline GLB mounting over a sim session ends the + // replay; a same-document inline GLB keeps sim mode but disables FK + // (renderGlbForDoc default) — inline GLBs are part-segmented, not the + // instances-mode layout the replay binds to. + if (typeof inlineDoc === "string" && simEnvId && inlineDoc !== simDocId) { + exitSimMode(); + } renderGlbForDoc(inline, typeof inlineDoc === "string" ? inlineDoc : null, changed); return; } @@ -1272,6 +2261,8 @@ async function handleToolResult(result: ToolResultLike): Promise { setStatus("no geometry to preview", "idle"); return; } + // A different document mounting over a sim session ends the replay. + if (simEnvId && docId !== simDocId) exitSimMode(); const sameDoc = docId === renderedDocId; lastDocumentId = docId; docLabelEl.textContent = docId; @@ -1401,14 +2392,29 @@ async function pollPreviewVersion(): Promise { function scheduleNextPoll(): void { const delay = pollIdleStreak >= POLL_IDLE_THRESHOLD ? POLL_SLOW_MS : POLL_FAST_MS; + if (pollHandle) clearTimeout(pollHandle); pollHandle = setTimeout(() => void runPoll(), delay); } +// Same in-flight guard as the order poll: a visibilitychange while a poll is +// awaiting the server must not fork a second parallel poll chain. +let previewPollInFlight = false; + async function runPoll(): Promise { - const before = lastPreviewVersion; - await pollPreviewVersion(); - pollIdleStreak = lastPreviewVersion !== before ? 0 : pollIdleStreak + 1; - scheduleNextPoll(); + if (previewPollInFlight) return; // the live chain reschedules on completion + previewPollInFlight = true; + try { + const before = lastPreviewVersion; + await pollPreviewVersion(); + const geomChanged = lastPreviewVersion !== before; + // Sim sessions piggyback the same adaptive cadence: fast while the env + // is stepping (step_count advancing), slow once it goes quiet. + const simChanged = await pollSimVersion(); + pollIdleStreak = geomChanged || simChanged ? 0 : pollIdleStreak + 1; + } finally { + previewPollInFlight = false; + scheduleNextPoll(); + } } function startPreviewPolling(): void { @@ -1453,6 +2459,69 @@ if (location.hash.startsWith("#dev")) { bbox: [-60, -40, 60, 40], }); docLabelEl.textContent = "dev-flat"; + } else if (location.hash === "#dev-sim") { + // Articulated pendulum with instance-named nodes (":") + // and a synthesized replay so the transport bar can be exercised + // hostless: play/pause, scrub, speed, sparkline, joint readout. + const sample = new THREE.Group(); + const steel = new THREE.MeshStandardMaterial({ color: 0x9da3ab, metalness: 0.9, roughness: 0.35 }); + const pink = new THREE.MeshStandardMaterial({ color: 0xf92672, metalness: 0.0, roughness: 0.55 }); + const base = new THREE.Mesh(new THREE.BoxGeometry(50, 50, 10), steel); + base.name = "1:base"; + base.position.z = 5; + const arm = new THREE.Mesh(new THREE.BoxGeometry(8, 8, 44), pink); + arm.name = "2:arm"; + arm.position.z = 32; + sample.add(base, arm); + tameMaterials(sample); + modelGroup.add(sample); + currentModel = sample; + hasModel = true; + updateAxesVisibility(); + controls.target.set(0, 25, 0); + camera.position.set(90, 80, 90); + controls.update(); + updateStats(sample, new THREE.Box3().setFromObject(sample).getSize(new THREE.Vector3())); + loadingEl.classList.add("hidden"); + setTicker("ready", "ready"); + docLabelEl.textContent = "dev-sim"; + + simEnvId = "env_dev"; + simDocId = "doc_dev"; + simUseInstances = true; + buildSimInstanceIndex(); + simGlbActive = true; // dev harness renders the instance-named scene directly + const steps = 240; + const joint: number[][] = []; + const rewards: number[] = []; + const dones: boolean[] = []; + const transforms: Array> = []; + for (let k = 0; k < steps; k++) { + const a = 55 * Math.sin(k * 0.06) * Math.exp(-k / 400); + joint.push([a]); + rewards.push(-Math.abs(a) / 55 + 0.02 * Math.sin(k * 0.5)); + dones.push(false); + transforms.push({ + "2": { translation: [0, 0, 32], rotation: [0, a, 0], scale: [1, 1, 1] }, + }); + } + adoptSimReplay( + { + env_id: "env_dev", + document_id: "doc_dev", + dt: 0.01, + substeps: 2, + steps, + total_steps: steps, + joint_trajectory: joint, + rewards, + dones, + instance_transforms: transforms, + version: "dev", + }, + false, + ); + setSimPlaying(true); } else { const sample = new THREE.Group(); const steel = new THREE.MeshStandardMaterial({ color: 0x9da3ab, metalness: 0.9, roughness: 0.35 }); @@ -1523,12 +2592,95 @@ if (location.hash.startsWith("#dev")) { sourcing: { lines: [1, 2, 3] }, }); } + if (location.hash === "#dev-orders") { + const now = Date.now(); + const iso = (offsetMs: number): string => new Date(now + offsetMs).toISOString(); + applyOrderFeed({ + orders: [ + { + order_id: "ord_dev1", + state_chip: "approval", + raw_state: "AWAITING_AUTHORIZATION", + process: "sheet_metal", + quantity: 5, + total_amount_usd: 182.4, + pricing_basis: "quoted", + vendor: "SendCutSend", + lead_time_days: 6, + quote_expires_at: iso(2 * 86400e3), + created_at: iso(-3600e3), + events: [ + { state: "QUOTED", at: iso(-3600e3), note: "laser + bend, 5052-H32 2.0mm" }, + { state: "AWAITING_AUTHORIZATION", at: iso(-1800e3) }, + ], + authorization: { + status: "pending_human", + max_amount_usd: 200, + expires_at: iso(86400e3), + approve_url: "https://vcad.io/authorize/auth_dev", + }, + tracking: null, + receipt: { status: "holds" }, + }, + { + order_id: "ord_dev2", + state_chip: "placing", + raw_state: "RECONCILING", + process: "cnc", + quantity: 1, + total_amount_usd: 512, + pricing_basis: "binding", + vendor: "Protolabs", + lead_time_days: 9, + created_at: iso(-7200e3), + events: [ + { state: "PLACING", at: iso(-600e3) }, + { state: "RECONCILING", at: iso(-60e3), note: "vendor confirmation pending" }, + ], + authorization: null, + tracking: null, + receipt: { status: "unverified" }, + }, + { + order_id: "ord_dev3", + state_chip: "failed", + raw_state: "FAILED_VALIDATION", + process: "3dp_sls", + quantity: 12, + total_amount_usd: 96.05, + pricing_basis: "estimate", + vendor: "JLC3DP", + created_at: iso(-2 * 86400e3), + events: [ + { state: "QUOTED", at: iso(-2 * 86400e3) }, + { state: "FAILED_VALIDATION", at: iso(-86400e3), note: "wall thickness below process minimum" }, + ], + authorization: null, + tracking: null, + receipt: { status: "violated" }, + }, + ], + wallet_balance_usd: 250, + version: "dev", + }); + } // Debug handle for poking the scene from the console. `loadGlb` lets a // harness swap in an arbitrary base64 GLB (e.g. a generated PCB preview). (window as unknown as Record).__vcad = { scene, camera, controls, grid, gridUniforms, contactShadow, renderer, select, partInfoFor, modelGroup, loadGlb, renderGlbForDoc, + adoptSimReplay, applySimFrame, applyOrderFeed, renderOrderFeed, get renderedDocId() { return renderedDocId; }, + get simState() { + return { + envId: simEnvId, + playhead: simPlayhead, + playing: simPlaying, + follow: simFollow, + steps: simStepCount(), + instances: [...simNodeIndex.keys()], + }; + }, }; } else { // ── Connect (handlers are all registered above) ──────────── @@ -1542,6 +2694,9 @@ if (location.hash.startsWith("#dev")) { ); // Keep the one mounted canvas live as the agent mutates the document. startPreviewPolling(); + // Lazy order feed: ticks idle until a document mounts, then renders the + // dock on the first non-empty feed. + startOrderPolling(); // Dock as a side panel when the host supports it, so it persists and // updates across the conversation rather than scrolling away. void maybeAutoDock(); diff --git a/supabase/migrations/034_fabricate_order_enrichment.sql b/supabase/migrations/034_fabricate_order_enrichment.sql new file mode 100644 index 000000000..97e0bff72 --- /dev/null +++ b/supabase/migrations/034_fabricate_order_enrichment.sql @@ -0,0 +1,63 @@ +-- vcad Fabricate — order enrichment for the agent-native factory loop (M2/M4). +-- +-- Closes the store.ts round-trip gaps the ordering gates and the order feed +-- need durable: +-- orders.fab_artifact — the fab-bundle HANDLE ({artifact_id, artifact_url, +-- bytes, manifest[{file, bytes, sha256}]}). Metadata +-- only, never bytes — files stay in the artifact +-- store; the manifest sha256s pin the exact bytes +-- the fab receives (and kerf's upload-hash oracle +-- verifies). Fixes the 024-era gap where the handle +-- survived only in memory + the order_placed event. +-- orders.receipt_status — design-receipt verdict recorded by place_order's +-- fail-closed gate at place time. 'stale'/'violated' +-- never reach a placed order (the gate refuses); +-- they are enumerated so a later re-verification +-- sweep can downgrade a stored status. +-- orders.kerf_intent_hash — kerf intent hash the order's quote was bound to: +-- sha256 of the canonical ConfiguratorIntent +-- (vendor + process + file sha256s + config + qty). +-- Geometry/config/quantity edit ⇒ new hash ⇒ the +-- vendor quote is dead ⇒ place_order refuses. +-- quotes.kerf_intent_hash — the same hash, recorded at quote time. +-- quotes.kerf_job_id — kerf quote-job id, the handle for job-state and +-- evidence-bundle lookups on the kerf rail. +-- +-- All five columns are SERVER-ONLY writes: the MCP server (service role) +-- records them; clients read their own rows via the existing RLS policies +-- ("Users manage own quotes/orders", migration 024) but have no reason to +-- write them, and no client-side path does. Additive + idempotent — safe to +-- run against a live database; pre-migration servers keep working (the store +-- retries writes without these keys on column skew). + +-- ── orders ─────────────────────────────────────────────────────────────────── + +alter table orders + add column if not exists fab_artifact jsonb; + +alter table orders + add column if not exists receipt_status text + check (receipt_status in ('holds', 'stale', 'violated', 'unverified')); + +alter table orders + add column if not exists kerf_intent_hash text; + +comment on column orders.fab_artifact is + 'Fab-bundle handle (artifact_id/url/bytes/manifest sha256s) — metadata only, never file bytes. Server-written (place_order / quote_manufacturing).'; +comment on column orders.receipt_status is + 'Design-receipt verdict recorded by place_order''s fail-closed gate. Server-written; holds = all clearance claims re-verified at place time, unverified = no claims to check.'; +comment on column orders.kerf_intent_hash is + 'kerf intent hash the order''s quote was bound to (geometry-edit tripwire). Server-written.'; + +-- ── quotes ─────────────────────────────────────────────────────────────────── + +alter table quotes + add column if not exists kerf_intent_hash text; + +alter table quotes + add column if not exists kerf_job_id text; + +comment on column quotes.kerf_intent_hash is + 'sha256 of the canonical kerf ConfiguratorIntent this quote priced — the identity the vendor quote (and any spend mandate) binds to. Server-written.'; +comment on column quotes.kerf_job_id is + 'kerf quote-job id for job-state / evidence-bundle lookups. Server-written.';