From 21e93013f19ddd55c6ac2daa85112a16d6d31302 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 9 Jun 2026 05:46:17 +0000 Subject: [PATCH 01/12] docs(run-384): design spec for SDK-gap closure (scope A) Grounds the plan in the actual code: the data:->runflow:// materialization and flat-403 attributed to @runflow-io/proxy live in the backend, not here. Covers in-repo gaps 1/3/4/5/7 + the live e2e-proof gate, and frames gaps 2/6 as a backend contract deliverable. --- docs/plans/run-384-sdk-gaps/design.md | 137 ++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/plans/run-384-sdk-gaps/design.md diff --git a/docs/plans/run-384-sdk-gaps/design.md b/docs/plans/run-384-sdk-gaps/design.md new file mode 100644 index 0000000..3ffff8e --- /dev/null +++ b/docs/plans/run-384-sdk-gaps/design.md @@ -0,0 +1,137 @@ +# RUN-384 — Close SDK gaps blocking external Studio forks + +**Repo:** `runflow-js` (this monorepo). **Branch:** `mr/bb13-sdk-gaps-https-l`. +**Scope:** A — five in-repo gaps (1, 3, 4, 5, 7) implemented + proven end-to-end; +two backend gaps (2, 6) delivered as a contract spec + companion ticket, **not** coded here. + +## Why (grounded in the actual code, which diverges from the ticket) + +The ticket attributes `data:`→`runflow://` materialization and a flat-403 path filter to +`@runflow-io/proxy`. **Neither is in this package.** The JS proxy forwards bodies verbatim and +gates on a *model* allow-list (`handler.ts:140`, `classify()` at `:277`). The materialization is a +**backend** feature (`api.runflow.io`) and is, per the API contract, *correct*: `data:` URIs +materialize to R2 and **reads return signed HTTPS**. The real defect (gaps 2/6) is narrower: +`runflow://assets/{uuid}` refs are resolved on **reads** (`GET /v1/runs`) but **not at dispatch** +before a model worker's media validator runs — so `google/nano-banana-pro/edit` 422s on +`runflow://`. + +Consequently the *real-world* browser breakage is fixed entirely in-repo by **gap 1 + gap 4**, +with no backend change required: + +- `rf.assets.upload(file)` returns a **signed HTTPS** url (confirmed: `routers/asset_uploads.py:94-96` + signs the confirm payload via `storage.sign_asset_payloads`). HTTPS ⇒ no 422. +- The proxy's default allow-list is widened to reach the asset-upload endpoints those calls need. + +## In-repo changes + +### Gap 1 — `rf.assets.upload(file)` · `@runflow-io/sdk` +New `AssetsResource`, wired in `Runflow` constructor (`client.ts:39-42`) as `rf.assets`. +`upload(file: File | Blob, opts?: { filename?: string; folderId?: string }): Promise` +lifts `assetService.uploadFile` (`runflow-monorepo/frontend/platform/src/services/api/asset.ts:29`): + +1. `POST /v1/asset-uploads` `{ filename, mime_type, size_bytes }` → `{ asset_id, upload_url }` +2. **raw** `fetch(upload_url, { method:"PUT", headers:{ "Content-Type": mime }, body: file })` + — absolute storage URL, must bypass `client.request()` (no base prefix, no `Authorization`). +3. `POST /v1/asset-uploads/{asset_id}/confirmations` `{ folder_id: opts.folderId ?? null }` → `Asset`. + +Returns `{ id, url /* signed https */, ref: "runflow://assets/{id}", name, mimeType, sizeBytes, thumbnailUrl, createdAt }`. +- 50 MB guard (matches backend `MAX_FILE_SIZE`). Server callers pass a `Blob` + explicit `filename`. +- `request()` already passes `FormData` through (`client.ts:61`); here we use JSON for steps 1/3 and + a direct `fetcher` call for step 2 (add a minimal internal `rawFetch` on the client). +- Errors surface as `RunflowError` with the failing step + status. +- New exports: `AssetsResource`, type `UploadedAsset`, from `index.ts`. + +### Gap 4 — proxy `allowedPaths` · `@runflow-io/proxy` +Add `allowedPaths?: ReadonlyArray` to `ProxyConfig` (`types.ts:55`): +```ts +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +interface AllowedPath { method: HttpMethod | HttpMethod[]; path: string; } // path may use :param +``` +- New `matchAllowedPath()`: **strict, full-path** segment match; `:param` matches exactly one + non-empty segment that is not `.`/`..`; method must match. No prefix/wildcard matching. +- Integrate into the gate at `handler.ts:140`: a request that matches an allowed path is forwarded + (still through CSRF + `authenticate` + `rateLimit` + body-cap). It is **not** dispatch, so the + model allow-list and `onRun` are untouched. +- `DEFAULT_ALLOWED_PATHS` (so `rf.assets.upload` works **zero-config**): + `POST /v1/asset-uploads`, `POST /v1/asset-uploads/:id/confirmations`. Customer `allowedPaths` + are **additive** over the defaults; built-in dispatch/runs/health stay always-on. +- **Security (council P0):** the proxy forwards with the org's secret key. Allowing *reads* like + `GET /v1/runs` or `GET /v1/billing/balance` exposes org-wide data to any same-origin browser — + therefore those are **opt-in only**, never defaulted. SSRF is bounded (upstream host is fixed to + `apiBase`); the risk is method/path scope, which strict matching controls. Documented in README + + JSDoc with an explicit warning. + +### Gap 5 — `composePinPrompt` · `@runflow-io/sdk` (+ de-dupe) +Export from SDK: +```ts +export function pinRegion(pin: { x: number; y: number }): string; // "upper-left" … "lower-right" +export function composePinPrompt(pin: { x:number;y:number }, instruction: string): string; +``` +`pinRegion` = `${y<.33?"upper":y<.66?"middle":"lower"}-${x<.33?"left":x<.66?"center":"right"}`. +`composePinPrompt` returns the **exact** existing template (behavior-preserving): +`Edit the ${region} area of this image: ${instruction}. Photoreal product photography, preserve the rest of the image, true colors and lighting.` +Replace all four copies: `studio/src/lib/runflow.ts:49,150`; `studio/src/tools/index.ts:78`; +`examples/e2e-proof/run.ts:104` (hardcoded) and `:513` (inline). The "no pin ⇒ center" fallback in +`runflow.ts:150` stays in the caller. + +### Gap 3 — `` props · `@runflow-io/studio` +`StudioShell` takes **zero** props today (`StudioShell.tsx:104`). Add four **optional** props, +preserving the zero-prop default exactly: +- `tools?` — override/extend the workflow catalogue (defaults to module `WORKFLOWS`). +- `source?` — initial source image(s) (defaults to `SAMPLES`). +- `sentinel?` — sentinel config: `{ enabled?, taskDescription?, judges? }` (defaults to current hardcoded call). +- `copy?` — UI copy overrides (headings/labels/CTA), shallow-merged over defaults. +`mount()` forwards an optional props arg. Internal `useState` seeds from props once; no behavior +change when omitted. (Full prop wiring decided against `StudioShell.tsx` during implementation.) + +### Gap 7 — brush + mask creation in `./headless` · `@runflow-io/studio` +Lift the dual-canvas mask logic out of `StudioShell.tsx` (`paintAt`/`updateCoverage`/`clearMask`/ +`generateMaskBlob`) — and the richer `runflow-prototypes/runflow-studio-v2` version — into a +**framework-agnostic** controller exported from `@runflow-io/studio/headless`: +```ts +createMaskController(opts): { + attach(visible: HTMLCanvasElement, hidden: HTMLCanvasElement, image: HTMLImageElement): void; + setBrushSize(px): void; strokeTo(x,y): void; beginStroke(x,y): void; endStroke(): void; + clear(): void; coverage(): number; toBlob(): Promise; // full-res B&W PNG +} +``` +React-free (so forks in any framework reuse it). `StudioShell` refactors to consume it — that +refactor is the proof it's reusable. `useMaskController` React hook ships from the main entry, not headless. + +### E2E proof — the gate · `examples/e2e-proof/run.ts` +`npm run proof` already runs browser SDK → in-process `@runflow-io/proxy` → **real api.runflow.io**. +Extend it so the five gaps are proven live (only `RUNFLOW_API_KEY` needed): +- **New `asset-upload` modality:** `rf.assets.upload(File)` **through the proxy** (proves gap 1 + + gap 4 defaults) → feed the returned **https** url to `google/nano-banana-pro/edit` → assert no 422 + + success. This is the direct repro of Fred's bug, now green. +- **Replace the R2 side-channel:** the `mask-reference` flow currently needs `R2_*` creds + (absent here). Re-upload source/mask/reference via `rf.assets.upload` instead → proof becomes + self-sufficient on the API key alone. (`uploads.ts` SigV4 helper retired; `buildSampleMask` kept.) +- **Gap-4 assertions (in-process):** a non-allowed path still 403s; asset-upload paths pass; a + custom `allowedPaths` entry (e.g. `GET /v1/runs`) passes only when configured. +- **Gap-5:** pin modality + chat-agent section call `composePinPrompt` (shared contract). +- All existing modalities stay green. Also add proxy/SDK **vitest** unit tests for each new surface. + +## Backend contract spec + companion ticket (gaps 2 & 6 — not coded here) +Deliver `docs/plans/run-384-sdk-gaps/backend-contract.md` + a ready-to-file ticket for +`runflow-monorepo`: +- **Gap 2:** resolve `runflow://assets/{uuid}` (and re-sign `{org}/assets|inline/...`) in + `body.input`/`metadata` at the **dispatch write path**, before the model worker's media + validator — mirroring the existing read-side resolver (`GET /v1/runs`). Makes asset refs + first-class so the SDK can eventually return `ref` instead of a TTL-bound signed url. +- **Gap 6:** reframed — `data:` materialization is **correct** (reads sign to https); the missing + piece is the same dispatch-side resolution as gap 2. Recommend that over a `data:` opt-out flag. +- Until shipped, gap 1 returns signed **https** so forks are unblocked today. + +## Cross-cutting +- **Docs:** README note — the worked-example story today is "consume headless primitives + build + UI"; link this repo's `examples/`. Mention `./headless` could later split into a minimal package. +- **Versioning:** changesets — `@runflow-io/sdk` **minor** (new `rf.assets`, `composePinPrompt`), + `@runflow-io/proxy` **minor** (`allowedPaths`), `@runflow-io/studio` **minor** (props + headless + mask). (Pre-1.0 minor = 0.0.3 → 0.1.0; flag if patch preferred.) +- **Quality gates:** `bun run typecheck`, `bun run test`, `bun run lint` (biome), then `npm run proof` + live. No `--no-verify`. + +## Out of scope +Backend code in `runflow-monorepo`; model-card doc edits; splitting `./headless` into its own +package; any change to the model allow-list semantics. From 47b899cf4f12baeb429d2c54a9474939c1af850c Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 10 Jun 2026 07:56:21 +0000 Subject: [PATCH 02/12] =?UTF-8?q?chore:=20make=20'bun=20run=20lint'=20pass?= =?UTF-8?q?=20=E2=80=94=20biome=20format=20sweep=20+=20targeted=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint script has been red since 0.0.3 (no CI enforced it). This brings the tree to green ahead of adding a CI lint gate: - biome format/organize-imports across the repo (mechanical) - type="button" on all 57 typeless buttons (no
exists, so none relied on submit semantics) - aria-hidden="true" on 46 decorative inline svgs - optional chains, template-literal cleanups, guarded shift() instead of non-null assertions, for..of instead of forEach, label htmlFor wiring - RateLimitResult: void -> undefined in the proxy's public types - biome.json override: 5 design-judgment rules (useExhaustiveDependencies, noArrayIndexKey, noLabelWithoutControl, noAutofocus, useSemanticElements) downgraded to warn for studio components only — their auto-fixes change runtime/UX behavior, so they stay visible as warnings instead of blocking No functional changes intended; build, tests, typecheck all green. --- biome.json | 20 + bun.lock | 10 +- examples/e2e-proof/run.ts | 106 +- examples/e2e-proof/uploads.ts | 16 +- examples/e2e-proof/workflows.ts | 2 +- package.json | 5 +- packages/proxy/package.json | 6 +- packages/proxy/src/handler.ts | 18 +- packages/proxy/src/node.ts | 3 +- packages/proxy/src/types.ts | 2 +- packages/proxy/tests/handler.test.ts | 28 +- packages/sdk/package.json | 6 +- packages/sdk/src/client.ts | 37 +- packages/sdk/src/tools/define.ts | 7 +- packages/sdk/src/tools/inputs.ts | 4 +- packages/sdk/src/tools/run.ts | 12 +- packages/sdk/tests/client.test.ts | 30 +- packages/studio/package.json | 10 +- packages/studio/src/components/Canvas.tsx | 51 +- packages/studio/src/components/ChatPanel.tsx | 262 +- .../studio/src/components/ComparePanel.tsx | 28 +- .../studio/src/components/GeneratePanel.tsx | 46 +- .../src/components/ReferenceGallery.tsx | 2 +- .../studio/src/components/SentinelBadge.tsx | 43 +- .../studio/src/components/SettingsMenu.tsx | 5 +- packages/studio/src/components/StepEditor.tsx | 4 +- .../studio/src/components/StudioShell.tsx | 391 +-- packages/studio/src/components/Toasts.tsx | 2 + .../studio/src/components/WorkflowsPanel.tsx | 784 +++--- packages/studio/src/components/icons.tsx | 387 ++- .../studio/src/data/samples.generated.json | 130 +- packages/studio/src/data/workflows.ts | 33 +- packages/studio/src/headless.ts | 21 +- packages/studio/src/index.ts | 7 +- packages/studio/src/lib/chat.ts | 59 +- packages/studio/src/lib/custom-workflows.ts | 4 +- packages/studio/src/lib/generation.ts | 10 +- packages/studio/src/lib/resolution.ts | 23 +- packages/studio/src/lib/runflow.ts | 11 +- packages/studio/src/lib/sentinel.ts | 12 +- packages/studio/src/lib/studio-settings.ts | 5 +- packages/studio/src/lib/version-summary.ts | 2 +- packages/studio/src/styles.css | 2141 ++++++++++++----- packages/studio/src/tools/index.ts | 2 +- packages/studio/tests/tools.test.ts | 6 +- 45 files changed, 3177 insertions(+), 1616 deletions(-) diff --git a/biome.json b/biome.json index 4efd033..fd6f82e 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,26 @@ } } }, + "overrides": [ + { + "include": ["packages/studio/src/components/**"], + "linter": { + "rules": { + "a11y": { + "noLabelWithoutControl": "warn", + "noAutofocus": "warn", + "useSemanticElements": "warn" + }, + "suspicious": { + "noArrayIndexKey": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + } + } + } + } + ], "javascript": { "formatter": { "quoteStyle": "double", diff --git a/bun.lock b/bun.lock index c8da09a..2e2c864 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/proxy": { "name": "@runflow-io/proxy", - "version": "0.0.2", + "version": "0.0.3", "devDependencies": { "@types/node": "^22.10.1", "tsup": "^8.3.5", @@ -32,7 +32,7 @@ }, "packages/sdk": { "name": "@runflow-io/sdk", - "version": "0.0.2", + "version": "0.0.3", "devDependencies": { "tsup": "^8.3.5", "typescript": "^5.7.2", @@ -41,12 +41,12 @@ }, "packages/studio": { "name": "@runflow-io/studio", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "jszip": "^3.10.1", }, "devDependencies": { - "@runflow-io/sdk": "^0.0.2", + "@runflow-io/sdk": "^0.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "react": "^18.3.1", @@ -56,7 +56,7 @@ "vitest": "^2.1.8", }, "peerDependencies": { - "@runflow-io/sdk": "^0.0.2", + "@runflow-io/sdk": "^0.0.3", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", }, diff --git a/examples/e2e-proof/run.ts b/examples/e2e-proof/run.ts index 89c23dd..452a91c 100644 --- a/examples/e2e-proof/run.ts +++ b/examples/e2e-proof/run.ts @@ -24,8 +24,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { Runflow, RunFailedError } from "@runflow-io/sdk"; import { runflowProxy } from "@runflow-io/proxy"; +import { RunFailedError, Runflow } from "@runflow-io/sdk"; import { buildSampleMask, fetchBytes, uploadAndPresign } from "./uploads.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -38,13 +38,7 @@ const SOURCE_URL = interface Modality { name: string; - modality: - | "simple" - | "prompt" - | "color" - | "select" - | "pin" - | "text-to-image"; + modality: "simple" | "prompt" | "color" | "select" | "pin" | "text-to-image"; model: string; body: Record; /** Some workflows take longer; bump the timeout where needed. */ @@ -291,7 +285,7 @@ async function main() { uploadAndPresign(`demos/${sessId}/mask.png`, maskBytes, "image/png"), uploadAndPresign(`demos/${sessId}/reference.jpg`, refBytes, "image/jpeg"), ]); - await log(` uploaded source + mask + reference to R2`); + await log(" uploaded source + mask + reference to R2"); const dispatched = await rf.models.run("runflow/reference-inpaint", { input: { @@ -301,7 +295,10 @@ async function main() { prompt: "match the reference style in the masked area", }, }); - const final = await rf.runs.wait(dispatched.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const final = await rf.runs.wait(dispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const elapsed = +((Date.now() - maskStart) / 1000).toFixed(1); const outputUrl = extractImage(final.output); await log(` ✓ SUCCEEDED in ${elapsed}s — ${outputUrl}`); @@ -313,7 +310,12 @@ async function main() { modality: "mask-reference", name: "reference-inpaint", model: "runflow/reference-inpaint", - inputs: { image: imageUrl, mask: maskUrl, reference: referenceUrl, prompt: "match the reference style in the masked area" }, + inputs: { + image: imageUrl, + mask: maskUrl, + reference: referenceUrl, + prompt: "match the reference style in the masked area", + }, runId: dispatched.id, output: { image: outputUrl }, rawOutput: final.output, @@ -358,8 +360,14 @@ async function main() { try { const prep = await runChain(rf, SOURCE_URL, [ { model: "runflow/tag-removal", extra: {} }, - { model: "runflow/product-isolation", extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" } }, - { model: "runflow/background-color", extra: { color_red: 255, color_green: 255, color_blue: 255 } }, + { + model: "runflow/product-isolation", + extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" }, + }, + { + model: "runflow/background-color", + extra: { color_red: 255, color_green: 255, color_blue: 255 }, + }, ]); await log(` prep done → ${prep.outputUrl.slice(0, 80)}…`); @@ -375,7 +383,12 @@ async function main() { input: { image_url: prep.outputUrl, aspect_ratio: v.aspect_ratio, resolution: "2K" }, }); const r = await rf.runs.wait(d.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); - return { variant: v.id, ratio: v.aspect_ratio, runId: d.id, output: extractImage(r.output) }; + return { + variant: v.id, + ratio: v.aspect_ratio, + runId: d.id, + output: extractImage(r.output), + }; }), ); @@ -383,7 +396,7 @@ async function main() { await log(` ✓ SUCCEEDED in ${elapsed}s — 4 variants`); for (const v of variantRuns) await log(` · ${v.variant} (${v.ratio}): ${v.output}`); await writeFile( - resolve(PROOF_DIR, `package-fanout-omnichannel.json`), + resolve(PROOF_DIR, "package-fanout-omnichannel.json"), JSON.stringify( { ok: true, @@ -432,24 +445,36 @@ async function main() { "on a sun-warmed cobblestone street at golden hour, mid-stride pose, low three-quarter angle, soft long shadows, blurred city backdrop, editorial photoreal product photography, true colors and materials preserved"; const prep = await runChain(rf, SOURCE_URL, [ { model: "runflow/tag-removal", extra: {} }, - { model: "runflow/product-isolation", extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" } }, - { model: "runflow/background-color", extra: { color_red: 255, color_green: 255, color_blue: 255 } }, + { + model: "runflow/product-isolation", + extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" }, + }, + { + model: "runflow/background-color", + extra: { color_red: 255, color_green: 255, color_blue: 255 }, + }, ]); - await log(` prep done`); + await log(" prep done"); const sceneDispatched = await rf.models.run("google/nano-banana-pro/edit", { input: { prompt: `Place the subject of this image ${direction}.`, image_urls: [prep.outputUrl], }, }); - const scene = await rf.runs.wait(sceneDispatched.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const scene = await rf.runs.wait(sceneDispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const sceneUrl = extractImage(scene.output); if (!sceneUrl) throw new Error("ai-scene returned no image"); - await log(` creative direction injected, scene generated`); + await log(" creative direction injected, scene generated"); const variantD = await rf.models.run("runflow/smart-resize", { input: { image_url: sceneUrl, aspect_ratio: "1:1", resolution: "2K" }, }); - const variant = await rf.runs.wait(variantD.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const variant = await rf.runs.wait(variantD.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const elapsed = +((Date.now() - cdStart) / 1000).toFixed(1); const finalUrl = extractImage(variant.output); await log(` ✓ SUCCEEDED in ${elapsed}s — ${finalUrl}`); @@ -505,7 +530,9 @@ async function main() { const chatStart = Date.now(); try { // Simulate the chat agent's plan: one ai-edit step with pin coords. - const planSteps = [{ workflow_id: "ai-edit", description: "Remove the price tag in the upper-left" }]; + const planSteps = [ + { workflow_id: "ai-edit", description: "Remove the price tag in the upper-left" }, + ]; const pin = { x: 0.25, y: 0.25 }; const instruction = "remove the price tag"; // Build the ai-edit prompt exactly the way @runflow-io/studio's ai-edit @@ -583,7 +610,11 @@ async function main() { ); await writeFile( resolve(PROOF_DIR, `sentinel-${verdict.evalId}.json`), - JSON.stringify({ ok: true, modality: "sentinel", ...verdict, elapsedSeconds: elapsed }, null, 2), + JSON.stringify( + { ok: true, modality: "sentinel", ...verdict, elapsedSeconds: elapsed }, + null, + 2, + ), ); summary.push({ modality: "sentinel", @@ -608,12 +639,16 @@ async function main() { await log(""); } } else { - await log("▶ sentinel — skipped (SENTINEL_API_KEY missing or no successful output to evaluate)"); + await log( + "▶ sentinel — skipped (SENTINEL_API_KEY missing or no successful output to evaluate)", + ); await log(""); } const ok = summary.every((s) => s.ok); - await log(`== summary: ${summary.filter((s) => s.ok).length}/${summary.length} modalities succeeded ==`); + await log( + `== summary: ${summary.filter((s) => s.ok).length}/${summary.length} modalities succeeded ==`, + ); await writeFile( resolve(PROOF_DIR, "summary.json"), JSON.stringify({ ok, summary, completedAt: new Date().toISOString() }, null, 2), @@ -634,7 +669,10 @@ async function runChain( const dispatched = await rf.models.run(step.model, { input: { image_url: current, ...step.extra }, }); - const final = await rf.runs.wait(dispatched.id, { pollIntervalMs: 2_000, timeoutMs: 3 * 60_000 }); + const final = await rf.runs.wait(dispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 3 * 60_000, + }); const url = extractImage(final.output); if (!url) throw new Error(`No output URL from ${step.model} run ${dispatched.id}`); current = url; @@ -648,7 +686,13 @@ async function runSentinel( apiKey: string, inputUrl: string, outputUrl: string, -): Promise<{ evalId: string; state: string; judgesPassed: number; judgesTotal: number; summary?: string }> { +): Promise<{ + evalId: string; + state: string; + judgesPassed: number; + judgesTotal: number; + summary?: string; +}> { const SENTINEL = "https://sentinel.runflow.io/api/v1"; const post = await fetch(`${SENTINEL}/evaluate?sync=false`, { method: "POST", @@ -661,11 +705,15 @@ async function runSentinel( generated_image_url: outputUrl, task_type: "product_photography", task_description: "Background removed and replaced with the requested backdrop.", - reference_images: [{ url: inputUrl, role: "reference_image", description: "Original source image" }], + reference_images: [ + { url: inputUrl, role: "reference_image", description: "Original source image" }, + ], }), }); if (!post.ok) { - throw new Error(`sentinel dispatch ${post.status}: ${await post.text().then((s) => s.slice(0, 200))}`); + throw new Error( + `sentinel dispatch ${post.status}: ${await post.text().then((s) => s.slice(0, 200))}`, + ); } const dispatched = (await post.json()) as { eval_id: string }; const evalId = dispatched.eval_id; diff --git a/examples/e2e-proof/uploads.ts b/examples/e2e-proof/uploads.ts index 879c9de..d81a556 100644 --- a/examples/e2e-proof/uploads.ts +++ b/examples/e2e-proof/uploads.ts @@ -79,7 +79,9 @@ export async function uploadAndPresign( const signedHeaderKeys = Object.keys(putHeaders).sort(); const signedHeaders = signedHeaderKeys.join(";"); const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${putHeaders[k]}\n`).join(""); - const canonicalRequest = ["PUT", path, "", canonicalHeaders, signedHeaders, payloadHash].join("\n"); + const canonicalRequest = ["PUT", path, "", canonicalHeaders, signedHeaders, payloadHash].join( + "\n", + ); const scope = `${shortDate}/${REGION}/s3/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", dateStamp, scope, sha256(canonicalRequest)].join("\n"); const kDate = hmacSha256(`AWS4${secretKey}`, shortDate); @@ -95,7 +97,8 @@ export async function uploadAndPresign( headers: { ...putHeaders, authorization }, body: new Uint8Array(body) as unknown as BodyInit, }); - if (!res.ok) throw new Error(`R2 upload ${res.status}: ${await res.text().then((s) => s.slice(0, 200))}`); + if (!res.ok) + throw new Error(`R2 upload ${res.status}: ${await res.text().then((s) => s.slice(0, 200))}`); // Presign GET const params: Array<[string, string]> = [ @@ -108,7 +111,14 @@ export async function uploadAndPresign( params.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const canonicalQs = params.map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`).join("&"); const ch = `host:${host}\n`; - const cr = ["GET", `/${BUCKET}/${encodeKeyPath(key)}`, canonicalQs, ch, "host", "UNSIGNED-PAYLOAD"].join("\n"); + const cr = [ + "GET", + `/${BUCKET}/${encodeKeyPath(key)}`, + canonicalQs, + ch, + "host", + "UNSIGNED-PAYLOAD", + ].join("\n"); const sts = ["AWS4-HMAC-SHA256", dateStamp, scope, sha256(cr)].join("\n"); const sig = hmacSha256(kSigning, sts).toString("hex"); return `${ENDPOINT.replace(`/${BUCKET}`, "")}/${BUCKET}/${encodeKeyPath(key)}?${canonicalQs}&X-Amz-Signature=${sig}`; diff --git a/examples/e2e-proof/workflows.ts b/examples/e2e-proof/workflows.ts index ef92273..6369058 100644 --- a/examples/e2e-proof/workflows.ts +++ b/examples/e2e-proof/workflows.ts @@ -16,8 +16,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { Runflow, RunFailedError } from "@runflow-io/sdk"; import { runflowProxy } from "@runflow-io/proxy"; +import { RunFailedError, Runflow } from "@runflow-io/sdk"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROOF_DIR = resolve(__dirname, "../../.proof"); diff --git a/package.json b/package.json index f1e12e9..0053742 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,7 @@ "type": "git", "url": "https://github.com/runflow-io/runflow-js.git" }, - "workspaces": [ - "packages/*", - "examples/*" - ], + "workspaces": ["packages/*", "examples/*"], "scripts": { "build": "cd packages/sdk && bun run build && cd ../proxy && bun run build && cd ../studio && bun run build", "test": "bun run --filter='./packages/*' test", diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 42ea8be..5513a02 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -26,11 +26,7 @@ "require": "./dist/node.cjs" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/proxy/src/handler.ts b/packages/proxy/src/handler.ts index fd3f992..47e616f 100644 --- a/packages/proxy/src/handler.ts +++ b/packages/proxy/src/handler.ts @@ -39,7 +39,7 @@ function normalize(cfg: ProxyConfig): NormalizedConfig { } const allowed = cfg.allowedModels ?? DEFAULT_ALLOWED_MODELS; const allowedModelsFor = - typeof allowed === "function" ? allowed : (() => allowed as ReadonlyArray); + typeof allowed === "function" ? allowed : () => allowed as ReadonlyArray; return { apiKey: cfg.apiKey, basePath: stripTrailing(cfg.basePath ?? DEFAULT_BASE_PATH), @@ -67,10 +67,7 @@ function normalize(cfg: ProxyConfig): NormalizedConfig { * header. `false` opts out entirely (not recommended). An explicit * array of origins is matched case-insensitively on scheme + host + port. */ -function originAllowed( - req: Request, - policy: NormalizedConfig["allowedOrigins"], -): boolean { +function originAllowed(req: Request, policy: NormalizedConfig["allowedOrigins"]): boolean { if (policy === false) return true; const origin = req.headers.get("origin"); if (!origin) { @@ -135,7 +132,10 @@ async function handle(c: NormalizedConfig, req: Request): Promise { const segments = upstreamPath.split("/").filter(Boolean); const { isDispatch, model, runId } = classify(req.method, segments); const isHealth = - req.method === "GET" && segments.length === 2 && segments[0] === "v1" && segments[1] === "health"; + req.method === "GET" && + segments.length === 2 && + segments[0] === "v1" && + segments[1] === "health"; if (!isDispatch && !runId && !isHealth) { return json({ error: "Not allowed" }, 403); @@ -339,7 +339,11 @@ async function readBoundedBody(req: Request, max: number): Promise { return new TextDecoder().decode(merged); } -function json(payload: unknown, status: number, extraHeaders: Record = {}): Response { +function json( + payload: unknown, + status: number, + extraHeaders: Record = {}, +): Response { return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json", ...extraHeaders }, diff --git a/packages/proxy/src/node.ts b/packages/proxy/src/node.ts index 8238208..62e1e9f 100644 --- a/packages/proxy/src/node.ts +++ b/packages/proxy/src/node.ts @@ -65,7 +65,8 @@ function toHeaders(h: IncomingMessage["headers"]): Headers { async function collectBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + for await (const chunk of req) + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); return Buffer.concat(chunks); } diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index e57d487..9da84cf 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -23,7 +23,7 @@ export interface RateLimitDeniedResult { export interface RateLimitAllowedResult { status: 0; } -export type RateLimitResult = RateLimitDeniedResult | RateLimitAllowedResult | void; +export type RateLimitResult = RateLimitDeniedResult | RateLimitAllowedResult | undefined; export interface ProxyRequestContext { method: string; diff --git a/packages/proxy/tests/handler.test.ts b/packages/proxy/tests/handler.test.ts index 5f47949..e8bb624 100644 --- a/packages/proxy/tests/handler.test.ts +++ b/packages/proxy/tests/handler.test.ts @@ -55,11 +55,17 @@ describe("runflowProxy — routing", () => { }), }); const res = await proxy( - new Request("http://app/api/runflow/v1/models/runfl0w/background-removal/runs".replace("runfl0w", "runflow"), { - method: "POST", - body: JSON.stringify({ input: { image_url: "https://cdn/x.png" } }), - headers: { "Content-Type": "application/json" }, - }), + new Request( + "http://app/api/runflow/v1/models/runfl0w/background-removal/runs".replace( + "runfl0w", + "runflow", + ), + { + method: "POST", + body: JSON.stringify({ input: { image_url: "https://cdn/x.png" } }), + headers: { "Content-Type": "application/json" }, + }, + ), ); expect(res.status).toBe(200); expect(seenAuth).toBe(`Bearer ${KEY}`); @@ -73,9 +79,15 @@ describe("runflowProxy — routing", () => { apiKey: KEY, fetch: mockUpstream( (req) => - new Response(JSON.stringify({ id: new URL(req.url).pathname.split("/").pop(), status_code: "succeeded" }), { - headers: { "Content-Type": "application/json" }, - }), + new Response( + JSON.stringify({ + id: new URL(req.url).pathname.split("/").pop(), + status_code: "succeeded", + }), + { + headers: { "Content-Type": "application/json" }, + }, + ), ), }); const goodUuid = "11111111-2222-3333-4444-555555555555"; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 943d8ef..fdf6d27 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -21,11 +21,7 @@ "require": "./dist/index.cjs" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 106e405..a1468e2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,7 +1,7 @@ import { RunflowError } from "./errors.js"; -import type { Run, RunDispatched, RunflowConfig, WaitOptions } from "./types.js"; import { RunFailedError, RunTimeoutError } from "./errors.js"; import { ToolsResource } from "./tools/run.js"; +import type { Run, RunDispatched, RunflowConfig, WaitOptions } from "./types.js"; const DEFAULT_API_BASE = "https://api.runflow.io"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -72,10 +72,13 @@ export class Runflow { res = await this.fetcher(url, { method, headers, body, signal: controller.signal }); } catch (err) { if (controller.timedOut()) { - throw new RunflowError(`Request timed out (${this.requestTimeoutMs}ms): ${method} ${path}`, { - code: "request_timeout", - cause: err, - }); + throw new RunflowError( + `Request timed out (${this.requestTimeoutMs}ms): ${method} ${path}`, + { + code: "request_timeout", + cause: err, + }, + ); } throw new RunflowError(`Request failed: ${method} ${path}`, { code: "network_error", @@ -166,7 +169,11 @@ export class RunsResource { } opts.onPoll?.(run); yield run; - if (run.status_code === "succeeded" || run.status_code === "failed" || run.status_code === "canceled") { + if ( + run.status_code === "succeeded" || + run.status_code === "failed" || + run.status_code === "canceled" + ) { return; } await sleep(interval); @@ -189,10 +196,11 @@ export class RunsResource { throw new RunflowError(`Run ${id} produced no status updates`, { code: "no_status" }); } if (last.status_code === "failed" || last.status_code === "canceled") { - throw new RunFailedError( - last.error?.message ?? `Run ${id} ${last.status_code}`, - { id: last.id, status: last.status_code, error: last.error }, - ); + throw new RunFailedError(last.error?.message ?? `Run ${id} ${last.status_code}`, { + id: last.id, + status: last.status_code, + error: last.error, + }); } return last; } @@ -223,9 +231,12 @@ function encodeModelId(model: string): string { const parts = model.split("/"); for (const p of parts) { if (p === "" || p === "." || p === "..") { - throw new RunflowError(`models.run: invalid model id segment ${JSON.stringify(p)} in ${JSON.stringify(model)}`, { - code: "invalid_model_id", - }); + throw new RunflowError( + `models.run: invalid model id segment ${JSON.stringify(p)} in ${JSON.stringify(model)}`, + { + code: "invalid_model_id", + }, + ); } } return parts.map(encodeURIComponent).join("/"); diff --git a/packages/sdk/src/tools/define.ts b/packages/sdk/src/tools/define.ts index 51e5e3b..524844b 100644 --- a/packages/sdk/src/tools/define.ts +++ b/packages/sdk/src/tools/define.ts @@ -78,10 +78,9 @@ export interface ToolDef< * }), * }); */ -export function defineTool< - I extends Record, - O extends Record, ->(def: ToolDef): ToolDef { +export function defineTool, O extends Record>( + def: ToolDef, +): ToolDef { return def; } diff --git a/packages/sdk/src/tools/inputs.ts b/packages/sdk/src/tools/inputs.ts index f8470f2..cab20fa 100644 --- a/packages/sdk/src/tools/inputs.ts +++ b/packages/sdk/src/tools/inputs.ts @@ -110,8 +110,8 @@ export type RuntimeInputValues> = : T[K]["optional"] extends true ? never : K]: InputValue; - } & // optional: source !== "preset" AND optional === true - { + } & { + // optional: source !== "preset" AND optional === true [K in keyof T as T[K]["source"] extends "preset" ? never : T[K]["optional"] extends true diff --git a/packages/sdk/src/tools/run.ts b/packages/sdk/src/tools/run.ts index b0f55b9..d0629ea 100644 --- a/packages/sdk/src/tools/run.ts +++ b/packages/sdk/src/tools/run.ts @@ -56,12 +56,14 @@ export class ToolsResource { const dispatched = await this.client.models.run(tool.model, enriched, { signal: opts.signal }); const final = await this.client.runs.wait(dispatched.id, opts); if (final.status_code !== "succeeded") { - throw new RunFailedError( - final.error?.message ?? `Run ${final.id} ${final.status_code}`, - { id: final.id, status: final.status_code, error: final.error }, - ); + throw new RunFailedError(final.error?.message ?? `Run ${final.id} ${final.status_code}`, { + id: final.id, + status: final.status_code, + error: final.error, + }); } - const extractor = tool.extractOutput ?? (defaultExtract as unknown as (raw: unknown) => OutputValues); + const extractor = + tool.extractOutput ?? (defaultExtract as unknown as (raw: unknown) => OutputValues); const extracted = extractor(final.output); return { runId: final.id, status: "succeeded", output: extracted, raw: final }; } diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 9d463a9..7d4cf2d 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { Runflow, RunflowError, RunFailedError } from "../src/index.js"; +import { RunFailedError, Runflow, RunflowError } from "../src/index.js"; function mockFetch(handler: (req: Request) => Response | Promise): typeof fetch { return ((input: RequestInfo | URL, init?: RequestInit) => { @@ -95,17 +95,20 @@ describe("models.run + runs.wait", () => { ); }), }); - await expect(rf.runs.wait("run_2", { pollIntervalMs: 1 })).rejects.toBeInstanceOf(RunFailedError); + await expect(rf.runs.wait("run_2", { pollIntervalMs: 1 })).rejects.toBeInstanceOf( + RunFailedError, + ); }); it("surfaces HTTP errors as RunflowError with status", async () => { const rf = new Runflow({ apiKey: "rf_live_x", - fetch: mockFetch(() => - new Response(JSON.stringify({ error: { message: "rate limited" } }), { - status: 429, - headers: { "Content-Type": "application/json" }, - }), + fetch: mockFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }), ), }); try { @@ -131,14 +134,14 @@ describe("models.run + runs.get — path encoding", () => { }), }); await rf.models.run("space owner/has spaces/runs slug", {}); - expect(seenUrl).toMatch( - /\/v1\/models\/space%20owner\/has%20spaces\/runs%20slug\/runs/, - ); + expect(seenUrl).toMatch(/\/v1\/models\/space%20owner\/has%20spaces\/runs%20slug\/runs/); }); it("rejects model id with empty / dot / dot-dot segments", async () => { const rf = new Runflow({ apiKey: "rf_live_x", fetch: mockFetch(() => new Response("")) }); - await expect(rf.models.run("runflow//background-removal", {})).rejects.toThrow(/invalid model id/); + await expect(rf.models.run("runflow//background-removal", {})).rejects.toThrow( + /invalid model id/, + ); await expect(rf.models.run("runflow/../secret", {})).rejects.toThrow(/invalid model id/); await expect(rf.models.run("./foo", {})).rejects.toThrow(/invalid model id/); }); @@ -207,8 +210,9 @@ describe("tools.run — type-contract enforcement", () => { describe("tools.run", () => { it("merges presets and runtime args, then extracts output", async () => { - const { defineTool, imageInput, textInput, imageOutput, extractFirstImageUrl } = - await import("../src/tools/index.js"); + const { defineTool, imageInput, textInput, imageOutput, extractFirstImageUrl } = await import( + "../src/tools/index.js" + ); const tool = defineTool({ id: "scene-test", diff --git a/packages/studio/package.json b/packages/studio/package.json index 482809c..0d23241 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -13,9 +13,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "sideEffects": [ - "./dist/styles.css" - ], + "sideEffects": ["./dist/styles.css"], "exports": { ".": { "types": "./dist/index.d.ts", @@ -34,11 +32,7 @@ }, "./styles.css": "./dist/styles.css" }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup && cp src/styles.css dist/styles.css", "dev": "tsup --watch", diff --git a/packages/studio/src/components/Canvas.tsx b/packages/studio/src/components/Canvas.tsx index 1332702..b1f53e0 100644 --- a/packages/studio/src/components/Canvas.tsx +++ b/packages/studio/src/components/Canvas.tsx @@ -6,9 +6,9 @@ // bounding box (no off-by-letterbox drift on pin clicks). import { type ReactNode, type Ref, useState } from "react"; -import { Icon } from "./icons"; -import { displayBucket, aspectRatioLabel } from "../lib/resolution"; +import { aspectRatioLabel, displayBucket } from "../lib/resolution"; import { usePendingPhrase } from "../lib/workflow-progress"; +import { Icon } from "./icons"; export type Pin = { x: number; y: number }; @@ -147,7 +147,9 @@ export function StudioCanvas({ {pendingPhrase} - + + + @@ -188,9 +190,18 @@ export function StudioCanvas({ } return ( -
+
- {imageTitle} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: pin placement is pointer-coordinate-driven; the keyboard path is the chat agent's pin flow */} + {imageTitle} {pin && isPinning ? (
? + {/* biome-ignore lint/a11y/useSemanticElements: role="status" on a styled div; carries form semantics and inline display */}
{(() => { const bucket = displayBucket(imageWidth, imageHeight, requestedResolution); @@ -245,7 +258,9 @@ export function StudioCanvas({ ); })()} - {aspectRatioLabel(imageWidth, imageHeight)} + + {aspectRatioLabel(imageWidth, imageHeight)} + {imageWidth}×{imageHeight} @@ -275,6 +290,7 @@ export function StudioCanvas({ ) : null} - + {Icon.download} {onDownloadAll ? ( - @@ -337,6 +366,7 @@ export function StudioCanvas({ ) : null} {chatPinMode ? (
) : ( @@ -623,7 +622,11 @@ export function ChatPanel({ /> ) : null} {refState && !refState.resolved ? ( - + ) : null} {textState && !textState.resolved ? (
- + +
@@ -872,7 +880,12 @@ function ReferenceBubble({ /> Click to upload a reference image -
@@ -907,6 +920,7 @@ function ColorBubble({
{COLOR_PRESETS.map((p) => (
-
@@ -969,7 +989,8 @@ function TextBubble({ return (
- {label}{optional ? " (optional)" : ""} + {label} + {optional ? " (optional)" : ""}