From 30b1ae042f481c39ab4cd64f20ecabeba6835766 Mon Sep 17 00:00:00 2001
From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:47:27 -0400
Subject: [PATCH 1/3] Control Kit link v1.2: STEP files and workbook to
download, part pictures, and AI review with Control Point's keys
- Files uploaded after a send (raw bytes, SHA-256 checked, 64 MB) are stored as CAD docs of the import and fill
its reference or placeholder entry. The robot page offers the newest STEP zip and workbook as downloads.
- Part pictures by identity and Control Kit's revision key: listed, stored, replaced, shown in BOM rows and the drawer.
- AI review for Control Kit: Gemini first, Anthropic once on a rate limit or server failure, 90-second calls,
20 an hour and 100 a day per workspace, strict request shape.
- Migration 154 adds cad_parts.ck_picture_*; docs/control-kit-link.md is this repo's copy of the contract.
---
docs/control-kit-link.md | 214 ++++++++++++++
migrations/versions/154-cad-part-pictures.sql | 14 +
server.ts | 1 +
server/__tests__/controlKitFiles.test.ts | 265 ++++++++++++++++++
server/cadRobots.ts | 3 +-
server/controlKitAi.ts | 118 ++++++++
server/controlKitLink.ts | 196 ++++++++++++-
src/components/cad/useRobots.ts | 4 +-
src/modern/__tests__/cadRobotBom.test.tsx | 35 ++-
src/modern/pages/cad/RobotPage.tsx | 73 ++++-
10 files changed, 905 insertions(+), 18 deletions(-)
create mode 100644 docs/control-kit-link.md
create mode 100644 migrations/versions/154-cad-part-pictures.sql
create mode 100644 server/__tests__/controlKitFiles.test.ts
create mode 100644 server/controlKitAi.ts
diff --git a/docs/control-kit-link.md b/docs/control-kit-link.md
new file mode 100644
index 000000000..62a685edd
--- /dev/null
+++ b/docs/control-kit-link.md
@@ -0,0 +1,214 @@
+# Control Kit ↔ Control Point link contract (v1.2)
+
+This is Control Point's copy of control-kit's `docs/control-point-link.md`. The two copies must stay identical below this note; change both in one pair of PRs.
+
+Written 2026-10-26 at the owner's request ("build both sides now"). Both repositories build to this document, so it is the confirmed contract that `control-point.ts` waited for. Change it only in both places at once.
+
+v1.1 (2026-10-26, the owner's CAD rebuild: robots by season, the whole BOM in Control Point, and choices made there): §2 gains the bundle's additive line fields and where an import lands, and §5 describes how choices made in Control Point reach Control Kit. Nothing in v1 changes meaning; `bundleVersion` stays 2 and every new field is optional for a reader.
+
+v1.2 (2026-10-28, the owner's requests "i should also be able to download all the parts in control point itself, also show the preview images in control point too" and, for AI review, "when control point is linked it should work"): §6 adds files uploaded after a send (the STEP zip always goes this way now, and anything over 4 MB) and part pictures; §7 adds AI review answered with Control Point's own model keys. `bundleVersion` stays 2; a v1.1 reader ignores everything new.
+
+Hosts: Control Point `https://tryctrlpoint.org` (Express on Render, SQLite). Control Kit Worker `https://control-kit.control-kit-worker.workers.dev`, Pages `https://control-kit.pages.dev`.
+
+## Principles
+
+- The browser never holds a Control Point token. Only the Control Kit Worker does, AES-GCM encrypted in `APP_STATE`, like the Onshape tokens.
+- A grant belongs to one Control Point **account** (its email), not one workspace. The workspace is picked in Control Kit and sent with every call; Control Point checks the account is an active member of that workspace, with CAD edit rights, on **every** call.
+- Nothing is sent automatically. Sending is a button press on a reviewed scan.
+- A failed link or send never blocks scan, Excel, or STEP.
+
+## 1. Linking (authorization code)
+
+1. Browser → Worker `GET /control-point/start`. The Worker makes a random `state`, puts it in a signed HttpOnly cookie `controlkit_cp` (same HMAC pattern as `controlkit_oauth`, `Path=/control-point`, 10 minutes), and redirects to:
+
+ `https://tryctrlpoint.org/connect/control-kit?state={state}&redirect_uri={encoded https://control-kit.control-kit-worker.workers.dev/control-point/callback}`
+
+ Control Kit opens this in a popup (`window.open`), because Onshape's iframe cannot show Control Point's sign-in.
+2. Control Point page `/connect/control-kit` (SPA route): if signed out, sign in and come back. Shows "Control Kit wants to send BOMs from Onshape to your workspaces" with the account email, the list of workspaces it will be able to see, **Allow** and **Cancel**. `redirect_uri` must equal one entry of the server allowlist `CONTROL_KIT_REDIRECT_URIS` (comma-separated env; default the production URI above, tests add a local one). Anything else: error page, no redirect.
+3. Allow → `POST /api/integrations/control-kit/authorize {state, redirect_uri}` (normal session auth) returns `{redirect}` = `redirect_uri?code={code}&state={state}`. Cancel → `redirect_uri?error=access_denied&state={state}`. The code is random, 32+ bytes, stored hashed, one-use, expires in 5 minutes, bound to the member email and redirect_uri.
+4. Worker `GET /control-point/callback` checks the state cookie, then calls server-to-server:
+
+ `POST https://tryctrlpoint.org/api/integrations/control-kit/token`
+ `Authorization: Basic base64("control-kit:" + CONTROL_KIT_CLIENT_SECRET)`
+ body `{ "grant_type": "authorization_code", "code": "...", "redirect_uri": "..." }`
+
+ → `200 { "access_token": "ckp_…", "token_type": "Bearer", "account": { "email": "…", "name": "…" } }`.
+ The token has no expiry and is revocable; Control Point stores only its SHA-256. Errors: `400 {error:"invalid_grant"}`, `401 {error:"invalid_client"}`.
+5. Worker stores `cp-link:{userId}` = encrypted `{token, account, workspaceId: null, linkedAt}` and returns a small HTML page that `postMessage`s `{type:"control-point-linked"}` to `https://control-kit.pages.dev` (and the fixture origin in dev) and closes itself.
+
+`CONTROL_KIT_CLIENT_SECRET` is a secret in both apps (Render env / Worker secret). It is never in Git.
+
+## 2. Bearer API (Control Point)
+
+All with `Authorization: Bearer ckp_…`. An unknown or revoked token → `401 {error:"revoked"}`; Control Kit then deletes `cp-link:{userId}` and shows "Link again".
+
+### `GET /api/integrations/control-kit/workspaces`
+
+```json
+{ "account": { "email": "a@b.org", "name": "Sam" },
+ "workspaces": [
+ { "id": 12, "name": "HORS", "ftcNumber": "6980", "accentColor": "#2f6bff",
+ "canEditCad": true, "cadUrl": "https://tryctrlpoint.org/cad-parts" } ] }
+```
+
+`accentColor` is the team's admin-set `teams.accent_color` when it is a valid `#rrggbb`, else `null` (Control Kit then stays volt). `ftcNumber` is the verified FTC number or `null`. Only active memberships of the token's email are listed. An empty list is normal (teamless account); create/join links are `https://tryctrlpoint.org/teams?tab=workspaces&new=1` and `…&join=1`.
+
+### `POST /api/integrations/control-kit/workspaces/{id}/bom`
+
+Body: the `ControlPointBomPayload` (bundleVersion 2, `packages/shared/src/control-point.ts` in Control Kit), **with `idempotencyKey` computed including the workspace id**. `Content-Type: application/json`, at most 12 MB.
+
+Behaviour:
+- 403 `{error:"not_member"}` if the email is not an active member of workspace `id`; 403 `{error:"no_cad_edit"}` without CAD edit rights; 413 over size; 400 `{error:"bad_payload", detail}` when `source !== "control-kit"`, `bundleVersion !== 2`, or `cadParts` is missing.
+- Same `idempotencyKey` already imported to that workspace → `200` with the earlier result and `"duplicate": true`. Nothing changes.
+- Otherwise one transaction:
+ - Upsert an import record `cad_imports(team_id, idempotency_key UNIQUE per team, document_id, element_id, assembly_name, revision, part_count, sent_by, created_at, files)`.
+ - For each `cadParts[i]`: the row is identified by `(team_id, ck_document_id, ck_element_id, ck_part_key)` where `ck_part_key = cadParts[i].extra.identity` (document:element:part:configuration from Control Kit, stable across revisions). New → insert with `status` from the payload. Existing → update name, section, quantity, source, unit_cost, notes, `ck_extra`, `ck_import_id`; **keep the people's fields** `status` (unless still `to_order`), `assignee`. Rows of this assembly missing from the new bundle are not deleted; they get `ck_removed_at` set and show as "Not in latest scan" in the Parts tab.
+ - Files (`files[]` with `delivery: 'inline'` and base64 bytes in `contentBase64`, as `ControlPointFile` defines them): stored as CAD docs attached to the import (existing upload storage, R2 when configured). Files with `delivery: 'reference'` are recorded as "available in Control Kit".
+- Response `200`:
+
+```json
+{ "importId": 41, "duplicate": false, "workspace": { "id": 12, "name": "HORS", "ftcNumber": "6980" },
+ "created": 30, "updated": 89, "markedRemoved": 2, "filesStored": 2,
+ "url": "https://tryctrlpoint.org/cad-parts?import=41", "sentAt": "2026-10-26T15:00:00.000Z" }
+```
+
+New `cad_parts` columns (nullable, migration): `ck_document_id`, `ck_element_id`, `ck_part_key`, `ck_import_id`, `ck_extra` (JSON text), `ck_removed_at`. Unique index on `(team_id, ck_document_id, ck_element_id, ck_part_key)` where `ck_part_key IS NOT NULL`. Hand-made parts are untouched.
+
+### `GET /api/integrations/control-kit/workspaces/{id}/imports?document={documentId}&element={elementId}`
+
+Latest 10 imports of that assembly: `[{importId, idempotencyKey, sentAt, sentBy, created, updated, markedRemoved, url}]`. Control Kit uses it to show "Last sent …".
+
+### `POST /api/integrations/control-kit/revoke`
+
+Revokes the bearer token. `204`. Control Point's own Settings also lists "Connected apps → Control Kit (linked {date})" with **Disconnect**, which revokes every Control Kit token of that account.
+
+### Where an import lands (v1.1)
+
+Control Point groups CAD by FTC season and robot (a season is named by its starting year, `2025` = 2025–26 DECODE; a season may hold several robots, one of them the main robot). An import goes to the robot already linked to its assembly (`document_id`, `element_id`). An assembly no robot has yet goes to the main robot of the latest season that has robots, when that robot has no assembly yet; otherwise Control Point creates a robot in the current season named after the assembly. People can move a robot to another season or relink its assembly in Control Point. The response gains `robot: {id, name, season}` and `url` opens that robot's BOM (`/cad/robots/{id}?import={importId}`); `/cad-parts?import={id}` keeps working and redirects there.
+
+### Bundle line fields added in v1.1 (all optional for a reader)
+
+Each `ControlPointLine` (so each `cadParts[i].extra`) also carries:
+
+| Field | Meaning |
+|---|---|
+| `description`, `material` | From the Onshape BOM, or null. |
+| `matchNormalizedSku` | The current match's normalized SKU (the `normalizedSku` a product choice names), or null. |
+| `image` | The matched product's licensed picture `{url, attribution}` (`LicensedImage`) from an official feed, or null. Control Point shows nothing without one. |
+| `priceCheck` | Control Kit's sanity-check note when it refused to price an order, or null. |
+| `manualOverrideAt`, `manualOverrideBy` | When and by whom the part's current shared type was set (Control Kit's `disposition:{documentId}:{sha256(identity)}` record), or null. |
+| `matchDecisionAt` | When the product choice in force on this line was saved, or null. |
+| `candidates` | The products a person may pick between: `{ id, vendor, sku, normalizedSku, name, priceUsd, unitsPerPack, stock, url, image, kind }`, where `id` = `{vendor}:{normalizedSku}` and `kind` is `match` (the current match), `choice` (Control Kit's `choices`), or `near` (`nearMatches`). At most 8. `unitsPerPack` is null unless the pack size is verified. |
+
+Control Point stores the whole line (`ck_extra`), so the rest of Control Kit's fields (disposition, match status, packs, price, stock, links, review reason) need no new columns.
+
+Rate limit: 30 calls per token per minute (existing `rateLimit.ts`), `429` with `Retry-After`.
+
+## 3. Control Kit Worker routes (browser-facing, cookie session)
+
+| Route | Does |
+|---|---|
+| `GET /control-point/start` | Step 1 above. Needs a Control Kit session. |
+| `GET /control-point/callback` | Step 4–5. |
+| `GET /api/control-point/link` | `{linked:false}` or `{linked:true, account, workspace: {id,name,ftcNumber,accentColor,canEditCad} \| null, workspaces:[…], linkedAt}`. Refreshes the workspace list from Control Point at most every 10 minutes (cached inside `cp-link`); on 401 unlinks. `?refresh=1` ("Check again", after creating or joining a workspace) forces that refresh, at most once per 30 seconds per person, counting failed attempts; within those 30 seconds no read calls Control Point, even with the cache expired, and the cached list is served (marked `stale` when expired). A linked answer carries `refreshAfter` (ISO time) for when the next forced refresh can happen. With no `CONTROL_KIT_CLIENT_SECRET` set: `{linked:false, available:false}`. |
+| `POST /api/control-point/workspace {workspaceId}` | Saves the picked workspace (must be in the list). Never sends anything. |
+| `POST /api/control-point/send?scanId=…&workspaceId=…` | `workspaceId` is the workspace the Send button named; when it is not the link's saved workspace the Worker sends nothing and answers 409 `{error, code:"workspace_changed"}`. Body: the payload JSON the browser built. The Worker checks `Content-Length` ≤ 12 MB and that `scanId` is the caller's `scan:{userId}:{scanId}` for the same document, then streams the body to Control Point **without parsing it** (free-plan 10 ms CPU), to the saved workspace. Returns Control Point's response. Records `cp-sent:{userId}:{documentId}:{elementId}` = `{importId, workspaceId, sentAt, idempotencyKey, url}`. |
+| `GET /api/control-point/sent?documentId&elementId` | That record, or null. |
+| `POST /api/control-point/unlink` | Calls revoke (best effort), deletes `cp-link:{userId}` and `cp-sent:*` is kept. Origin-checked like `/api/logout`. |
+
+Idempotency: `buildControlPointPayload` gains `workspaceId` and folds it into the hash, so one scan sent to two workspaces is two imports. The sorted media types of the files sent inline are folded in too (not their names, which carry a date, nor their bytes, which change on every workbook build), so sending again with the STEP zip attached is a new import rather than a duplicate that drops the zip. `controlPointIdempotencyKey(result, {workspaceId, inlineMediaTypes})` in `packages/shared/src/control-point.ts` computes the same key without building files.
+
+## 4. What the browser shows
+
+- Not available (no secret): Control Point card with "View CAD in Control Point" and the preview; no link button.
+- Not linked: preview of this scan in Control Point's sections and statuses, labelled as a preview, with **Link Control Point** and **Create a free workspace**.
+- Linked, no workspace or several: picker. One workspace: shown selected, with a confirm.
+- Linked with workspace: "Send to {name} · FTC {n}" and the last-sent line. Workspace `accentColor` replaces the brand accent (primary buttons, active tab, header mark) when it passes contrast; status colours never change.
+
+## 5. Choices made in Control Point (v1.1)
+
+People decide in Control Point what Control Kit asks for, and those choices reach Control Kit's next scan. Control Point never writes to Control Kit: Control Kit reads the choices with the link's bearer token and saves them as its own.
+
+### What a choice is
+
+Per part identity (`ck_part_key`, document:element:part:configuration) in a workspace, two independent choices, each with who and when:
+
+- **Type**: `buy`, `print`, `cnc`, `cut-2d`, `fabricate`, `hardware` (fastener), or `ignore` (not needed); or `null`, meaning "let Control Kit decide" (clears a manual type).
+- **Product**: one of the line's `candidates` (`vendor` + `normalizedSku`). Picking a product also means the part is bought; Control Point sets the type to `buy` in the same choice unless it already is.
+
+Who may choose: an active member of the workspace whose role carries Control Point's **Manage CAD** permission (`manage_cad`; admins have it, and the default Member and Verified Member roles are given it so nobody loses CAD editing). Every change is kept in Control Point's history with who and when.
+
+### `GET /api/integrations/control-kit/workspaces/{id}/decisions?document={documentId}`
+
+Bearer, same checks as `/imports` (active member; CAD module on). Returns every choice made in Control Point for parts of that document:
+
+```json
+{ "decisions": [
+ { "identity": "d1:e1:JHD:default", "elementId": "e1",
+ "type": { "value": "print", "by": "Sam Lee", "at": "2026-10-26T18:00:00.000Z" } | null,
+ "product": { "vendor": "gobilda", "normalizedSku": "1611001408", "by": "Sam Lee", "at": "2026-10-26T18:01:00.000Z" } | null } ] }
+```
+
+`type.value` may be `null` (cleared in Control Point). At most 2,000 decisions, newest first. Errors as §2.
+
+### Control Kit: `POST /api/control-point/decisions {documentId, scanId}`
+
+Browser-facing Worker route (Origin-checked, cookie session). The app calls it after a scan and when a saved scan reopens, while linked with a workspace; a failure is shown quietly and never blocks the scan. The Worker:
+
+1. Checks `scan:{userId}:{scanId}` is the caller's scan of `documentId`, reads the decisions above for the link's workspace, and ignores any identity not in that scan.
+2. **Type**: when the Control Point choice is newer than Control Kit's `disposition:{documentId}:{sha256(identity)}` record (`at`), or there is none, and the value differs, it saves it there exactly as a person in Control Kit would (current record plus a never-overwritten history key) with `by: "{name} in Control Point"`, `byId: "control-point"`, and `at` = Control Point's time.
+3. **Product**: when the choice is newer than the caller's saved match decision (`match-decision:{userId}:{documentId}:{sha256(identity)}`; a decision saved before v1.1 has no time and counts as older), it saves `{status: "confirmed", vendorId, normalizedSku, at}`. Match decisions stay per person in Control Kit, so each linked person picks up Control Point's product choices on their own next scan.
+4. Answers `{ types: n, products: m }` (how many it saved). The app then reloads choices (`/api/choices`) and rematches, so the open scan shows them at once.
+
+Writes happen only for real changes, so an unchanged pull writes nothing to KV.
+
+### Which choice wins
+
+The newest. A person's later choice in Control Kit is newer than Control Point's, so the pull skips it, and the next send carries it (`manualOverrideAt`, `matchDecisionAt`). Control Point shows, per part, the newer of its own choice and the line's; its own choice reads "Chosen in Control Point · reaches Control Kit at its next scan" until a send carries the same value. A re-send never erases a Control Point choice: imports update `ck_extra` and the Control Kit columns only, and choices live in their own columns.
+
+## 6. Files after a send, and part pictures (v1.2)
+
+### Files
+
+A bundle names every file it should hold. Files over 4 MB, and **the STEP zip always** (whatever its size), are listed with `delivery: "reference"` and uploaded right after the send, to the import the send answered with (a duplicate send included). The zip therefore never changes the idempotency key: adding STEP later reaches the same import instead of creating a new one. A file over 64 MB stays in Control Kit (its `reason` says so).
+
+`POST /api/integrations/control-kit/workspaces/{id}/imports/{importId}/files?name={name}`
+
+- Bearer, same checks as the BOM send (active member, CAD module on). The import must belong to workspace `id` (else 404 `not_found`) and be finished (else 409 `in_progress` with `Retry-After`).
+- Body: the file's raw bytes, at most 64 MB (413 `too_large`). `Content-Type` is `application/zip`, the xlsx type, or `text/csv`, and must match the name's extension (415 `bad_file`). `X-Content-SHA256` is the bytes' hex SHA-256; a mismatch is 400 `checksum_mismatch`. `name` is 1 to 160 characters with no slash, backslash, or control characters.
+- Stored as a CAD doc of the import (on its robot). In the import's `files` it replaces the entry of the same name, else the first unstored entry it completes (a `.zip` fills an entry named `*.zip` or mentioning STEP; an `.xlsx` one named `*.xlsx`, Excel, or workbook), else it is appended. The list is swapped from the value it was read as, so two uploads to one import never drop each other.
+- The same name with the same bytes again: `200 {duplicate: true}`, nothing changes. New bytes under the same name replace the stored file (the old one is deleted).
+- `200 { name, url, sizeBytes, sha256, duplicate }`; `url` is `/api/files/{id}` on Control Point.
+
+Control Kit Worker: `POST /api/control-point/file?documentId&elementId&importId&workspaceId&name` (Origin-checked, cookie session), headers `Content-Type`, `Content-Length` (411 without), `X-Content-SHA256`. Only for the caller's own latest send of that assembly (`cp-sent:{userId}:{documentId}:{elementId}` names that `importId` and workspace; else 409 `not_sent` or `workspace_changed`), with the link still on that workspace. The body streams through unread (the free plan's 10 ms CPU rules out hashing 64 MB; Control Point checks the hash), and a body longer than declared errors rather than arriving short. A new name is added to the sent record's `files`, so the panel knows STEP is there. A Control Point 401 ends the link and answers 409 `revoked`.
+
+The panel uploads the referenced files right after each send, and offers **Add STEP files** (exporting first when this session has no current zip) while the last send of the current scan has no zip in Control Point. Control Point shows the newest stored STEP zip and workbook of a robot as downloads.
+
+### Part pictures
+
+Each Control Point CAD part may carry one Onshape picture of the part (or, for a vendor product listed as one unit, of that product), drawn by Control Kit's `/api/preview` and stored in `cad_parts.ck_picture_url` with Control Kit's picture **key**: the revision the Worker caches the picture at (the scanned microversion `m:{id}` for workspace parts, else `{v|m}:{sourceId}`) plus 16 hex characters of SHA-256 of `{bomLineKey}|{part|assembly}` (`controlPointPictureKey`, at most 128 characters of `[A-Za-z0-9_:.-]`). The robot's own picture stays the send's `{assembly} assembly.png` file.
+
+- `GET …/workspaces/{id}/imports/{importId}/pictures` (Bearer, membership): `{ pictures: [{ identity, key, at }] }` for the parts of that import's assembly that have one.
+- `POST …/workspaces/{id}/imports/{importId}/pictures` (Bearer, CAD edit), JSON at most 8 MB: `{ pictures: [{ identity, key, contentBase64 }] }`, 1 to 40 items. Each must be a PNG of at most 1 MB for a part of that import's assembly; others count as `rejected` (bad key, not a PNG, too large) or `unknown` (no such part). A part already holding that key is `unchanged`; a new key replaces the picture (the old file is deleted). Imports never touch these columns. `200 { stored, unchanged, unknown, rejected }`.
+
+Control Kit Worker: `GET /api/control-point/pictures?documentId&elementId&importId` and `POST /api/control-point/pictures?documentId&elementId&importId&workspaceId` (Origin-checked, at most 8 MB, streamed unread), with the same send and link checks as files. `GET /api/preview?scanId&line&cached=1` answers a picture the Worker already drew, or 404 `not_cached`, never an Onshape request.
+
+**Onshape budget.** After a send the panel lists Control Point's keys and walks the scan's parts (one per identity, parts to make first, Not needed and undrawable lines left out). A part whose key Control Point holds is skipped. A picture the Worker already drew (cached 30 days per revision) is sent free. Only a part Control Point has **no** picture of is drawn now, at most `PICTURE_FILL_PER_SEND` = 30 per send (one billable `shaded-view` request each). A part whose newer revision was never drawn keeps its older picture. So a robot of N drawable parts costs at most N requests once, 30 per send, and later sends cost nothing unless new parts appear (again at most 30 per send).
+
+## 7. AI review through Control Point (v1.2)
+
+When the Control Kit Worker has no model key of its own (`GEMINI_API_KEY`, `ANTHROPIC_API_KEY`), a person linked with a workspace gets AI review through Control Point; Control Kit's own keys, if set, always take precedence. Control Kit keeps its per-person and global allowance (the `AiAllowance` Durable Object), its 30-day answer cache (`ai-review:{hash}:control-point`, apart from its own models' answers), and its `parseAiReview` check of every answer. No key or token reaches the browser.
+
+`POST /api/integrations/control-kit/workspaces/{id}/ai/review` (Bearer, active member, CAD module on), JSON at most 256 KB:
+
+```json
+{ "instructions": "…", "schema": { "…": "JSON Schema for the answer" }, "geminiSchema": { "…": "the same in Gemini's OpenAPI subset" },
+ "request": { "version": 1, "assembly": "…", "leftOut": 0, "lines": [] } }
+```
+
+- `request` is the review the Worker rebuilt from the caller's own stored scan (`trustedAiRequest`): version 1, 1 to 150 lines, each with `id`, `name`, optional facts, and at most 8 catalog `candidates` (`id`, `vendor`, `sku`, `name`). `instructions` at most 8,000 characters, each schema at most 8,000 characters of JSON. Anything else is 400 `bad_payload`, so the route is not a general model proxy.
+- Control Point asks Gemini (`GEMINI_MODEL`, default `gemini-3.5-flash-lite`) with the instructions as the system prompt, the request as JSON, and `responseSchema: geminiSchema`; it asks Anthropic (`ANTHROPIC_MODEL`, default `claude-sonnet-5-5`, effort low, `output_config.format` from `schema`) once, only when Gemini answers 429 or 5xx or cannot be reached, never on an auth or request error. Each call times out at 90 seconds; nothing is retried blindly.
+- Per workspace, at most 20 reviews an hour and 100 a day (in memory, reset on restart): 429 `rate_limited` with `Retry-After`. The token's own 30 calls a minute still apply.
+- `200 { items: [...], provider: "gemini" | "anthropic", usage: { inputTokens, outputTokens } }`: the model's items as it gave them (an unparseable answer is `items: []`). 501 `ai_unavailable` without keys; 502 `ai_failed`; 504 `ai_timeout`.
+
+Control Kit Worker: `GET /api/ai/status` answers `{enabled: true, via: "control-kit"}` with its own keys, `{enabled: true, via: "control-point"}` for a person linked with a workspace (and the `AI_ALLOWANCE` binding), else `{enabled: false}` (with `linkToEnable: true` when linking would turn it on). `POST /api/ai/review` is unchanged for the browser; through Control Point it waits at most 190 seconds (Gemini then Anthropic), answers with `via: "control-point"`, maps Control Point's 429, 501, 502 and 504 to the same statuses with plain words, and a 401 ends the link and answers 409 `{code: "revoked", linked: false}` like the other link routes.
diff --git a/migrations/versions/154-cad-part-pictures.sql b/migrations/versions/154-cad-part-pictures.sql
new file mode 100644
index 000000000..a4d2de735
--- /dev/null
+++ b/migrations/versions/154-cad-part-pictures.sql
@@ -0,0 +1,14 @@
+-- 154-cad-part-pictures.sql — each Control Kit part's Onshape picture
+-- (contract: control-kit docs/control-point-link.md v1.2 §6).
+-- cad_parts.ck_picture_url: the stored PNG (/api/files/:id, kind 'cad').
+-- cad_parts.ck_picture_key: Control Kit's name for the revision it drew, so a
+-- later send skips pictures Control Point already holds.
+-- cad_parts.ck_picture_at: when it was stored here.
+-- Imports never touch these columns; only the pictures route writes them.
+BEGIN;
+
+ALTER TABLE cad_parts ADD COLUMN ck_picture_url TEXT;
+ALTER TABLE cad_parts ADD COLUMN ck_picture_key TEXT;
+ALTER TABLE cad_parts ADD COLUMN ck_picture_at TEXT;
+
+COMMIT;
diff --git a/server.ts b/server.ts
index 4a728cdd7..cfb3e950e 100644
--- a/server.ts
+++ b/server.ts
@@ -2407,6 +2407,7 @@ async function startServer() {
requireAuth: requireAuth as any, authEmail: (auth) => authEmail(auth as any),
// Declared further down; only called per request, after startup.
storeFile: (opts) => storeFile(opts), fileUrl: (id) => fileUrl(id),
+ deleteStoredFile: (url) => deleteStoredFileByUrl(url),
appUrl: (req) => (process.env.APP_URL || "").replace(/\/$/, "") || getBaseUrl(req),
});
controlKit.registerBearerRoutes(app);
diff --git a/server/__tests__/controlKitFiles.test.ts b/server/__tests__/controlKitFiles.test.ts
new file mode 100644
index 000000000..57959310e
--- /dev/null
+++ b/server/__tests__/controlKitFiles.test.ts
@@ -0,0 +1,265 @@
+/**
+ * Control Kit link v1.2 (contract: control-kit docs/control-point-link.md §6 and §7): files uploaded after a send, part
+ * pictures, and AI review answered with Control Point's own keys. The models are a fake on loopback.
+ */
+import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
+import { createHash } from "node:crypto";
+import type { AddressInfo } from "node:net";
+import { startTestServer, seedTeam, seedMember, type TestServer } from "./helpers/testServer";
+import { checkReviewAsk, runReview, ReviewError } from "../controlKitAi";
+import { uploadName, withUploadedFile } from "../controlKitLink";
+
+vi.setConfig({ testTimeout: 60_000 });
+
+const SECRET = "test-control-kit-secret";
+const REDIRECT = "http://127.0.0.1:8787/control-point/callback";
+const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex");
+const PNG = (fill: string) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(`IHDR-${fill}-pixels`)]);
+
+let t: TestServer;
+let models: import("node:http").Server;
+let team = 0, other = 0;
+let sam = "", olive = "";
+/** What the fake models answer next, and what they were asked. */
+let gemini: { status: number; text?: string } = { status: 200 };
+let anthropic: { status: number; text?: string } = { status: 200 };
+const asked: string[] = [];
+
+async function ck(path: string, opts: { method?: string; token?: string; body?: any; raw?: Buffer; type?: string; headers?: Record } = {}) {
+ const headers: Record = { ...opts.headers };
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`;
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
+ if (opts.raw) headers["content-type"] = opts.type ?? "application/zip";
+ const res = await fetch(`${t.base}${path}`, { method: opts.method || (opts.body !== undefined || opts.raw ? "POST" : "GET"), headers, body: opts.raw ?? (opts.body !== undefined ? JSON.stringify(opts.body) : undefined) });
+ let body: any = null;
+ try { body = await res.json(); } catch { /* none */ }
+ return { status: res.status, body, headers: res.headers };
+}
+async function link(session: string) {
+ const r = await t.post("/api/integrations/control-kit/authorize", { state: "s", redirect_uri: REDIRECT }, session);
+ const code = new URL(r.body.redirect).searchParams.get("code")!;
+ const auth = `Basic ${Buffer.from(`control-kit:${SECRET}`).toString("base64")}`;
+ const res = await fetch(`${t.base}/api/integrations/control-kit/token`, { method: "POST", headers: { authorization: auth, "content-type": "application/json" },
+ body: JSON.stringify({ grant_type: "authorization_code", code, redirect_uri: REDIRECT }) });
+ return (await res.json()).access_token as string;
+}
+const cad = (part: string) => ({
+ name: `Part ${part}`, section: "Other", quantity: 1, source: "other", unit_cost: 0, status: "to_order", notes: "",
+ extra: { key: `line-${part}`, identity: `doc9:ps:${part}:default`, documentId: "doc9", elementId: "ps", partId: part, configuration: "default" },
+});
+const payload = (key: string, over: Record = {}) => ({
+ idempotencyKey: key, source: "control-kit", bundleVersion: 2,
+ assembly: { name: "Drive", documentId: "doc9", elementId: "asm9", onshapeUrl: "https://cad.onshape.com/documents/doc9", assemblyUrl: "https://cad.onshape.com/documents/doc9/w/w1/e/asm9", documentName: "Drive" },
+ revision: { type: "m", id: "m1" }, generatedAt: "2026-10-28T10:00:00.000Z", lines: [], suppressedLines: [], missingMetadataLines: [], suppressedCount: 0,
+ totals: { uniqueLines: 2, totalInstances: 2, totalCostUsd: null, outOfStockLines: 0 }, cadParts: [cad("A"), cad("B")],
+ controlPointCadUrl: "https://tryctrlpoint.org/cad-parts",
+ files: [{ name: "Drive STEP.zip", mediaType: "application/zip", sizeBytes: 9, sha256: "ab".repeat(32), delivery: "reference", reason: "Uploaded after this bundle." }],
+ filesNotIncluded: [], ...over,
+});
+const review = (lines = 1) => ({
+ instructions: "Review these parts. Answer JSON.", schema: { type: "object" }, geminiSchema: { type: "object" },
+ request: { version: 1, assembly: "Drive", leftOut: 0, lines: Array.from({ length: lines }, (_, i) => ({ id: `L${i}`, name: `Part ${i}`, description: null, material: null, partNumber: null, quantity: 1,
+ candidates: [{ id: "gobilda:1611001408", vendor: "gobilda", sku: "1611-0014-0008", name: "Bearing" }] })) },
+});
+
+beforeAll(async () => {
+ const express = (await import("express")).default;
+ const app = express();
+ app.use(express.json({ limit: "2mb" }));
+ app.post(/\/models\/[^/]+:generateContent$/, (req, res) => {
+ asked.push(`gemini ${req.headers["x-goog-api-key"]}`);
+ if (gemini.status !== 200) { res.status(gemini.status).json({ error: "x" }); return; }
+ res.json({ candidates: [{ content: { parts: [{ text: gemini.text ?? JSON.stringify({ items: [{ id: "L0", disposition: "buy", candidateId: "gobilda:1611001408", confidence: 0.9, reason: "A bearing" }] }) }] } }],
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } });
+ });
+ app.post("/messages", (req, res) => {
+ asked.push(`anthropic ${req.headers["x-api-key"]} ${req.body?.output_config?.format?.type}`);
+ if (anthropic.status !== 200) { res.status(anthropic.status).json({ error: "x" }); return; }
+ res.json({ stop_reason: "end_turn", content: [{ type: "text", text: anthropic.text ?? JSON.stringify({ items: [{ id: "L0", disposition: "print", candidateId: null, confidence: 0.7, reason: "Team part" }] }) }],
+ usage: { input_tokens: 11, output_tokens: 6 } });
+ });
+ models = app.listen(0);
+ await new Promise((r) => models.once("listening", r));
+ const modelBase = `http://127.0.0.1:${(models.address() as AddressInfo).port}`;
+ t = await startTestServer("cp-ckfiles-", {
+ CONTROL_KIT_CLIENT_SECRET: SECRET, CONTROL_KIT_REDIRECT_URIS: REDIRECT, CONTROL_KIT_REPLAY_WAIT_MS: "300",
+ GEMINI_API_KEY: "g-key", ANTHROPIC_API_KEY: "a-key", GEMINI_API_BASE: modelBase, ANTHROPIC_API_BASE: modelBase,
+ });
+ team = await seedTeam(t.db, "Files Team");
+ other = await seedTeam(t.db, "Other Team");
+ sam = await t.session(await seedMember(t.db, team, "Sam", "sam@files.local"));
+ olive = await t.session(await seedMember(t.db, other, "Olive", "olive@files.local"));
+}, 120_000);
+afterAll(async () => { await t?.stop(); models?.close(); });
+
+describe("helpers", () => {
+ it("takes plain file names only", () => {
+ expect(uploadName("Drive STEP.zip")).toBe("Drive STEP.zip");
+ expect(uploadName("../x.zip")).toBeNull();
+ expect(uploadName("a\\b.zip")).toBeNull();
+ expect(uploadName("x".repeat(161))).toBeNull();
+ });
+ it("puts an upload in place of its own entry or the placeholder it fills", () => {
+ const up = { name: "Drive STEP.zip", stored: true, url: "/api/files/9" };
+ expect(withUploadedFile([{ name: "Drive STEP.zip", stored: false }], up)).toEqual([up]);
+ expect(withUploadedFile([{ name: "a.xlsx", stored: true }, { name: "STEP files", stored: false, note: "Not exported" }], up)).toEqual([{ name: "a.xlsx", stored: true }, up]);
+ expect(withUploadedFile([{ name: "a.xlsx", stored: true }], up)).toEqual([{ name: "a.xlsx", stored: true }, up]);
+ });
+ it("accepts only a review of the documented shape", () => {
+ expect(checkReviewAsk(review()).ok).toBe(true);
+ expect(checkReviewAsk({ ...review(), request: { ...review().request, version: 2 } }).ok).toBe(false);
+ expect(checkReviewAsk(review(151)).ok).toBe(false);
+ expect(checkReviewAsk({ ...review(), instructions: "x".repeat(8001) }).ok).toBe(false);
+ expect(checkReviewAsk({ ...review(), request: { ...review().request, lines: [{ id: "L0", name: "x", candidates: "nope" }] } }).ok).toBe(false);
+ });
+ it("fails over to Anthropic once on Gemini's rate limit, never on a rejected key", async () => {
+ const env = { GEMINI_API_KEY: "g", ANTHROPIC_API_KEY: "a" };
+ const ask = (checkReviewAsk(review()) as any).ask;
+ const calls: string[] = [];
+ const fake = (gStatus: number) => (async (url: string) => {
+ calls.push(String(url).includes("generateContent") ? "gemini" : "anthropic");
+ if (String(url).includes("generateContent")) return new Response("{}", { status: gStatus });
+ return Response.json({ stop_reason: "end_turn", content: [{ type: "text", text: '{"items":[]}' }], usage: {} });
+ }) as unknown as typeof fetch;
+ expect((await runReview(env, ask, fake(429))).provider).toBe("anthropic");
+ expect(calls).toEqual(["gemini", "anthropic"]);
+ calls.length = 0;
+ await expect(runReview(env, ask, fake(403))).rejects.toBeInstanceOf(ReviewError);
+ expect(calls).toEqual(["gemini"]);
+ await expect(runReview({}, ask, fake(200))).rejects.toMatchObject({ status: 501 });
+ });
+});
+
+describe("files after a send", () => {
+ let token = "";
+ let importId = 0;
+ let robotId = 0;
+ const upload = (bytes: Buffer, name: string, opts: { type?: string; hash?: string; ws?: number; id?: number; tok?: string } = {}) =>
+ ck(`/api/integrations/control-kit/workspaces/${opts.ws ?? team}/imports/${opts.id ?? importId}/files?name=${encodeURIComponent(name)}`,
+ { token: opts.tok ?? token, raw: bytes, type: opts.type ?? "application/zip", headers: { "x-content-sha256": opts.hash ?? sha(bytes) } });
+ beforeAll(async () => {
+ token = await link(sam);
+ const sent = await ck(`/api/integrations/control-kit/workspaces/${team}/bom`, { token, body: payload("files-1") });
+ expect(sent.status).toBe(200);
+ importId = sent.body.importId;
+ robotId = sent.body.robot.id;
+ });
+
+ it("stores the STEP zip in place of the reference, as a CAD doc of the import's robot, downloadable from Control Point", async () => {
+ const zip = Buffer.from("PK\u0003\u0004 step files");
+ const r = await upload(zip, "Drive STEP.zip");
+ expect(r.status).toBe(200);
+ expect(r.body).toMatchObject({ name: "Drive STEP.zip", sizeBytes: zip.length, duplicate: false });
+ const detail = await t.api(`/api/cad/robots/${robotId}`, { session: sam });
+ expect(detail.body.imports[0].files).toEqual([expect.objectContaining({ name: "Drive STEP.zip", stored: true, url: r.body.url, sizeBytes: zip.length })]);
+ const doc = (await t.db.execute({ sql: "SELECT robot_id, ck_import_id FROM cad_docs WHERE url = ?", args: [r.body.url] })).rows[0] as any;
+ expect(doc).toMatchObject({ robot_id: robotId, ck_import_id: importId });
+ const file = await fetch(`${t.base}${r.body.url}`, { headers: { cookie: `cp_session=${encodeURIComponent(sam)}` } });
+ // Served as an attachment (a zip never renders inline), byte for byte.
+ expect(file.headers.get("content-disposition")).toMatch(/^attachment/);
+ expect(Buffer.from(await file.arrayBuffer()).equals(zip)).toBe(true);
+ // The same bytes again change nothing; new bytes replace the file and remove the old one.
+ expect((await upload(zip, "Drive STEP.zip")).body).toMatchObject({ duplicate: true, url: r.body.url });
+ const zip2 = Buffer.from("PK\u0003\u0004 newer step files");
+ const again = await upload(zip2, "Drive STEP.zip");
+ expect(again.body).toMatchObject({ duplicate: false });
+ expect(again.body.url).not.toBe(r.body.url);
+ const docs = (await t.db.execute({ sql: "SELECT url FROM cad_docs WHERE ck_import_id = ?", args: [importId] })).rows.map((x: any) => x.url);
+ expect(docs).toEqual([again.body.url]);
+ });
+
+ it("adds a workbook beside it and fills a labelled placeholder", async () => {
+ const sent = await ck(`/api/integrations/control-kit/workspaces/${team}/bom`, { token, body: payload("files-2", { files: [], filesNotIncluded: [{ name: "Excel BOM", reason: "Could not be built" }, { name: "STEP files (.zip)", reason: "Not exported" }] }) });
+ const book = Buffer.from("xlsx bytes");
+ const r = await upload(book, "Drive BOM.xlsx", { type: XLSX, id: sent.body.importId });
+ expect(r.status).toBe(200);
+ const detail = await t.api(`/api/cad/imports/${sent.body.importId}`, { session: sam });
+ expect(detail.body.files.map((f: any) => [f.name, f.stored])).toEqual([["Drive BOM.xlsx", true], ["STEP files (.zip)", false]]);
+ expect(detail.body.filesStored).toBe(1);
+ });
+
+ it("refuses a wrong checksum, a mismatched type, another workspace's import, and an unknown token", async () => {
+ const zip = Buffer.from("PK zip");
+ expect((await upload(zip, "x.zip", { hash: "00".repeat(32) })).body).toMatchObject({ error: "checksum_mismatch" });
+ expect((await upload(zip, "x.zip", { type: "text/html" })).status).toBe(415);
+ expect((await upload(zip, "x.exe")).status).toBe(415);
+ expect((await upload(zip, "x.zip", { ws: other })).body).toMatchObject({ error: "not_member" });
+ const oliveToken = await link(olive);
+ expect((await upload(zip, "x.zip", { ws: other, tok: oliveToken })).status).toBe(404);
+ expect((await upload(zip, "x.zip", { tok: "ckp_nope" })).status).toBe(401);
+ });
+
+ it("stores part pictures by identity and revision key, skips what it already holds, and lists them", async () => {
+ const base = `/api/integrations/control-kit/workspaces/${team}/imports/${importId}/pictures`;
+ expect((await ck(base, { token })).body).toEqual({ pictures: [] });
+ const r = await ck(base, { token, body: { pictures: [
+ { identity: "doc9:ps:A:default", key: "m:m1:aaa", contentBase64: PNG("a").toString("base64") },
+ { identity: "doc9:ps:B:default", key: "m:m1:bbb", contentBase64: PNG("b").toString("base64") },
+ { identity: "doc9:ps:Z:default", key: "m:m1:zzz", contentBase64: PNG("z").toString("base64") },
+ { identity: "doc9:ps:A:default", key: "bad key!", contentBase64: PNG("a").toString("base64") },
+ { identity: "doc9:ps:B:default", key: "m:m1:ccc", contentBase64: Buffer.from("").toString("base64") },
+ ] } });
+ expect(r.body).toEqual({ stored: 2, unchanged: 0, unknown: 1, rejected: 2 });
+ const list = (await ck(base, { token })).body.pictures;
+ expect(list.map((p: any) => [p.identity, p.key]).sort()).toEqual([["doc9:ps:A:default", "m:m1:aaa"], ["doc9:ps:B:default", "m:m1:bbb"]]);
+ const robot = await t.api(`/api/cad/robots/${robotId}`, { session: sam });
+ const a = robot.body.parts.find((p: any) => p.ck_part_key === "doc9:ps:A:default");
+ expect(a.ck_picture_url).toMatch(/^\/api\/files\/\d+$/);
+ const png = await fetch(`${t.base}${a.ck_picture_url}`, { headers: { cookie: `cp_session=${encodeURIComponent(sam)}` } });
+ expect(png.headers.get("content-type")).toBe("image/png");
+ // The same key again stores nothing; a newer revision replaces the file.
+ expect((await ck(base, { token, body: { pictures: [{ identity: "doc9:ps:A:default", key: "m:m1:aaa", contentBase64: PNG("a").toString("base64") }] } })).body).toMatchObject({ stored: 0, unchanged: 1 });
+ expect((await ck(base, { token, body: { pictures: [{ identity: "doc9:ps:A:default", key: "m:m2:aaa", contentBase64: PNG("a2").toString("base64") }] } })).body).toMatchObject({ stored: 1 });
+ const gone = (await t.db.execute({ sql: "SELECT COUNT(*) AS n FROM stored_files WHERE id = ?", args: [Number(a.ck_picture_url.split("/").pop())] })).rows[0] as any;
+ expect(Number(gone.n)).toBe(0);
+ // Another workspace's member can't fetch them.
+ const now = (await t.api(`/api/cad/robots/${robotId}`, { session: sam })).body.parts.find((p: any) => p.ck_part_key === "doc9:ps:A:default");
+ expect((await fetch(`${t.base}${now.ck_picture_url}`, { headers: { cookie: `cp_session=${encodeURIComponent(olive)}` } })).status).toBe(403);
+ expect((await ck(base, { token, body: { pictures: [] } })).status).toBe(400);
+ });
+});
+
+describe("AI review", () => {
+ let token = "";
+ const ask = (body: any, ws = team, tok = token) => ck(`/api/integrations/control-kit/workspaces/${ws}/ai/review`, { token: tok, body });
+ beforeAll(async () => { token = await link(sam); });
+
+ it("answers with Gemini's JSON items and usage, using the server's own key", async () => {
+ gemini = { status: 200 }; asked.length = 0;
+ const r = await ask(review());
+ expect(r.status).toBe(200);
+ expect(r.body).toEqual({ items: [{ id: "L0", disposition: "buy", candidateId: "gobilda:1611001408", confidence: 0.9, reason: "A bearing" }], provider: "gemini", usage: { inputTokens: 10, outputTokens: 5 } });
+ expect(asked).toEqual(["gemini g-key"]);
+ });
+
+ it("asks Anthropic once when Gemini is rate limited, with the JSON schema format", async () => {
+ gemini = { status: 429 }; anthropic = { status: 200 }; asked.length = 0;
+ const r = await ask(review());
+ expect(r.body).toMatchObject({ provider: "anthropic", items: [{ id: "L0", disposition: "print" }] });
+ expect(asked).toEqual(["gemini g-key", "anthropic a-key json_schema"]);
+ });
+
+ it("says why when both fail, without retrying", async () => {
+ gemini = { status: 503 }; anthropic = { status: 500 }; asked.length = 0;
+ const r = await ask(review());
+ expect(r).toMatchObject({ status: 502, body: { error: "ai_failed" } });
+ expect(asked).toHaveLength(2);
+ gemini = { status: 200, text: "not json" };
+ expect((await ask(review())).body).toMatchObject({ items: [], provider: "gemini" });
+ });
+
+ it("refuses a bad request, another workspace, and goes over the workspace's hourly allowance with 429", async () => {
+ gemini = { status: 200 };
+ expect((await ask({ ...review(), request: { version: 1, lines: [] } })).body).toMatchObject({ error: "bad_payload" });
+ expect((await ask(review(), other)).body).toMatchObject({ error: "not_member" });
+ // Four reviews so far in this suite; the hour allows 20 per workspace. Spread over tokens so no token's own
+ // 30-a-minute limit answers first.
+ const tokens = [token, await link(sam), await link(sam)];
+ let last: any = null;
+ for (let i = 0; i < 20; i++) { last = await ask(review(), team, tokens[i % 3]); if (last.status === 429) break; }
+ expect(last.status).toBe(429);
+ expect(last.body).toMatchObject({ error: "rate_limited" });
+ expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0);
+ });
+});
diff --git a/server/cadRobots.ts b/server/cadRobots.ts
index 3774031b9..6be1e3e7f 100644
--- a/server/cadRobots.ts
+++ b/server/cadRobots.ts
@@ -129,7 +129,8 @@ export function createCadRobots(deps: CadRobotDeps) {
const rows = await dbAll(
`SELECT id, name, section, quantity, source, unit_cost, status, assignee, notes, created_at, updated_at, robot_id,
ck_document_id, ck_element_id, ck_part_key, ck_import_id, ck_extra, ck_removed_at,
- choice_type, choice_type_set, choice_type_by, choice_type_at, choice_product, choice_product_by, choice_product_at
+ choice_type, choice_type_set, choice_type_by, choice_type_at, choice_product, choice_product_by, choice_product_at,
+ ck_picture_url, ck_picture_at
FROM cad_parts WHERE team_id = ? AND (robot_id = ?${unassigned}) ORDER BY name COLLATE NOCASE, id`, teamId, robotId);
return rows.map(partView);
}
diff --git a/server/controlKitAi.ts b/server/controlKitAi.ts
new file mode 100644
index 000000000..6c1d095bb
--- /dev/null
+++ b/server/controlKitAi.ts
@@ -0,0 +1,118 @@
+// Control Kit's AI review, answered with Control Point's own model keys
+// (contract: control-kit docs/control-point-link.md v1.2 §7). Control Kit's
+// Worker sends a review it has already rebuilt from the person's stored scan;
+// this asks Gemini first and Anthropic once, only when Gemini is rate limited,
+// failing on its side, or unreachable (never on an auth or request error, which
+// would hide a bad key), the same policy as Bruno (ai-hybrid.ts). Nothing is
+// retried blindly. The answer is the model's JSON as it came; Control Kit
+// validates every item against its own request.
+
+const GEMINI_TIMEOUT_MS = 90_000;
+const ANTHROPIC_TIMEOUT_MS = 90_000;
+const DEFAULT_GEMINI_MODEL = "gemini-3.5-flash-lite";
+const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5-5";
+export const MAX_REVIEW_LINES = 150;
+export const MAX_INSTRUCTIONS = 8000;
+export const MAX_SCHEMA = 8000;
+
+export type ReviewProvider = "gemini" | "anthropic";
+export type ReviewAnswer = { output: unknown; provider: ReviewProvider; usage: { inputTokens: number | null; outputTokens: number | null } };
+export type ReviewAsk = { instructions: string; schema: Record; geminiSchema: Record; request: Record };
+/** A failed review: `status` is what Control Point answers (429 rate limit, 502 refused or failed, 504 timeout). */
+export class ReviewError extends Error {
+ constructor(readonly status: number, readonly code: string, message: string, readonly failover = false) { super(message); }
+}
+
+type Env = Record;
+const geminiKey = (env: Env) => String(env.GEMINI_API_KEY || "").trim();
+const anthropicKey = (env: Env) => String(env.ANTHROPIC_API_KEY || "").trim();
+export const reviewConfigured = (env: Env) => !!(geminiKey(env) || anthropicKey(env));
+
+const isStr = (v: unknown, max: number) => typeof v === "string" && v.length <= max;
+const isStrOrNull = (v: unknown, max: number) => v === null || v === undefined || isStr(v, max);
+const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v);
+const jsonSize = (v: unknown) => { try { return JSON.stringify(v).length; } catch { return Infinity; } };
+
+/**
+ * The review Control Kit may ask for, and nothing else: its instructions and output schemas (bounded), and a request of
+ * the documented shape (version 1, 1 to 150 lines of part facts and catalog candidates). Anything else is refused, so the
+ * route cannot be used as a general model proxy.
+ */
+export function checkReviewAsk(body: unknown): { ok: true; ask: ReviewAsk } | { ok: false; detail: string } {
+ if (!isObj(body)) return { ok: false, detail: "Body must be a JSON object." };
+ const { instructions, schema, geminiSchema, request } = body;
+ if (typeof instructions !== "string" || !instructions.trim() || instructions.length > MAX_INSTRUCTIONS) return { ok: false, detail: "instructions are missing or too long." };
+ if (!isObj(schema) || jsonSize(schema) > MAX_SCHEMA) return { ok: false, detail: "schema is missing or too large." };
+ if (!isObj(geminiSchema) || jsonSize(geminiSchema) > MAX_SCHEMA) return { ok: false, detail: "geminiSchema is missing or too large." };
+ if (!isObj(request) || request.version !== 1 || !isStr(request.assembly, 300) || !Array.isArray(request.lines)) return { ok: false, detail: "request must be a version 1 review." };
+ if (!request.lines.length || request.lines.length > MAX_REVIEW_LINES) return { ok: false, detail: `request.lines must hold 1 to ${MAX_REVIEW_LINES} lines.` };
+ for (const [i, line] of (request.lines as unknown[]).entries()) {
+ if (!isObj(line) || !isStr(line.id, 8) || !isStr(line.name, 500) || !isStrOrNull(line.description, 2000) || !isStrOrNull(line.material, 300) || !isStrOrNull(line.partNumber, 300)
+ || !Array.isArray(line.candidates) || line.candidates.length > 8 || (line.quantity !== undefined && typeof line.quantity !== "number")) {
+ return { ok: false, detail: `request.lines[${i}] is not a review line.` };
+ }
+ for (const c of line.candidates as unknown[]) {
+ if (!isObj(c) || !isStr(c.id, 140) || !isStr(c.sku, 80) || !isStr(c.vendor, 40) || !isStr(c.name, 200)) return { ok: false, detail: `request.lines[${i}] has a bad candidate.` };
+ }
+ if (jsonSize(line) > 6000) return { ok: false, detail: `request.lines[${i}] is too large.` };
+ }
+ return { ok: true, ask: { instructions, schema, geminiSchema, request } };
+}
+
+async function timed(fetcher: typeof fetch, url: string, init: RequestInit, ms: number, label: string) {
+ try { return await fetcher(url, { ...init, signal: AbortSignal.timeout(ms) }); }
+ catch (e: any) {
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new ReviewError(504, "ai_timeout", `${label} did not answer within ${ms / 1000} seconds.`, true);
+ throw new ReviewError(502, "ai_failed", `Could not reach ${label}.`, true);
+ }
+}
+const parsed = (text: string) => { try { return JSON.parse(text); } catch { return null; } };
+
+async function askGemini(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Promise {
+ const base = env.GEMINI_API_BASE || "https://generativelanguage.googleapis.com/v1beta";
+ const model = env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL;
+ const res = await timed(fetcher, `${base}/models/${encodeURIComponent(model)}:generateContent`, {
+ method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": geminiKey(env) },
+ body: JSON.stringify({ systemInstruction: { parts: [{ text: ask.instructions }] }, contents: [{ role: "user", parts: [{ text: JSON.stringify(ask.request) }] }],
+ generationConfig: { responseMimeType: "application/json", responseSchema: ask.geminiSchema } }),
+ }, GEMINI_TIMEOUT_MS, "Gemini");
+ if (!res.ok) {
+ const failover = res.status === 429 || res.status >= 500;
+ throw new ReviewError(res.status === 429 ? 429 : 502, res.status === 429 ? "rate_limited" : "ai_failed",
+ res.status === 429 ? "Gemini rate limit reached." : failover ? `Gemini failed (HTTP ${res.status}).` : `Gemini rejected the request (HTTP ${res.status}).`, failover);
+ }
+ const body: any = await res.json().catch(() => null);
+ const text = (body?.candidates?.[0]?.content?.parts ?? []).map((p: any) => (typeof p?.text === "string" ? p.text : "")).join("");
+ return { output: parsed(text), provider: "gemini", usage: { inputTokens: body?.usageMetadata?.promptTokenCount ?? null, outputTokens: body?.usageMetadata?.candidatesTokenCount ?? null } };
+}
+
+async function askAnthropic(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Promise {
+ const base = env.ANTHROPIC_API_BASE || "https://api.anthropic.com/v1";
+ const res = await timed(fetcher, `${base}/messages`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "x-api-key": anthropicKey(env), "anthropic-version": "2023-06-01", "anthropic-beta": "server-side-fallback-2026-07-01" },
+ body: JSON.stringify({ model: env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL, max_tokens: 16000, system: ask.instructions, fallbacks: "default",
+ messages: [{ role: "user", content: JSON.stringify(ask.request) }], output_config: { effort: "low", format: { type: "json_schema", schema: ask.schema } } }),
+ }, ANTHROPIC_TIMEOUT_MS, "Anthropic");
+ if (!res.ok) {
+ throw new ReviewError(res.status === 429 ? 429 : 502, res.status === 429 ? "rate_limited" : "ai_failed",
+ res.status === 429 ? "Anthropic rate limit reached." : res.status >= 500 ? `Anthropic failed (HTTP ${res.status}).` : `Anthropic rejected the request (HTTP ${res.status}).`);
+ }
+ const body: any = await res.json().catch(() => null);
+ if (body?.stop_reason === "refusal") throw new ReviewError(502, "ai_failed", "Anthropic declined the request.");
+ if (body?.stop_reason === "max_tokens") throw new ReviewError(502, "ai_failed", "Anthropic's answer was cut off.");
+ const text = (body?.content ?? []).filter((b: any) => b?.type === "text").map((b: any) => String(b.text ?? "")).join("");
+ return { output: parsed(text), provider: "anthropic", usage: { inputTokens: body?.usage?.input_tokens ?? null, outputTokens: body?.usage?.output_tokens ?? null } };
+}
+
+/** One review: Gemini when its key is set, Anthropic once when Gemini cannot answer for a reason worth failing over for. */
+export async function runReview(env: Env, ask: ReviewAsk, fetcher: typeof fetch = fetch): Promise {
+ if (!reviewConfigured(env)) throw new ReviewError(501, "ai_unavailable", "AI review is not set up on this Control Point server.");
+ if (!geminiKey(env)) return askAnthropic(env, ask, fetcher);
+ try { return await askGemini(env, ask, fetcher); }
+ catch (e) {
+ if (!(e instanceof ReviewError) || !e.failover || !anthropicKey(env)) throw e;
+ console.warn(`[Control Kit AI] ${e.message} Asking Anthropic once.`);
+ return askAnthropic(env, ask, fetcher);
+ }
+}
diff --git a/server/controlKitLink.ts b/server/controlKitLink.ts
index 64bef88ba..02628438f 100644
--- a/server/controlKitLink.ts
+++ b/server/controlKitLink.ts
@@ -1,4 +1,4 @@
-// Control Kit link (contract: control-kit docs/control-point-link.md, v1).
+// Control Kit link (contract: control-kit docs/control-point-link.md, v1.2).
// Control Kit, the Onshape BOM app, links to a Control Point account with an
// authorization code, then sends reviewed BOMs into a workspace's parts list
// with a bearer token. A grant belongs to an account (its email), never to a
@@ -14,6 +14,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { RateLimiter, clientIp } from "./rateLimit.js";
import { sanitizePartInput } from "./cad.js";
import { robotForAssembly } from "./cadRobots.js";
+import { checkReviewAsk, reviewConfigured, ReviewError, runReview } from "./controlKitAi.js";
import { parseDisabledModules } from "../src/types/teamModules.js";
type Auth = { memberId: number; teamId: number | null; teamless?: boolean; email?: string };
@@ -31,6 +32,10 @@ export interface ControlKitDeps {
env: Record;
/** Choices made in Control Point for one document's parts (cadRobots.decisionsFor). */
decisionsFor: (teamId: number, documentId: string) => Promise;
+ /** Delete a stored file by its /api/files/:id URL (best effort): a replaced upload or picture. */
+ deleteStoredFile?: (url: string) => Promise;
+ /** The fetch behind AI review's model calls (controlKitAi.runReview); tests swap it. */
+ reviewFetch?: typeof fetch;
}
export const CONTROL_KIT_CLIENT_ID = "control-kit";
@@ -40,6 +45,44 @@ export const BOM_LIMIT = "12mb";
const MAX_CAD_PARTS = 5000;
const MAX_FILES = 20;
const TOKEN_PREFIX = "ckp_";
+/** A file uploaded after a send (contract v1.2 §6): the STEP zip, a large workbook, or a CSV. */
+export const FILE_LIMIT_BYTES = 64 * 1024 * 1024;
+export const UPLOAD_TYPES: Record = {
+ "application/zip": /\.zip$/i,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": /\.xlsx$/i,
+ "text/csv": /\.csv$/i,
+};
+/** Part pictures: one call holds at most this many, each a PNG of at most 1 MB. */
+export const PICTURES_PER_CALL = 40;
+export const PICTURE_LIMIT_BYTES = 1024 * 1024;
+export const PICTURES_BODY_LIMIT = "8mb";
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+/** AI review per workspace (contract v1.2 §7): at most this many an hour and a day. Control Kit keeps its own per-person limits. */
+export const AI_REVIEWS_PER_HOUR = 20;
+export const AI_REVIEWS_PER_DAY = 100;
+
+/** A file name as uploaded: 1 to 160 characters, no path separators or control characters. */
+export function uploadName(v: unknown): string | null {
+ if (typeof v !== "string") return null;
+ const name = v.trim();
+ return name && name.length <= 160 && !/[\\/\u0000-\u001f\u007f]/.test(name) && name !== "." && name !== ".." ? name : null;
+}
+/** Which placeholder an uploaded file fills: a bundle lists a missing STEP zip or workbook by a label, not a file name. */
+function fillsPlaceholder(entry: any, name: string): boolean {
+ if (entry?.stored) return false;
+ const n = String(entry?.name || "");
+ if (/\.zip$/i.test(name)) return /\.zip$/i.test(n) || /\bSTEP\b/i.test(n);
+ if (/\.xlsx$/i.test(name)) return /\.xlsx$/i.test(n) || /\b(Excel|workbook)\b/i.test(n);
+ if (/\.csv$/i.test(name)) return /\.csv$/i.test(n);
+ return false;
+}
+/** An import's files with `upload` in place of the entry of the same name, or of the placeholder it fills, else appended. */
+export function withUploadedFile(files: any[], upload: any): any[] {
+ const same = files.findIndex((f) => f?.name === upload.name);
+ const at = same >= 0 ? same : files.findIndex((f) => fillsPlaceholder(f, upload.name));
+ if (at < 0) return [...files, upload].slice(-MAX_FILES);
+ return files.map((f, i) => (i === at ? upload : f));
+}
/** An unfinished import whose claim is older than this was abandoned; a retry finishes it. */
export const IMPORT_STALL_MS = 2 * 60 * 1000;
/** A claim on an unfinished import: when it was taken (so it ages and sorts like a timestamp) plus who took it. */
@@ -382,6 +425,131 @@ export function createControlKitLink(deps: ControlKitDeps) {
await finishImport(req, res, importId, team, body, false, claim);
}
+ /** One finished import of the guarded workspace, or a 404/409 already sent. */
+ async function finishedImport(req: any, res: any) {
+ const row = await dbGet("SELECT * FROM cad_imports WHERE id = ? AND team_id = ?", Number(req.params.importId), Number(req.ck.team.id));
+ if (!row) { res.status(404).json({ error: "not_found", detail: "No such import in this workspace." }); return null; }
+ if (!row.finished_at) { res.setHeader("Retry-After", "5"); res.status(409).json({ error: "in_progress", retryAfter: 5 }); return null; }
+ return row;
+ }
+ const parseFiles = (raw: unknown): any[] => { try { const v = JSON.parse(String(raw || "[]")); return Array.isArray(v) ? v : []; } catch { return []; } };
+ const dropStored = async (url: string | null | undefined) => { if (url && deps.deleteStoredFile) await deps.deleteStoredFile(url).catch(() => {}); };
+
+ /**
+ * A file uploaded after its send (contract v1.2 §6): raw bytes, checked against the SHA-256 Control Kit names, stored as
+ * a CAD doc of the import, and put in the import's file list in place of the entry it completes. The list is swapped
+ * only from the value it was read as, so two uploads to one import never drop each other.
+ */
+ async function uploadFile(req: any, res: any) {
+ const teamId = Number(req.ck.team.id);
+ const row = await finishedImport(req, res);
+ if (!row) return;
+ const name = uploadName(req.query.name);
+ const mediaType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
+ if (!name) return res.status(400).json({ error: "bad_file", detail: "name is missing or not a plain file name." });
+ if (!UPLOAD_TYPES[mediaType]?.test(name)) return res.status(415).json({ error: "bad_file", detail: "Only a .zip, .xlsx or .csv file of the matching type." });
+ const want = String(req.headers["x-content-sha256"] || "").trim().toLowerCase();
+ if (!/^[0-9a-f]{64}$/.test(want)) return res.status(400).json({ error: "bad_file", detail: "X-Content-SHA256 is missing." });
+ const buffer: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ if (!buffer.length) return res.status(400).json({ error: "bad_file", detail: "The file is empty." });
+ if (sha256(buffer) !== want) return res.status(400).json({ error: "checksum_mismatch", detail: "The bytes do not match X-Content-SHA256." });
+ const earlier = parseFiles(row.files).find((f) => f?.name === name && f?.stored);
+ if (earlier && String(earlier.sha256 || "").toLowerCase() === want) {
+ return res.json({ name, url: earlier.url, sizeBytes: buffer.length, sha256: want, duplicate: true });
+ }
+ const fileId = await deps.storeFile({ teamId, memberId: Number(req.ck.team.member_id), kind: "cad", filename: name, mimeType: mediaType, buffer });
+ const url = deps.fileUrl(fileId);
+ const entry = { name, mediaType, sizeBytes: buffer.length, sha256: want, stored: true, url, uploadedAt: new Date().toISOString() };
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const current = await dbGet("SELECT files FROM cad_imports WHERE id = ?", row.id);
+ const before = parseFiles(current?.files);
+ const replaced = before.find((f) => f?.name === name && f?.stored && f?.url);
+ const next = withUploadedFile(before, entry);
+ const swapped = await dbRun(
+ "UPDATE cad_imports SET files = ?, result = json_set(COALESCE(result, '{}'), '$.filesStored', ?) WHERE id = ? AND COALESCE(files, '') = COALESCE(?, '')",
+ JSON.stringify(next), next.filter((f) => f?.stored).length, row.id, current?.files ?? null);
+ if (!swapped.changes) continue;
+ await dbRun("INSERT INTO cad_docs (team_id, name, url, created_by, created_at, ck_import_id, robot_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ teamId, name, url, Number(req.ck.team.member_id), entry.uploadedAt, row.id, row.robot_id ?? null);
+ if (replaced) {
+ await dbRun("DELETE FROM cad_docs WHERE team_id = ? AND ck_import_id = ? AND url = ?", teamId, row.id, replaced.url);
+ await dropStored(replaced.url);
+ }
+ return res.json({ name, url, sizeBytes: buffer.length, sha256: want, duplicate: false });
+ }
+ await dropStored(url);
+ res.setHeader("Retry-After", "2");
+ res.status(409).json({ error: "busy", detail: "The import's files changed while saving. Try again.", retryAfter: 2 });
+ }
+
+ /** Which parts of an import's assembly already have a picture, and of which revision (Control Kit's key). */
+ async function pictureKeys(req: any, res: any) {
+ const row = await finishedImport(req, res);
+ if (!row) return;
+ const rows = await dbAll(
+ "SELECT ck_part_key, ck_picture_key, ck_picture_at FROM cad_parts WHERE team_id = ? AND ck_document_id = ? AND ck_element_id = ? AND ck_part_key IS NOT NULL AND ck_picture_key IS NOT NULL",
+ Number(req.ck.team.id), row.document_id, row.element_id);
+ res.json({ pictures: rows.map((r) => ({ identity: String(r.ck_part_key), key: String(r.ck_picture_key), at: r.ck_picture_at || null })) });
+ }
+
+ /**
+ * Part pictures for an import's assembly (contract v1.2 §6): PNGs keyed by part identity and Control Kit's revision
+ * key. A part already holding that key is left alone; a newer one replaces the old file. Unknown parts are counted.
+ */
+ async function uploadPictures(req: any, res: any) {
+ const teamId = Number(req.ck.team.id);
+ const row = await finishedImport(req, res);
+ if (!row) return;
+ const list = req.body?.pictures;
+ if (!Array.isArray(list) || !list.length || list.length > PICTURES_PER_CALL) return res.status(400).json({ error: "bad_payload", detail: `pictures must hold 1 to ${PICTURES_PER_CALL} items.` });
+ let stored = 0, unchanged = 0, unknown = 0, rejected = 0;
+ for (const p of list) {
+ const key = typeof p?.key === "string" && /^[\w:.-]{1,128}$/.test(p.key) ? p.key : null;
+ const identity = partKey({ identity: p?.identity });
+ const buffer = typeof p?.contentBase64 === "string" && p.contentBase64.length <= Math.ceil(PICTURE_LIMIT_BYTES / 3) * 4 + 4 ? Buffer.from(p.contentBase64, "base64") : null;
+ if (!key || !identity || !buffer || buffer.length > PICTURE_LIMIT_BYTES || buffer.length < 16 || !buffer.subarray(0, 8).equals(PNG_MAGIC)) { rejected++; continue; }
+ const part = await dbGet("SELECT id, ck_picture_url, ck_picture_key FROM cad_parts WHERE team_id = ? AND ck_document_id = ? AND ck_element_id = ? AND ck_part_key = ?",
+ teamId, row.document_id, row.element_id, identity);
+ if (!part) { unknown++; continue; }
+ if (part.ck_picture_key === key) { unchanged++; continue; }
+ const fileId = await deps.storeFile({ teamId, memberId: Number(req.ck.team.member_id), kind: "cad", filename: `${String(row.assembly_name || "part").slice(0, 60)} part.png`, mimeType: "image/png", buffer });
+ const url = deps.fileUrl(fileId);
+ // Swapped only from the picture this read saw: a picture stored meanwhile by another send keeps its place.
+ const swapped = await dbRun("UPDATE cad_parts SET ck_picture_url = ?, ck_picture_key = ?, ck_picture_at = ? WHERE id = ? AND COALESCE(ck_picture_url, '') = ?",
+ url, key, new Date().toISOString(), part.id, part.ck_picture_url || "");
+ if (!swapped.changes) { await dropStored(url); unchanged++; continue; }
+ await dropStored(part.ck_picture_url);
+ stored++;
+ }
+ res.json({ stored, unchanged, unknown, rejected });
+ }
+
+ /** Control Kit's AI review with Control Point's own model keys (contract v1.2 §7), within a per-workspace allowance. */
+ async function aiReview(req: any, res: any) {
+ if (!reviewConfigured(deps.env)) return res.status(501).json({ error: "ai_unavailable", detail: "AI review is not set up on this Control Point server." });
+ const check = checkReviewAsk(req.body);
+ if (check.ok === false) return res.status(400).json({ error: "bad_payload", detail: check.detail });
+ const teamId = Number(req.ck.team.id);
+ const wait = limiter.hit(`ai-hour:${teamId}`, { max: AI_REVIEWS_PER_HOUR, windowMs: 3600_000 }) || limiter.hit(`ai-day:${teamId}`, { max: AI_REVIEWS_PER_DAY, windowMs: 86400_000 });
+ if (wait) {
+ res.setHeader("Retry-After", String(wait));
+ return res.status(429).json({ error: "rate_limited", detail: "This workspace has used its AI reviews for now.", retryAfter: wait });
+ }
+ try {
+ const answer = await runReview(deps.env, check.ask, deps.reviewFetch ?? fetch);
+ res.setHeader("Cache-Control", "no-store");
+ // The model's items as it gave them; Control Kit keeps only those that fit its own request.
+ const items = Array.isArray((answer.output as any)?.items) ? (answer.output as any).items.slice(0, 200) : [];
+ res.json({ items, provider: answer.provider, usage: answer.usage });
+ } catch (e) {
+ if (e instanceof ReviewError) {
+ if (e.status === 429) res.setHeader("Retry-After", "60");
+ return res.status(e.status).json({ error: e.code, detail: e.message });
+ }
+ throw e;
+ }
+ }
+
/** Token exchange, workspaces, BOM send, import history, revoke. Register before the /api session and CSRF guard. */
function registerBearerRoutes(app: Express) {
const base = "/api/integrations/control-kit";
@@ -448,6 +616,32 @@ export function createControlKitLink(deps: ControlKitDeps) {
} catch (e) { console.error("Control Kit decisions error:", e); res.status(500).json({ error: "server_error" }); }
});
+ // Contract v1.2 §6: files uploaded after a send, and part pictures.
+ app.post(`${base}/workspaces/:id/imports/:importId/files`, guard(true), express.raw({ type: () => true, limit: FILE_LIMIT_BYTES }), (err: any, _req: any, res: any, next: any) => {
+ if (err?.type === "entity.too.large") return res.status(413).json({ error: "too_large", detail: `A file can be at most ${FILE_LIMIT_BYTES / 1024 / 1024} MB.` });
+ next(err);
+ }, async (req: any, res: any) => {
+ try { await uploadFile(req, res); } catch (e) { console.error("Control Kit file upload error:", e); res.status(500).json({ error: "server_error" }); }
+ });
+ app.get(`${base}/workspaces/:id/imports/:importId/pictures`, guard(false), async (req: any, res) => {
+ try { await pictureKeys(req, res); } catch (e) { console.error("Control Kit picture list error:", e); res.status(500).json({ error: "server_error" }); }
+ });
+ app.post(`${base}/workspaces/:id/imports/:importId/pictures`, guard(true), express.json({ limit: PICTURES_BODY_LIMIT }), (err: any, _req: any, res: any, next: any) => {
+ if (err?.type === "entity.too.large") return res.status(413).json({ error: "too_large", detail: `Pictures go at most ${PICTURES_BODY_LIMIT.toUpperCase()} per call.` });
+ if (err?.type === "entity.parse.failed") return res.status(400).json({ error: "bad_payload", detail: "Body is not valid JSON." });
+ next(err);
+ }, async (req: any, res: any) => {
+ try { await uploadPictures(req, res); } catch (e) { console.error("Control Kit picture upload error:", e); res.status(500).json({ error: "server_error" }); }
+ });
+ // Contract v1.2 §7: AI review with Control Point's keys.
+ app.post(`${base}/workspaces/:id/ai/review`, guard(true), express.json({ limit: "256kb" }), (err: any, _req: any, res: any, next: any) => {
+ if (err?.type === "entity.too.large") return res.status(413).json({ error: "too_large", detail: "An AI review can be at most 256 KB." });
+ if (err?.type === "entity.parse.failed") return res.status(400).json({ error: "bad_payload", detail: "Body is not valid JSON." });
+ next(err);
+ }, async (req: any, res: any) => {
+ try { await aiReview(req, res); } catch (e) { console.error("Control Kit AI review error:", e); res.status(500).json({ error: "server_error" }); }
+ });
+
app.post(`${base}/revoke`, async (req, res) => {
try {
const who = await bearer(req, res);
diff --git a/src/components/cad/useRobots.ts b/src/components/cad/useRobots.ts
index 24c299b41..b4134efe5 100644
--- a/src/components/cad/useRobots.ts
+++ b/src/components/cad/useRobots.ts
@@ -18,11 +18,13 @@ export type BomPart = {
ck_part_key: string | null; ck_import_id: number | null; ck_removed_at: string | null; ck_extra: any;
choice_type: CadType | null; choice_type_set: number; choice_type_by: string | null; choice_type_at: string | null;
choice_product: string | null; choice_product_by: string | null; choice_product_at: string | null;
+ /** The part's Onshape picture from Control Kit (contract v1.2 §6), or null. */
+ ck_picture_url?: string | null; ck_picture_at?: string | null;
effective: EffectivePart;
};
export type RobotImport = {
id: number; sentAt: string; sentBy: string | null; assemblyName: string | null; assemblyUrl: string | null; revision: string | null; partCount: number;
- files: { name: string; stored: boolean; url?: string; note?: string; sizeBytes?: number | null }[]; created: number; updated: number; markedRemoved: number;
+ files: { name: string; stored: boolean; url?: string; note?: string; sizeBytes?: number | null; uploadedAt?: string }[]; created: number; updated: number; markedRemoved: number;
};
export type RobotDetail = {
robot: { id: number; name: string; season: number; seasonName: string; isMain: boolean; assemblyUrl: string | null; documentId: string | null; elementId: string | null; notes: string };
diff --git a/src/modern/__tests__/cadRobotBom.test.tsx b/src/modern/__tests__/cadRobotBom.test.tsx
index f648e05fb..e8ef1dbe2 100644
--- a/src/modern/__tests__/cadRobotBom.test.tsx
+++ b/src/modern/__tests__/cadRobotBom.test.tsx
@@ -140,6 +140,39 @@ describe("a robot's BOM", () => {
it('lists the files Control Kit sent on the Files tab', async () => {
setup('/cad/robots/1?tab=files');
expect(await screen.findByRole('link', { name: 'MR. HORS BOM.xlsx' })).toHaveAttribute('href', '/api/files/9');
- expect(screen.getByText('Available in Control Kit')).toBeInTheDocument();
+ expect(screen.getByText('Not uploaded yet. In Control Kit, press Add STEP files.')).toBeInTheDocument();
+ });
+
+ it('downloads the newest STEP zip and workbook from the header, the Make group and the Files tab', async () => {
+ const zip = { name: 'MR. HORS STEP.zip', stored: true, url: '/api/files/12', sizeBytes: 3_400_000 };
+ imports.unshift({ ...imports[0], id: 42, sentAt: '2026-10-27T15:00:00.000Z', files: [{ name: 'MR. HORS BOM.xlsx', stored: true, url: '/api/files/13' }] } as any);
+ imports[1] = { ...imports[1], files: [...imports[1].files.filter((f) => !/zip$/.test(f.name)), zip] } as any;
+ try {
+ setup('/cad/robots/1');
+ const step = await screen.findByRole('link', { name: /STEP files for every part to make/ });
+ expect(step).toHaveAttribute('href', '/api/files/12');
+ expect(step).toHaveAttribute('download', 'MR. HORS STEP.zip');
+ // The workbook is the newest send's; the zip comes from the send that had one.
+ expect(screen.getByRole('link', { name: 'Workbook' })).toHaveAttribute('href', '/api/files/13');
+ const make = screen.getByRole('heading', { name: /^Make/ }).closest('section')!;
+ expect(within(make).getByRole('link', { name: 'STEP files' })).toHaveAttribute('href', '/api/files/12');
+ cleanup();
+ setup('/cad/robots/1?tab=files');
+ expect(await screen.findByText(/STEP files from the send of/)).toBeInTheDocument();
+ } finally {
+ imports.shift();
+ imports[0] = { ...imports[0], files: imports[0].files.filter((f) => !/zip$/.test(f.name)).concat({ name: 'MR. HORS STEP.zip', stored: false, note: 'Available in Control Kit' } as any) } as any;
+ }
+ });
+
+ it("shows each part's Onshape picture in its row and drawer", async () => {
+ parts = parts.map((p) => (p.id === 3 ? { ...p, ck_picture_url: '/api/files/30', ck_picture_at: '2026-10-27T15:00:00.000Z' } : p));
+ setup('/cad/robots/1');
+ const make = (await screen.findByRole('heading', { name: /^Make/ })).closest('section')!;
+ expect(within(make).getByRole('img', { name: 'Side panel in Onshape' })).toHaveAttribute('src', '/api/files/30');
+ fireEvent.click(within(make).getByRole('button', { name: 'Side panel' }));
+ const drawer = await screen.findByRole('dialog');
+ expect(within(drawer).getByRole('img', { name: 'Side panel in Onshape' })).toHaveAttribute('src', '/api/files/30');
+ expect(within(drawer).getByText(/Picture drawn by Onshape, from Control Kit/)).toBeInTheDocument();
});
});
diff --git a/src/modern/pages/cad/RobotPage.tsx b/src/modern/pages/cad/RobotPage.tsx
index 6182b1e25..276e77cc5 100644
--- a/src/modern/pages/cad/RobotPage.tsx
+++ b/src/modern/pages/cad/RobotPage.tsx
@@ -6,7 +6,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
- ArrowLeft, Check, ClipboardCheck, Trash2, ExternalLink, FileBox, FileCheck2, FileX2, History, Layers, MoreHorizontal, Package, Pencil, Plus, Search,
+ ArrowLeft, Check, ClipboardCheck, Download, Trash2, ExternalLink, FileBox, FileCheck2, FileX2, History, Layers, MoreHorizontal, Package, Pencil, Plus, Search,
Send, Sparkles, Star,
} from 'lucide-react';
import {
@@ -43,12 +43,43 @@ const TABS = [
/** The assembly picture Control Kit sent with the latest import that had one. */
function assemblyPicture(imports: RobotImport[]) {
- for (const i of imports) {
- const f = i.files.find((x) => x.stored && x.url && /\.(png|jpe?g|webp)$/i.test(x.name));
- if (f) return f.url!;
+ return latestFile(imports, /\.(png|jpe?g|webp)$/i)?.file.url ?? null;
+}
+
+type ImportFile = RobotImport['files'][number];
+/** The newest stored file of a kind across this robot's sends (a later send without its STEP zip keeps the earlier zip downloadable). */
+export function latestFile(imports: RobotImport[], pattern: RegExp): { file: ImportFile; sent: RobotImport } | null {
+ for (const sent of imports) {
+ const file = sent.files.find((f) => f.stored && f.url && pattern.test(f.name));
+ if (file) return { file, sent };
}
return null;
}
+const STEP_ZIP = /\.zip$/i;
+const WORKBOOK = /\.xlsx$/i;
+
+/** Download the robot's STEP files and workbook straight from Control Point. */
+function Downloads({ imports, compact = false }: { imports: RobotImport[]; compact?: boolean }) {
+ const step = latestFile(imports, STEP_ZIP);
+ const book = latestFile(imports, WORKBOOK);
+ if (!step && !book) return compact ? null :
No STEP files or workbook yet. In Control Kit, send this assembly, then press Add STEP files.
))}
From 559dd117f4b30bcbb5951f39b1363f910c8f4d21 Mon Sep 17 00:00:00 2001
From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com>
Date: Wed, 28 Oct 2026 23:24:51 -0400
Subject: [PATCH 2/3] Greptile: atomic file swap and documents, no dropped
entries, failed model bodies fail over, downloads across every send, picture
reset and cleanup
---
docs/control-kit-link.md | 10 +++---
server.ts | 4 ++-
server/__tests__/controlKitFiles.test.ts | 36 +++++++++++++++++++++
server/cadRobots.ts | 12 +++++++
server/controlKitAi.ts | 25 ++++++++++-----
server/controlKitLink.ts | 40 +++++++++++++++++-------
src/components/cad/useRobots.ts | 3 ++
src/modern/pages/cad/RobotPage.tsx | 33 ++++++++++++-------
8 files changed, 126 insertions(+), 37 deletions(-)
diff --git a/docs/control-kit-link.md b/docs/control-kit-link.md
index 62a685edd..fde5f85f3 100644
--- a/docs/control-kit-link.md
+++ b/docs/control-kit-link.md
@@ -176,20 +176,20 @@ A bundle names every file it should hold. Files over 4 MB, and **the STEP zip al
- Bearer, same checks as the BOM send (active member, CAD module on). The import must belong to workspace `id` (else 404 `not_found`) and be finished (else 409 `in_progress` with `Retry-After`).
- Body: the file's raw bytes, at most 64 MB (413 `too_large`). `Content-Type` is `application/zip`, the xlsx type, or `text/csv`, and must match the name's extension (415 `bad_file`). `X-Content-SHA256` is the bytes' hex SHA-256; a mismatch is 400 `checksum_mismatch`. `name` is 1 to 160 characters with no slash, backslash, or control characters.
-- Stored as a CAD doc of the import (on its robot). In the import's `files` it replaces the entry of the same name, else the first unstored entry it completes (a `.zip` fills an entry named `*.zip` or mentioning STEP; an `.xlsx` one named `*.xlsx`, Excel, or workbook), else it is appended. The list is swapped from the value it was read as, so two uploads to one import never drop each other.
+- Stored as a CAD doc of the import (on its robot). In the import's `files` it replaces the entry of the same name, else the first unstored entry it completes (a `.zip` fills an entry named `*.zip` or mentioning STEP; an `.xlsx` one named `*.xlsx`, Excel, or workbook), else it is appended (at most 40 entries: a full list answers 409 `too_many_files` rather than drop one). The list swap, the new document, and removing the document it replaces are one transaction, applied only from the value the list was read as, so overlapping uploads never drop each other or leave a document pointing at deleted bytes.
- The same name with the same bytes again: `200 {duplicate: true}`, nothing changes. New bytes under the same name replace the stored file (the old one is deleted).
- `200 { name, url, sizeBytes, sha256, duplicate }`; `url` is `/api/files/{id}` on Control Point.
-Control Kit Worker: `POST /api/control-point/file?documentId&elementId&importId&workspaceId&name` (Origin-checked, cookie session), headers `Content-Type`, `Content-Length` (411 without), `X-Content-SHA256`. Only for the caller's own latest send of that assembly (`cp-sent:{userId}:{documentId}:{elementId}` names that `importId` and workspace; else 409 `not_sent` or `workspace_changed`), with the link still on that workspace. The body streams through unread (the free plan's 10 ms CPU rules out hashing 64 MB; Control Point checks the hash), and a body longer than declared errors rather than arriving short. A new name is added to the sent record's `files`, so the panel knows STEP is there. A Control Point 401 ends the link and answers 409 `revoked`.
+Control Kit Worker: `POST /api/control-point/file?documentId&elementId&importId&workspaceId&name` (Origin-checked, cookie session), headers `Content-Type`, `Content-Length` (411 without), `X-Content-SHA256`. Only for the caller's own latest send of that assembly (`cp-sent:{userId}:{documentId}:{elementId}` names that `importId` and workspace; else 409 `not_sent` or `workspace_changed`), with the link still on that workspace. The body streams through unread (the free plan's 10 ms CPU rules out hashing 64 MB; Control Point checks the hash), and a body longer than declared errors rather than arriving short. Each upload is recorded per import and kind at `cp-upload:{userId}:{importId}:{zip|xlsx|csv}` = `{name, sha256, at}` (never merged into `cp-sent`, so a newer send or another upload cannot erase it), and `GET /api/control-point/sent` returns them as `files`. The panel uploads a file again only when Control Point does not already hold those exact bytes under that name, so a re-exported zip replaces an incomplete one. A Control Point 401 ends the link and answers 409 `revoked`.
-The panel uploads the referenced files right after each send, and offers **Add STEP files** (exporting first when this session has no current zip) while the last send of the current scan has no zip in Control Point. Control Point shows the newest stored STEP zip and workbook of a robot as downloads.
+The panel uploads the referenced files right after each send, and offers **Add STEP files** (exporting first when this session has no current zip) while the last send of the current scan has no zip in Control Point. Control Point shows the newest stored STEP zip and workbook across every send of a robot as downloads.
### Part pictures
Each Control Point CAD part may carry one Onshape picture of the part (or, for a vendor product listed as one unit, of that product), drawn by Control Kit's `/api/preview` and stored in `cad_parts.ck_picture_url` with Control Kit's picture **key**: the revision the Worker caches the picture at (the scanned microversion `m:{id}` for workspace parts, else `{v|m}:{sourceId}`) plus 16 hex characters of SHA-256 of `{bomLineKey}|{part|assembly}` (`controlPointPictureKey`, at most 128 characters of `[A-Za-z0-9_:.-]`). The robot's own picture stays the send's `{assembly} assembly.png` file.
- `GET …/workspaces/{id}/imports/{importId}/pictures` (Bearer, membership): `{ pictures: [{ identity, key, at }] }` for the parts of that import's assembly that have one.
-- `POST …/workspaces/{id}/imports/{importId}/pictures` (Bearer, CAD edit), JSON at most 8 MB: `{ pictures: [{ identity, key, contentBase64 }] }`, 1 to 40 items. Each must be a PNG of at most 1 MB for a part of that import's assembly; others count as `rejected` (bad key, not a PNG, too large) or `unknown` (no such part). A part already holding that key is `unchanged`; a new key replaces the picture (the old file is deleted). Imports never touch these columns. `200 { stored, unchanged, unknown, rejected }`.
+- `POST …/workspaces/{id}/imports/{importId}/pictures` (Bearer, CAD edit), JSON at most 8 MB: `{ pictures: [{ identity, key, contentBase64 }] }`, 1 to 40 items. Each must be a PNG of at most 1 MB for a part of that import's assembly; others count as `rejected` (bad key, not a PNG, too large) or `unknown` (no such part). A part already holding that key is `unchanged`; a new key replaces the picture (the old file is deleted). Imports never touch these columns, and deleting a part deletes its picture. `200 { stored, unchanged, unknown, rejected }`.
Control Kit Worker: `GET /api/control-point/pictures?documentId&elementId&importId` and `POST /api/control-point/pictures?documentId&elementId&importId&workspaceId` (Origin-checked, at most 8 MB, streamed unread), with the same send and link checks as files. `GET /api/preview?scanId&line&cached=1` answers a picture the Worker already drew, or 404 `not_cached`, never an Onshape request.
@@ -209,6 +209,6 @@ When the Control Kit Worker has no model key of its own (`GEMINI_API_KEY`, `ANTH
- `request` is the review the Worker rebuilt from the caller's own stored scan (`trustedAiRequest`): version 1, 1 to 150 lines, each with `id`, `name`, optional facts, and at most 8 catalog `candidates` (`id`, `vendor`, `sku`, `name`). `instructions` at most 8,000 characters, each schema at most 8,000 characters of JSON. Anything else is 400 `bad_payload`, so the route is not a general model proxy.
- Control Point asks Gemini (`GEMINI_MODEL`, default `gemini-3.5-flash-lite`) with the instructions as the system prompt, the request as JSON, and `responseSchema: geminiSchema`; it asks Anthropic (`ANTHROPIC_MODEL`, default `claude-sonnet-5-5`, effort low, `output_config.format` from `schema`) once, only when Gemini answers 429 or 5xx or cannot be reached, never on an auth or request error. Each call times out at 90 seconds; nothing is retried blindly.
- Per workspace, at most 20 reviews an hour and 100 a day (in memory, reset on restart): 429 `rate_limited` with `Retry-After`. The token's own 30 calls a minute still apply.
-- `200 { items: [...], provider: "gemini" | "anthropic", usage: { inputTokens, outputTokens } }`: the model's items as it gave them (an unparseable answer is `items: []`). 501 `ai_unavailable` without keys; 502 `ai_failed`; 504 `ai_timeout`.
+- `200 { items: [...], provider: "gemini" | "anthropic", usage: { inputTokens, outputTokens } }`: the model's items as it gave them (model text that is not JSON is `items: []`). A response body that breaks off, times out while arriving, or is not the provider's JSON envelope is a provider failure (Gemini's fails over to Anthropic), never an empty answer. 501 `ai_unavailable` without keys; 502 `ai_failed`; 504 `ai_timeout`.
Control Kit Worker: `GET /api/ai/status` answers `{enabled: true, via: "control-kit"}` with its own keys, `{enabled: true, via: "control-point"}` for a person linked with a workspace (and the `AI_ALLOWANCE` binding), else `{enabled: false}` (with `linkToEnable: true` when linking would turn it on). `POST /api/ai/review` is unchanged for the browser; through Control Point it waits at most 190 seconds (Gemini then Anthropic), answers with `via: "control-point"`, maps Control Point's 429, 501, 502 and 504 to the same statuses with plain words, and a 401 ends the link and answers 409 `{code: "revoked", linked: false}` like the other link routes.
diff --git a/server.ts b/server.ts
index cfb3e950e..07060067e 100644
--- a/server.ts
+++ b/server.ts
@@ -16741,9 +16741,11 @@ NOTES>>>`;
const teamId = cadTeam(auth, res); if (!teamId) return;
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
- const part = (await dbGet("SELECT id FROM cad_parts WHERE id = ? AND team_id = ?", id, teamId)) as any;
+ const part = (await dbGet("SELECT id, ck_picture_url FROM cad_parts WHERE id = ? AND team_id = ?", id, teamId)) as any;
if (!part) return res.status(404).json({ error: "Not found" });
await dbRun("DELETE FROM cad_parts WHERE id = ?", id);
+ // Its Onshape picture from Control Kit (contract v1.2 §6) goes with it.
+ if (part.ck_picture_url) await deleteStoredFileByUrl(part.ck_picture_url);
res.json({ success: true });
} catch (e) { console.error("CAD part delete error:", e); res.status(500).json({ error: "Internal server error" }); }
});
diff --git a/server/__tests__/controlKitFiles.test.ts b/server/__tests__/controlKitFiles.test.ts
index 57959310e..9cded9cb4 100644
--- a/server/__tests__/controlKitFiles.test.ts
+++ b/server/__tests__/controlKitFiles.test.ts
@@ -105,6 +105,10 @@ describe("helpers", () => {
expect(withUploadedFile([{ name: "Drive STEP.zip", stored: false }], up)).toEqual([up]);
expect(withUploadedFile([{ name: "a.xlsx", stored: true }, { name: "STEP files", stored: false, note: "Not exported" }], up)).toEqual([{ name: "a.xlsx", stored: true }, up]);
expect(withUploadedFile([{ name: "a.xlsx", stored: true }], up)).toEqual([{ name: "a.xlsx", stored: true }, up]);
+ // A full list refuses a new name instead of dropping an entry; replacing one still works.
+ const full = Array.from({ length: 40 }, (_, i) => ({ name: `f${i}.xlsx`, stored: true }));
+ expect(withUploadedFile(full, up)).toBeNull();
+ expect(withUploadedFile(full, { name: "f3.xlsx", stored: true, url: "/api/files/1" })).toHaveLength(40);
});
it("accepts only a review of the documented shape", () => {
expect(checkReviewAsk(review()).ok).toBe(true);
@@ -129,6 +133,21 @@ describe("helpers", () => {
expect(calls).toEqual(["gemini"]);
await expect(runReview({}, ask, fake(200))).rejects.toMatchObject({ status: 501 });
});
+ it("treats a body that breaks off, or is not the provider's JSON, as a failure worth failing over for", async () => {
+ const env = { GEMINI_API_KEY: "g", ANTHROPIC_API_KEY: "a" };
+ const ask = (checkReviewAsk(review()) as any).ask;
+ const calls: string[] = [];
+ const broken = (kind: "drop" | "html") => (async (url: string) => {
+ const gemini = String(url).includes("generateContent");
+ calls.push(gemini ? "gemini" : "anthropic");
+ if (!gemini) return Response.json({ stop_reason: "end_turn", content: [{ type: "text", text: '{"items":[]}' }], usage: {} });
+ if (kind === "html") return new Response("proxy", { status: 200 });
+ return new Response(new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode('{"candi')); c.error(new TypeError("terminated")); } }), { status: 200 });
+ }) as unknown as typeof fetch;
+ expect((await runReview(env, ask, broken("drop"))).provider).toBe("anthropic");
+ expect((await runReview(env, ask, broken("html"))).provider).toBe("anthropic");
+ expect(calls).toEqual(["gemini", "anthropic", "gemini", "anthropic"]);
+ });
});
describe("files after a send", () => {
@@ -217,6 +236,23 @@ describe("files after a send", () => {
const now = (await t.api(`/api/cad/robots/${robotId}`, { session: sam })).body.parts.find((p: any) => p.ck_part_key === "doc9:ps:A:default");
expect((await fetch(`${t.base}${now.ck_picture_url}`, { headers: { cookie: `cp_session=${encodeURIComponent(olive)}` } })).status).toBe(403);
expect((await ck(base, { token, body: { pictures: [] } })).status).toBe(400);
+ // Deleting a part takes its picture file with it.
+ const pictureId = Number(now.ck_picture_url.split("/").pop());
+ await t.db.execute({ sql: "UPDATE cad_parts SET ck_removed_at = ? WHERE id = ?", args: [new Date().toISOString(), now.id] });
+ expect((await t.api(`/api/cad/parts/${now.id}`, { method: "DELETE", session: sam })).status).toBe(200);
+ expect(Number(((await t.db.execute({ sql: "SELECT COUNT(*) AS n FROM stored_files WHERE id = ?", args: [pictureId] })).rows[0] as any).n)).toBe(0);
+ });
+
+ it("offers the newest stored STEP zip and workbook across every send, past the ten listed", async () => {
+ const before = (await t.api(`/api/cad/robots/${robotId}`, { session: sam })).body.downloads;
+ expect(before.step).toMatchObject({ importId, file: { name: "Drive STEP.zip" } });
+ for (let i = 0; i < 11; i++) {
+ const r = await ck(`/api/integrations/control-kit/workspaces/${team}/bom`, { token, body: payload(`many-${i}`, { files: [], cadParts: [cad(i % 2 ? "A" : "B")] }) });
+ expect(r.status).toBe(200);
+ }
+ const detail = (await t.api(`/api/cad/robots/${robotId}`, { session: sam })).body;
+ expect(detail.imports.some((i: any) => i.id === importId)).toBe(false);
+ expect(detail.downloads.step).toMatchObject({ importId, file: { name: "Drive STEP.zip", url: before.step.file.url } });
});
});
diff --git a/server/cadRobots.ts b/server/cadRobots.ts
index 6be1e3e7f..692e57d30 100644
--- a/server/cadRobots.ts
+++ b/server/cadRobots.ts
@@ -280,7 +280,19 @@ export function createCadRobots(deps: CadRobotDeps) {
return { id: Number(i.id), sentAt: i.created_at, sentBy: i.sent_by, assemblyName: i.assembly_name, assemblyUrl: i.assembly_url, revision: i.revision,
partCount: Number(i.part_count) || 0, files, created: Number(result.created) || 0, updated: Number(result.updated) || 0, markedRemoved: Number(result.markedRemoved) || 0 };
});
+ // The newest stored STEP zip and workbook across every send of this robot, not just the ten listed.
+ const withFiles = await dbAll("SELECT id, created_at, files FROM cad_imports WHERE team_id = ? AND robot_id = ? AND files LIKE '%\"stored\":true%' ORDER BY id DESC LIMIT 200", teamId, robot.id);
+ const newest = (pattern: RegExp) => {
+ for (const i of withFiles) {
+ let files: any[] = [];
+ try { files = JSON.parse(i.files || "[]") || []; } catch { /* none */ }
+ const file = files.find((f) => f?.stored && typeof f.url === "string" && pattern.test(String(f.name)));
+ if (file) return { importId: Number(i.id), sentAt: i.created_at, file: { name: String(file.name), url: String(file.url), sizeBytes: Number(file.sizeBytes) || null } };
+ }
+ return null;
+ };
res.json({
+ downloads: { step: newest(/\.zip$/i), workbook: newest(/\.xlsx$/i) },
robot: { id: Number(robot.id), name: robot.name, season: Number(robot.season), seasonName: seasonName(Number(robot.season)), isMain: !!robot.is_main,
assemblyUrl: robot.assembly_url, documentId: robot.document_id, elementId: robot.element_id, notes: robot.notes },
canManage: await canManage(auth), parts, summary: bomSummary(parts), imports,
diff --git a/server/controlKitAi.ts b/server/controlKitAi.ts
index 6c1d095bb..9d958544d 100644
--- a/server/controlKitAi.ts
+++ b/server/controlKitAi.ts
@@ -59,19 +59,32 @@ export function checkReviewAsk(body: unknown): { ok: true; ask: ReviewAsk } | {
return { ok: true, ask: { instructions, schema, geminiSchema, request } };
}
-async function timed(fetcher: typeof fetch, url: string, init: RequestInit, ms: number, label: string) {
- try { return await fetcher(url, { ...init, signal: AbortSignal.timeout(ms) }); }
- catch (e: any) {
+/**
+ * One provider call, its body read inside the same deadline: a dropped connection or the deadline passing while the body
+ * arrives is a failure (worth failing over for), never an empty answer. A 2xx whose body is not the provider's JSON
+ * envelope fails the same way; only the model's own text may be unparseable, which is an answer with no items.
+ */
+async function call(fetcher: typeof fetch, url: string, init: RequestInit, ms: number, label: string): Promise<{ res: Response; body: any }> {
+ let res: Response;
+ let text: string;
+ try {
+ res = await fetcher(url, { ...init, signal: AbortSignal.timeout(ms) });
+ text = await res.text();
+ } catch (e: any) {
if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new ReviewError(504, "ai_timeout", `${label} did not answer within ${ms / 1000} seconds.`, true);
throw new ReviewError(502, "ai_failed", `Could not reach ${label}.`, true);
}
+ if (!res.ok) return { res, body: null };
+ const body = parsed(text);
+ if (!body || typeof body !== "object") throw new ReviewError(502, "ai_failed", `${label} sent an unreadable response.`, true);
+ return { res, body };
}
const parsed = (text: string) => { try { return JSON.parse(text); } catch { return null; } };
async function askGemini(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Promise {
const base = env.GEMINI_API_BASE || "https://generativelanguage.googleapis.com/v1beta";
const model = env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL;
- const res = await timed(fetcher, `${base}/models/${encodeURIComponent(model)}:generateContent`, {
+ const { res, body } = await call(fetcher, `${base}/models/${encodeURIComponent(model)}:generateContent`, {
method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": geminiKey(env) },
body: JSON.stringify({ systemInstruction: { parts: [{ text: ask.instructions }] }, contents: [{ role: "user", parts: [{ text: JSON.stringify(ask.request) }] }],
generationConfig: { responseMimeType: "application/json", responseSchema: ask.geminiSchema } }),
@@ -81,14 +94,13 @@ async function askGemini(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Promi
throw new ReviewError(res.status === 429 ? 429 : 502, res.status === 429 ? "rate_limited" : "ai_failed",
res.status === 429 ? "Gemini rate limit reached." : failover ? `Gemini failed (HTTP ${res.status}).` : `Gemini rejected the request (HTTP ${res.status}).`, failover);
}
- const body: any = await res.json().catch(() => null);
const text = (body?.candidates?.[0]?.content?.parts ?? []).map((p: any) => (typeof p?.text === "string" ? p.text : "")).join("");
return { output: parsed(text), provider: "gemini", usage: { inputTokens: body?.usageMetadata?.promptTokenCount ?? null, outputTokens: body?.usageMetadata?.candidatesTokenCount ?? null } };
}
async function askAnthropic(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Promise {
const base = env.ANTHROPIC_API_BASE || "https://api.anthropic.com/v1";
- const res = await timed(fetcher, `${base}/messages`, {
+ const { res, body } = await call(fetcher, `${base}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": anthropicKey(env), "anthropic-version": "2023-06-01", "anthropic-beta": "server-side-fallback-2026-07-01" },
body: JSON.stringify({ model: env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL, max_tokens: 16000, system: ask.instructions, fallbacks: "default",
@@ -98,7 +110,6 @@ async function askAnthropic(env: Env, ask: ReviewAsk, fetcher: typeof fetch): Pr
throw new ReviewError(res.status === 429 ? 429 : 502, res.status === 429 ? "rate_limited" : "ai_failed",
res.status === 429 ? "Anthropic rate limit reached." : res.status >= 500 ? `Anthropic failed (HTTP ${res.status}).` : `Anthropic rejected the request (HTTP ${res.status}).`);
}
- const body: any = await res.json().catch(() => null);
if (body?.stop_reason === "refusal") throw new ReviewError(502, "ai_failed", "Anthropic declined the request.");
if (body?.stop_reason === "max_tokens") throw new ReviewError(502, "ai_failed", "Anthropic's answer was cut off.");
const text = (body?.content ?? []).filter((b: any) => b?.type === "text").map((b: any) => String(b.text ?? "")).join("");
diff --git a/server/controlKitLink.ts b/server/controlKitLink.ts
index 02628438f..be1184783 100644
--- a/server/controlKitLink.ts
+++ b/server/controlKitLink.ts
@@ -76,11 +76,16 @@ function fillsPlaceholder(entry: any, name: string): boolean {
if (/\.csv$/i.test(name)) return /\.csv$/i.test(n);
return false;
}
-/** An import's files with `upload` in place of the entry of the same name, or of the placeholder it fills, else appended. */
-export function withUploadedFile(files: any[], upload: any): any[] {
+/** How many entries an import's file list may reach through uploads; a full list refuses a new name rather than drop one. */
+export const MAX_IMPORT_FILES = 2 * MAX_FILES;
+/**
+ * An import's files with `upload` in place of the entry of the same name, or of the placeholder it fills, else appended.
+ * Null when appending would pass MAX_IMPORT_FILES: no existing entry is ever dropped to make room.
+ */
+export function withUploadedFile(files: any[], upload: any): any[] | null {
const same = files.findIndex((f) => f?.name === upload.name);
const at = same >= 0 ? same : files.findIndex((f) => fillsPlaceholder(f, upload.name));
- if (at < 0) return [...files, upload].slice(-MAX_FILES);
+ if (at < 0) return files.length >= MAX_IMPORT_FILES ? null : [...files, upload];
return files.map((f, i) => (i === at ? upload : f));
}
/** An unfinished import whose claim is older than this was abandoned; a retry finishes it. */
@@ -457,6 +462,7 @@ export function createControlKitLink(deps: ControlKitDeps) {
if (earlier && String(earlier.sha256 || "").toLowerCase() === want) {
return res.json({ name, url: earlier.url, sizeBytes: buffer.length, sha256: want, duplicate: true });
}
+ if (!withUploadedFile(parseFiles(row.files), { name })) return res.status(409).json({ error: "too_many_files", detail: `An import holds at most ${MAX_IMPORT_FILES} files.` });
const fileId = await deps.storeFile({ teamId, memberId: Number(req.ck.team.member_id), kind: "cad", filename: name, mimeType: mediaType, buffer });
const url = deps.fileUrl(fileId);
const entry = { name, mediaType, sizeBytes: buffer.length, sha256: want, stored: true, url, uploadedAt: new Date().toISOString() };
@@ -464,17 +470,27 @@ export function createControlKitLink(deps: ControlKitDeps) {
const current = await dbGet("SELECT files FROM cad_imports WHERE id = ?", row.id);
const before = parseFiles(current?.files);
const replaced = before.find((f) => f?.name === name && f?.stored && f?.url);
+ // The same bytes arrived meanwhile (a retry racing this one): keep that file, drop this copy.
+ if (replaced && String(replaced.sha256 || "").toLowerCase() === want) {
+ await dropStored(url);
+ return res.json({ name, url: replaced.url, sizeBytes: buffer.length, sha256: want, duplicate: true });
+ }
const next = withUploadedFile(before, entry);
- const swapped = await dbRun(
- "UPDATE cad_imports SET files = ?, result = json_set(COALESCE(result, '{}'), '$.filesStored', ?) WHERE id = ? AND COALESCE(files, '') = COALESCE(?, '')",
- JSON.stringify(next), next.filter((f) => f?.stored).length, row.id, current?.files ?? null);
+ if (!next) { await dropStored(url); return res.status(409).json({ error: "too_many_files", detail: `An import holds at most ${MAX_IMPORT_FILES} files.` }); }
+ const nextJson = JSON.stringify(next);
+ // One transaction: the list swap, this file's document, and removing the document it replaces. The document
+ // statements run only when this request's swap is the list now, so an overlapping upload never leaves a
+ // document pointing at bytes another upload deleted.
+ const [swapped] = await deps.dbBatchResults([
+ { sql: "UPDATE cad_imports SET files = ?, result = json_set(COALESCE(result, '{}'), '$.filesStored', ?) WHERE id = ? AND COALESCE(files, '') = COALESCE(?, '')",
+ args: [nextJson, next.filter((f) => f?.stored).length, row.id, current?.files ?? null] },
+ { sql: "INSERT INTO cad_docs (team_id, name, url, created_by, created_at, ck_import_id, robot_id) SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM cad_imports WHERE id = ? AND files = ?)",
+ args: [teamId, name, url, Number(req.ck.team.member_id), entry.uploadedAt, row.id, row.robot_id ?? null, row.id, nextJson] },
+ { sql: "DELETE FROM cad_docs WHERE team_id = ? AND ck_import_id = ? AND url = ? AND EXISTS (SELECT 1 FROM cad_imports WHERE id = ? AND files = ?)",
+ args: [teamId, row.id, replaced?.url ?? "", row.id, nextJson] },
+ ]);
if (!swapped.changes) continue;
- await dbRun("INSERT INTO cad_docs (team_id, name, url, created_by, created_at, ck_import_id, robot_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
- teamId, name, url, Number(req.ck.team.member_id), entry.uploadedAt, row.id, row.robot_id ?? null);
- if (replaced) {
- await dbRun("DELETE FROM cad_docs WHERE team_id = ? AND ck_import_id = ? AND url = ?", teamId, row.id, replaced.url);
- await dropStored(replaced.url);
- }
+ if (replaced) await dropStored(replaced.url);
return res.json({ name, url, sizeBytes: buffer.length, sha256: want, duplicate: false });
}
await dropStored(url);
diff --git a/src/components/cad/useRobots.ts b/src/components/cad/useRobots.ts
index b4134efe5..27185f05a 100644
--- a/src/components/cad/useRobots.ts
+++ b/src/components/cad/useRobots.ts
@@ -26,7 +26,10 @@ export type RobotImport = {
id: number; sentAt: string; sentBy: string | null; assemblyName: string | null; assemblyUrl: string | null; revision: string | null; partCount: number;
files: { name: string; stored: boolean; url?: string; note?: string; sizeBytes?: number | null; uploadedAt?: string }[]; created: number; updated: number; markedRemoved: number;
};
+/** The newest stored file of a kind across every send of a robot. */
+export type RobotDownload = { importId: number; sentAt: string; file: { name: string; url: string; sizeBytes: number | null } } | null;
export type RobotDetail = {
+ downloads?: { step: RobotDownload; workbook: RobotDownload };
robot: { id: number; name: string; season: number; seasonName: string; isMain: boolean; assemblyUrl: string | null; documentId: string | null; elementId: string | null; notes: string };
canManage: boolean; parts: BomPart[]; summary: BomSummary; imports: RobotImport[];
};
diff --git a/src/modern/pages/cad/RobotPage.tsx b/src/modern/pages/cad/RobotPage.tsx
index 276e77cc5..114b72dfb 100644
--- a/src/modern/pages/cad/RobotPage.tsx
+++ b/src/modern/pages/cad/RobotPage.tsx
@@ -58,10 +58,18 @@ export function latestFile(imports: RobotImport[], pattern: RegExp): { file: Imp
const STEP_ZIP = /\.zip$/i;
const WORKBOOK = /\.xlsx$/i;
+/** The newest stored zip or workbook: the server's answer across every send, else the listed sends (an older server). */
+function newestDownload(data: RobotDetail, kind: 'step' | 'workbook') {
+ const found = data.downloads?.[kind];
+ if (found) return { file: { ...found.file, stored: true, sizeBytes: found.file.sizeBytes ?? undefined } as ImportFile, sent: { id: found.importId, sentAt: found.sentAt } };
+ return latestFile(data.imports, kind === 'step' ? STEP_ZIP : WORKBOOK);
+}
+
/** Download the robot's STEP files and workbook straight from Control Point. */
-function Downloads({ imports, compact = false }: { imports: RobotImport[]; compact?: boolean }) {
- const step = latestFile(imports, STEP_ZIP);
- const book = latestFile(imports, WORKBOOK);
+function Downloads({ data, compact = false }: { data: RobotDetail; compact?: boolean }) {
+ const imports = data.imports;
+ const step = newestDownload(data, 'step');
+ const book = newestDownload(data, 'workbook');
if (!step && !book) return compact ? null :
No STEP files or workbook yet. In Control Kit, send this assembly, then press Add STEP files.
;
const older = !!step && !!imports[0] && step.sent.id !== imports[0].id;
return (
@@ -76,9 +84,10 @@ function Downloads({ imports, compact = false }: { imports: RobotImport[]; compa
/** A part's Onshape picture, drawn by Control Kit, on the same dark stage Control Kit uses (its pictures are transparent). */
export function PartPicture({ url, name, className }: { url: string | null | undefined; name: string; className?: string }) {
- const [broken, setBroken] = useState(false);
- if (!url || broken) return null;
- return setBroken(true)} className={cn('rounded-lg bg-[#26262b] object-contain', className)} />;
+ // Which URL failed: a replacement picture (new URL) gets its own chance to load.
+ const [broken, setBroken] = useState(null);
+ if (!url || broken === url) return null;
+ return setBroken(url)} className={cn('rounded-lg bg-[#26262b] object-contain', className)} />;
}
export function RobotPage({ robotId, currentUser, isAdmin }: { robotId: number; currentUser?: any; isAdmin: boolean }) {
@@ -119,7 +128,7 @@ export function RobotPage({ robotId, currentUser, isAdmin }: { robotId: number;
: No assembly linked}
{d.imports[0] && Last sent {fmtDate(d.imports[0].sentAt)}{d.imports[0].sentBy ? ` by ${d.imports[0].sentBy}` : ''}}
-