diff --git a/CLAUDE.md b/CLAUDE.md index 8649d60..5385bd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,6 +301,12 @@ Review flow (2026-10-28, owner feedback from the live HORS panel): - **Fastener counts.** Onshape's OpenAPI (`BTRootAssemblyInfo`, `BTSubAssemblyInfo`) documents `instances` as "including those created by patterns and replicates"; `patterns` only maps seeds to copies and `features` holds the Replicate feature. The walk already counts every instance, so pattern and Replicate copies were always counted (the HORS scan's BOM quantities matched the definition on all 102 matched rows). `fixtures/assembly-patterns.json` pins this, including a suppressed pattern copy. No extra Onshape request is needed; the "can be low with Replicate or patterns" notes in the Buy list, workbook, and classifier now say copies are counted and only fasteners never modelled are missing. Not verified against a live assembly with a Replicate feature. - Screenshots of the flow at 380 and 1440 px, from fixture mode with the saved HORS scan, are in `docs/screenshots/review-flow/`. +Control Point link v1.2 (2026-10-28, owner 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 "when control point is linked it should work" for AI review; contract §6 and §7, built with Control Point's matching PR): +- **Files after a send.** The STEP zip is now always a `reference` in the bundle (`uploadedAfterSend`), never inline, and anything over 4 MB too; the panel uploads them right after the send (a duplicate included) through `POST /api/control-point/file`, which streams the bytes unread to Control Point's new `…/imports/{importId}/files` (64 MB cap, `X-Content-SHA256` checked there). The zip therefore left the idempotency key: adding STEP later reaches the same import. The route only accepts the caller's own latest send (`cp-sent:` names the import and workspace) and records each upload at `cp-upload:{userId}:{importId}:{zip|xlsx|csv}` (`{name, sha256, at}`, never merged into `cp-sent`; `/api/control-point/sent` returns them as `files`), so the Control Point card shows "STEP files … are in Control Point" or offers **Add STEP files** (exporting first when the session has no current zip). Control Point shows the robot's newest stored zip and workbook as downloads. +- **Part pictures.** After a send the panel reads Control Point's picture keys (`GET /api/control-point/pictures`), walks `pictureTargets` (one line per identity, parts to make first, Not needed and undrawable lines out), and sends PNGs in batches of at most 40 and 6 MB (`POST /api/control-point/pictures`, streamed unread, 8 MB cap). `controlPointPictureKey` is the revision the Worker caches the picture at plus a line hash, so a picture Control Point holds is skipped. `GET /api/preview?…&cached=1` returns an already drawn picture or 404 `not_cached` without an Onshape request. Only parts Control Point has no picture of are drawn, at most `PICTURE_FILL_PER_SEND` (30) per send, by one run at a time (a newer send stops the older run and waits for it); failures show as not arrived; a newer revision never drawn keeps the older picture there. Onshape cost: a first HORS send (about 120 unique parts) is at most 30 billable `shaded-view` requests (fewer for every part whose drawer was opened, which is cached), the whole robot is pictured after at most four sends (at most about 120 requests, once), and a send of an unchanged or edited robot costs nothing unless it gained parts. The robot picture is unchanged (the send's assembly PNG, one request per new revision, usually already drawn by the Overview). +- **AI review through Control Point.** Without this Worker's own keys, a person linked with a workspace gets AI review: `/api/ai/status` answers `{enabled, via: 'control-point'}` (the panel re-asks when the link or workspace changes), and `/api/ai/review` sends `aiReviewAsk(trusted)` (Control Kit's instructions, both schemas, and the `trustedAiRequest` rebuild) to Control Point's `…/workspaces/{id}/ai/review` with the link's token, 190-second bound, no retries (`reviewThroughControlPoint` in `ai-review.ts`). The answer goes through `parseAiReview`, is cached apart at `ai-review:{hash}:control-point` for 30 days, and still takes one of the person's and everyone's daily reviews from `AiAllowance`. Control Point's 429/501/502/504 pass on in plain words; a 401 ends the link and answers 409 `revoked`. Own keys, if set, still win. Control Point applies its own limit of 20 reviews an hour and 100 a day per workspace. +- **Tests.** `mock-control-point.ts` serves the new routes (uploads, pictures, `setAiAnswer`); `control-point.test.ts` covers streaming, refusals (another import, workspace, wrong hash, oversize or overlong body, revoked), pictures, `cached=1`, AI status and review through Control Point. The fixture API stands in for the new routes. No live Control Point, model, or Onshape request was made. + ## Tuning, tests, and release Match normalization, thresholds, and alternative ranking are in `packages/shared/src/matching.ts`; an exact SKU still forces Buy absent a manual override. Disposition rules (`decideDisposition`), word lists, and print folders are in `packages/shared/src/classifier.ts`; order quantities are in `packages/shared/src/purchasing.ts`. Vendor stale hours and cron schedule are in `wrangler.toml`; catalog crawl batch limits are in `apps/worker/src/vendor-ingest.ts`. Scan request flow, source cache, and backoff are in `apps/worker/src/scan.ts`; BOM column mapping is in `packages/shared/src/bom.ts`; STEP export job bounds (per-call request budget, 5-minute translation timeout, 20 MB file cap) are in `apps/worker/src/step-export.ts`. Change these with fixture tests and record any changed data shape here. diff --git a/apps/web/fixture-api.ts b/apps/web/fixture-api.ts index d47884f..377b0e4 100644 --- a/apps/web/fixture-api.ts +++ b/apps/web/fixture-api.ts @@ -40,6 +40,7 @@ export function fixtureApi(fixturePath?: string, catalogOrigin?: string, preview let cpLinked = cpMode === 'linked' || cpMode === 'none'; let cpWorkspace: number | null = cpMode === 'linked' ? 12 : null; let cpSent: Record | null = null; + const cpPictures = new Map(); return { name: 'control-kit-fixture-api', configureServer(server) { @@ -94,9 +95,24 @@ export function fixtureApi(fixturePath?: string, catalogOrigin?: string, preview if (url.pathname === '/api/control-point/send') { const payload = await body() as { idempotencyKey: string; cadParts: unknown[] }; const duplicate = cpSent?.idempotencyKey === payload.idempotencyKey; - cpSent = { importId: 41, workspaceId: cpWorkspace, sentAt: new Date().toISOString(), idempotencyKey: payload.idempotencyKey, url: 'https://tryctrlpoint.org/cad-parts?import=41' }; + cpSent = { importId: 41, workspaceId: cpWorkspace, sentAt: new Date().toISOString(), idempotencyKey: payload.idempotencyKey, url: 'https://tryctrlpoint.org/cad-parts?import=41', files: duplicate ? cpSent?.files : [] }; return json({ importId: 41, duplicate, workspace: workspaces.find(item => item.id === cpWorkspace), created: duplicate ? 0 : payload.cadParts.length, updated: 0, markedRemoved: 0, filesStored: 1, url: cpSent.url, sentAt: cpSent.sentAt }); } + // Contract v1.2 §6: files after a send and part pictures, kept in memory like the send itself. + if (url.pathname === '/api/control-point/file') { + let size = 0; + for await (const chunk of request) size += (chunk as Buffer).length; + const name = url.searchParams.get('name') ?? 'file'; + const sha256 = String(request.headers['x-content-sha256'] ?? ''); + if (cpSent) cpSent = { ...cpSent, files: [...((cpSent.files as Array<{ name: string }> | undefined) ?? []).filter(item => item.name !== name), { name, sha256 }] }; + return json({ name, url: 'https://tryctrlpoint.org/api/files/1', sizeBytes: size, sha256: request.headers['x-content-sha256'], duplicate: false }); + } + if (url.pathname === '/api/control-point/pictures') { + if (request.method === 'GET') return json({ pictures: [...cpPictures].map(([identity, key]) => ({ identity, key })) }); + const sent = await body() as { pictures: Array<{ identity: string; key: string }> }; + for (const item of sent.pictures) cpPictures.set(item.identity, item.key); + return json({ stored: sent.pictures.length, unchanged: 0, unknown: 0, rejected: 0 }); + } if (url.pathname === '/control-point/start') { cpLinked = true; response.setHeader('content-type', 'text/html'); return response.end('Linked.'); } if (url.pathname === '/api/scan') { // A new scan reads a newer revision, so its own pictures replace any redrawn ones, as in the Worker. diff --git a/apps/web/src/app/ControlPoint.tsx b/apps/web/src/app/ControlPoint.tsx index 539a89d..95af0e3 100644 --- a/apps/web/src/app/ControlPoint.tsx +++ b/apps/web/src/app/ControlPoint.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Check, ExternalLink, Send } from 'lucide-react'; +import { Check, ExternalLink, FileArchive, Image, Send } from 'lucide-react'; import { buildControlPointPayload, CONTROL_POINT_CAD_URL, type ControlPointCadPart } from '@control-kit/shared'; import { Alert } from '../components/arc/alert/alert'; import { Button } from './bits'; @@ -118,19 +118,40 @@ function pulledText(pulled: { types: number; products: number }) { return [types, products].filter(Boolean).join(' and '); } +/** "38 part pictures sent, 30 newly drawn by Onshape; 12 more next send" */ +function picturesText(sync: NonNullable) { + if (sync.running) return 'Sending part pictures…'; + if (sync.unreachable) return 'Part pictures could not reach Control Point. The next send tries again.'; + const parts = [sync.sent ? `${sync.sent} part picture${sync.sent === 1 ? '' : 's'} sent` : 'Part pictures are up to date in Control Point']; + if (sync.drawn) parts.push(`${sync.drawn} newly drawn by Onshape`); + const text = parts.join(', '); + const later = sync.missing ? `; ${sync.missing} more arrive with later sends` : ''; + const failed = sync.failed ? `. ${sync.failed} did not arrive; the next send tries again` : ''; + if (!sync.sent && !sync.drawn && !sync.missing && sync.failed) return `${sync.failed} part picture${sync.failed === 1 ? '' : 's'} did not reach Control Point. The next send tries again.`; + return `${text}${later}${failed}.`; +} + function Linked({ kit, cp, now }: { kit: ControlKit; cp: ControlPoint; now: number | null }) { const workspace = cp.workspace!; const sent = cp.sent && cp.sent.workspaceId === workspace.id ? cp.sent : null; const current = Boolean(sent) && cp.sentCurrent; + // A partial export (some parts failed) still makes a zip; the card never claims it holds every part. + const stepGaps = kit.step && !kit.step.running ? kit.step.failed : 0; return <>
{workspace.name}{workspace.ftcNumber ? `FTC ${workspace.ftcNumber}` : 'No FTC number'}
{!workspace.canEditCad && Ask a workspace admin for CAD access, or switch workspace in Settings.} {sent &&

{current ? 'This scan was sent' : 'An earlier scan was sent'}{now ? ` ${ago(sent.sentAt, now)}` : ''}{sent.created !== undefined ? `: ${sent.created} new, ${sent.updated ?? 0} updated` : ''}. View in Control Point

} {cp.pulled && cp.pulled.types + cp.pulled.products > 0 &&

Picked up {pulledText(cp.pulled)} made in Control Point.

} + {sent && current && cp.makeCount > 0 && (cp.stepAttached + ?

A STEP zip of this scan is in Control Point, ready to download.{stepGaps ? ` The last export left out ${stepGaps} part${stepGaps === 1 ? '' : 's'}; export again to add them.` : ''}

+ :

STEP files are not in Control Point yet.

)} + {cp.pictures &&

{picturesText(cp.pictures)}

}
+ {sent && current && cp.makeCount > 0 && (!cp.stepAttached || stepGaps > 0) && }
-

Sends every part with its quantity, vendor, price, and status, plus the Excel BOM, the assembly picture, and this session's STEP files. Sending the same scan again changes nothing; a new scan updates the same parts and keeps your team's statuses. Part types and products chosen in Control Point arrive with your next scan.

+

Sends every part with its quantity, vendor, price, and status, plus the Excel BOM, the assembly picture, part pictures, and this session's STEP files, which your team can download from Control Point. Sending the same scan again changes nothing; a new scan updates the same parts and keeps your team's statuses. Part types and products chosen in Control Point arrive with your next scan.

; } diff --git a/apps/web/src/app/useControlKit.ts b/apps/web/src/app/useControlKit.ts index 072059c..d9187ec 100644 --- a/apps/web/src/app/useControlKit.ts +++ b/apps/web/src/app/useControlKit.ts @@ -336,10 +336,12 @@ export function useControlKit(context: URLSearchParams) { } finally { markSaving(line.bomLineKey, false, 'product'); } } - useEffect(() => { + /** Whether the AI review button shows: this Worker's own keys, or (contract v1.2 §7) a Control Point workspace linked. */ + function refreshAiStatus() { api('/api/ai/status').then(response => response.ok ? response.json() as Promise<{ enabled?: boolean }> : { enabled: false }) .then(status => setAi(current => ({ ...current, enabled: Boolean(status.enabled) }))).catch(() => { /* The review stays hidden. */ }); - }, []); + } + useEffect(refreshAiStatus, []); /** One batched request for every uncertain line. Suggestions only: nothing is saved until a person accepts one. */ async function runAiReview() { @@ -353,11 +355,13 @@ export function useControlKit(context: URLSearchParams) { setAi(state => ({ ...state, running: true, note: '' })); try { const response = await postJson('/api/ai/review', { documentId: result.documentId, scanId, request, keys }); - const body = await response.json().catch(() => ({})) as { items?: AiReviewItem[]; provider?: AiProvider; cached?: boolean; error?: string }; + const body = await response.json().catch(() => ({})) as { items?: AiReviewItem[]; provider?: AiProvider; cached?: boolean; error?: string; via?: string; code?: string }; + // Control Point ended the link: the review goes away with it until the person links again. + if (body.code === 'revoked' || body.code === 'not_linked') refreshAiStatus(); if (!current()) return; if (!response.ok || !body.items || !body.provider) { setAi(state => ({ ...state, note: body.error ?? 'The AI review failed. Your parts are unchanged.' })); return; } setResult(state => state && state.scanId === scanId ? applyAiReview(state, keys, body.items!, body.provider!) : state); - const who = body.provider === 'gemini' ? 'Gemini' : 'Claude'; + const who = `${body.provider === 'gemini' ? 'Gemini' : 'Claude'}${body.via === 'control-point' ? ' (through Control Point)' : ''}`; setAi(state => ({ ...state, note: `${who} suggested ${body.items!.length} of ${request.lines.length} uncertain parts${body.cached ? ' (a saved answer, no new AI call)' : ''}${request.leftOut ? `; ${request.leftOut} more were not sent` : ''}. Nothing changes until you accept a suggestion.` })); } catch { if (current()) setAi(state => ({ ...state, note: 'Could not reach the AI review. Your parts are unchanged.' })); } finally { if (current()) setAi(state => ({ ...state, running: false })); } @@ -678,7 +682,7 @@ export function useControlKit(context: URLSearchParams) { return { url: URL.createObjectURL(await response.blob()), drawnAt: response.headers.get('x-picture-drawn'), view }; } - return { assemblyPicture, redrawAssemblyPicture, choiceTimes, refreshChoices, context, buildWorkbook, lastStepZip, previous, ai, runAiReview, acceptAi, dismissAi, saving, showInOnshape, partPicture, session, checkingSession, refreshSession, connectUrl, error, clearError: () => setError(''), result, progress, scan, cancelScan, decide, setDisposition, + return { assemblyPicture, redrawAssemblyPicture, choiceTimes, refreshChoices, context, buildWorkbook, lastStepZip, previous, ai, refreshAiStatus, runAiReview, acceptAi, dismissAi, saving, showInOnshape, partPicture, session, checkingSession, refreshSession, connectUrl, error, clearError: () => setError(''), result, progress, scan, cancelScan, decide, setDisposition, acceptSuggestions, setDispositions, bulk, pending, settings, savingSettings, saveSettings, signOut, excel, downloadExcel, downloadCsv, step, downloadStep }; } export type ControlKit = ReturnType; diff --git a/apps/web/src/app/useControlPoint.ts b/apps/web/src/app/useControlPoint.ts index 45724f9..c563c10 100644 --- a/apps/web/src/app/useControlPoint.ts +++ b/apps/web/src/app/useControlPoint.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { buildControlPointPayload, controlPointIdempotencyKey, INLINE_FILE_LIMIT_BYTES, type AssemblyContext } from '@control-kit/shared'; +import { buildControlPointPayload, controlPointIdempotencyKey, controlPointPictureKey, INLINE_FILE_LIMIT_BYTES, PICTURE_FILL_PER_SEND, PICTURES_PER_CALL, pictureTargets, UPLOAD_FILE_LIMIT_BYTES, uploadedAfterSend, wantsStep, type AssemblyContext, type ScanResult } from '@control-kit/shared'; import { api, postJson, workerUrl, type ControlKit } from './useControlKit'; import { makeListKey, workspaceAccent, type Accent } from './model'; @@ -10,7 +10,14 @@ export type LinkState = | { linked: false; available?: boolean } | { linked: true; account: { email: string; name: string }; workspace: Workspace | null; workspaces: Workspace[]; linkedAt: string; refreshAfter?: string }; /** The last import of this assembly into Control Point. */ -export type SentRecord = { importId: number; workspaceId: number; sentAt: string; idempotencyKey: string; url: string; created?: number; updated?: number; duplicate?: boolean }; +export type SentRecord = { importId: number; workspaceId: number; sentAt: string; idempotencyKey: string; url: string; created?: number; updated?: number; duplicate?: boolean; files?: Array<{ name: string; sha256: string }> }; +/** + * Part pictures sent with the last send: how many went, how many Onshape drew for it, how many wait for a later send + * (`missing`), and how many did not arrive (`failed`: a call failed or Control Point refused them; `unreachable` when + * the list of pictures it holds could not be read at all). + */ +export type PictureSync = { importId: number; running: boolean; sent: number; drawn: number; missing: number; failed: number; unreachable: boolean }; +type LocalFile = { name: string; mediaType: string; bytes: Uint8Array }; const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; /** The assembly picture (already drawn for the Overview) goes with a send, so Control Point can show the robot whole. */ @@ -18,6 +25,14 @@ const PICTURE_TYPE = 'image/png'; /** Choices made in Control Point that this scan picked up (`POST /api/control-point/decisions`, contract §5). */ export type Pulled = { scanId: string; types: number; products: number }; const workerOrigin = () => new URL(workerUrl || location.origin, location.href).origin; +const sha256Hex = async (bytes: Uint8Array) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)))].map(value => value.toString(16).padStart(2, '0')).join(''); +function base64(bytes: Uint8Array) { + let text = ''; + for (let start = 0; start < bytes.length; start += 0x8000) text += String.fromCharCode(...bytes.subarray(start, start + 0x8000)); + return btoa(text); +} +/** One pictures call stays under Control Point's 8 MB body limit with room for the JSON around the base64. */ +const PICTURE_BATCH_BYTES = 6 * 1024 * 1024; /** The Worker's own wording for a failure when it gave one. */ const message = async (response: Response, fallback: string) => (await response.json().catch(() => null) as { error?: string } | null)?.error ?? fallback; @@ -29,7 +44,8 @@ const message = async (response: Response, fallback: string) => (await response. export function useControlPoint(kit: ControlKit) { const [link, setLink] = useState(null); const [sent, setSent] = useState(null); - const [busy, setBusy] = useState<'link' | 'workspace' | 'send' | 'unlink' | null>(null); + const [busy, setBusy] = useState<'link' | 'workspace' | 'send' | 'unlink' | 'step' | null>(null); + const [pictures, setPictures] = useState(null); const [error, setError] = useState(''); const [pulled, setPulled] = useState(null); const popup = useRef(null); @@ -72,14 +88,16 @@ export function useControlPoint(kit: ControlKit) { // A linked workspace's colour becomes the brand accent; unlinked, or with a colour that fails contrast, it stays volt. const workspace = link?.linked ? link.workspace : null; + // Linked with a workspace, AI review can run through Control Point (contract v1.2 §7): ask again whether it is on. + useEffect(() => { if (link) kit.refreshAiStatus(); }, [link?.linked, workspace?.id]); // The key a send would carry now: it changes with the BOM, the workspace, and which files would go inline (the // workbook always, the STEP ZIP once exported this session), so "sent" means this exact BOM went to this workspace. const [currentKey, setCurrentKey] = useState(null); - // The STEP ZIP goes with a send only when it was exported from this scan's current make list; it goes inline (and so - // into the key) only up to the payload's inline limit, larger ZIPs travel as references, exactly as the builder decides. + // The STEP ZIP goes with a send only when it was exported from this scan's current make list. It is uploaded right + // after the bundle (contract v1.2 §6), never inside it, so it is not part of the key: adding it later reaches the + // same import instead of making a new one. const zip = kit.lastStepZip.current; const zipCurrent = Boolean(kit.step?.zipMade) && Boolean(result) && zip?.scanId === result?.scanId && zip?.makeList === makeListKey(result!); - const zipReady = zipCurrent && zip!.bytes.length <= INLINE_FILE_LIMIT_BYTES; useEffect(() => { setCurrentKey(null); if (!result || !workspace) return; @@ -87,10 +105,10 @@ export function useControlPoint(kit: ControlKit) { // The same choice times and files a send carries, so a choice saved since the last send shows as a change, and a // send without a picture (Onshape would not draw one) still reads as current. void Promise.all([kit.choiceTimes(result), assemblyPictureBytes()]).then(([choiceTimes, picture]) => controlPointIdempotencyKey(result, { workspaceId: workspace.id, - inlineMediaTypes: [XLSX_TYPE, ...(picture ? [PICTURE_TYPE] : []), ...(zipReady ? ['application/zip'] : [])], choiceTimes: choiceTimes ?? undefined })) + inlineMediaTypes: [XLSX_TYPE, ...(picture ? [PICTURE_TYPE] : [])], choiceTimes: choiceTimes ?? undefined })) .then(key => { if (live) setCurrentKey(key); }).catch(() => undefined); return () => { live = false; }; - }, [result, workspace?.id, zipReady]); + }, [result, workspace?.id]); // Choices made in Control Point reach a scan here: once per scan, after matching, while a workspace is linked. The // Worker saves the newer ones as Control Kit's own, then the scan reloads its choices. A failure is quiet: the next @@ -111,6 +129,100 @@ export function useControlPoint(kit: ControlKit) { }, [result?.scanId, result?.vendorMatching, workspace?.id]); const sentCurrent = Boolean(sent && workspace && currentKey && sent.workspaceId === workspace.id && sent.idempotencyKey === currentKey); + /** Parts to make in this scan, and whether the last send of it already holds their STEP zip in Control Point. */ + const makeCount = result ? result.lines.filter(wantsStep).length : 0; + const stepAttached = Boolean(sent?.files?.some(file => /\.zip$/i.test(file.name))); + + /** A file of the last send, uploaded to Control Point through the Worker, which streams it there unread. */ + async function uploadFile(record: SentRecord, scanned: ScanResult, file: LocalFile) { + const query = new URLSearchParams({ documentId: scanned.documentId, elementId: scanned.elementId, importId: String(record.importId), workspaceId: String(record.workspaceId), name: file.name }); + const hash = await sha256Hex(file.bytes); + const response = await api(`/api/control-point/file?${query}`, { method: 'POST', headers: { 'content-type': file.mediaType, 'x-content-sha256': hash }, body: new Uint8Array(file.bytes) }); + if (!response.ok) { + const body = await response.json().catch(() => null) as { error?: string; code?: string } | null; + if (body?.code === 'revoked') { await refresh(); throw new Error('Control Point ended the link. Link it again to send.'); } + throw new Error(body?.error ?? `${file.name} did not reach Control Point. Try again.`); + } + setSent(current => current && current.importId === record.importId ? { ...current, files: [...(current.files ?? []).filter(item => item.name !== file.name), { name: file.name, sha256: hash }] } : current); + } + + /** + * Part pictures for Control Point (contract v1.2 §6). Pictures Control Point already holds at the same revision are + * skipped; ones the Worker has drawn before go free; at most PICTURE_FILL_PER_SEND parts Control Point has no picture + * of at all are drawn now (one billable Onshape request each). A part whose newer revision was never drawn keeps its + * older picture there. Quiet: a failure here never touches the send. + */ + // One picture run at a time: a newer send stops the older run and waits for it, so two runs never draw the same part. + const pictureRun = useRef<{ id: number; done: Promise }>({ id: 0, done: Promise.resolve() }); + function sendPictures(record: SentRecord, scanned: ScanResult) { + const previous = pictureRun.current; + const id = previous.id + 1; + const done = previous.done.then(() => runPictures(id, record, scanned)).catch(() => undefined); + pictureRun.current = { id, done }; + return done; + } + async function runPictures(id: number, record: SentRecord, scanned: ScanResult) { + const live = () => pictureRun.current.id === id; + if (!scanned.scanId || !live()) return; + const where = new URLSearchParams({ documentId: scanned.documentId, elementId: scanned.elementId, importId: String(record.importId) }); + setPictures({ importId: record.importId, running: true, sent: 0, drawn: 0, missing: 0, failed: 0, unreachable: false }); + let sentCount = 0, drawn = 0, missing = 0, failed = 0, unreachable = false; + try { + const listed = await api(`/api/control-point/pictures?${where}`).catch(() => null); + if (!listed?.ok) { unreachable = true; return; } + const held = new Map(((await listed.json()) as { pictures: Array<{ identity: string; key: string }> }).pictures.map(item => [item.identity, item.key])); + let batch: string[] = []; + let batchBytes = 0; + const flush = async () => { + if (!batch.length) return; + const count = batch.length; + const body = `{"pictures":[${batch.join(',')}]}`; + batch = []; batchBytes = 0; + const response = await api(`/api/control-point/pictures?${where}&workspaceId=${record.workspaceId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body }).catch(() => null); + if (!response?.ok) { failed += count; return; } + const answer = await response.json() as { stored: number; rejected?: number; unknown?: number }; + sentCount += answer.stored; + failed += (answer.rejected ?? 0) + (answer.unknown ?? 0); + }; + for (const { identity, line } of pictureTargets(scanned)) { + // A newer send took over: it carries on from what this run already stored. + if (!live()) return; + const key = await controlPointPictureKey(scanned, line); + if (held.get(identity) === key) continue; + const path = `/api/preview?${new URLSearchParams({ scanId: scanned.scanId, line: line.bomLineKey })}`; + let response = await api(`${path}&cached=1`).catch(() => null); + if (!response?.ok) { + // Only a part Control Point has no picture of is drawn now, and only within this send's budget. + if (held.has(identity) || drawn >= PICTURE_FILL_PER_SEND) { if (!held.has(identity)) missing++; continue; } + drawn++; + response = await api(path).catch(() => null); + if (!response?.ok) { missing++; continue; } + } + const encoded = base64(new Uint8Array(await response.arrayBuffer())); + if (batchBytes + encoded.length > PICTURE_BATCH_BYTES || batch.length >= PICTURES_PER_CALL) await flush(); + batch.push(JSON.stringify({ identity, key, contentBase64: encoded })); + batchBytes += encoded.length; + } + if (live()) await flush(); + } catch { failed++; /* Pictures are extra: the next send tries again. */ } + finally { if (live()) setPictures({ importId: record.importId, running: false, sent: sentCount, drawn, missing, failed, unreachable }); } + } + + /** Puts this scan's STEP zip in Control Point: exported now if needed (Onshape translations), then uploaded to the last send. */ + async function attachStep() { + if (!result || !sent || !sentCurrent) return; + setBusy('step'); setError(''); + try { + const current = () => { const made = kit.lastStepZip.current; return made && made.scanId === result.scanId && made.makeList === makeListKey(result) ? made : null; }; + // No current zip, or the last export left parts out: export (again) before uploading. + if (!current() || (kit.step?.failed ?? 0) > 0) await kit.downloadStep(); + const made = current(); + if (!made) throw new Error('The STEP export did not finish, so nothing was added. Try again.'); + if (made.bytes.length > UPLOAD_FILE_LIMIT_BYTES) throw new Error(`The STEP zip is over ${UPLOAD_FILE_LIMIT_BYTES / 1024 / 1024} MB, so it stays in Control Kit.`); + await uploadFile(sent, result, { name: made.name, mediaType: 'application/zip', bytes: made.bytes }); + } catch (cause) { setError(cause instanceof Error ? cause.message : 'The STEP files did not reach Control Point. Try again.'); } + finally { setBusy(null); } + } const accent: Accent | null = workspaceAccent(workspace?.accentColor); useEffect(() => { const root = document.documentElement; @@ -193,7 +305,16 @@ export function useControlPoint(kit: ControlKit) { const body = await response.json() as { importId: number; url: string; sentAt: string; created: number; updated: number; duplicate: boolean }; // What was just sent is current, whichever files it carried (a picture can arrive only at send time). setCurrentKey(payload.idempotencyKey); - setSent({ importId: body.importId, workspaceId: workspace.id, sentAt: body.sentAt, idempotencyKey: payload.idempotencyKey, url: body.url, created: body.created, updated: body.updated, duplicate: body.duplicate }); + const record: SentRecord = { importId: body.importId, workspaceId: workspace.id, sentAt: body.sentAt, idempotencyKey: payload.idempotencyKey, url: body.url, created: body.created, updated: body.updated, duplicate: body.duplicate, + files: body.duplicate && sent?.importId === body.importId ? sent.files : [] }; + setSent(record); + // Files the bundle names but does not carry (the STEP zip, anything over 4 MB) follow it, to the same import. + // Skipped only when Control Point already holds those exact bytes under that name: a re-exported zip goes again. + const held = new Map((record.files ?? []).map(item => [item.name, item.sha256])); + const hashes = new Map(payload.files.map(item => [item.name, item.sha256])); + const later = files.filter(item => uploadedAfterSend(item.mediaType, item.bytes.length) && item.bytes.length <= UPLOAD_FILE_LIMIT_BYTES && held.get(item.name) !== hashes.get(item.name)); + for (const item of later) await uploadFile(record, result, item); + void sendPictures(record, result); } catch (cause) { setError(cause instanceof Error ? cause.message : 'The send failed. Try again.'); } finally { setBusy(null); } } @@ -208,6 +329,7 @@ export function useControlPoint(kit: ControlKit) { finally { setBusy(null); } } - return { refresh, link, workspace, accent, sent, sentCurrent, pulled: pulled && pulled.scanId === result?.scanId ? pulled : null, busy, error, clearError: () => setError(''), startLink, chooseWorkspace, send, unlink }; + return { refresh, link, workspace, accent, sent, sentCurrent, makeCount, stepAttached, zipCurrent, attachStep, pictures: pictures && pictures.importId === sent?.importId ? pictures : null, + pulled: pulled && pulled.scanId === result?.scanId ? pulled : null, busy, error, clearError: () => setError(''), startLink, chooseWorkspace, send, unlink }; } export type ControlPoint = ReturnType; diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index 8c1589b..b6f0af2 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -396,6 +396,8 @@ button.row:hover { background: var(--ck-raised); } .workspace-line > div { display: grid; } .cp-sent { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; color: var(--ck-done); } .cp-sent .link { color: var(--ck-text); } +.cp-step { display: flex; align-items: center; gap: 6px; color: var(--ck-attention); } +.cp-pictures { display: flex; align-items: center; gap: 6px; } .button-link { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--ck-line-strong); color: var(--ck-text); font-size: 13.5px; font-weight: 500; text-decoration: none; } .button-link:hover { background: var(--ck-muted); } .primary-link { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 18px; border-radius: var(--radius-control); background: var(--ck-buy); color: #04212e; font-weight: 600; text-decoration: none; } diff --git a/apps/worker/src/ai-review.ts b/apps/worker/src/ai-review.ts index 21f2eef..7233919 100644 --- a/apps/worker/src/ai-review.ts +++ b/apps/worker/src/ai-review.ts @@ -1,5 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; -import { aiReviewHash, aiReviewInstructions, aiReviewSchema, configurationValues, dispositions, normalizeSku, parseAiReview, AI_REVIEW_MAX_LINES, AI_REVIEW_VERSION, type AiProvider, type AiReviewItem, type AiReviewLine, type AiReviewRequest, type ScanResult } from '@control-kit/shared'; +import { aiReviewHash, aiReviewInstructions, aiReviewSchema, configurationValues, ControlPointError, dispositions, normalizeSku, parseAiReview, AI_REVIEW_MAX_LINES, AI_REVIEW_VERSION, type AiProvider, type AiReviewItem, type AiReviewLine, type AiReviewRequest, type ControlPointAiAnswer, type ControlPointAiAsk, type ScanResult } from '@control-kit/shared'; import type { AllowanceReply, AllowanceRequest } from './ai-allowance'; import type { Kv } from './index'; @@ -25,7 +25,9 @@ const TIMEOUT_MS = 90_000; const CACHE_SECONDS = 30 * 24 * 3600; export type AiOutcome = | { ok: true; items: AiReviewItem[]; provider: AiProvider; cached: boolean; usage: { inputTokens: number | null; outputTokens: number | null } } - | { ok: false; status: number; error: string }; + | { ok: false; status: number; error: string; code?: string }; +/** Where a review is answered: this Worker's own keys, or Control Point's through the person's link (contract v1.2 §7). */ +export type AiRoute = 'control-kit' | 'control-point'; /** Errors that may fall back to the other provider: a rate limit or the provider's own failure. */ class ProviderError extends Error { @@ -34,7 +36,7 @@ class ProviderError extends Error { const providerLabel: Record = { gemini: 'Gemini', anthropic: 'Anthropic' }; /** Gemini's response schema is an OpenAPI subset: nullable instead of a type list, and no additionalProperties. */ -const geminiSchema = { +export const geminiSchema = { type: 'object', properties: { items: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, disposition: { type: 'string', enum: [...aiReviewSchema.properties.items.items.properties.disposition.enum] }, candidateId: { type: 'string', nullable: true }, confidence: { type: 'number' }, reason: { type: 'string' }, @@ -114,9 +116,11 @@ export function validAiRequest(value: unknown): value is AiReviewRequest { && line.candidates.every(candidate => candidate && typeof candidate.id === 'string' && typeof candidate.sku === 'string')); } +/** Answers through Control Point are cached apart: its models are its own, so they never answer for this Worker's, or back. */ +const controlPointCacheKey = async (request: AiReviewRequest) => `ai-review:${await aiReviewHash(request)}:control-point`; /** A saved answer for exactly this request, or null. Costs no AI call and no allowance. */ -export async function cachedAiReview(env: AiEnv, request: AiReviewRequest): Promise { - const cached = await env.APP_STATE.get(await cacheKeyFor(env, request)); +export async function cachedAiReview(env: AiEnv, request: AiReviewRequest, route: AiRoute = 'control-kit'): Promise { + const cached = await env.APP_STATE.get(route === 'control-point' ? await controlPointCacheKey(request) : await cacheKeyFor(env, request)); if (!cached) return null; try { // A cached answer costs nothing and writes nothing, so repeated hits cannot use up KV writes. @@ -185,6 +189,35 @@ export function trustedAiRequest(request: AiReviewRequest, keys: unknown, scan: return { version: AI_REVIEW_VERSION, assembly: scan.assemblyName, leftOut: Math.max(0, Math.min(Number(request.leftOut) || 0, 10_000)), lines }; } +/** What Control Point is asked (contract v1.2 §7): the same instructions and schemas this Worker would use, and the rebuilt request. */ +export const aiReviewAsk = (request: AiReviewRequest): ControlPointAiAsk => ({ instructions: aiReviewInstructions, schema: aiReviewSchema as unknown as Record, geminiSchema, request }); + +/** + * The review answered by Control Point's own Gemini and Anthropic setup, for a person linked with a workspace when this + * Worker has no key. The answer is checked with `parseAiReview` exactly as a direct one, then cached and logged here. + * `ask` makes the one bounded call (no retries); a refused grant comes back as 409 `revoked`, like the other link routes. + */ +export async function reviewThroughControlPoint(env: AiEnv, request: AiReviewRequest, ask: (ask: ControlPointAiAsk) => Promise): Promise { + const saved = await cachedAiReview(env, request, 'control-point'); + if (saved) return saved; + let answer: ControlPointAiAnswer; + try { answer = await ask(aiReviewAsk(request)); } + catch (error) { + // Not logged here: no model of this Worker's was asked, and Control Point keeps its own record. + const refused = error instanceof ControlPointError ? error : new ControlPointError(503, 'unavailable'); + if (refused.status === 401) return { ok: false, status: 409, code: 'revoked', error: 'Control Point access was removed. Link Control Point again.' }; + if (refused.status === 429) return { ok: false, status: 429, code: 'rate_limited', error: 'Your Control Point workspace has used its AI reviews for now. Try again later.' }; + if (refused.status === 501) return { ok: false, status: 501, code: 'ai_unavailable', error: 'AI review is not set up in Control Point yet. Everything else works as before.' }; + if (refused.status === 403) return { ok: false, status: 403, code: refused.code, error: 'Your role in this Control Point workspace cannot use AI review. Pick another workspace.' }; + if (refused.status === 504 || refused.code === 'ai_timeout') return { ok: false, status: 504, code: 'ai_timeout', error: 'The AI review took too long in Control Point. Your parts are unchanged.' }; + return { ok: false, status: 502, code: refused.code, error: 'Control Point could not finish the AI review. Your parts are unchanged.' }; + } + const items = parseAiReview({ items: answer.items }, request); + await logAiCall(env, { provider: answer.provider, lines: request.lines.length, ok: true, cached: false, ...answer.usage }); + if (items.length) await env.APP_STATE.put(await controlPointCacheKey(request), JSON.stringify({ items, provider: answer.provider }), { expirationTtl: CACHE_SECONDS }); + return { ok: true, items, provider: answer.provider, cached: false, usage: answer.usage }; +} + export async function reviewWithAi(env: AiEnv, request: AiReviewRequest, fetcher: typeof fetch = fetch): Promise { if (!aiReviewEnabled(env)) return { ok: false, status: 501, error: 'AI review is not set up on this server. Everything else works as before.' }; const saved = await cachedAiReview(env, request); diff --git a/apps/worker/src/control-point.test.ts b/apps/worker/src/control-point.test.ts index 2f2e9be..9d25aeb 100644 --- a/apps/worker/src/control-point.test.ts +++ b/apps/worker/src/control-point.test.ts @@ -5,7 +5,7 @@ import { createMockOnshape } from './mock-onshape'; import { createMockControlPoint } from './mock-control-point'; import { HttpControlPointAdapter } from './control-point'; import { seal, unseal } from './secrets'; -import { buildControlPointPayload, type ScanResult } from '@control-kit/shared'; +import { aiReviewInstructions, buildControlPointPayload, controlPointPictureKey, pictureTargets, type ScanResult } from '@control-kit/shared'; class MemoryKv implements Kv { values = new Map(); @@ -281,7 +281,7 @@ describe('sending a BOM to Control Point', () => { expect(cp.received[0]).toEqual({ workspaceId: 12, payload }); expect(cp.received[0].payload.cadParts[0].extra.identity).toBe(`${payload.cadParts[0].extra.documentId}:${payload.cadParts[0].extra.elementId}:${payload.cadParts[0].extra.partId ?? ''}:${payload.cadParts[0].extra.configuration ?? ''}`); const sent = await (await call(`/api/control-point/sent?documentId=${result.documentId}&elementId=${result.elementId}`, { cookie })).json(); - expect(sent).toEqual({ importId: 41, workspaceId: 12, sentAt: answer.sentAt, idempotencyKey: payload.idempotencyKey, url: `${cpOrigin}/cad-parts?import=41` }); + expect(sent).toEqual({ importId: 41, workspaceId: 12, sentAt: answer.sentAt, idempotencyKey: payload.idempotencyKey, url: `${cpOrigin}/cad-parts?import=41`, files: [] }); expect(await (await send(cookie, result.scanId!, body)).json()).toMatchObject({ importId: 41, duplicate: true }); expect(cp.received).toHaveLength(1); expect(await (await call('/api/control-point/sent?documentId=other&elementId=elem1', { cookie })).json()).toBeNull(); @@ -506,6 +506,166 @@ describe('assembly picture', () => { }); }); +describe('files and pictures after a send (contract v1.2 §6)', () => { + async function sent() { + const cookie = await session(); + await link(cookie); + await post(cookie, '/api/control-point/workspace', { workspaceId: 12 }); + const result = await scan(cookie); + const zip = new TextEncoder().encode('PK step files for every part'); + const payload = await buildControlPointPayload(result, { workspaceId: 12, files: [{ name: 'Robot STEP.zip', mediaType: 'application/zip', bytes: zip }] }); + const answer = await (await send(cookie, result.scanId!, JSON.stringify(payload))).json() as { importId: number }; + return { cookie, result, zip, payload, importId: answer.importId }; + } + const hex = async (bytes: Uint8Array) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)))].map(value => value.toString(16).padStart(2, '0')).join(''); + const upload = async (cookie: string, result: ScanResult, importId: number, bytes: Uint8Array, opts: { name?: string; length?: number; workspaceId?: number; hash?: string } = {}) => + call(`/api/control-point/file?${new URLSearchParams({ documentId: result.documentId, elementId: result.elementId, importId: String(importId), workspaceId: String(opts.workspaceId ?? 12), name: opts.name ?? 'Robot STEP.zip' })}`, + { cookie, method: 'POST', body: new TextDecoder().decode(bytes), headers: { 'content-type': 'application/zip', 'content-length': String(opts.length ?? bytes.byteLength), 'x-content-sha256': opts.hash ?? await hex(bytes) } }); + + it('never puts the STEP zip inside the bundle: it is a reference uploaded after, so its key does not change', async () => { + const { payload, result } = await sent(); + expect(payload.files).toEqual([expect.objectContaining({ name: 'Robot STEP.zip', delivery: 'reference', reason: 'Uploaded right after this bundle.' })]); + expect(payload.idempotencyKey).toBe((await buildControlPointPayload(result, { workspaceId: 12 })).idempotencyKey); + }); + it('streams the zip to that import, records it on the send, and recognises the same bytes again', async () => { + const { cookie, result, zip, importId } = await sent(); + const first = await upload(cookie, result, importId, zip); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ name: 'Robot STEP.zip', url: `${cpOrigin}/api/files/1`, sizeBytes: zip.byteLength, sha256: await hex(zip), duplicate: false }); + expect(cp.uploads).toEqual([expect.objectContaining({ importId, name: 'Robot STEP.zip', mediaType: 'application/zip', bytes: zip })]); + const record = await (await call(`/api/control-point/sent?documentId=${result.documentId}&elementId=${result.elementId}`, { cookie })).json(); + expect(record).toMatchObject({ importId, files: [{ name: 'Robot STEP.zip', sha256: await hex(zip) }] }); + expect(await (await upload(cookie, result, importId, zip)).json()).toMatchObject({ duplicate: true }); + expect(cp.uploads).toHaveLength(1); + // A repeat send of the same scan rewrites the sent record; the upload is kept per import, so it survives. + const again = await buildControlPointPayload(result, { workspaceId: 12 }); + expect(await (await send(cookie, result.scanId!, JSON.stringify(again))).json()).toMatchObject({ importId, duplicate: true }); + expect((await (await call(`/api/control-point/sent?documentId=${result.documentId}&elementId=${result.elementId}`, { cookie })).json()).files).toEqual([expect.objectContaining({ name: 'Robot STEP.zip' })]); + // A re-exported zip under the same name is new bytes: it goes again and replaces the record. + const repaired = new TextEncoder().encode('PK step files, every part this time'); + expect(await (await upload(cookie, result, importId, repaired)).json()).toMatchObject({ duplicate: false }); + expect((await (await call(`/api/control-point/sent?documentId=${result.documentId}&elementId=${result.elementId}`, { cookie })).json()).files).toEqual([expect.objectContaining({ sha256: await hex(repaired) })]); + }); + it('refuses another import, another workspace, a wrong hash, an oversize or overlong body, and a revoked grant', async () => { + const { cookie, result, zip, importId } = await sent(); + const asked = cp.requests.length; + expect(await (await upload(cookie, result, importId + 1, zip)).json()).toMatchObject({ code: 'not_sent' }); + expect(await (await upload(cookie, result, importId, zip, { workspaceId: 13 })).json()).toMatchObject({ code: 'workspace_changed' }); + expect((await upload(cookie, result, importId, zip, { length: 64 * 1024 * 1024 + 1 })).status).toBe(413); + expect(cp.requests.length).toBe(asked); + expect(await (await upload(cookie, result, importId, zip, { hash: '0'.repeat(64) })).json()).toMatchObject({ code: 'checksum_mismatch' }); + // More bytes than declared are cut off with an error, never sent short. + expect((await upload(cookie, result, importId, zip, { length: 4 })).status).not.toBe(200); + expect(cp.uploads).toHaveLength(0); + cp.revokeAll(); + expect(await (await upload(cookie, result, importId, zip)).json()).toMatchObject({ code: 'revoked' }); + expect(await storedLink()).toBeNull(); + }); + it('lists and uploads part pictures for that import, through the Worker without reading them', async () => { + const { cookie, result, importId } = await sent(); + const where = new URLSearchParams({ documentId: result.documentId, elementId: result.elementId, importId: String(importId), workspaceId: '12' }); + expect(await (await call(`/api/control-point/pictures?${where}`, { cookie })).json()).toEqual({ pictures: [] }); + const [target] = pictureTargets(result); + const key = await controlPointPictureKey(result, target.line); + expect(key).toMatch(/^m:mv-1:[0-9a-f]{16}$/); + const body = JSON.stringify({ pictures: [{ identity: target.identity, key, contentBase64: btoa('PNG part') }] }); + const stored = await call(`/api/control-point/pictures?${where}`, { cookie, method: 'POST', body, headers: { 'content-type': 'application/json', 'content-length': String(body.length) } }); + expect(await stored.json()).toEqual({ stored: 1, unchanged: 0, unknown: 0, rejected: 0 }); + expect(await (await call(`/api/control-point/pictures?${where}`, { cookie })).json()).toEqual({ pictures: [{ identity: target.identity, key }] }); + const big = await call(`/api/control-point/pictures?${where}`, { cookie, method: 'POST', body, headers: { 'content-type': 'application/json', 'content-length': String(8 * 1024 * 1024 + 1) } }); + expect(big.status).toBe(413); + }); + it('serves a part picture already drawn with cached=1, and never asks Onshape for one that is not', async () => { + const cookie = await session(); + const result = await scan(cookie); + const line = pictureTargets(result)[0].line; + const path = `/api/preview?${new URLSearchParams({ scanId: result.scanId!, line: line.bomLineKey })}`; + const before = onshape.apiRequests; + const missing = await call(`${path}&cached=1`, { cookie }); + expect(missing.status).toBe(404); + expect(await missing.json()).toMatchObject({ code: 'not_cached' }); + expect(onshape.apiRequests).toBe(before); + expect((await call(path, { cookie })).status).toBe(200); + expect(onshape.apiRequests).toBe(before + 1); + const cached = await call(`${path}&cached=1`, { cookie }); + expect(cached.headers.get('content-type')).toBe('image/png'); + expect(onshape.apiRequests).toBe(before + 1); + }); +}); + +describe('AI review through Control Point (contract v1.2 §7)', () => { + const allowance = () => { const instance = new AiAllowance(memoryStorage()); return { idFromName: () => 'global', get: () => ({ fetch: (request: Request) => instance.fetch(request) }) }; }; + const aiEnv = () => ({ ...env, AI_ALLOWANCE: allowance() }) as Env; + async function ready(config: Env) { + const cookie = await session(); + await link(cookie); + await post(cookie, '/api/control-point/workspace', { workspaceId: 12 }); + const result = await scan(cookie); + const line = result.lines[0]; + const request = { version: 1, assembly: result.assemblyName, leftOut: 0, lines: [{ id: 'L1', name: line.name, description: null, material: null, partNumber: null, configuration: [], usedIn: [], quantity: 1, + source: 'this document', current: { disposition: 'review', suggestion: null, match: 'unmatched', reason: null, packUnknown: false }, candidates: [] }] }; + const review = () => call('/api/ai/review', { cookie, config, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentId: result.documentId, scanId: result.scanId, request, keys: { L1: line.bomLineKey } }) }); + return { cookie, result, review }; + } + // Saved answers would otherwise carry from one test to the next. + beforeEach(() => { for (const key of [...env.APP_STATE.values.keys()]) if (key.startsWith('ai-')) env.APP_STATE.values.delete(key); }); + + it('is enabled for a person linked with a workspace when this Worker has no key, and says so', async () => { + const config = aiEnv(); + const cookie = await session(); + expect(await (await call('/api/ai/status', { cookie, config })).json()).toEqual({ enabled: false, linkToEnable: true }); + await link(cookie); + expect(await (await call('/api/ai/status', { cookie, config })).json()).toMatchObject({ enabled: false }); + await post(cookie, '/api/control-point/workspace', { workspaceId: 12 }); + expect(await (await call('/api/ai/status', { cookie, config })).json()).toEqual({ enabled: true, via: 'control-point' }); + // This Worker's own key wins. + expect(await (await call('/api/ai/status', { cookie, config: { ...config, GEMINI_API_KEY: 'own' } as Env })).json()).toEqual({ enabled: true, via: 'control-kit' }); + expect(await (await call('/api/ai/status', { cookie, config: { ...config, AI_ALLOWANCE: undefined } as Env })).json()).toEqual({ enabled: false }); + }); + it('forwards the rebuilt request with the bearer token, keeps only valid items, and caches the answer here', async () => { + const config = aiEnv(); + const { review, result } = await ready(config); + cp.setAiAnswer({ status: 200, body: { items: [ + { id: 'L1', disposition: 'print', candidateId: null, confidence: 0.8, reason: 'Team-designed bracket' }, + { id: 'L9', disposition: 'buy', candidateId: null, confidence: 0.9, reason: 'Not asked' }, + { id: 'L1', disposition: 'buy', candidateId: 'rev:REV311152', confidence: 0.9, reason: 'Invented SKU' }, + ], provider: 'gemini', usage: { inputTokens: 100, outputTokens: 20 } } }); + const first = await review(); + expect(first.status).toBe(200); + const body = await first.json(); + expect(body).toEqual({ items: [{ id: 'L1', disposition: 'print', candidateId: null, confidence: 0.8, reason: 'Team-designed bracket' }], provider: 'gemini', cached: false, usage: { inputTokens: 100, outputTokens: 20 }, via: 'control-point' }); + expect(JSON.stringify(body)).not.toContain('ckp_'); + expect(cp.aiAsks).toHaveLength(1); + const ask = cp.aiAsks[0]; + expect(ask.workspaceId).toBe(12); + expect(ask.ask).toMatchObject({ instructions: aiReviewInstructions, schema: expect.any(Object), geminiSchema: expect.any(Object), request: { version: 1, assembly: result.assemblyName, lines: [expect.objectContaining({ id: 'L1', name: result.lines[0].name })] } }); + expect(cp.requests).toContain('POST /api/integrations/control-kit/workspaces/12/ai/review'); + // The same request again is the saved answer: no second call to Control Point. + expect(await (await review()).json()).toMatchObject({ cached: true, via: 'control-point' }); + expect(cp.aiAsks).toHaveLength(1); + }); + it('passes on Control Point refusals in plain words, and ends the link on a revoked token', async () => { + const config = aiEnv(); + const { review } = await ready(config); + cp.setAiAnswer(null); + const off = await review(); + expect(off.status).toBe(501); + expect(await off.json()).toMatchObject({ code: 'ai_unavailable' }); + cp.setAiAnswer({ status: 429, body: { error: 'rate_limited' } }); + expect((await review()).status).toBe(429); + cp.setAiAnswer({ status: 502, body: { error: 'ai_failed' } }); + expect((await review()).status).toBe(502); + expect(cp.aiAsks).toHaveLength(3); + cp.revokeAll(); + const gone = await review(); + expect(gone.status).toBe(409); + expect(await gone.json()).toMatchObject({ code: 'revoked', linked: false }); + expect(await storedLink()).toBeNull(); + // Unlinked and without a key of its own, the review is off again. + expect((await review()).status).toBe(501); + }); +}); + describe('redraw pauses', () => { it('holds a pause atomically, gives a failed one back, and keeps a newer one', async () => { const instance = new AiAllowance(memoryStorage()); diff --git a/apps/worker/src/control-point.ts b/apps/worker/src/control-point.ts index 13ebc25..cb00f94 100644 --- a/apps/worker/src/control-point.ts +++ b/apps/worker/src/control-point.ts @@ -1,4 +1,4 @@ -import { ControlPointError, dispositions, type ControlPointAccount, type ControlPointAdapter, type ControlPointDecision, type ControlPointImport, type ControlPointImportSummary, type ControlPointWorkspace, type ScanResult } from '@control-kit/shared'; +import { ControlPointError, dispositions, PICTURES_BODY_LIMIT_BYTES, UPLOAD_FILE_LIMIT_BYTES, type ControlPointAccount, type ControlPointAdapter, type ControlPointAiAnswer, type ControlPointAiAsk, type ControlPointDecision, type ControlPointImport, type ControlPointImportSummary, type ControlPointPictureKey, type ControlPointUpload, type ControlPointWorkspace, type PassThroughBody, type ScanResult } from '@control-kit/shared'; import type { Kv } from './index'; import { clearCookie, readSignedCookie, seal, signedCookie, unseal, type SignedCookie } from './secrets'; @@ -14,6 +14,14 @@ type User = { id: string; name: string }; */ type Link = { token: string; account: ControlPointAccount; workspaceId: number | null; linkedAt: string; workspaces?: ControlPointWorkspace[]; workspacesAt?: number; refreshTriedAt?: number }; export type SentRecord = { importId: number; workspaceId: number; sentAt: string; idempotencyKey: string; url: string }; +/** + * A file uploaded to an import after its send (contract v1.2 §6), kept per import and file kind at + * `cp-upload:{userId}:{importId}:{zip|xlsx|csv}`: one key per kind, never merged into the sent record, so a newer send + * or another upload can never erase it. `/api/control-point/sent` returns them as `files`. + */ +export type UploadRecord = { name: string; sha256: string; at: string }; +const uploadKinds = ['zip', 'xlsx', 'csv'] as const; +const uploadKey = (userId: string, importId: number, kind: string) => `cp-upload:${userId}:${importId}:${kind}`; /** * Saves choices made in Control Point as Control Kit's own (contract §5): a part's shared type, and the person's product * pick, each only when Control Point's is newer. Implemented over the Worker's KV keys in index.ts. @@ -56,6 +64,18 @@ function readWorkspace(value: unknown, origin: string): ControlPointWorkspace | cadUrl: cadUrl?.startsWith(`${origin}/`) ? cadUrl : `${origin}/cad-parts` }; } const ownUrl = (value: unknown, origin: string) => { const url = text(value, 2048); return url?.startsWith(`${origin}/`) ? url : `${origin}/cad-parts`; }; +/** A stored file's link: Control Point answers `/api/files/{id}`, which only opens inside Control Point itself. */ +const fileUrl = (value: unknown, origin: string) => { const url = text(value, 2048); return url && /^\/api\/files\/\d+$/.test(url) ? `${origin}${url}` : ownUrl(url, origin); }; +const aiProviders = ['gemini', 'anthropic']; +const tokens = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +/** Workers sends a stream of known length with that Content-Length; elsewhere (tests) it streams as-is. */ +function passThrough(body: PassThroughBody, length: number): PassThroughBody { + const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream }).FixedLengthStream; + if (!Fixed || typeof body === 'string') return body; + const fixed = new Fixed(length); + body.pipeTo(fixed.writable).catch(() => undefined); + return fixed.readable; +} async function refusal(response: Response) { const body = await response.text().then(value => { try { return JSON.parse(value.slice(0, 4000)) as { error?: unknown; detail?: unknown }; } catch { return null; } }).catch(() => null); const code = typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) ? body.error : `http_${response.status}`; @@ -87,12 +107,8 @@ export class HttpControlPointAdapter implements ControlPointAdapter { const body = await this.json(await this.call('/workspaces', { headers: this.bearer(token) }, 10_000)); return { account: readAccount(body.account), workspaces: (Array.isArray(body.workspaces) ? body.workspaces : []).map(item => readWorkspace(item, this.origin)).filter(item => item !== null) }; } - async sendBom(token: string, workspaceId: number, body: ReadableStream | string, length: number): Promise { - // Workers sends a stream of known length with that Content-Length; elsewhere (tests) it streams as-is. - const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream }).FixedLengthStream; - let sent: ReadableStream | string = body; - if (Fixed && typeof body !== 'string') { const fixed = new Fixed(length); body.pipeTo(fixed.writable).catch(() => undefined); sent = fixed.readable; } - const response = await this.call(`/workspaces/${workspaceId}/bom`, { method: 'POST', headers: { ...this.bearer(token), 'content-type': 'application/json' }, body: sent, duplex: 'half' }, 60_000); + async sendBom(token: string, workspaceId: number, body: PassThroughBody, length: number): Promise { + const response = await this.call(`/workspaces/${workspaceId}/bom`, { method: 'POST', headers: { ...this.bearer(token), 'content-type': 'application/json' }, body: passThrough(body, length), duplex: 'half' }, 60_000); const result = await this.json(response); const workspace = result.workspace as Record | null; if (typeof result.importId !== 'number' || !Number.isSafeInteger(result.importId)) throw badResponse(); @@ -121,6 +137,37 @@ export class HttpControlPointAdapter implements ControlPointAdapter { }); } async revoke(token: string) { await this.call('/revoke', { method: 'POST', headers: this.bearer(token) }, 5_000); } + async uploadFile(token: string, workspaceId: number, importId: number, file: { name: string; mediaType: string; sha256: string; body: PassThroughBody; length: number }): Promise { + const query = new URLSearchParams({ name: file.name }); + // Two minutes: up to 64 MB goes through, never retried. + const response = await this.call(`/workspaces/${workspaceId}/imports/${importId}/files?${query}`, { method: 'POST', duplex: 'half', + headers: { ...this.bearer(token), 'content-type': file.mediaType, 'x-content-sha256': file.sha256 }, body: passThrough(file.body, file.length) }, 120_000); + const body = await this.json(response); + return { name: text(body.name, 160) ?? file.name, url: fileUrl(body.url, this.origin), sizeBytes: count(body.sizeBytes), sha256: text(body.sha256, 64) ?? file.sha256, duplicate: body.duplicate === true }; + } + async pictureKeys(token: string, workspaceId: number, importId: number): Promise { + const body = await this.json(await this.call(`/workspaces/${workspaceId}/imports/${importId}/pictures`, { headers: this.bearer(token) }, 10_000)); + return (Array.isArray(body.pictures) ? body.pictures : []).slice(0, 5000).flatMap(value => { + const item = value as { identity?: unknown; key?: unknown } | null; + const identity = text(item?.identity, 2000); + const key = text(item?.key, 128); + return identity && key && /^[\w:.-]+$/.test(key) ? [{ identity, key }] : []; + }); + } + async uploadPictures(token: string, workspaceId: number, importId: number, body: PassThroughBody, length: number) { + const response = await this.call(`/workspaces/${workspaceId}/imports/${importId}/pictures`, { method: 'POST', duplex: 'half', + headers: { ...this.bearer(token), 'content-type': 'application/json' }, body: passThrough(body, length) }, 60_000); + const result = await this.json(response); + return { stored: count(result.stored), unchanged: count(result.unchanged), unknown: count(result.unknown), rejected: count(result.rejected) }; + } + async aiReview(token: string, workspaceId: number, ask: ControlPointAiAsk): Promise { + // Control Point asks Gemini (90 s) and at most once Anthropic (90 s); this waits for both, never retries. + const response = await this.call(`/workspaces/${workspaceId}/ai/review`, { method: 'POST', headers: { ...this.bearer(token), 'content-type': 'application/json' }, body: JSON.stringify(ask) }, 190_000); + const body = await this.json(response); + if (typeof body.provider !== 'string' || !aiProviders.includes(body.provider)) throw badResponse(); + const usage = body.usage as { inputTokens?: unknown; outputTokens?: unknown } | null; + return { items: body.items, provider: body.provider as ControlPointAiAnswer['provider'], usage: { inputTokens: tokens(usage?.inputTokens), outputTokens: tokens(usage?.outputTokens) } }; + } } const vendorIds = ['gobilda', 'servocity', 'andymark', 'rev', 'pitsco']; @@ -271,7 +318,40 @@ async function splitBody(body: ReadableStream, length: number) { return { stream, idempotencyKey: match?.[1] ?? null, documentId: match?.[2] ?? null, elementId: match?.[3] ?? null, oversized: size > length }; } -export async function controlPointRoute(request: Request, url: URL, env: ControlPointEnv, user: User | null, adapter: ControlPointAdapter = new HttpControlPointAdapter(controlPointOrigin(env), env.CONTROL_KIT_CLIENT_SECRET?.trim() ?? ''), store?: DecisionStore): Promise { +/** + * The link's bearer token and saved workspace when the person is linked with one (AI review through Control Point, + * contract v1.2 §7), else null. `forget` ends this grant after Control Point refused it, unless a newer one replaced it. + */ +export async function linkedWorkspace(env: ControlPointEnv, userId: string): Promise<{ token: string; workspaceId: number; forget(): Promise } | null> { + if (!controlPointAvailable(env)) return null; + const link = await readLink(env, userId); + if (!link || link.workspaceId === null) return null; + return { token: link.token, workspaceId: link.workspaceId, forget: async () => { await forgetLink(env, userId, link.token); } }; +} +export const defaultAdapter = (env: ControlPointEnv): ControlPointAdapter => new HttpControlPointAdapter(controlPointOrigin(env), env.CONTROL_KIT_CLIENT_SECRET?.trim() ?? ''); + +/** Counts bytes passing through and errors once more arrive than the request declared, rather than sending a truncated file. */ +function capped(body: ReadableStream, length: number) { + let total = 0; + return body.pipeThrough(new TransformStream({ + transform(chunk, controller) { total += chunk.byteLength; if (total > length) controller.error(new Error('Body longer than its Content-Length')); else controller.enqueue(chunk); }, + })); +} +const declaredLength = (request: Request) => { const value = request.headers.get('content-length'); return value && /^\d{1,10}$/.test(value) ? Number(value) : null; }; +const sentWords: Record = { + not_found: 'Control Point no longer has that send. Send this BOM again.', in_progress: 'Control Point is still saving that send. Try again in a few seconds.', + checksum_mismatch: 'The file changed on the way. Try again.', too_large: 'This file is too large for Control Point.', bad_file: 'Control Point would not take this file.', + not_member: 'You are no longer a member of this Control Point workspace. Pick another one.', no_cad_edit: 'Your role in this workspace cannot edit CAD parts.', +}; +/** Plain words for a refused upload; 401 is handled by the caller (it ends the link). */ +function uploadRefusal(error: ControlPointError) { + if (error.status === 429 || error.code === 'in_progress' || error.code === 'busy') return failure(429, error.code === 'rate_limited' ? 'rate_limited' : error.code, sentWords.in_progress, {}, { 'retry-after': String(error.retryAfter ?? 5) }); + if (error.status === 404) return failure(409, 'not_sent', sentWords.not_found); + if (error.status === 400 || error.status === 403 || error.status === 413 || error.status === 415) return failure(error.status, error.code, sentWords[error.code] ?? 'Control Point refused this upload.'); + return failure(502, 'unavailable', 'Control Point did not answer. Uploading again is safe.'); +} + +export async function controlPointRoute(request: Request, url: URL, env: ControlPointEnv, user: User | null, adapter: ControlPointAdapter = defaultAdapter(env), store?: DecisionStore): Promise { const available = controlPointAvailable(env); const callbackUri = `${url.origin}/control-point/callback`; if (url.pathname === '/control-point/start' && request.method === 'GET') { @@ -407,13 +487,66 @@ export async function controlPointRoute(request: Request, url: URL, env: Control } return reply(await store.apply(user.id, scanned, decisions)); } + if ((url.pathname === '/api/control-point/file' || url.pathname === '/api/control-point/pictures') && (request.method === 'POST' || request.method === 'GET')) { + // Contract v1.2 §6: files after a send and part pictures, only for the person's own latest send of this assembly. + if (request.method === 'POST' && request.headers.get('origin') !== env.WEB_ORIGIN) return reply({ error: 'Invalid request origin.' }, 403); + if (!available) return failure(501, 'unavailable', 'Control Point linking is not set up on this server.'); + if (!user) return reply({ error: 'Reconnect Onshape.' }, 401); + const documentId = url.searchParams.get('documentId'); + const elementId = url.searchParams.get('elementId'); + const importId = Number(url.searchParams.get('importId')); + if (!documentId || !/^[\w-]{1,128}$/.test(documentId) || !elementId || !/^[\w-]{1,128}$/.test(elementId) || !Number.isSafeInteger(importId) || importId <= 0) return failure(400, 'bad_request', 'Invalid send.'); + const raw = await env.APP_STATE.get(sentKey(user.id, documentId, elementId)); + const sent = raw ? JSON.parse(raw) as SentRecord : null; + // The import this person sent last for this assembly; that send proved their Onshape access read it. + if (!sent || sent.importId !== importId) return failure(409, 'not_sent', 'Send this BOM to Control Point first.'); + const link = await readLink(env, user.id); + if (!link) return failure(409, 'not_linked', 'Link Control Point first.', { linked: false }); + if (link.workspaceId !== sent.workspaceId) return failure(409, 'workspace_changed', 'That send went to another workspace. Send this BOM again.'); + const named = url.searchParams.get('workspaceId'); + if (request.method === 'POST' && named !== String(sent.workspaceId)) return failure(409, 'workspace_changed', 'The workspace changed. Check where it goes and send again.'); + const length = declaredLength(request); + try { + if (url.pathname === '/api/control-point/pictures' && request.method === 'GET') return reply({ pictures: await adapter.pictureKeys(link.token, sent.workspaceId, importId) }); + if (request.method !== 'POST') return reply({ error: 'Not found' }, 404); + if (length === null) return failure(411, 'length_required', 'Control Kit could not size this upload. Try again.'); + if (!request.body || length === 0) return failure(400, 'bad_request', 'Nothing to upload.'); + if (url.pathname === '/api/control-point/pictures') { + if (length > PICTURES_BODY_LIMIT_BYTES) return failure(413, 'too_large', 'Too many pictures in one upload.'); + return reply(await adapter.uploadPictures(link.token, sent.workspaceId, importId, capped(request.body, length), length)); + } + if (length > UPLOAD_FILE_LIMIT_BYTES) return failure(413, 'too_large', `Files over ${UPLOAD_FILE_LIMIT_BYTES / 1024 / 1024} MB stay in Control Kit.`); + const name = url.searchParams.get('name') ?? ''; + const sha256 = request.headers.get('x-content-sha256') ?? ''; + const mediaType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (!name || name.length > 160 || /[\\/]/.test(name) || [...name].some(char => char.charCodeAt(0) < 32) ||!/^[0-9a-f]{64}$/.test(sha256) || !mediaType) return failure(400, 'bad_file', 'Name the file and its SHA-256.'); + // The bytes stream through unread: the free plan's CPU limit rules out hashing them here; Control Point checks the hash. + const stored = await adapter.uploadFile(link.token, sent.workspaceId, importId, { name, mediaType, sha256, body: capped(request.body, length), length }); + // Recorded per import and kind, so the panel shows STEP as attached; written only when it changed. + const kind = name.split('.').pop()?.toLowerCase() ?? ''; + if ((uploadKinds as readonly string[]).includes(kind)) { + const key = uploadKey(user.id, importId, kind); + const before = await env.APP_STATE.get(key).then(value => value ? JSON.parse(value) as UploadRecord : null).catch(() => null); + if (before?.name !== name || before.sha256 !== sha256) await env.APP_STATE.put(key, JSON.stringify({ name, sha256, at: new Date().toISOString() } satisfies UploadRecord), { expirationTtl: 365 * 86400 }); + } + return reply(stored); + } catch (error) { + if (!(error instanceof ControlPointError)) return failure(502, 'unavailable', 'Control Point did not answer. Uploading again is safe.'); + if (error.status === 401) { await forgetLink(env, user.id, link.token); return revoked(); } + return uploadRefusal(error); + } + } if (url.pathname === '/api/control-point/sent' && request.method === 'GET') { if (!user) return reply({ error: 'Reconnect Onshape.' }, 401); const documentId = url.searchParams.get('documentId'); const elementId = url.searchParams.get('elementId'); if (!documentId || !/^[\w-]{1,128}$/.test(documentId) || !elementId || !/^[\w-]{1,128}$/.test(elementId)) return failure(400, 'bad_request', 'Invalid assembly.'); const raw = await env.APP_STATE.get(sentKey(user.id, documentId, elementId)); - return reply(raw ? JSON.parse(raw) as SentRecord : null); + if (!raw) return reply(null); + const record = JSON.parse(raw) as SentRecord; + const files = (await Promise.all(uploadKinds.map(kind => env.APP_STATE.get(uploadKey(user.id, record.importId, kind))))) + .flatMap(value => { try { return value ? [JSON.parse(value) as UploadRecord] : []; } catch { return []; } }); + return reply({ ...record, files }); } if (url.pathname === '/api/control-point/unlink' && request.method === 'POST') { if (request.headers.get('origin') !== env.WEB_ORIGIN) return reply({ error: 'Invalid request origin.' }, 403); diff --git a/apps/worker/src/index.test.ts b/apps/worker/src/index.test.ts index b602068..63d5c2f 100644 --- a/apps/worker/src/index.test.ts +++ b/apps/worker/src/index.test.ts @@ -208,6 +208,8 @@ describe('Onshape OAuth and session against mock Onshape', () => { expect(preflight.status).toBe(204); expect(preflight.headers.get('access-control-allow-origin')).toBe(origin); expect(preflight.headers.get('access-control-allow-credentials')).toBe('true'); + // Uploads to Control Point name their SHA-256 in a header the browser must be allowed to send. + expect(preflight.headers.get('access-control-allow-headers')).toContain('x-content-sha256'); const session = await worker.fetch(new Request('http://localhost:8787/api/session', { headers: { origin } }), env); expect(session.status).toBe(401); expect(session.headers.get('access-control-allow-origin')).toBe(origin); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 87a0b56..819ff59 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -4,13 +4,13 @@ import { applyMatchDecision, catalogReadKey, decisionRecord, classificationIdent import { runRevFeed } from './rev-feed'; import { runVendor } from './vendor-ingest'; import { catalogHealth } from './catalog'; -import { aiReviewEnabled, cachedAiReview, releaseAiReview, reserveAiReview, reviewWithAi, trustedAiRequest, validAiRequest, type AiEnv } from './ai-review'; +import { aiReviewEnabled, cachedAiReview, releaseAiReview, reserveAiReview, reviewThroughControlPoint, reviewWithAi, trustedAiRequest, validAiRequest, type AiEnv } from './ai-review'; export { AiAllowance } from './ai-allowance'; import { advanceExport, BudgetExhaustedError, createExportJob, dxfKey, fileKey, jobKey, jobSummary, pollBudget, type ExportJob, type RequestBudget } from './step-export'; import { countedFetch, persistLedger, RequestLedger } from './onshape-client'; import { assemblyNowKey, assemblyPreview, assemblyPreviewCacheKey, assemblyView, linePreview, previewCacheKey, PreviewUnavailableError, readAssemblyNow, redrawAssembly, reserveRedraw, type AssemblySource } from './preview'; import { clearCookie, readSignedEnvelope, seal, signedCookie, unseal, type SignedCookie } from './secrets'; -import { controlPointRoute, type ControlPointEnv, type DecisionStore } from './control-point'; +import { controlPointAvailable, controlPointRoute, defaultAdapter, linkedWorkspace, type ControlPointEnv, type DecisionStore } from './control-point'; export interface Env extends ControlPointEnv { APP_STATE: Kv; VENDOR_CATALOG: Kv; ONSHAPE_OAUTH_CLIENT_ID: string; ONSHAPE_OAUTH_CLIENT_SECRET: string; ONSHAPE_OAUTH_REDIRECT_URI: string; ONSHAPE_API_BASE: string; ONSHAPE_API_VERSION: string; ONSHAPE_OAUTH_BASE: string; SESSION_ENCRYPTION_KEY: string; WEB_ORIGIN: string; VENDOR_STALE_HOURS?: string; /** Optional AI review keys (Worker secrets). Without either, the review is off and nothing else changes. */ @@ -312,12 +312,23 @@ async function route(request: Request, env: Env): Promise { } return reply({ choices }); } - if (url.pathname === '/api/ai/status' && request.method === 'GET') return reply({ enabled: aiReviewEnabled(env) }); + if (url.pathname === '/api/ai/status' && request.method === 'GET') { + // This Worker's own keys come first; without them, a person linked with a Control Point workspace reviews through it. + if (aiReviewEnabled(env)) return reply({ enabled: true, via: 'control-kit' }); + if (!env.AI_ALLOWANCE || !controlPointAvailable(env)) return reply({ enabled: false }); + const current = await getSession(request, env); + const linked = current ? await linkedWorkspace(env, current.data.user.id) : null; + return reply(linked ? { enabled: true, via: 'control-point' } : { enabled: false, linkToEnable: true }); + } if (url.pathname === '/api/ai/review' && request.method === 'POST') { if (request.headers.get('origin') !== env.WEB_ORIGIN) return reply({ error: 'Invalid request origin.' }, 403); - if (!aiReviewEnabled(env)) return reply({ error: 'AI review is not set up on this server. Everything else works as before.' }, 501); + const own = aiReviewEnabled(env); + if (!own && !(env.AI_ALLOWANCE && controlPointAvailable(env))) return reply({ error: 'AI review is not set up on this server. Everything else works as before.' }, 501); const current = await getSession(request, env); if (!current) return reply({ error: 'Reconnect Onshape.' }, 401); + // Through Control Point only when this Worker has no key of its own; the allowance and the answer cache stay here. + const linked = own ? null : await linkedWorkspace(env, current.data.user.id); + if (!own && !linked) return reply({ error: 'Link Control Point with a workspace to use AI review.', code: 'not_linked' }, 501); const text = await request.text(); if (text.length > 150_000) return reply({ error: 'Too many parts for one AI review.' }, 413); let body: { documentId?: string; scanId?: string; request?: unknown; keys?: unknown }; @@ -331,14 +342,23 @@ async function route(request: Request, env: Env): Promise { const trusted = trustedAiRequest(body.request, body.keys, scanned); if (!trusted) return reply({ error: 'These parts are not in your scan. Rescan and try again.' }, 409); // A saved answer is free; anything else takes one of the person's daily reviews, reserved before the call. - let outcome = await cachedAiReview(env, trusted); + let outcome = await cachedAiReview(env, trusted, linked ? 'control-point' : 'control-kit'); if (!outcome) { const refused = await reserveAiReview(env, current.data.user.id); if (refused) return reply({ error: refused }, 429); - try { outcome = await reviewWithAi(env, trusted); } + try { + outcome = linked + ? await reviewThroughControlPoint(env, trusted, async ask => { + try { return await defaultAdapter(env).aiReview(linked.token, linked.workspaceId, ask); } + catch (error) { if ((error as { status?: number }).status === 401) await linked.forget(); throw error; } + }) + : await reviewWithAi(env, trusted); + } finally { await releaseAiReview(env, current.data.user.id); } } - return outcome.ok ? reply({ items: outcome.items, provider: outcome.provider, cached: outcome.cached, usage: outcome.usage }) : reply({ error: outcome.error }, outcome.status); + const via = linked ? 'control-point' : 'control-kit'; + return outcome.ok ? reply({ items: outcome.items, provider: outcome.provider, cached: outcome.cached, usage: outcome.usage, via }) + : reply({ error: outcome.error, ...(outcome.code ? { code: outcome.code } : {}), ...(outcome.code === 'revoked' ? { linked: false } : {}) }, outcome.status); } if (url.pathname === '/api/classification/override' && request.method === 'POST') { if (request.headers.get('origin') !== env.WEB_ORIGIN) return reply({ error: 'Invalid request origin.' }, 403); @@ -473,9 +493,16 @@ async function route(request: Request, env: Env): Promise { const result = JSON.parse(raw) as ScanResult; const line = [...result.lines, ...result.suppressed].find(item => item.bomLineKey === lineKey); if (!line) return reply({ error: 'This part is not in your latest scan.' }, 404); + const cacheKey = previewCacheKey(current.data.user.id, result, line, await lineKeyDigest(`${line.bomLineKey}|${line.purchasedAssembly ? 'assembly' : 'part'}`)); + // `cached=1` (pictures for Control Point, contract v1.2 §6): only a picture already drawn, never an Onshape request. + if (url.searchParams.get('cached') === '1') { + const saved = await env.APP_STATE.get(cacheKey); + return saved ? new Response(Uint8Array.from(atob(saved), char => char.charCodeAt(0)), { headers: { 'content-type': 'image/png', 'cache-control': 'private, max-age=3600' } }) + : reply({ error: 'Not drawn yet.', code: 'not_cached' }, 404); + } const ledger = new RequestLedger('preview'); try { - const png = await linePreview(line, previewCacheKey(current.data.user.id, result, line, await lineKeyDigest(`${line.bomLineKey}|${line.purchasedAssembly ? 'assembly' : 'part'}`)), env.APP_STATE, onshapeFetcher(env, current, ledger, { remaining: 3 }), env.ONSHAPE_API_VERSION, result.diagnostics?.revision); + const png = await linePreview(line, cacheKey, env.APP_STATE, onshapeFetcher(env, current, ledger, { remaining: 3 }), env.ONSHAPE_API_VERSION, result.diagnostics?.revision); return new Response(new Uint8Array(png), { headers: { 'content-type': 'image/png', 'cache-control': 'private, max-age=3600' } }); } catch (error) { if (error instanceof SessionExpiredError) return reply({ error: 'Reconnect Onshape.' }, 401); @@ -555,7 +582,7 @@ export const cronVendors: Record = { '0 * * * *': 'gobilda', ' export default { async fetch(request: Request, env: Env) { const origin = request.headers.get('origin'); - if (request.method === 'OPTIONS' && origin === env.WEB_ORIGIN) return new Response(null, { status: 204, headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' } }); + if (request.method === 'OPTIONS' && origin === env.WEB_ORIGIN) return new Response(null, { status: 204, headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type, x-content-sha256' } }); let result: Response; try { result = await route(request, env); } catch (error) { console.error('Control Kit request failed', error); result = reply({ error: 'Control Kit could not complete this request. Try again.' }, 502); } diff --git a/apps/worker/src/mock-control-point.ts b/apps/worker/src/mock-control-point.ts index 61a9ba7..e35fb53 100644 --- a/apps/worker/src/mock-control-point.ts +++ b/apps/worker/src/mock-control-point.ts @@ -20,6 +20,12 @@ export function createMockControlPoint(options: { origin?: string; secret?: stri const tokens = new Set(); const imports: Array<{ importId: number; workspaceId: number; idempotencyKey: string; documentId: string; elementId: string; sentAt: string; created: number; files: number }> = []; const received: Array<{ workspaceId: number; payload: ControlPointBomPayload }> = []; + /** Contract v1.2 §6: files uploaded after a send, and part pictures by identity (their picture key and bytes). */ + const uploads: Array<{ importId: number; name: string; mediaType: string; sha256: string; bytes: Uint8Array }> = []; + const pictures = new Map(); + /** Contract v1.2 §7: AI reviews asked, and what the next one answers (null: no keys, 501). */ + const aiAsks: Array<{ workspaceId: number; ask: Record }> = []; + let aiAnswer: { status: number; body: unknown } | null = null; const requests: string[] = []; let deny = false; /** Choices made in Control Point, per `{workspaceId}:{documentId}` (contract §5). */ @@ -90,6 +96,42 @@ export function createMockControlPoint(options: { origin?: string; secret?: stri return json(200, imports.filter(item => item.workspaceId === Number(list[1]) && item.documentId === url.searchParams.get('document') && item.elementId === url.searchParams.get('element')).reverse().slice(0, 10) .map(item => ({ importId: item.importId, idempotencyKey: item.idempotencyKey, sentAt: item.sentAt, sentBy: account.email, created: item.created, updated: 0, markedRemoved: 0, url: `${origin}/cad-parts?import=${item.importId}` }))); } + const files = url.pathname.match(new RegExp(`^${base}/workspaces/(\\d+)/imports/(\\d+)/(files|pictures)$`)); + if (files) { + const workspace = workspaces.find(item => item.id === Number(files[1])); + if (!workspace) return json(403, { error: 'not_member' }); + const item = imports.find(entry => entry.workspaceId === workspace.id && entry.importId === Number(files[2])); + if (!item) return json(404, { error: 'not_found' }); + if (files[3] === 'pictures' && request.method === 'GET') return json(200, { pictures: [...pictures].map(([identity, picture]) => ({ identity, key: picture.key })) }); + if (request.method !== 'POST') return json(404, { error: 'not_found' }); + if (!workspace.canEditCad) return json(403, { error: 'no_cad_edit' }); + if (files[3] === 'pictures') { + const body = await request.json() as { pictures?: Array<{ identity: string; key: string; contentBase64: string }> }; + if (!Array.isArray(body.pictures) || !body.pictures.length || body.pictures.length > 40) return json(400, { error: 'bad_payload' }); + let stored = 0, unchanged = 0; + for (const picture of body.pictures) { + if (pictures.get(picture.identity)?.key === picture.key) { unchanged++; continue; } + pictures.set(picture.identity, { key: picture.key, bytes: Uint8Array.from(atob(picture.contentBase64), char => char.charCodeAt(0)) }); + stored++; + } + return json(200, { stored, unchanged, unknown: 0, rejected: 0 }); + } + const bytes = new Uint8Array(await request.arrayBuffer()); + const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''); + const name = url.searchParams.get('name') ?? ''; + if (digest !== request.headers.get('x-content-sha256')) return json(400, { error: 'checksum_mismatch' }); + const earlier = uploads.findIndex(entry => entry.importId === item.importId && entry.name === name && entry.sha256 === digest); + if (earlier >= 0) return json(200, { name, url: `/api/files/${earlier + 1}`, sizeBytes: bytes.byteLength, sha256: digest, duplicate: true }); + uploads.push({ importId: item.importId, name, mediaType: request.headers.get('content-type') ?? '', sha256: digest, bytes }); + return json(200, { name, url: `/api/files/${uploads.length}`, sizeBytes: bytes.byteLength, sha256: digest, duplicate: false }); + } + const review = url.pathname.match(new RegExp(`^${base}/workspaces/(\\d+)/ai/review$`)); + if (review && request.method === 'POST') { + if (!workspaces.some(item => item.id === Number(review[1]))) return json(403, { error: 'not_member' }); + aiAsks.push({ workspaceId: Number(review[1]), ask: await request.json() as Record }); + if (!aiAnswer) return json(501, { error: 'ai_unavailable' }); + return json(aiAnswer.status, aiAnswer.body); + } const chosen = url.pathname.match(new RegExp(`^${base}/workspaces/(\\d+)/decisions$`)); if (chosen && request.method === 'GET') { if (!workspaces.some(item => item.id === Number(chosen[1]))) return json(403, { error: 'not_member' }); @@ -107,7 +149,9 @@ export function createMockControlPoint(options: { origin?: string; secret?: stri response.end(Buffer.from(await result.arrayBuffer())); }); return { - handle, server, received, requests, + handle, server, received, requests, uploads, pictures, aiAsks, + /** What the next AI review answers: a status and body, or null for "no keys in Control Point" (501). */ + setAiAnswer(value: { status: number; body: unknown } | null) { aiAnswer = value; }, /** Control Point's Settings → Disconnect: every token of the account stops working. */ revokeAll() { tokens.clear(); }, setWorkspaces(list: ControlPointWorkspace[]) { workspaces = list; }, diff --git a/docs/control-point-link.md b/docs/control-point-link.md index a6e2452..ac08eea 100644 --- a/docs/control-point-link.md +++ b/docs/control-point-link.md @@ -1,9 +1,11 @@ -# Control Kit ↔ Control Point link contract (v1.1) +# Control Kit ↔ Control Point link contract (v1.2) 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 @@ -161,3 +163,50 @@ 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 (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. 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 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, 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. + +**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 (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/packages/shared/src/control-point.test.ts b/packages/shared/src/control-point.test.ts index e985755..9f85a8f 100644 --- a/packages/shared/src/control-point.test.ts +++ b/packages/shared/src/control-point.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { aggregateAssembly, recalculatePurchasing, type VendorRecord } from './index'; -import { buildControlPointPayload, controlPointIdempotencyKey, INLINE_FILE_LIMIT_BYTES, MockControlPointAdapter } from './control-point'; +import { buildControlPointPayload, controlPointIdempotencyKey, controlPointPictureKey, INLINE_FILE_LIMIT_BYTES, MockControlPointAdapter, pictureTargets } from './control-point'; describe('Control Point payload and adapter', () => { it('round-trips a payload through the in-memory adapter with a stable key per workspace', async () => { @@ -44,7 +44,7 @@ describe('Control Point payload and adapter', () => { expect(before.cadParts[0].extra.identity).toBe('doc:parts:JHD:size=M4'); expect(after.cadParts[0].extra.identity).toBe(before.cadParts[0].extra.identity); }); - it('makes a send with the STEP zip attached a new import, whatever the file names or workbook bytes', async () => { + it('keys a send by its inline files only: the STEP zip goes after the bundle (v1.2), so adding it reaches the same import', async () => { const result = aggregateAssembly({ rootAssembly: { instances: [ { id: 'a', name: 'Bracket', type: 'Part', documentId: 'doc', elementId: 'parts', partId: 'JHD' }, ] } }, { documentId: 'doc', elementId: 'assembly', workspaceOrVersion: 'w', workspaceOrVersionId: 'work' }, 'Robot'); @@ -55,10 +55,12 @@ describe('Control Point payload and adapter', () => { const rebuilt = await buildControlPointPayload(result, { workspaceId: 12, files: [workbook('2026-10-27', 2)] }); const withStep = await buildControlPointPayload(result, { workspaceId: 12, files: [zip, workbook('2026-10-26', 1)] }); expect(rebuilt.idempotencyKey).toBe(withoutStep.idempotencyKey); - expect(withStep.idempotencyKey).not.toBe(withoutStep.idempotencyKey); + // Even a small zip is a reference uploaded right after the bundle, so it never changes the key. + expect(withStep.files.find(item => item.name === 'Robot STEP.zip')).toMatchObject({ delivery: 'reference', reason: 'Uploaded right after this bundle.' }); + expect(withStep.idempotencyKey).toBe(withoutStep.idempotencyKey); // The browser can learn the key without building files; order does not matter. expect(await controlPointIdempotencyKey(result, { workspaceId: 12, inlineMediaTypes: [xlsx] })).toBe(withoutStep.idempotencyKey); - expect(await controlPointIdempotencyKey(result, { workspaceId: 12, inlineMediaTypes: [xlsx, 'application/zip'] })).toBe(withStep.idempotencyKey); + expect(await controlPointIdempotencyKey(result, { workspaceId: 12, inlineMediaTypes: ['image/png', xlsx] })).toBe(await controlPointIdempotencyKey(result, { workspaceId: 12, inlineMediaTypes: [xlsx, 'image/png'] })); expect(await controlPointIdempotencyKey(result, { workspaceId: 12, inlineMediaTypes: [] })).toBe((await buildControlPointPayload(result, { workspaceId: 12 })).idempotencyKey); // A file sent only as a reference is not inline, so it does not change the key. const big = { name: 'Robot STEP.zip', mediaType: 'application/zip', bytes: new Uint8Array(INLINE_FILE_LIMIT_BYTES + 1) }; @@ -145,6 +147,22 @@ describe('Control Point attach bundle', () => { // A microversion context cannot be opened from outside Onshape, so the assembly link falls back to the document. expect((await buildControlPointPayload(scan(), { workspaceId: 12, context: { ...context, workspaceOrVersion: 'm', workspaceOrVersionId: 'micro1' } })).assembly.assemblyUrl).toBe('https://cad.onshape.com/documents/doc'); }); + it('picks one picture per part identity, parts to make first, and keys it by the revision the Worker caches it at', async () => { + const result = aggregateAssembly({ rootAssembly: { documentMicroversion: 'mv-9', instances: [ + { id: 'a', name: 'Bracket', type: 'Part', documentId: 'doc', elementId: 'parts', partId: 'JHD' }, + { id: 'b', name: 'Bracket', type: 'Part', documentId: 'doc', elementId: 'parts', partId: 'JHD' }, + { id: 'c', name: 'Plate', type: 'Part', documentId: 'doc', elementId: 'parts', partId: 'JKD' }, + ] } }, { documentId: 'doc', elementId: 'assembly', workspaceOrVersion: 'w', workspaceOrVersionId: 'work' }, 'Robot'); + result.diagnostics = { ...(result.diagnostics ?? {}), revision: { type: 'm', id: 'mv-9' } } as never; + result.lines = result.lines.map(line => ({ ...line, disposition: line.partId === 'JKD' ? 'cut-2d' : 'buy' })); + result.lines.push({ ...result.lines[0], partId: 'JZZ', bomLineKey: 'doc:parts:JZZ:', disposition: 'ignore' }); + const targets = pictureTargets(result); + expect(targets.map(item => item.line.partId)).toEqual(['JKD', 'JHD']); + const key = await controlPointPictureKey(result, targets[0].line); + expect(key).toMatch(/^m:mv-9:[0-9a-f]{16}$/); + expect(await controlPointPictureKey(result, targets[0].line)).toBe(key); + expect(await controlPointPictureKey({ scanId: result.scanId, diagnostics: { revision: { type: 'm', id: 'mv-10' } } } as never, targets[0].line)).not.toBe(key); + }); it('attaches files inline or by reference, never drops one, and keeps the key independent of file bytes', async () => { const small = new Uint8Array([1, 2, 3]); const large = new Uint8Array(INLINE_FILE_LIMIT_BYTES + 1); diff --git a/packages/shared/src/control-point.ts b/packages/shared/src/control-point.ts index aaafd90..03d6148 100644 --- a/packages/shared/src/control-point.ts +++ b/packages/shared/src/control-point.ts @@ -1,6 +1,7 @@ import type { AssemblyContext, BomLine, ScanResult } from './index'; import type { LicensedImage, VendorRecord } from './matching'; import { classificationIdentity, type Disposition } from './classifier'; +import type { AiProvider, AiReviewRequest } from './ai-review'; /** * The bundle a reviewed scan attaches to Control Point: every line (active, suppressed, and missing-metadata), scan @@ -12,6 +13,16 @@ import { classificationIdentity, type Disposition } from './classifier'; export const CONTROL_POINT_CAD_URL = 'https://tryctrlpoint.org/cad-parts'; /** Files up to this size travel inside the bundle (base64); larger ones as a reference with name, size, and hash. */ export const INLINE_FILE_LIMIT_BYTES = 4 * 1024 * 1024; +/** + * Contract v1.2 §6: a referenced file is uploaded right after its send, up to this size. The STEP zip always travels + * that way, so a send's key never depends on whether STEP was exported, and adding it later reaches the same import. + */ +export const UPLOAD_FILE_LIMIT_BYTES = 64 * 1024 * 1024; +/** Part pictures: at most this many new Onshape drawings per send (each one billable request); cached ones are free. */ +export const PICTURE_FILL_PER_SEND = 30; +/** One pictures call to Control Point holds at most this many, and at most this many bytes of JSON. */ +export const PICTURES_PER_CALL = 40; +export const PICTURES_BODY_LIMIT_BYTES = 8 * 1024 * 1024; /** Control Point's `cad_parts` row (server/cad.ts): sections, sources, and statuses are its own enums. */ export type ControlPointCadPart = { @@ -86,6 +97,16 @@ export type ControlPointImport = { created: number; updated: number; markedRemoved: number; filesStored: number; url: string; sentAt: string; }; export type ControlPointImportSummary = { importId: number; idempotencyKey: string; sentAt: string; sentBy: string; created: number; updated: number; markedRemoved: number; url: string }; +/** A file stored in Control Point after its send (contract v1.2 §6); `duplicate` means those exact bytes were already there. */ +export type ControlPointUpload = { name: string; url: string; sizeBytes: number; sha256: string; duplicate: boolean }; +/** Which parts of an import's assembly already have a picture in Control Point, by Control Kit's picture key. */ +export type ControlPointPictureKey = { identity: string; key: string }; +export type ControlPointPictureResult = { stored: number; unchanged: number; unknown: number; rejected: number }; +/** An AI review asked of Control Point (contract v1.2 §7): Control Kit's own instructions and schemas, and the request it rebuilt. */ +export type ControlPointAiAsk = { instructions: string; schema: Record; geminiSchema: Record; request: AiReviewRequest }; +export type ControlPointAiAnswer = { items: unknown; provider: AiProvider; usage: { inputTokens: number | null; outputTokens: number | null } }; +/** A body the Worker passes through without reading it: a stream of known length, or (tests) a string. */ +export type PassThroughBody = ReadableStream | string; /** A refused Control Point call: HTTP status, Control Point's error code (`revoked`, `not_member`, ...), and any `Retry-After` seconds. */ export class ControlPointError extends Error { constructor(readonly status: number, readonly code: string, readonly detail: string | null = null, readonly retryAfter: number | null = null) { super(`Control Point ${status} ${code}`); } @@ -103,6 +124,14 @@ export interface ControlPointAdapter { /** Choices people made in Control Point for one document's parts (contract §5). */ decisions(token: string, workspaceId: number, documentId: string): Promise; revoke(token: string): Promise; + /** v1.2 §6: a file of an import, uploaded after its send as raw bytes Control Point checks against `sha256`. */ + uploadFile(token: string, workspaceId: number, importId: number, file: { name: string; mediaType: string; sha256: string; body: PassThroughBody; length: number }): Promise; + /** v1.2 §6: the picture keys Control Point holds for the parts of an import's assembly. */ + pictureKeys(token: string, workspaceId: number, importId: number): Promise; + /** v1.2 §6: part pictures, a JSON body `{ pictures: [{ identity, key, contentBase64 }] }` passed through as-is. */ + uploadPictures(token: string, workspaceId: number, importId: number, body: PassThroughBody, length: number): Promise; + /** v1.2 §7: an AI review answered with Control Point's own model keys. */ + aiReview(token: string, workspaceId: number, ask: ControlPointAiAsk): Promise; } const hex = (buffer: ArrayBuffer) => [...new Uint8Array(buffer)].map(value => value.toString(16).padStart(2, '0')).join(''); @@ -193,10 +222,40 @@ function expectedFiles(files: Array<{ name: string }>, explained: Array<{ name: return missing; } +/** Whether a file goes after its bundle (contract v1.2 §6) rather than inside it: the STEP zip always, anything over 4 MB. */ +export const uploadedAfterSend = (mediaType: string, sizeBytes: number) => mediaType === 'application/zip' || sizeBytes > INLINE_FILE_LIMIT_BYTES; async function file(name: string, mediaType: string, bytes: Uint8Array): Promise { const base = { name, mediaType, sizeBytes: bytes.byteLength, sha256: await sha256(bytes) }; - return bytes.byteLength <= INLINE_FILE_LIMIT_BYTES ? { ...base, delivery: 'inline', contentBase64: base64(bytes) } - : { ...base, delivery: 'reference', reason: `Larger than ${INLINE_FILE_LIMIT_BYTES / 1024 / 1024} MB, so it is uploaded separately once the file-upload contract is confirmed.` }; + if (!uploadedAfterSend(mediaType, bytes.byteLength)) return { ...base, delivery: 'inline', contentBase64: base64(bytes) }; + return { ...base, delivery: 'reference', reason: bytes.byteLength > UPLOAD_FILE_LIMIT_BYTES ? `Larger than ${UPLOAD_FILE_LIMIT_BYTES / 1024 / 1024} MB, so it stays in Control Kit.` : 'Uploaded right after this bundle.' }; +} + +/** + * Control Kit's name for the picture of a line at the revision it was drawn (contract v1.2 §6): the source revision the + * Worker caches it by (the scanned microversion for workspace parts) and a short hash of the line and what is drawn. + * Control Point skips a picture whose key it already holds. + */ +export async function controlPointPictureKey(result: Pick, line: BomLine): Promise { + const revision = line.sourceType === 'w' ? `m:${result.diagnostics?.revision?.id ?? result.scanId ?? 'unknown'}` : `${line.sourceType}:${line.sourceId}`; + const digest = (await sha256(`${line.bomLineKey}|${line.purchasedAssembly ? 'assembly' : 'part'}`)).slice(0, 16); + return `${revision}:${digest}`.replace(/[^\w:.-]/g, '_').slice(0, 128); +} +const makeOrder: Partial> = { print: 0, cnc: 0, 'cut-2d': 0, fabricate: 0, review: 1, buy: 2, hardware: 3 }; +/** + * The lines whose pictures Control Point shows, one per part identity (the line that leads its CAD part), parts to make + * first: those are the ones a team needs to recognise. Not-needed parts and lines Onshape cannot draw are left out. + */ +export function pictureTargets(result: ScanResult): Array<{ identity: string; line: BomLine }> { + const seen = new Set(); + const out: Array<{ identity: string; line: BomLine }> = []; + for (const line of [...result.lines].sort((a, b) => a.bomLineKey.localeCompare(b.bomLineKey))) { + const identity = classificationIdentity(line); + if (seen.has(identity)) continue; + seen.add(identity); + if (line.disposition === 'ignore' || !line.sourceType || !line.sourceId || (!line.partId && !line.purchasedAssembly)) continue; + out.push({ identity, line }); + } + return out.sort((a, b) => (makeOrder[a.line.disposition] ?? 4) - (makeOrder[b.line.disposition] ?? 4)); } /** A scan's three line lists as the bundle carries them, sorted by line key. */ @@ -300,4 +359,45 @@ export class MockControlPointAdapter implements ControlPointAdapter { return structuredClone(this.choices.get(`${workspaceId}:${documentId}`) ?? []); } async revoke(token: string) { this.check(token); this.revoked = true; } + /** Files uploaded after a send and part pictures, per workspace and import (contract v1.2 §6). */ + readonly uploads: Array<{ workspaceId: number; importId: number; name: string; mediaType: string; sha256: string; bytes: Uint8Array }> = []; + readonly pictures = new Map(); + private ownImport(workspaceId: number, importId: number) { + const found = [...this.imported.values()].find(item => item.workspace.id === workspaceId && item.importId === importId); + if (!found) throw new ControlPointError(404, 'not_found'); + return found; + } + async uploadFile(token: string, workspaceId: number, importId: number, file: { name: string; mediaType: string; sha256: string; body: PassThroughBody; length: number }) { + this.check(token); + this.ownImport(workspaceId, importId); + const bytes = new Uint8Array(await new Response(file.body).arrayBuffer()); + if (await sha256(bytes) !== file.sha256) throw new ControlPointError(400, 'checksum_mismatch'); + const earlier = this.uploads.find(item => item.importId === importId && item.name === file.name); + if (earlier?.sha256 === file.sha256) return { name: file.name, url: `/api/files/${this.uploads.indexOf(earlier) + 1}`, sizeBytes: bytes.byteLength, sha256: file.sha256, duplicate: true }; + this.uploads.push({ workspaceId, importId, name: file.name, mediaType: file.mediaType, sha256: file.sha256, bytes }); + return { name: file.name, url: `/api/files/${this.uploads.length}`, sizeBytes: bytes.byteLength, sha256: file.sha256, duplicate: false }; + } + async pictureKeys(token: string, workspaceId: number, importId: number) { + this.check(token); + this.ownImport(workspaceId, importId); + return [...this.pictures].map(([identity, key]) => ({ identity, key })); + } + async uploadPictures(token: string, workspaceId: number, importId: number, body: PassThroughBody) { + this.check(token); + this.ownImport(workspaceId, importId); + const parsed = JSON.parse(await new Response(body).text()) as { pictures: Array<{ identity: string; key: string }> }; + let stored = 0, unchanged = 0; + for (const item of parsed.pictures) { if (this.pictures.get(item.identity) === item.key) unchanged++; else { this.pictures.set(item.identity, item.key); stored++; } } + return { stored, unchanged, unknown: 0, rejected: 0 }; + } + /** What `aiReview` answers next; null answers 501 (no keys in Control Point). */ + aiAnswer: ControlPointAiAnswer | null = null; + readonly aiAsks: ControlPointAiAsk[] = []; + async aiReview(token: string, workspaceId: number, ask: ControlPointAiAsk) { + this.check(token); + if (!this.list.some(item => item.id === workspaceId)) throw new ControlPointError(403, 'not_member'); + this.aiAsks.push(structuredClone(ask)); + if (!this.aiAnswer) throw new ControlPointError(501, 'ai_unavailable'); + return structuredClone(this.aiAnswer); + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e78f40..aa0ad96 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,7 +4,7 @@ export { applyClassificationChoice, classificationIdentity, classifyLine, classi import { purchasedAssemblyReason } from './classifier'; export type { ClassificationOverride, ClassificationOverrides, Confidence, Disposition, DispositionSource, Provenance, UserSettings } from './classifier'; export type { Attribution, LibraryMapping, LicensedImage, VendorId, VendorRecord, MatchResult } from './matching'; -export { buildControlPointPayload, controlPointIdempotencyKey, CONTROL_POINT_CAD_URL, ControlPointError, INLINE_FILE_LIMIT_BYTES, MockControlPointAdapter } from './control-point'; +export { buildControlPointPayload, controlPointIdempotencyKey, controlPointPictureKey, CONTROL_POINT_CAD_URL, ControlPointError, INLINE_FILE_LIMIT_BYTES, MockControlPointAdapter, PICTURE_FILL_PER_SEND, PICTURES_BODY_LIMIT_BYTES, PICTURES_PER_CALL, pictureTargets, UPLOAD_FILE_LIMIT_BYTES, uploadedAfterSend } from './control-point'; export { recalculatePurchasing, updatePurchasing } from './purchasing'; export { isBundle, knownProduct, successorOf, withKnownPack } from './known-products'; export { AI_REVIEW_MAX_LINES, AI_REVIEW_VERSION, aiCandidateRecord, aiDispositions, aiReviewHash, aiReviewInstructions, aiReviewSchema, applyAiReview, buildAiReviewRequest, lineCandidates, needsAiReview, parseAiReview } from './ai-review'; @@ -13,7 +13,7 @@ import { recalculatePurchasing } from './purchasing'; export { batchKey, catalogReadKey, catalogVendors, clearCatalogMemoryCache, indexKey, matchCatalog, pointerKey } from './catalog-read'; export type { MatchProgress } from './catalog-read'; export type { CatalogReader, IndexEntry } from './catalog-read'; -export type { ControlPointAccount, ControlPointAdapter, ControlPointBomPayload, ControlPointCadExtra, ControlPointCadPart, ControlPointCandidate, ControlPointChoiceTimes, ControlPointDecision, ControlPointFile, ControlPointImport, ControlPointImportSummary, ControlPointLine, ControlPointWorkspace } from './control-point'; +export type { ControlPointAccount, ControlPointAdapter, ControlPointAiAnswer, ControlPointAiAsk, ControlPointPictureKey, ControlPointPictureResult, ControlPointUpload, PassThroughBody, ControlPointBomPayload, ControlPointCadExtra, ControlPointCadPart, ControlPointCandidate, ControlPointChoiceTimes, ControlPointDecision, ControlPointFile, ControlPointImport, ControlPointImportSummary, ControlPointLine, ControlPointWorkspace } from './control-point'; export { checkStep, exportManifest, stepDispositions, stepFileName, wantsDxf, wantsStep } from './step'; export type { DxfPart, ExportPart, StepCheck } from './step'; export { cutsFromSheet, maxCutThicknessMm, statedThicknessMm, thicknessRule, tooThickText } from './thickness';