{"openapi":"3.1.0","info":{"title":"JobAPI","description":"Generic Upload → Job → Result API Framework for NullCAM","license":{"name":"MIT"},"version":"0.1.0"},"paths":{"/health":{"get":{"tags":["health"],"summary":"Health check endpoint","operationId":"health_check","responses":{"200":{"description":"Service is healthy","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/v1/balance":{"get":{"tags":["billing"],"summary":"Get user's credit balance","operationId":"get_balance","responses":{"200":{"description":"User's credit balance","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BalanceResponse"}}}},"401":{"description":"Unauthorized"}}}},"/v1/checkout":{"post":{"tags":["billing"],"summary":"Create a checkout session for purchasing credits","operationId":"create_checkout","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutRequest"}}},"required":true},"responses":{"200":{"description":"Checkout session created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutResponse"}}}},"400":{"description":"Invalid package","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized"},"500":{"description":"Stripe error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/files":{"post":{"tags":["files"],"summary":"Get a presigned URL for uploading a file","operationId":"get_upload_url","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}},"required":true},"responses":{"200":{"description":"Presigned upload URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs":{"get":{"tags":["jobs"],"summary":"List user's jobs (requires authentication)","operationId":"list_jobs","responses":{"200":{"description":"List of user's jobs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListJobsResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["jobs"],"summary":"POST /v1/jobs - unified job creation","description":"Dispatches to multipart upload path or JSON pre-uploaded path based on Content-Type.","operationId":"create_job","parameters":[{"name":"workflow","in":"query","description":"\"mill\" (default) or \"analyze\"","required":false,"schema":{"type":"string"}}],"requestBody":{"description":"JSON with a `file_id` from POST /v1/files, or `multipart/form-data` carrying `file` (the bytes) plus an optional `options` part holding this same JSON. `settings` is OPTIONAL: omit it and the server resolves your saved machine profile, or the canonical default.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitJobRequest"}}},"required":true},"responses":{"202":{"description":"Job created; poll GET /v1/jobs/{id}/result?wait=90","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedJob"}}}},"400":{"description":"Malformed body or invalid settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Out of credits","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"File too large","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Per-user active-job cap reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"Queue unavailable — retry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs/{job_id}":{"get":{"tags":["jobs"],"summary":"Get job status (requires authentication)","operationId":"get_job_status","parameters":[{"name":"job_id","in":"path","description":"The job ID","required":true,"schema":{"type":"string"}},{"name":"wait","in":"query","description":"Wait up to N seconds for completion (max 30)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Job status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Job not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs/{job_id}/feedback":{"post":{"tags":["jobs"],"summary":"Rate a job's output.","description":"One rating per job; sending a second overwrites the first. Scoped to the caller\nlike every other job read — someone else's job is a 404, never a 403.","operationId":"submit_feedback","parameters":[{"name":"job_id","in":"path","description":"The job being rated","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"`rating` is `positive` or `negative` — nothing else is accepted. `notes` is optional freeform text, truncated server-side at 2000 bytes (on a char boundary, so the truncation cannot split a multi-byte character).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackRequest"}}},"required":true},"responses":{"200":{"description":"Feedback recorded"},"400":{"description":"`rating` was not `positive` or `negative`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such job, or not yours","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs/{job_id}/quality":{"get":{"tags":["jobs"],"summary":"Get a job's machine-simulation quality report (requires authentication).","description":"Its own resource on purpose. Quality is a SECOND artifact, produced after the\nG-code has already shipped, and it is by far the largest thing we store (~9 MB\non dev). Hanging it off `JobStatusResponse` meant every client polling for\n\"is my job done yet\" — at 1 Hz, sometimes from five contexts at once — was\nserved the whole report on every poll, before it existed and after it had\nbeen rendered once. `quality_status` on the job says whether there is\nanything here; this route is how you get it, once.","operationId":"get_job_quality","parameters":[{"name":"job_id","in":"path","description":"The job ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The structured quality report"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No quality report for this job","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs/{job_id}/rerun":{"post":{"tags":["jobs"],"summary":"Rerun a job using the same uploaded file but (potentially) new settings.","description":"The `file_id` field in the request body is ignored — the file from the\nsource job is reused. The source job must be in a terminal state.","operationId":"rerun_job","parameters":[{"name":"job_id","in":"path","description":"The job whose uploaded file to reuse","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Same body as POST /v1/jobs; `file_id` is ignored (the source job's file is reused). Send different `settings` / `material_id` here to re-cut the same part without re-uploading it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitJobRequest"}}},"required":true},"responses":{"202":{"description":"Rerun created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedJob"}}}},"400":{"description":"Source job is not in a terminal state, or bad settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Out of credits","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such job, or not yours","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Per-user active-job cap reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/jobs/{job_id}/result":{"get":{"tags":["jobs"],"summary":"GET /v1/jobs/{job_id}/result","operationId":"get_job_result","parameters":[{"name":"job_id","in":"path","description":"Job id from the create response","required":true,"schema":{"type":"string"}},{"name":"wait","in":"query","description":"Seconds to block for a terminal state, clamped to 90. 0 (default) answers now.","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"202":{"description":"Not ready yet; call again","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"303":{"description":"Ready — redirects to a short-lived signed URL for the artifact. Follow it (`curl -L`)."},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such job, not yours, or no artifact","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"410":{"description":"Job was cancelled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Job ran and failed — the body says why","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/mill/default":{"get":{"tags":["machines"],"summary":"NOT folded into `/v1/profile`, deliberately.","description":"This serves the COMPILE-TIME canonical default (`defaults_compiled.json`);\n`/v1/profile` serves whatever the user saved. They are different resources that\nhappen to share a type: the console needs both at once to offer \"reset to\ndefaults\", and a caller with a saved profile cannot obtain the default from\n`/v1/profile` at all. Merging them would make the default reachable only by\nusers who have never customized anything.","operationId":"get_default_mill","responses":{"200":{"description":"The canonical default `MillSettings`. This is the recommended starting point for an API caller who wants to override the machine: fetch it, edit it, send it back as `settings` on POST /v1/jobs. Hand-building a MillSettings from the proto instead will usually be rejected — `validate_submission` requires a nonzero 3-element `machine.max_velocity_cut`, which the proto defaults leave at [0,0,0].","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/packages":{"get":{"tags":["billing"],"summary":"List available credit packages","operationId":"list_packages","responses":{"200":{"description":"Available credit packages","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CreditPackage"}}}}}}}},"/v1/profile":{"get":{"tags":["profile"],"summary":"Get user's machine profile","operationId":"get_profile","responses":{"200":{"description":"The saved `MillSettings`, or the canonical default when nothing has been saved. This is what a job runs with when you omit `settings` — so an API caller who wants the machine setup applied to every job should PUT it here once rather than send it on every submit.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["profile"],"summary":"Save user's machine profile. Body is a full `MillSettings` proto — the\nsame shape the editor draft holds and the same shape sent in job\nsubmissions, so what's stored and what runs are the same bytes.","operationId":"save_profile","requestBody":{"description":"A full `MillSettings`. Start from GET /v1/mill/default rather than building one from the proto.","content":{"application/json":{"schema":{"type":"object"}}},"required":true},"responses":{"200":{"description":"Profile saved"},"400":{"description":"Settings failed validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Profile exceeds the 4 MiB cap","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["profile"],"summary":"Delete user's machine profile (row removed; subsequent job submissions\nfall back to the canonical default machine).","operationId":"delete_profile","responses":{"200":{"description":"Profile removed; later jobs use the canonical default"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/profile/api-token":{"get":{"tags":["profile"],"summary":"GET /profile/api-token - check if user has a token","operationId":"get_api_token_info","responses":{"200":{"description":"Token presence, mint date, and expiry. The token VALUE is never returned — it is shown once at creation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenInfoResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["profile"],"summary":"POST /profile/api-token - generate a new token (replaces existing)","operationId":"create_api_token","responses":{"200":{"description":"The new token, shown ONCE — store it now. Replaces any existing token, so an old one stops working immediately. Send it as `Authorization: Bearer nc_...`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["profile"],"summary":"DELETE /profile/api-token - revoke the user's token","operationId":"revoke_api_token","responses":{"200":{"description":"Token revoked; it stops authenticating immediately"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/tools/format":{"post":{"tags":["tools"],"summary":"`POST /v1/tools/format?format=fusion|mastercam`","description":"Body is a full `MillSettings` (the editor draft). Returns the library file\nas a download.","operationId":"format_tools","parameters":[{"name":"format","in":"query","description":"`fusion` or `mastercam`","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"A full `MillSettings`; its `tools` are exported","content":{"application/json":{"schema":{"type":"object"}}},"required":true},"responses":{"200":{"description":"The tool-library file, as a download"},"400":{"description":"Unsupported format, or the settings would not serialize","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/tools/parse":{"post":{"tags":["tools"],"summary":"`POST /v1/tools/parse?format=fusion|mastercam`","description":"Body is the raw tool-library file. Returns `{ \"tools\": [Tool, ...] }` for\nthe client to merge into the editor draft (not persisted here).","operationId":"parse_tools","parameters":[{"name":"format","in":"query","description":"`fusion` or `mastercam`","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"The raw tool-library file bytes","content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"`{ \"tools\": [Tool, ...] }` — parsed, NOT persisted. Merge them into a `MillSettings.tools` yourself and PUT /v1/profile (or send as `settings`) to use them.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Unsupported format, unparseable file, or no millable tools","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/usage":{"get":{"tags":["usage"],"summary":"GET /v1/usage - returns usage counts for the current user (single DB round-trip)","operationId":"get_usage","responses":{"200":{"description":"Mutating-request counts for today and this month","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/webhook/stripe":{"post":{"tags":["billing"],"summary":"Handle Stripe webhooks","operationId":"stripe_webhook","requestBody":{"description":"Raw Stripe webhook payload","content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"Webhook processed"},"400":{"description":"Invalid webhook","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"schemas":{"ApiTokenInfoResponse":{"type":"object","required":["has_token"],"properties":{"created_at":{"type":["string","null"]},"expires_at":{"type":["string","null"],"description":"When the token stops being accepted. Absent means it never does, which is\nonly true of tokens minted before the TTL existed.\n\nReported so a caller can rotate BEFORE the credential dies. Without it the\nfirst news of an expiry is a 401 in the middle of someone's script, and a\n401 does not say \"expired\" — it says `INVALID_API_TOKEN`, which reads as\n\"you typed it wrong\"."},"has_token":{"type":"boolean"}}},"ApiTokenResponse":{"type":"object","required":["token"],"properties":{"expires_at":{"type":["string","null"],"description":"When this token dies. Reported at mint time so the caller can write it down\nalongside the secret — the token itself is shown exactly once, and its\nlifetime is the other thing you cannot look up later."},"token":{"type":"string"}}},"BalanceResponse":{"type":"object","description":"What the caller has to spend — from BOTH sides of the money seam, never summed.","required":["free_allowance"],"properties":{"free_allowance":{"type":"integer","format":"int64","description":"jobapi-local free-tier job allowance. A purchase does NOT raise this."},"paid_tokens":{"type":["integer","null"],"format":"int64","description":"clearpdm wallet balance, or `null` when it could not be read.\n\n`null` is NOT zero. Rendering it as zero would tell a paying customer to\nbuy tokens they already own, so clients must show \"unavailable\" instead."}}},"CheckoutRequest":{"type":"object","description":"Request to create a checkout session","required":["package_id"],"properties":{"package_id":{"type":"string","description":"Package ID (starter, pro, shop)","example":"pro"}}},"CheckoutResponse":{"type":"object","description":"Response with checkout session URL","required":["session_id","url"],"properties":{"session_id":{"type":"string","description":"Session ID from Stripe"},"url":{"type":"string","description":"URL to redirect user to for payment"}}},"CreatedJob":{"type":"object","description":"The 202 body of a job create, whichever way the file arrived.\n\nONE shape, which this doc has claimed since it was written on the multipart-only\nversion. It was not true: `POST /v1/jobs` answered multipart with this struct plus a\n`Location` header, and JSON with a `SubmitJobResponse { job_id, message }` and no\n`Location` — same endpoint, same 202, two bodies, and only one of the two was\nregistered in `ApiDoc::components`, so half the endpoint's contract was undocumented.\n`/v1/jobs/:id/rerun` answered with the second one too.\n\nUnified onto this one. Nothing lost: `message` was the constant string \"Job\nsubmitted successfully\", and the only client reads `job_id` (`parseJobResponse` in\n`website/src/scripts/api.ts`). `state` and the `Location` header are gained by the\npaths that lacked them.","required":["job_id","state"],"properties":{"job_id":{"type":"string","description":"The unique job ID","example":"550e8400-e29b-41d4-a716-446655440000"},"state":{"type":"string","description":"Always `\"pending\"` at creation; poll `GET /v1/jobs/{id}` for movement.","example":"pending"}}},"CreditPackage":{"type":"object","description":"Credit package definitions","required":["id","name","credits","price_cents","stripe_price_id","description"],"properties":{"credits":{"type":"integer","format":"int64","description":"Number of credits"},"description":{"type":"string","description":"Description for display"},"id":{"type":"string","description":"Package identifier"},"name":{"type":"string","description":"Display name"},"price_cents":{"type":"integer","format":"int64","description":"Price in cents (USD)"},"stripe_price_id":{"type":"string","description":"Stripe price ID"}}},"ErrorResponse":{"type":"object","description":"Generic error response","required":["error","code"],"properties":{"code":{"type":"string","description":"Error code"},"error":{"type":"string","description":"Error message"}}},"FeedbackRequest":{"type":"object","description":"Request body for submitting feedback on a job","required":["rating"],"properties":{"notes":{"type":["string","null"],"description":"Optional freeform notes"},"rating":{"type":"string","description":"Rating: \"positive\" or \"negative\""}},"additionalProperties":false},"JobState":{"type":"string","description":"Current state of a job","enum":["pending","running","completed","failed","cancelled","queued"]},"JobStatusResponse":{"type":"object","description":"Unified response for job status.\n\nCarried a `type` discriminator \"for WebSocket routing\" until 2026-07. There is no\nWebSocket transport any more, it was `None` at every construction site, and\n`skip_serializing_if` meant it never reached the wire — while ts-rs generated it as\n`type: string | null` (required, not optional), so the binding promised consumers a\nfield the server could not send.","required":["id","state"],"properties":{"error":{"type":["string","null"],"description":"Error message if failed"},"estimated_wait_max_secs":{"type":["integer","null"],"format":"int64"},"estimated_wait_min_secs":{"type":["integer","null"],"format":"int64","description":"Estimated wait, in seconds, as a range: `min` uses the measured median\nper-job time, `max` the worker's hard compute cap. Only present when\nqueued. The `stage` field carries the server's \"starting\" vs \"queued\"\nlabel for this wait."},"expires_at":{"type":["integer","null"],"format":"int64","description":"When the job will be deleted (Unix timestamp, created_at + 89 days)","minimum":0},"feedback_notes":{"type":["string","null"],"description":"Existing feedback notes if already submitted"},"feedback_rating":{"type":["string","null"],"description":"Existing feedback rating (\"positive\" or \"negative\") if already submitted"},"filename":{"type":["string","null"],"description":"Original upload filename"},"id":{"type":"string","description":"The job ID"},"material_id":{"type":["string","null"],"description":"The resolved material id the job actually ran with, echoed from the mill\nresult (e.g. \"aluminum_6061_t6\", or the resolved glob). The results page\nre-lists it next to the stock. (when completed)"},"millplan_url":{"type":["string","null"],"description":"Download URL for MillPlan simulation data (when completed)"},"notify_enabled":{"type":["boolean","null"],"description":"Whether the user has opted in to email notifications for this job"},"part_extents":{"type":["array","null"],"items":{"type":"number","format":"double"},"description":"Part oriented-bounding-box extents [X, Y, Z] in meters (when completed) —\nthe true part size, so it reflects the file's loaded units directly."},"progress":{"type":["integer","null"],"format":"int32","description":"Progress percentage (0-100)","minimum":0},"quality":{"type":["number","null"],"format":"double","description":"Quality score from workflow outcome (0.0 to 1.0)"},"quality_status":{"type":"string","description":"Machine-simulation quality phase: `none` | `running` | `ready` | `failed`.\n`running` tells the results page to show the \"Waiting for full machine\nsimulation\" loader and keep long-polling; `ready` means the fragment below\nis populated. (Anonymous / non-mill jobs stay `none`.)"},"queue_position":{"type":["integer","null"],"format":"int64","description":"Queue position (0 = next, N = N jobs ahead). Only present when queued."},"result_filename":{"type":["string","null"],"description":"Original filename from MillFabrication.gcode.name (when completed)"},"result_url":{"type":["string","null"],"description":"Download URL for G-code result (when completed)"},"stage":{"type":["string","null"],"description":"Processing stage"},"state":{"$ref":"#/components/schemas/JobState","description":"Current state"},"stats":{"type":["array","null"],"items":{"type":"object"},"description":"Per-trajectory statistics (when completed)"},"stock_extents":{"type":["array","null"],"items":{"type":"number","format":"double"},"description":"Stock billet extents [X, Y, Z] in meters (when completed)"},"tools":{"type":["array","null"],"items":{"type":"object"},"description":"Tool magazine (when completed)"}}},"JobSummary":{"type":"object","description":"Summary of a job for listing","required":["id","state","created_at","expires_at"],"properties":{"created_at":{"type":"integer","format":"int64","description":"When the job was created (Unix timestamp)","minimum":0},"expires_at":{"type":"integer","format":"int64","description":"When the job will be deleted (Unix timestamp, created_at + 89 days)","minimum":0},"filename":{"type":["string","null"],"description":"Original upload filename"},"id":{"type":"string","description":"The job ID"},"state":{"$ref":"#/components/schemas/JobState","description":"Current state"}}},"ListJobsResponse":{"type":"object","description":"Response for listing user's jobs","required":["jobs"],"properties":{"jobs":{"type":"array","items":{"$ref":"#/components/schemas/JobSummary"},"description":"List of jobs, newest first."},"next_cursor":{"type":["string","null"],"description":"Echo this back as `?cursor=` for the next page. `null` means this is the last\npage. OPAQUE — do not parse it; it encodes the sort key, so parsing would\ncouple a client to an ordering we intend to be free to change."}}},"SubmitJobRequest":{"type":"object","description":"Request body for submitting a job.\n\nMachine setup is server-authoritative: the web client omits `settings`\nand the server resolves the saved profile row (or the canonical default).\nAPI-direct/CLI callers may still send the full `MillSettings` proto inline\n— the same shape stored in `user_machines.machines_json` and accepted by\n`PUT /v1/profile`. `material_id` is the per-upload material choice; when\npresent it overrides whatever the resolved settings carry.","properties":{"file_id":{"type":"string","description":"File ID from the /v1/files upload endpoint. Defaulted (empty) because the\nmultipart form of `POST /v1/jobs` reuses this exact struct for its\n`options` field — there the file rides in the body and the id is filled in\nserver-side after upload. The JSON form always sends it; an empty id there\nfails the storage existence check with a clear \"file not found\".","example":"abc123"},"material_id":{"type":["string","null"],"description":"Per-upload material selection (glob, e.g. \"steel_*\"; \"*\" = automatic).\nOverrides the resolved settings' material_id when present."},"notify_email":{"type":"boolean","description":"Whether to send an email notification when the job completes."},"settings":{"type":["object","null"],"description":"Full mill settings to use for the job. Omitted by the web client —\nthe server resolves the saved profile (or canonical default) instead."},"stock_override":{"type":["array","null"],"items":{"type":"number","format":"double"},"description":"Per-upload stock. Stock is no longer a machine setting — it travels\nwith the request alongside `material_id`. `stock_override` is explicit\nbillet [X, Y, Z] in meters; `stock_padding` is per-side padding (one\nuniform value or three). When present they overwrite the resolved\nsettings' stock."},"stock_padding":{"type":["array","null"],"items":{"type":"number","format":"double"}},"units":{"type":["string","null"],"description":"Per-upload units override for the uploaded file (a `trimesh.units` key,\ne.g. \"mm\" / \"in\"). Meshes (STL/OBJ) often carry no units; this pins the\ninput units. Sent to the worker as `AnalyzeFileRequest.source_units`.\nEmpty/absent = Automatic (the worker guesses)."},"workflow":{"type":"string","description":"Workflow type: \"mill\" (default) or \"analyze\" (geometry analysis only).","example":"mill"}},"additionalProperties":false},"UploadRequest":{"type":"object","description":"Request for getting a presigned upload URL","required":["filename"],"properties":{"filename":{"type":"string","description":"Filename for the upload","example":"part.step"}},"additionalProperties":false},"UploadResponse":{"type":"object","description":"Response with presigned upload URL","required":["file_id","upload_url","expires_at"],"properties":{"expires_at":{"type":"string","description":"When the upload URL expires"},"file_id":{"type":"string","description":"Unique file ID to reference in job submission","example":"abc123"},"upload_url":{"type":"string","description":"URL to PUT the file to (expires in 15 minutes)","example":"https://storage.googleapis.com/..."}}},"UsageResponse":{"type":"object","required":["requests_today","requests_this_month"],"properties":{"requests_this_month":{"type":"integer","format":"int64"},"requests_today":{"type":"integer","format":"int64"}}}}}}