Compare commits

..
Author SHA1 Message Date
Hanzo AI b89cf707a2 studio v0.18.3: monochrome shell chrome — coherent with the Home + @hanzo/gui design system
shell.css (chat sidebar · Developers dock · GPUs panel) was still on the old
charcoal palette; snap it to the canonical monochrome tokens the Home already
uses: page #000 · surface #0a0a0a · raised #1a1a1a · border #1f1f1f · text
#ededed · dim #888 · brand/CTA #fff. Semantic green (online) / red (error) kept.
The shared shell now matches the create experience + the chat's own empty-state
and friendly-error work already on main.
2026-07-25 10:17:15 -07:00
zandClaude Opus 4.8 d8399c312f refactor(studio/docs): decomplect documents AI-fill onto the ONE canonical gateway_auth
documents.py carried a parity copy of the gateway-auth allowlist (_jwt_alg,
_forwardable_bearer, _SECRET_PREFIXES) — and it DID drift: the same crafted-alg
hardening + exact-JWS tightening had to be applied to both copies. _ai_token now
delegates to gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV); the
~55 duplicated lines are deleted. One and only one fail-closed allowlist, so a
future fix can never land on one copy and miss the other. Tests repointed to the
_ai_token delegation surface (crafted-alg no-crash, HS256/secret refused, pk-/
asymmetric forwarded, server-key-first). 67 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:36:21 -07:00
zandClaude Opus 4.8 20d2f809af fix(studio/create): RED findings — dedup double-bill, dim type-confusion, alg over-match, sub-caption XSS (v0.18.2)
- F1 (MED): _dedup_key now keys on stable identity (org|kind|prompt|refs|uploads),
  not the random-tagged output_prefix, so two identical creates collapse to one
  dispatch (the anti-double-bill guard actually fires now).
- F2 (LOW): _image_graph/_video_graph raise DispatchError on non-numeric dims (clean 400, not 500).
- F3 (LOW): alg allowlist tightened to the exact JWS set in gateway_auth + documents.
- F4 (INFO): esc() the gallery sub-caption (crafted-upload filename_prefix XSS).
Red verified /v1/library/image cross-org/traversal/secret-exfil fail closed. +8 tests, 65 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:31:48 -07:00
zandClaude Opus 4.8 c00384cf1a fix(studio/auth): harden canonical gateway_auth against a crafted non-string JWT alg
gateway_auth.py (the new canonical fail-closed allowlist, now on the live copilot
path) had the same crafted-alg gap RED found in documents.py's copy: jwt_alg
returned a non-string alg verbatim and forwardable_bearer did alg[:2]/startswith
with no isinstance guard → a JWT header {alg:null} 500s the copilot. Guarded at
both points (+ regression test). FLAG: documents.py still has a private copy of
this allowlist — decomplect it onto gateway_auth (one way) as the follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:11:42 -07:00
zeekayandClaude Opus 4.8 cadc589875 studio v0.18.1: text-to-image default create + fail-closed gateway auth + UX fixes
P1 — the primary Create now produces something, and Chat reaches the gateway:
- middleware/gateway_auth.py: ONE fail-closed allowlist for the browser-edge
  gateway bearer — a server key first, else pk-/asymmetric-JWT only; the HS256
  hanzo.id session token and sk-/hk-/rk- secrets are NEVER forwarded (the old
  "unsupported signing method: HS256" 502). copilot._resolve_target adopts it.
- Z-Image-Turbo text-to-image lane: _image_graph + dispatch_image + POST
  /v1/library/image. The composer's default up-arrow (words, no photo) now
  generates an image instead of the old "attach a photo first" dead end.
- composer defaults to an IMAGE model (hz_create_model, decoupled from the chat
  model); routes by modality (assistant -> Chat via window.HanzoChat, else
  image/video/fix/compose); create() ALWAYS shows a generating state + friendly
  retry, never a silent no-op.
- shell.js: copilot errors render one friendly line (never raw JSON); chat empty
  state (assistant identity + 3 starters); one public HanzoChat.send hook.

P2 — friction on the common path:
- first-class Download on every tile AND in the viewer (PNG / JPG / WebP)
- opaque fixed header + sticky live-bar (content no longer scrolls under them)
- ARCHIVED badge only on truly deleted/flagged items (active work is unbadged)
- mobile: ~30 filter pills collapse to one scrollable row; tab-bar scroll fade

P3 — composer pill tooltips, per-model descriptions, code model hidden, and the
</> request-preview gated behind Developers mode.

Tests: gateway_auth_test (allowlist refuses HS256/sk-, accepts pk-/asymmetric),
copilot_test (never forwards the HS256 session token), create_image_test
(Z-Image graph shape + served-HTML invariants: default-model-is-image,
Create-always-shows-a-state, first-class Download). Green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:04:03 -07:00
zandClaude Opus 4.8 a8dc089716 fix(studio/docs): RED findings — XLSX formula-injection guard, C0 control-char strip, crafted-JWT-alg hardening
Red review of the office-document subsystem (0 crit/high, 1 med, 2 low):
- MED: _sheet_xlsx now neutralizes formula-trigger text cells (=,+,-,@,tab,cr →
  leading ') so shared XLSX (budgets/expense reports) can't fire DDE/HYPERLINK.
- LOW: _coerce strips C0 control chars (keep \t\n\r) at the one boundary, so a
  NUL in a field can't 500/poison the docx/xlsx OOXML writers.
- LOW: _jwt_alg + _forwardable_bearer guard a non-string JWT alg (no 500).
- INFO: _ID_RE uses \Z not $ (no trailing-newline bypass).
Tenancy + secret-containment + escaping were RED-verified airtight. +3 regression
tests (formula inert, control-char valid-OOXML, crafted-alg no-crash). 150 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:42:21 -07:00
zeekayandClaude Opus 4.8 e631d35fd0 test(documents): skip a format when its wheel is absent (portable unit gate)
pytest tests-unit runs in the GitHub-mirror unit workflow across an
ubuntu/windows/macos matrix. PDF (built-in fallback) and Markdown are stdlib and
always render; DOCX/XLSX/PPTX now skip gracefully when python-docx/openpyxl/
python-pptx isn't importable in that env instead of erroring. The canonical gate
(hanzo.yml installs requirements.txt) still exercises every format for real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:18:20 -07:00
zeekayandClaude Opus 4.8 267735d0bd studio: office documents — a non-GPU document pipeline (PDF/DOCX/XLSX/PPTX/MD)
Add a first-class office-document capability ALONGSIDE the ComfyUI/GPU creative
pipeline: structured content (typed template fields, filled by a person or the
AI) → a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md. No graph, no
GPU. Orthogonal subsystem — not bolted onto graphs.

- middleware/doc_render.py: one neutral IR per archetype (doc/sheet/deck) and
  exactly one renderer per format (DRY). PDF via WeasyPrint (full-CSS monochrome
  print style) with a self-contained pure-Python PDF fallback (valid %PDF, zero
  native deps — so CI/model-less pods still render). DOCX/XLSX/PPTX via
  python-docx/openpyxl/python-pptx.
- middleware/documents.py: one declarative CATALOG of 22 common templates
  (Documents 15, Spreadsheets 4, Presentations 3). Each template's typed fields
  drive the form, the AI-fill JSON, and the renderer inputs (one schema). Per-org
  store (orgs/<org>/documents, atomic index.json + <id>/doc.json), rendered on
  demand. Endpoints (/v1 house style): GET /v1/documents, POST
  /v1/documents/generate, GET /v1/documents/library, GET|PATCH|DELETE
  /v1/documents/{id}, GET /v1/documents/{id}/download?format=. Registered via the
  same injected tenancy resolvers as stacks_store.
- AI-fill is a layered enhancement — the form path renders with NO LLM, so a
  broken/unauthorized gateway never blocks creation (422 needFields → prefilled
  form). Reuses the shipped Chat's working token: the browser sends window.HZ.pk
  (publishable pk- key) as the bearer; the backend forwards it fail-closed —
  server key, else a gateway-verifiable pk-/RS-JWT; an HS256 session token or
  sk-/hk- secret is NEVER forwarded. Model default enso (window.HZ.model).
- studio_home.html: a "Documents" Home tab (searchable catalog + your documents
  with Download PDF/Word/Excel/PowerPoint) + describe/fill dialog; replaced the
  dead doc/sheet/paper composer placeholders with one "Office documents" card.
- deps: weasyprint/python-docx/openpyxl/python-pptx (requirements.txt) + pango
  native libs (Dockerfile). CI builds the image; not built locally.
- tests-unit/documents_test: catalog invariants, full template×format render
  matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist,
  and the REST surface incl. per-org isolation (44 tests, green).
- version 0.17.24 → 0.18.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:14:55 -07:00
Hanzo AI 657e2b5fc0 release: studio v0.17.24 — monochrome create experience + dropdowns/switcher + Enso default live 2026-07-24 23:44:48 -07:00
Hanzo AI 99720f2bf4 studio: monochrome create experience — design-system mode, wizard, agentic builder, template picker
The Studio Home 'What should we create?' surface, fully monochrome and DRY on
the canonical design tokens + icon()/_popover:

- Line-icon system (icon(name) → currentColor SVG). No emoji anywhere in the
  create experience; template cards + tabs + chat button all use lucide-style
  monochrome marks.
- Searchable template picker (Template chip → .tpick): every blueprint grouped,
  filterable, keyboard-navigable; None returns to edit/compose.
- Design systems mode (new tab): reusable brand styles a generation can pull
  from — preset systems (Modernist/Classical/Nocturne/Organic/Broadsheet/
  Industry) with Use / Set org default, plus Create design system.
- Set up your design system wizard (#dswiz): company blurb + link a public
  GitHub repo (→ e.g. github.com/hanzoai/design), a local folder, a .fig
  (parsed in-browser, never uploaded), or fonts/logos.
- Agentic design-system builder (#bld): chat + todos + file tree + editor to
  generate a system from those inputs.

shell.js: Chat dock button uses a monochrome SVG icon (was emoji).
2026-07-24 23:06:38 -07:00
Hanzo AI 49b8d1ad0f studio: monochrome shell theme + Copilot chat, Enso default
De-purple the ComfyUI shell theme (hanzo-theme.css) and the Copilot sidebar
chat (hanzo-copilot.css) to the canonical monochrome design system — no color
accent. Accent tokens map to neutral (icons/links/active #ededed, primary
buttons white-on-dark with #111 text like the Home CTA, selected/checked
neutral gray, focus ring #8a8a8a). Semantic status colors are unaffected.

Copilot default model 'zen' -> 'enso' (the Hanzo flagship assistant) —
Enso default everywhere, including Studio.
2026-07-24 17:14:30 -07:00
Hanzo AI c20b6b6fa5 studio: real org/user/project switcher + @hanzo/gui design tokens on Home
Collapse the Studio Home account pill's three overlapping mechanisms (static
#acctmenu, dynamic #omenu, two competing #user handlers) into ONE identity
popover rendered through the same openMenu the composer pills use. It shows who
you are (avatar/name/email) and switches organization AND project against the
same /v1/session API the node editor uses (re-scopes tenancy + output paths on
reload). Pill now reads avatar · name · org · caret.

openMenu gains a headNode + per-item {on,href,act} so one popover serves the
composer pills AND the identity menu (backward-compatible — existing pill call
sites unchanged).

Snap the palette to the canonical neutral Hanzo design system (hanzo.app /
hanzo.ai): page #000 · surface/popover #0a0a0a · raised/hover #1a1a1a · border
#1f1f1f · text #ededed · dim #888 · brand/CTA #fff · ring #333. Every dropdown
panel (.pmenu, .ovfmenu) uses the one canonical menu spec: 10px radius + the
two-layer shadow. Fix the latent feedback-bubble contrast (white-on-white).
2026-07-24 17:00:07 -07:00
Hanzo AI 09dc7af5ab test(studio): split semicolon statements — green the ruff gate on main
The Python Linting gate (ruff check .) had been RED on main across multiple
commits: 5x E702 (multiple-statements-on-one-line) in studio_home_fix_test.py.
Split each `x = ...; x.write_bytes(...)` onto two lines. Behavior identical
(19/19 pass); no other files affected.
2026-07-24 16:58:10 -07:00
Hanzo AI f9ea2d2eda merge: studio composer dropdowns + unified Chat → main
Ships fix/studio-chat-and-dropdowns: real Design/Template/</>/Model dropdowns on
the /studio composer and Chat unified on the @hanzo/ai contract, with the three
review mustFix resolved (fail-closed pk- allowlist, chat stream→copilot fallback,
committed guard + SSE parser tests). No file overlap with the marketing-shell /
native-deploy work already on main.
2026-07-24 16:50:07 -07:00
Hanzo AI d75983c437 studio: fail-closed pk allowlist + chat stream fallback + parser/guard tests
Resolves the three review mustFix before a publishable key is provisioned to the
studio pod (the next deploy step), which flips on the streaming path and relies
on the key guard:

- M1 (security): _publishable is now an ALLOWLIST — emit only `pk-` (the
  gateway's single client-safe prefix, iamauth.go:472-476). The old denylist
  missed fw_/hz_/JWT server secrets and failed OPEN, leaking them into
  window.HZ.pk in page source.
- M2 (robustness): the /studio Chat stream degrades to the same-origin copilot on
  any transport failure (fetch throws / non-2xx / no body) instead of
  dead-ending, so a PK provisioned before the gateway allows CORS+Authorization
  for studio.hanzo.ai can't hard-break Chat. SSE parse decomplected into pure
  sseLines/sseDelta; stream behavior otherwise identical.
- M3 (coverage): commit publishable_test.py (allowlist + <script> break-out incl.
  U+2028/9) and sse_parser_test.py (runs the SHIPPED shell.js parser under node).

103/103 studio_home_test, 207/207 middleware_test green.
2026-07-24 16:49:17 -07:00
Hanzo AI 131081184a studio: real composer dropdowns + unify Chat on the @hanzo/ai contract
The "What should we create?" composer's four pills were broken: Design
system and Template were stub toasts / a tab-jump, </> was dead, and Model
was a hardcoded "Opus 4.8" with no handler. Replace all four with ONE
monochrome popover (open · real options · select · Esc/outside-close ·
keyboard), wired to real sources:

- Design system → the org's real designs (the SAME FILTER the gallery chips
  drive; pill and chips stay in sync via paintChips — one state, two controls)
- Template → the KIND catalog, grouped, reusing pickTemplate/clearLane
- </> → a read-only request preview from composerPlan(), the ONE planner
  create() now also executes (no drift; billable dispatch behavior unchanged)
- Model → the real catalog from api.hanzo.ai/v1/models, curated to the
  Studio set (enso · zen · image/video), shown only where the id truly
  exists upstream; default enso, persisted (hz_model), drives the Chat model

Chat sidebar (shell.js): stream enso from api.hanzo.ai/v1/chat/completions
(OpenAI-compatible SSE) when a PUBLISHABLE key is present, else the secure
same-origin copilot fallback (httpOnly cookie; keeps graph ops in the
editor). "Powered by Hanzo AI" footer; model follows the composer's pick.

Config injected as window.HZ from env (NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY):
_publishable() fails closed — refuses to hand the browser anything that
looks like a server secret (sk-/hk-/secret*), so a misconfig drops to the
copilot path rather than leaking. No secret ever reaches page source.
2026-07-24 16:17:59 -07:00
Hanzo Dev caeef70dba ci: neutralize cicd.yml → sync-notice; native deploy is .hanzo/workflows/deploy.yml 2026-07-24 15:23:52 -07:00
Hanzo Dev 6fb9cfc956 ci: native Hanzo deploy pipeline (BuildKit → ghcr.io/hanzoai/studio → operator reconcile) 2026-07-24 15:23:45 -07:00
zeekay 79bbe7cfc6 merge: unified Hanzo shell on studio.hanzo.ai logged-out page 2026-07-23 22:02:16 -07:00
zeekayandClaude Opus 4.8 3893495338 feat(login): adopt unified Hanzo shell on the logged-out page
Logged-out studio.hanzo.ai now renders the canonical HanzoHeader (Sign in -> same-origin /login) above the sign-in card, plus HanzoPreFooterCTA + HanzoFooter (currentProductId=studio) below it. server.py serves the bundle via a guarded /marketing static route (mirrors /docs); assets are public (.js/.css) so they load pre-auth. The sign-in card stays as the progressive-enhancement fallback -- auth path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:14 -07:00
zeekayandClaude Opus 4.8 7275e1c5eb build(marketing): add built unified-shell bundle (studio.hanzo.ai surface)
Committed output of web/shell (pnpm build). Production React, 172kb; served at /marketing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:07 -07:00
zeekayandClaude Opus 4.8 3eb2d90971 build(marketing): add web/shell esbuild package for the unified Hanzo shell bundle
Bundles @hanzogui/shell (HanzoHeader + HanzoPreFooterCTA + HanzoFooter) + @hanzo/brand + React into one self-contained ESM asset. Only the three logged-out components are imported so the signed-in graph (@hanzo/iam) is tree-shaken away. node_modules gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:03 -07:00
Hanzo AI 9df0c317b7 studio: framed unauth navigation gets a same-origin connect card, not the IdP 302 — makes the console's Studio embed work (the IdP refuses framing) 2026-07-21 21:51:04 -07:00
Hanzo Dev 160b1913c2 merge(chore/fork-hygiene-notice): consolidate onto main 2026-07-21 17:18:58 -07:00
Hanzo AI 8383230a98 studio: Developers workbench dock in the shared shell — Queue · Logs · read-only API Shell (both views) 2026-07-21 15:56:09 -07:00
Hanzo AI 481add2183 dispatch: retry enqueue across the cloud tasks-backend deploy gap
The render queue (/v1/tasks/*) is served by the single-pod cloud binary (Recreate
on an RWO SQLite volume), so every cloud deploy leaves a ~30s window where Traefik
has zero Ready endpoints and returns 503 'no available server' for the enqueue —
surfacing as 'GPU render worker is momentarily unavailable'. Retry the enqueue
across that transient gap (503 / connection-refused only — the request never
reached the backend, so re-sending the idempotent activityId can't double-enqueue;
a 4xx or timeout still falls straight through). ~32s ladder covers a same-node
Recreate. 3 tests: retries-through-503, no-retry-4xx, no-retry-timeout.
2026-07-21 15:51:14 -07:00
hanzo-dev cd92ea2a6c fix(worker): mount /v1/worker/execute BEFORE add_routes()
In worker-mode the coordinator seam POST /v1/worker/execute was appended to
the route table AFTER prompt_server.add_routes() already mounted it (incl. the
SPA catch-all), so the POST route never registered — the catch-all answered
GET/HEAD and the worker got 405, every claimed render FAILED. Register the
worker routes before add_routes(). Verified: gpu:spark smoke render now
SCHEDULED→STARTED→COMPLETED via the gated seam.
2026-07-21 15:33:32 -07:00
ce491d2848 feat: per-GPU gpu-jobs queue + worker-mode gate (no hidden runs) (#30)
Make EVERY studio render flow through the org's visible gpu-jobs queue and
eliminate the direct-to-127.0.0.1:8188 bypass. Studio-side only (cloud CLI /
worker two-lane claim owned by the cloud agent).

- gpu_dispatch: per-GPU targeting. X-Target-GPU -> taskQueue "gpu:<identity>"
  (namespace stays gpu-jobs); an explicit target always enqueues. Untargeted =
  shared "gpu-jobs" lane.
- server.post_prompt: in --worker-mode refuse a direct /prompt POST without
  X-Worker-Token (403) via worker_client.reject_untrusted_worker_submit (one
  policy gate). The only accepted execution is a claimed job over
  /v1/worker/execute. Legacy prompt_router push is OFF unless
  STUDIO_LEGACY_PUSH_ROUTER=1 -> gpu-jobs enqueue is the ONE submit path.
- worker_client: /v1/worker/execute validates X-Worker-Token, runs the graph on
  the local prompt_queue, scopes outputs to the job's org, returns {prompt_id}.
- studio_home: /v1/render-queue + /v1/nodes are AUTHORITATIVE — read the cloud
  tasks engine's gpu-jobs activities (real claiming identity, status ->
  queued/running/done/failed, lastHeartbeatTime, per-node depth). render_jobs.json
  is only a ~2s pre-claim placeholder. X-Target-GPU forwarded through _queue_prompt.
- UI: "Run on <gpu>" chips (studio_home.html/shell.js) set X-Target-GPU; in-flight
  rows show the REAL worker + queued/running state.

Tests: gpu_dispatch_test (targeting), worker_seam_test (gate 403 + claim seam).
378 passed. Follow-up (cloud agent): content/studio_render.go:86 convergence.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-21 14:14:23 -07:00
Hanzo AI 537149fd36 studio: rate/comment from the full-image view + archive trains the model
Two flywheel surfaces the gallery already had but the asset views did not.

1) Full-image (zoom) rating bar. Opening any asset full-size now shows the same
   👍/👎/💬 controls as the cards, bound to the asset in view (ZPATH threaded
   through every zoom entry — grid, queue list, stack view, openLibItem). 👍/👎
   toggle + persist via the existing castVote; 💬 opens the existing critique
   chat. Note-less; reuses the proven .vote classes. Verified on desktop + mobile
   (Playwright): centred pill, correct up/down highlight toggle, critique dialog
   opens, 44px tap targets, safe-area bottom inset, Comment label no-wrap.

2) Curation IS training signal. Archiving or flagging an output (the ONE
   /v1/library/status route every archive path funnels through — queue Archive,
   gallery delete, stack delete) now records it as a NEGATIVE example: vote -1 in
   ratings.json + an event in feedback.jsonl carrying the deleted/flagged status
   (negative-only downstream). Restoring clears that auto-negative. The downvote
   is NOTE-LESS so it trains the reward dataset without polluting prompt lessons
   (_mistakes gates on note text). A judgement the user typed is user-owned —
   never overwritten or cleared by the auto-negative. Automatic dedup writes the
   library directly (not via the route), so byte-identical twins are never
   mislabelled bad. The queue button is now an explicit "🗑 Archive" and says it
   teaches the model.

Decomplect: one atomic ratings writer (_rate_write, unique-temp — kills the
shared-name clobber the fixed ratings.json.tmp had, same hazard fixed for
library.json) shared by the rate route and the archive hook. Tests: archive →
vote -1 + feedback event + status=deleted + no injected lesson; restore clears
it; a user's downvote+note survives archive/restore and stays a lesson. 268
studio_home + middleware tests pass.
2026-07-19 15:07:45 -07:00
Hanzo AI 2f73744113 studio: content-address staged edit sources + lifecycle trace (state isolation)
Trace of the whole Edit lifecycle (click -> /v1/library/fix -> dispatch_fix ->
_fix_graph -> gpu_dispatch -> worker LoadImage) confirmed the selected asset is
bound explicitly at every hop and the low-denoise Edit graph is guarded by
_assert_edit_graph (no EmptyLatentImage / text-to-image fallback). The one
residual state-isolation hole was the staged source FILENAME.

Every lane (fix/video/model3d/compose/amend) staged its source under
    f"{kind}_src_{abs(hash((str(p), mtime))) % 10**10}.png"
— Python's process-salted hash, duplicated in 5 places. Two concurrent edits of
DIFFERENT sources whose names collided on a shared worker input dir would
overwrite each other's LoadImage file: the "Edit produced an unrelated picture /
mixed a different image" class of bug.

Decomplect to ONE helper, _stage_source(): the staged name is the sha256 of the
source bytes. The name IS a verifiable checksum of the exact selected image, so
the bytes the worker loads can never drift to another asset, and different
sources are cryptographically un-collidable (same source -> one idempotent
file). 5 call sites -> 1; the 3D downsize rides an optional max_px.

Observability the report asked for: a _trace() line correlated by job id at
build -> queue -> gpu.publish (studio.home + studio.gpu_dispatch). Logs ids,
content-address checksums, counts, lane/node ONLY — never signed URLs, tokens,
cookies or image bytes.

Tests (pure/fs, no torch/GPU/net): content-addressing (same bytes->same name,
one byte flips it, name==sha256); two concurrent edits share no staged file /
LoadImage ref / positive prompt / latent / output prefix; _collect_input_images
ships the selected org's exact bytes and is org-scoped; _assert_edit_graph
rejects a text-to-image graph. 188 middleware tests pass.
2026-07-19 13:46:12 -07:00
Hanzo AI b6f6186a39 studio: update the middleware_test fix-graph tests to the low-denoise Edit design
The CI 'Test (per hanzo.yml)' step failed because tests-unit/middleware_test/
studio_home_fix_test.py still asserted the OLD fix behavior (denoise 1.0, the
'same model, pose, framing' prompt, and the removed size=→EmptySD3LatentImage repose
branch). Updated to the new invariants: denoise == EDIT_DENOISE_DEFAULT (0.25), the
new preservation prompt, and — replacing the repose-Empty test — a
test_fix_graph_never_uses_empty_latent that proves Edit NEVER falls back to an empty
latent for any instruction (repose is now Regenerate, not Edit). Full CI set: 375 pass.
2026-07-19 13:26:00 -07:00
Hanzo AI 3e50f9414e studio: generated-image Edit is a low-denoise edit of the SOURCE, never a regenerate
ROOT CAUSE: 'Fix' on a generated gallery image ran the Qwen edit at denoise 1.0 with a
RANDOM seed (full re-paint → color/composition/subject drift even for 'brighten very
slightly'), and a broad _REPOSE keyword regex silently swapped in EmptySD3LatentImage
(a literal text-to-image regenerate) on words like 'standing'/'looking'/'side view'.

THREE DISTINCT ACTIONS now, no ambiguity:
- Regenerate = Re-run (/v1/library/rerun: re-queue the recipe, new seed) — the only
  path that may use EmptyLatentImage + denoise 1.0.
- Edit = low-denoise Qwen edit of the SOURCE: LoadImage → FluxKontextImageScale →
  VAEEncode → KSampler(latent = the source, denoise from an edit-strength slider,
  default 0.25) → VAEDecode → Save. Reuses the source's ORIGINAL seed (read from the
  embedded graph via _provenance) so a locked seed is deterministic; a varied seed
  still edits the source. NO _REPOSE→Empty branch. Full-res source (never the thumb).
- Edit selected area = masked inpaint: SetLatentNoiseMask restricts sampling to the
  painted region + ImageCompositeMasked keeps pixels OUTSIDE the mask byte-identical.

INVARIANT (impossible-by-construction): _assert_edit_graph refuses any edit whose
KSampler.latent_image is an EmptyLatentImage or whose denoise leaves the edit band
[0.05,0.95] — an Edit can NEVER silently become a Regenerate; a bad edit surfaces an
inline error, never a fallback to random generation.

Frontend: Fix dialog → 'Edit image' with an edit-strength slider (10–95% → denoise,
default 25%) + a paint-the-area mask canvas (Edit area) + clear labels (gallery card:
Regenerate · Versions · Edit). sendFix passes {strength, seed?, mask?}. Output linked
as a child (parents=[source]); color-match post-process (v0.17.9) still runs.

Tests: 6 new edit-invariant tests (VAEEncode-not-Empty, denoise 0.25, regenerate
rejected, seed determinism, prompt-preservation-not-expansion, masked composite);
existing fix test updated 1.0→0.25. 42 pass. Model-compatible with the Qwen edit model
already on spark (kept per the user's choice); I did NOT submit any live render to
verify visuals (per the no-autonomous-image-creation rule) — the graph structure is
unit-proven and the user validates the visual output.
2026-07-19 08:10:36 -07:00
Hanzo AI c3ecffd4cd studio: delete queue/history items — remove render requests you didn't want
You can now delete any queue entry (a cancelled probe, a failed attempt, clutter) —
a 🗑 on every non-active queue row + a Delete button in the item detail dialog.
worklog.remove(org, ids) + POST /v1/worklog/delete drop ONLY the log rows; output
images live in the library and are archived/deleted there (unchanged). Optimistic +
idempotent. Pairs with the earlier Queue archive (output→Archive) and the retry-cap +
dispatch-dedup that stopped the workflow from re-running/duplicating a render in the
first place. Verified: remove by id + bulk + idempotent-missing.
2026-07-19 07:44:51 -07:00
Hanzo AI 7d564eaa40 studio: gallery shows only PUBLISHED assets; ✎ Drafts chip to curate the rest
The Assets gallery (and every design filter) now shows only status=published — a
curated view. Nothing is deleted: draft + approved items stay in the library,
reachable via the new ✎ Drafts chip (count shown), where each keeps its
Approve/Publish/Fix tools so you can publish it into the gallery. Empty published
view shows a 'open Drafts to publish' hint instead of looking like work vanished.
Filter predicate verified: all→published-only, design→published-of-design,
drafts→all unpublished (excl. deleted/flagged).
2026-07-19 06:51:48 -07:00
Hanzo AI 436a965fa0 studio: thumbnails set Content-Type image/webp — fix blank grid ('assets won't load')
The WebP-thumbnail change served the .webp via FileResponse WITHOUT an explicit
Content-Type. aiohttp guesses from the extension via Python's mimetypes, which
does not know .webp, so every grid thumbnail came back application/octet-stream —
which the browser refuses to render as an <img>, so the whole gallery went blank.
The full-image path was unaffected (it sets Content-Type explicitly). Set
image/webp on the thumb response (and image/jpeg on the video poster for
consistency). Regression test pins it.
2026-07-19 06:33:17 -07:00
Hanzo AI d278c03f80 studio: org-aware empty state — 'wrong workspace' not 'my work vanished'
Root-caused 'can't see any assets': it's org resolution, not lost data. The active
org lives in the studio_active_org cookie; a fresh re-login clears it, dropping the
session to the token's home org — and if that resolves to 'default' (doesn't exist)
or an empty org (maxpower=0), the gallery is blank. The user's work is intact in
karma (416 visible) + hanzo (130). Verified the render path is sound at full scale.

Fix the confusion at the source: the empty gallery now NAMES the current workspace
and points at the org switcher (account pill, top-right) — so landing on the wrong
org reads as 'switch workspace', never 'my assets disappeared'. Also distinguishes a
no-search-match empty from a truly-empty workspace.

Verified: empty maxpower org shows 'No assets in maxpower yet — switch from the
account menu'.
2026-07-18 23:14:09 -07:00
Hanzo AI 141c4b3fc9 studio: assets/queue resilience — no silent blank, no year-long cache trap
Investigating 'can't see assets': verified the gallery renders all 416 karma cards at
full scale (1300 assets) with thumbnails painting on scroll, so the render path is
sound. Two real fragilities hardened + one cache trap removed:

- Thumb cache was 'immutable, max-age=1yr' (v0.17.17). Since the ?v=sha URL already
  busts on change, immutable bought nothing but risked locking a single bad thumb
  response in the browser for a YEAR (un-clearable without hard-refresh). → long but
  REVALIDATABLE (max-age=1wk, stale-while-revalidate), so a bad response self-heals.
- runs() did (await fetch(x)).json().catch() — catches the json reject but NOT a
  fetch reject, so a failed ratings/favorites fetch threw before rgrid rendered →
  blank queue. Now each non-critical fetch is fully guarded.
- load() now shows 'Couldn't load your library — retry' on a transient /v1/library
  failure instead of a silent blank gallery.

Happy path verified unchanged (416 cards, queue renders).
2026-07-18 17:37:21 -07:00
hanzo-devandz f5e7483570 style(studio): clear pre-existing ruff E702/F841 in 3D + video tests
`ruff check .` (the Python Linting gate) had been red on main since the
3D-lane commit: E702 semicolon-joined statements in
studio_home_model3d_test.py and an unused `root` local (F841) in
studio_home_video_test.py. Split the statements onto their own lines and
drop the dead assignment. No behavior change; both suites stay green.
2026-07-18 17:31:25 -07:00
hanzo-devandz d45067db4d fix(studio): anon /studio nav → 302 /login, not a raw 401
The IAM auth gate refused any unauthenticated request whose Accept lacked
`text/html` with a 401, so a plain browser/curl navigation to a protected
page (e.g. /studio) hit a dead 401 instead of the login flow.

Invert the rule and decomplect it: a top-level browser navigation is now
redirected (302) to the one server-owned login entry point,
`/login?next=<path>` — which starts the OIDC code flow and returns the user
where they were. Only a request that self-identifies as API/XHR/JSON
(Sec-Fetch-Mode != navigate, X-Requested-With, or Accept: application/json
without HTML) still gets 401, so the SPA's own fetch calls handle re-auth
instead of following a redirect into HTML. An ambiguous client (plain curl,
Accept: */*) counts as a navigation and is redirected — a human never sees a
raw 401. handle_login honors a sanitized ?next (open-redirect/loop-safe).

Bump 0.17.18.
2026-07-18 17:31:25 -07:00
Hanzo AI 4b128ccfc8 studio: GPU banner never sticks on 'connect a GPU' — cache only a positive
/v1/gpu-status returns online reliably (verified live), but gpuOnline() cached
BOTH true and false for 15s. When a worker's heartbeat blipped for a few seconds
(spark flapped tonight on a local-network issue), the banner cached 'no GPU' and
held it — showing 'connect a GPU to generate' even with GPUs 3/3 in the chip.
Now only a POSITIVE is cached; a negative re-checks every call, so the banner
self-heals on the next 5s livebar tick.
2026-07-18 11:18:16 -07:00
Hanzo AI 20a433678c studio: faster + lighter image loading — lazy thumbs, WebP, immutable cache
Four wins across gallery + queue + stacks:
1. LAZY: cards no longer eager-load every thumbnail (a 1000-image gallery fired 1000
   requests up front). Each .im carries its URL as data-bg; an IntersectionObserver
   (rootMargin 600px) paints it only as the card nears the viewport → a screenful of
   requests, not N. Falls back to eager where IO is unavailable.
2. WEBP: thumbnails are now WebP q80 instead of JPEG q82 — measured 45% smaller.
3. QUEUE was loading FULL-RES outputs (vURL) as row backgrounds — now uses the same
   downsized thumbnail as the gallery (huge cut on the History tab).
4. IMMUTABLE CACHE: thumbURL is versioned by content sha (?v=, from the indexer) or
   mtime, so a thumb is served Cache-Control: immutable, max-age=1yr — instant on
   repeat loads; a re-render gets a new sha → new URL → never stale.

Verified: lazy observer paints in-view cards + defers off-screen; WebP 23KB vs JPEG
41KB on a sample; ?v= present on thumb URLs.
2026-07-18 11:08:36 -07:00
Hanzo AI c474161fba studio: Queue view — fullscreen lightbox + ←/→ nav, 👍/👎 + reject on outputs
On Queue & History, per the ask:
- Click a photo → fullscreen (already opened zoom, now with navigation). Generalized
  the lightbox: zoomList(list,i) carries the queue's outputs so the on-screen ‹ › AND
  the keyboard ← / → step through them (wraps both ways); Esc closes. Stack view still
  uses svStep — zStep() routes to whichever context is open.
- 👍/👎 + 💬 on each output — the SAME rate endpoint + feedback.jsonl training log as
  the gallery, keyed by the output's library path (a queue vote == a gallery vote).
- 🗑 Reject → Archive — soft-deletes the output (⌘Z-undoable), dims the row + marks it
  ✓ Rejected. Reuses the one status/undo path; no new lifecycle.

Each queue output resolves to its library asset (path = subfolder/filename), so it
reuses the gallery's vote/archive machinery — one way to do everything, no duplication.
Verified headless: lightbox a→b→c + wrap via ←/→ and the arrows; 👍 logs training
feedback (stats up=1); reject archives + ⌘Z toast.
2026-07-18 10:47:53 -07:00
Hanzo AI e16131b0b6 studio: manifest indexes .glb — 3D meshes survive the sweep (kind=model3d)
The build-manifest sidecar scanned only image/video/marketing extensions, so a
.glb was dropped on every rebuild — the ingested mesh vanished from the library
within a sweep cycle (same class as the earlier video/ingest drops). Add
MODEL3D_EXTS to the scan and the kind default.
2026-07-18 10:02:49 -07:00
Hanzo AI 2a5bb8a71d studio: reliable dispatch with large inputs — cap 3D ref at 1536px, raise enqueue timeout to 90s
A 4k catalog master (~20MB) inlined to the gpu-jobs enqueue overran the 15s
urlopen timeout, so dispatch_if_worker fell back and the model-less pod returned
'GPU worker momentarily unavailable' on a perfectly good render — for EVERY lane,
not just 3D. Two fixes: the 3D lane downscales its staged reference to 1536px
(Hunyuan3D works at ~518px internally, so the mesh is identical and the body is
small), and the enqueue upload timeout goes 15s→90s so large fix/compose/video
references actually finish uploading.
2026-07-18 09:59:13 -07:00
Hanzo AI 94e0c4e685 studio: job-detail UX — big result preview (image/video/3D), mobile bottom-sheet
The queue/history detail showed the render as a 60px thumbnail — you open a job
to SEE the result. Now the result is a large hero: an image (zoom on click), a
video (inline player), or an interactive rotatable <model-viewer> for a .glb.
References move below as a labelled small row. On phone every dialog becomes a
full-width bottom sheet (safe-area padding, scrollable, stretched action
buttons), the hero caps at 48vh so it fits, and queue-row thumbs grew 60→74px.
2026-07-18 09:57:46 -07:00
Hanzo AI 100ca8d8a2 studio: structural fix — duplicate renders impossible by construction
The retry-cap + dispatch-dedup patches stop the two known triggers; this makes the
whole class impossible at every layer, independent of any upstream bug:

COMPUTE — don't render a duplicate:
  * retry cap (maximumAttempts=1): a failed render never re-runs itself.
  * dispatch dedup: identical (org, prefix, instruction, refs) submits within 60s
    collapse to the in-flight render.

QUEUE — nothing lingers to replay:
  * scheduleToStartTimeout=1800s: a job no worker picks up in 30 min EXPIRES, so a
    worker outage can't leave a backlog that silently re-bills when a worker returns.

STORAGE — the gallery is CONTENT-ADDRESSED (the last line of defense):
  * library_manifest (the one indexer that builds every library.json, sweeping each
    org continuously) now enforces AT MOST ONE active asset per content hash. Any
    byte-identical duplicate is auto-soft-deleted, keeper = a curated asset if one
    exists else the earliest. Idempotent, recoverable, and O(new-files) per sweep
    (sha reused when mtime is stable). So even 873 identical re-renders can NEVER
    accumulate in the gallery — the invariant heals it within one sweep.

Verified: 3 identical + 1 distinct -> 2 active (one-per-hash), distinct untouched,
stable across sweeps.
2026-07-18 09:56:13 -07:00
Hanzo AI c088a3833c studio: 3D lane — image→.glb mesh (Hunyuan3D-2.1) over the shared library/queue/flywheel
Third suite content type. Proven node-for-node on spark from ComfyUI's own
3d_hunyuan3d-v2.1 template: ImageOnlyCheckpointLoader (the ONE bundled
model+CLIP-vision+VAE — separate loaders produced noise) → CLIPVisionEncode →
Hunyuan3Dv2Conditioning → ModelSamplingAuraFlow(shift 1, the flow-matching
wrapper WITHOUT which the sampler yields fragments) → KSampler → VAEDecodeHunyuan3D
→ VoxelToMesh(surface net) → SaveGLB. Verified: a clean 3D garment mesh from a
ghost-mannequin shot (341k faces, coherent).

Lane contract, same as video: _model3d_graph + POST /v1/library/model3d (ONE
reference image required, no text-to-3D, unique job tag); .glb ingest (glTF
magic bytes, model/gltf-binary CTYPE, kind=model3d); an interactive <model-viewer>
zoom card (grid shows a 🧊 3D badge, click to rotate); the 'Image → 3D' template.
No new infra — one library, one queue, one wallet, one GPU fleet.

Tests: proven graph, dispatch route (org-from-token, unique tag, one required
ref), .glb ingest. All studio suites green.
2026-07-18 09:27:33 -07:00
Hanzo AI 1671b21334 studio: video lane verify-fixes — reset composer lane after video; one saver list
Adversarial review (contract lens) blocked on a real bug: the composer KIND flag
stayed 'video' after a video dispatch, so the next single-photo generate silently
POSTed /v1/library/video (image-to-video) instead of /v1/library/fix — a wrong,
billable GPU dispatch. clearLane() now resets after every video dispatch, and the
Template chip is clickable to return to the neutral edit/compose lane.

DRY (identity lens): the SaveImage/SaveVideo/SaveWEBM triple was hardcoded three
times. Consolidated the two Python copies into gpu_dispatch.SAVE_NODES (the base
module, no cycle) imported by studio_home; the HTML keeps its own JS-runtime copy.

244 pass; XSS esc() + job-identity _retag + library.json lock all intact post-rebase.
2026-07-18 02:07:10 -07:00
studio-implandHanzo AI 3565974382 studio: video lane — generate/card/ingest/judge over the shared library and flywheel 2026-07-18 02:02:30 -07:00
Hanzo AI 193aee3aac studio: fix the library.json curation-wipe race + make /ready check worker liveness (Red HIGH x2)
LIBRARY.JSON CORRUPTION (Red V3, the curation-wipe mechanism): the build-manifest
sidecar wrote a FIXED '.library.json.tmp', shared by every pod's sidecar — two
overlapping during a roll interleaved into one inode → torn JSON → load_prior()/
reader hit JSONDecodeError → {} → every asset reset to status=draft, which mirror
then pushed to S3 and restore rehydrated. Fixed both writers: mkstemp gives each a
private tmp (kills the tear), and a shared flock (.library.lock, taken by the
sidecar across build+write and by the app's _save_library across its write)
serializes the two rebuild-then-replace paths so neither clobbers the other.

/READY LIVENESS (Red V1): the readiness probe checked ,
set once at boot and never cleared — constant-true, saying nothing about the render
path. A dead worker thread kept the pod in-rotation accepting jobs that never ran,
and a roll would promote a wedged pod. /ready now gates on the prompt_worker daemon
thread actually being alive.

Tests: unique-tmp, no fixed-tmp residue, concurrent-writers-never-tear, lock file.
226 pass.
2026-07-18 01:27:54 -07:00
Hanzo AI 9370bf82b2 studio: stop 'the same reject over and over' — dedup dispatch + cap retries
Two root causes of repeated/duplicate GPU renders (money + gallery clutter):

1. NO retry cap: the render activity had heartbeat/startToClose timeouts but no
   retryPolicy, so the tasks framework re-ran a failed render (e.g. spark OOM mid-
   sample) — the same reject re-rendered. Add retryPolicy maximumAttempts=1: one
   attempt, a failure surfaces to the user, no auto re-burn.

2. NO dispatch idempotency: a double-fired/unguarded submit queued a SECOND render
   of the same thing (logs showed one output dispatched 4x in a second). _dispatch
   is now idempotent — an identical (org, output-prefix, instruction, refs) dispatch
   within a 60s window returns the in-flight prompt_id instead of billing a new job.
   A genuinely different instruction on the same source still dispatches.

Server-side, so it covers every submit path (composer, fix dialog, compose, chat).
Verified: 4 identical submits → 1 render; different instruction → allowed; retry
cap present.
2026-07-18 01:07:36 -07:00
Hanzo AI 9d18ad1c75 studio: unique job identity for re-run + template-render; composer double-submit latch
Red-team, same phantom-image class the user hit: re-run replayed the source
graph's own SaveImage prefix, so its file landed in the SOURCE namespace and N
re-runs shared one prefix — the library then showed the newest sibling for every
row (a job displaying a photo it never made). _retag() now points every SaveImage
at a unique per-dispatch prefix for re-run and template-render, the guarantee
fix/compose/amend already had.

Composer create() (↑ button / Enter) had no double-submit guard — a fast double
fired two billable dispatches. Now wrapped in the same enqueue()/QBUSY latch as
the dialog buttons. 223 pass.
2026-07-18 00:39:24 -07:00
Hanzo AI 6fcd119bc8 studio: per-image critique chat + archive from queue + stored-XSS fixes
CRITIQUE CHAT (the ask: chat about a generated photo, to make it better and
train it): the 💬 on any render now opens a conversation ABOUT that image.
/v1/library/critique loads the actual photo so the vision model's reply is
grounded in what was rendered, and EVERY user turn is recorded as the note the
flywheel reads (_mistakes → _lessons), so critiquing here conditions the next
generation of that design. The old sidebar copilot stays graph-only; this is
the image-scoped path. Thread persists per image (reopen to continue).

ARCHIVE FROM QUEUE: done rows in Queue & History get a 🗄 button — archive the
render straight from history (soft, ⌘Z to undo), no need to find it in the grid.

STORED XSS (red-team, launch-blocking): a hostile upload filename like
'<img onerror=...>.png' rendered raw into gallery card HTML and executed for any
org member. Fixed at the boundary (upload name collapsed to [A-Za-z0-9._-]) and
in depth (esc() on the filename text in render(); template Render onclick now
carries only the safe slug, name looked up client-side). svCard already esc'd —
render() and the template card were the gaps.

Tests: critique thread persistence + lesson recording + tenancy + guards;
upload-name sanitization. 222 pass.
2026-07-18 00:36:45 -07:00
Hanzo AI 5ccee8f052 gate: survive reasoning judges — verdict from reasoning_content, last-token rule, unstarved budget
zen-vl became a reasoning model: max_tokens 8 was spent entirely on
reasoning_content, content came back null, the strict parse read nothing and
the gate failed open on EVERY render — flagged reached zero while corrupted
outputs sat in the grid, and the failure logged at an invisible level.

_reply_text now falls back to reasoning_content when content is empty;
max_tokens 768; the verdict is the LAST whole PASS/FAIL token (deliberation
mentions both — position is the conclusion); judge failures log at warning.
2026-07-17 23:10:10 -07:00
Hanzo AI 7d419c2101 studio: rebrand the Editor frontend → Hanzo Studio (no ComfyUI in the UI)
The Advanced-mode node editor is served from the upstream comfyui-frontend package,
whose index still shipped <title>ComfyUI</title> + 'Loading ComfyUI...' — the only
user-facing surface still showing the upstream name (the home + web/dist SPA are
already Hanzo Studio, and the running image is our fork ghcr.io/hanzoai/studio).

editor_index() already injects our shell into that index; add two idempotent swaps
there so the Editor tab title + loading status read 'Hanzo Studio'. The ComfyUI-format
compatibility stays internal, per rule — the name is out of the UI. (Deeper editor
menu/i18n strings live in the minified frontend bundle; a full frontend rebrand is a
separate build-side change.)
2026-07-17 22:55:35 -07:00
Hanzo AI 5087403496 studio: launch batch — product analytics + UX polish + dispatch-test reconcile
Usage tracking: Umami pageviews (analytics.hanzo.ai, pre-existing site id) and
six product events (generate, fix, compose, stack_create, rate, template_use)
via a zero-dependency sendBeacon to insights.hanzo.ai/v1/e — the /v1 prefix is
load-bearing (root /e/ is IAM-gated) and the key is the project's public write
token. Telemetry fails silent.

UX: body clears the floating shell dock (content was hidden under it at page
bottom on phone); stack name input flexes instead of truncating.

Tests: gpu_dispatch tests updated to the has_models_for(prompt) seam
(per-graph model check renamed the old has_local_models).
2026-07-17 22:39:37 -07:00
Hanzo AI d6b888dbd0 studio: ingest owns its dispatch row — prefix-aware has_output kills the duplicate history rows every mirrored render created 2026-07-17 22:18:02 -07:00
Hanzo AI 225658f60f studio: kill 'Prompt outputs failed validation' for real — per-graph model check
Reported again on compose (base + ref). Root cause the v0.17.5 guard missed: the
/prompt intercept fell back to LOCAL validate_prompt whenever dispatch didn't happen
AND has_local_models() was True — but has_local_models() is 'ANY model present', and
the coordinator pod carries a stray SD1.5 checkpoint. So a Qwen fix/compose graph the
pod CANNOT load (no qwen-image-edit unet) still validated locally → 'unet not in []'
→ 'Prompt outputs failed validation'. The stray checkpoint defeated the model-less
invariant.

Fix: replace has_local_models() with has_models_for(prompt) — a per-GRAPH check of
the UNET/checkpoint/CLIP/VAE loader inputs against folder_paths. A box renders locally
only a graph it can actually load; every other graph dispatches to the fleet (or a
retryable 503), regardless of unrelated models on disk. Used in BOTH the dispatch
decision (gpu_dispatch.dispatch_if_worker) and the intercept fallback (server.post_prompt).

Verified: stray-SD1.5 pod → False (enqueue, not local); real Qwen box → True (renders
locally, dev/worker unaffected); model-less pod → False.
2026-07-17 22:17:31 -07:00
Hanzo AI d561e35584 studio: fix the top composer — one create box, ↑ actually generates
Reported: adding a photo to the top 'What should we create?' box opened a SECOND
box at the bottom; only that bottom box worked; typing in the top box + clicking ↑
did nothing.

Root cause: the top ↑ (create()) was a STUB that only toast'd a HIP-0506 placeholder
and never generated, while the '+' funnelled photos into a separate fixed bottom
'Next generation' bar (#reftray/genFromTray) that was the only real generate path.
Two composers, only the second wired.

Fix — collapse to ONE composer (the top box):
- create() now really generates from the top prompt + the photos attached to it:
  1 photo → edit (/v1/library/fix), 2-5 → compose — the same proven graphs the
  gallery uses. Text-only (no photo) gives a clear 'attach a photo with +' hint.
- attached photos render INSIDE the top composer (a thumbnail strip with × to
  remove), fed by the same TRAY (+ / drag / paste / a gallery card's +).
- the bottom #reftray bar + genFromTray are removed (dead once create() is real).
- Enter submits (Shift+Enter = newline).

Verified headless: attach → thumb in the top box, NO bottom bar; type + ↑ → POST
/v1/library/fix {upload, instruction} → Queued.
2026-07-17 22:17:31 -07:00
Hanzo AI d3c5dca63f studio: repose graph mirrors the proven manual family — raw reference into the encoders, no CFGNorm on the free-latent path
Scaling the reference against a differently-sized empty latent re-created the
size-mismatch artifact class (oversaturated drift + reinvented garment,
observed live). Raw ref + AuraFlow-only matches the graphs that delivered the
velvet-front and mesh-back renders. Known limit, verified across two families
and three prompts: a true 90-degree profile from a front-facing photo
reference is beyond the current model — back views and three-quarter turns
render correctly.
2026-07-17 20:58:57 -07:00
Hanzo AI 9062c238e8 studio: the queue detail shows which notes conditioned the job — 'Learning from your notes' from worklog params 2026-07-17 20:24:02 -07:00
Hanzo AI b81974b52f studio: flywheel closes at dispatch — every pointed-out mistake conditions the next generation
Rating an output (👍/👎/💬 on every card) already logs to the per-org training
dataset. Now the retrieval half: at fix/compose/amend dispatch, the mistakes the
user noted on the base and its lineage (👎 or ≤2★ notes, and bare notes) are
injected into BOTH prompt encoders — an avoid clause in the positive, the texts
in the negative — so the very next render on that design cannot repeat what was
pointed out. Praise notes are never injected. The same judgements keep
accumulating in feedback.jsonl (with curation status) for weight training.
2026-07-17 19:21:38 -07:00
Hanzo AI 1c7d66ce34 studio: one output resolution for every view — lineage can no longer attribute a pending job's prefix-matched sibling as a version 2026-07-17 19:16:09 -07:00
Hanzo AI 76af4f30e8 studio: unique output prefix per dispatch + done-only result display — a queued job can never show or claim an earlier run's rejected file
The queue detail showed a prior (rejected) render as a queued job's result:
re-fixing the same source reused the same SaveImage prefix, and every view
prefix-globbed disk regardless of row status. Each dispatch now appends a
6-hex job tag to its prefix (fix/compose/amend), rows expose an output only
once THEY are done, and the workflow route runs the same finalize mechanism.

Also routes view/pose-change instructions (from the side, back view, turn
around…) to the free-latent edit variant: the anchored fix latent encodes
the base — deliberately structure-preserving — and its prompt tail pinned
'same pose, framing', which contradicted exactly these asks. Repose asks now
get EmptySD3LatentImage at the reference's aspect and a tail that frees the
pose while keeping model/outfit/hair/lighting.

Feedback records now carry the judged output's curation status, so a
flagged/archived output is negative-only signal downstream — never a
positive training exemplar.
2026-07-17 19:14:46 -07:00
Hanzo AI 701d166b3f studio: vote + text feedback on renders → training dataset (flywheel)
There was no way to turn user judgement into a training signal — ratings.json fed
only the runtime copilot, on the runs view, as stars. This adds a clear 👍/👎 vote
+ a written note on EVERY render (gallery card + a feedback dialog), and logs each
judgement, immutable, to the org's append-only training dataset.

- 👍/👎 on each image card (one click; click again to clear) + 💬 opens a dialog
  for the written critique ('what should the model do differently?').
- /v1/library/rate extended with a normalized vote (1|-1|0); every rate also
  appends {ts,user,org,key,output,vote,stars,text,prov} to orgs/<org>/output/
  feedback.jsonl — the flywheel the model trainer consumes. The output PNG each row
  anchors to carries its own prompt+refs in its embedded graph, so the row stays
  small and the dataset is training-ready (preference / reward / instruction-tuning).
- GET /v1/feedback/stats (👍/👎/with-text/total) + GET /v1/feedback/export (the JSONL,
  as a download). Toolbar shows '⬇ Feedback (N)'.

ratings.json stays the current-state store the UI reads; feedback.jsonl is the
append-only event log — orthogonal concerns, one rate call writes both. Verified
end-to-end headless: vote registers + stats increment, dialog captures vote+text,
export emits training-ready JSONL; backend unit + full app reboot clean.
2026-07-17 19:01:48 -07:00
Hanzo AI 49c4f28e76 studio: serve webp with its real content type — attached webp references render everywhere 2026-07-17 18:58:14 -07:00
Hanzo AI 21d25458b9 studio: double-queue guard — queue buttons react instantly and latch until the enqueue resolves 2026-07-17 18:58:14 -07:00
Hanzo AI 0fd473923b studio: color-fidelity for Fix outputs — kill the fix-of-a-fix color drift
A Qwen-Image-Edit fix runs at denoise 1.0 → it re-paints EVERY pixel, so the
render's global white-balance/exposure/saturation drift off the source, and the
drift COMPOUNDS across fix-of-a-fix (each fix re-encodes the prior, already-shifted
output). That is why repeatedly-fixed images end up visibly mis-colored.

Fix: restore the render's global color statistics to its source (Reinhard mean/std
transfer, per RGB channel) — the local edit stays, the global cast goes; a render
that didn't drift matches to itself = untouched. Verified: 4 successive fixes hold
Δ→source ~1 with the match vs 6.5→16.5 (compounding) without.

Applied POD-SIDE at worker-render ingest (server.upload_output → colormatch_fix_ingest):
- needs NO node on the GPU worker and touches no render graph → can never make a
  render fail (avoids the 'failed validation' class entirely)
- scoped to fixes/… outputs only (never compose/normal renders), keyed to the
  staged source via the render-jobs record
- fail-safe throughout: any error / missing source / size mismatch leaves the raw
  bytes exactly as uploaded (proven: a save-format bug left files untouched, never
  corrupted)
- strength 0.85 default (STUDIO_COLORMATCH_STRENGTH), leaving headroom for an
  intentional global recolor in the instruction

New middleware/color_fidelity.py (pure algorithm + atomic in-place match);
studio_home.colormatch_fix_ingest + _fix_source_ref resolve org+source.
2026-07-17 18:11:56 -07:00
Hanzo AI 4ac849c28e studio: select circle sits below the archive x — the two corner controls no longer overlap 2026-07-17 17:40:58 -07:00
Hanzo AI 2ee53f67e4 studio: select circle always visible on every card (ghost at rest, solid on hover/selection) 2026-07-17 17:18:15 -07:00
Hanzo AI 5016d2c5d8 studio: rename Deleted → Archive + permanent Delete forever
The soft-delete holding area is now an Archive (recover any item, or the
whole history stays visible). Inside it, permanent removal:

- 🗑 Delete forever per card + bulk on a selection, confirmed via the
  existing <dialog> ask() (irreversible — no ⌘Z can undo an unlinked file)
- 🗑 Empty Archive clears the lot in one confirmed step
- POST /v1/library/purge {paths[]|all}: unlink file + drop record + drop
  cached thumb. Archive-only by design — refuses any asset whose status is
  not 'deleted', so a gallery mis-click can only ever archive (recoverable),
  never destroy. Verified end-to-end headless: per-card/bulk/empty purge,
  the archive-only guard, and rename coverage (tab, count, chips, toasts).

Rename is display-only; the underlying status stays 'deleted', so the
1,259 already-archived items need no migration.
2026-07-17 17:10:21 -07:00
baa40cf16c studio fix: rebuild the fix graph on the proven family + honesty guard + hygiene (#28)
The /v1/library/fix flow degraded images and compounded the damage every pass —
the source of the noise-stipple "bad skin" epidemic across the library. Three
layers, all decomplected.

1. GRAPH. _fix_graph (and _compose_graph) dropped the two
   FluxKontextMultiReferenceLatentMethod nodes — a Flux-Kontext
   `index_timestep_zero` reference method grafted onto Qwen-Image-Edit
   conditioning. It survived on in-distribution studio shots but on real photos
   with large flat regions (sky/water) it baked latent-grid mosaic + skin
   speckle, and each fix-of-a-fix amplified it. What remains is exactly the
   render line's proven-clean family: TextEncodeQwenImageEditPlus reference edit
   into an EMPTY latent at denoise 1.0, model names pinned to the exact files on
   the worker (qwen-image-edit-2511 / qwen2.5-vl-7b / qwen-image-vae). Proven
   e2e on spark: the same input+seed that produced severe mosaic renders clean.

2. HONESTY GUARD. A >=10-step render that lands within 5s of dispatch cannot
   have sampled (a graph that errored into history, stale-history reuse, a
   degenerate self-POST). _finalize_landed — the ONE place a landed render goes
   terminal, shared by the queue poller and the work-log view — marks it FAILED
   ("engine did not sample") instead of delivering it, and skips a matched
   output that predates the dispatch (a same-prefix sibling, not this job).

3. ITERATION HYGIENE. When the chosen fix base is itself corrupt (quality-gate
   flagged, or deleted), _clean_base walks the lineage to the nearest clean
   ancestor and fixes from THAT, and the dialog says so ("Fixing from the
   original — this version is corrupted"). One mechanism in the dispatch path,
   used by both the route and the chat create capability.

Tests: graphs name only worker-present models and carry no Flux-Kontext
reference method; the implausibly-fast guard flips status; _finalize_landed
does done/failed/skip-stale; the ancestor fallback picks the clean root.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 16:55:18 -07:00
Hanzo AI 7ba4956e8a studio: select any image directly — circle on every card enters selection; group delete / stack / move 2026-07-17 16:45:09 -07:00
Hanzo AI 8423eada67 studio: in-page ask dialog — suppressed native prompt()/confirm() silently killed Stack create/rename/tag/delete confirms 2026-07-17 16:43:47 -07:00
Hanzo AI 4ee5adcef9 studio: Dedupe — one-click removal of exact-duplicate images (recoverable, ⌘Z)
The catalog seeder emits byte-identical copies (found 94 dups in one org's 268
visible assets). POST /v1/library/dedup hashes visible assets and soft-deletes
exact-content duplicates — keeps one per cluster, distinct re-renders (different
seeds) untouched — and returns the moved paths. Frontend "Dedupe" button beside
Select; the whole sweep pushes ONE undo entry so ⌘Z restores it all. no-store on
the response. Verified in-browser: dedup hides dups, Deleted count updates, ⌘Z
restores. v0.17.7
2026-07-17 16:19:13 -07:00
9e4d098af2 studio: cloud pod never renders locally — enqueue every render to gpu-jobs (#27)
The org-scoped fleet read is org-blind: a BYO GPU registered under a sibling
org (karma's workers live under hanzo) is invisible to a karma user token, so
dispatch_if_worker saw "no GPU", fell through, and the GPU-less coordinator
self-POSTed its own ComfyUI — an instant "Prompt outputs failed validation"
(node "studio pod"). Composes for karma died on this every time.

Two halves, one mechanism:

1. A model-less cloud coordinator NEVER renders locally. dispatch_if_worker now
   enqueues to the SHARED gpu-jobs lane whenever this box has no models of its
   own, even when the org-scoped fleet read shows none — the job WAITS for a
   worker (workers drain the shared queue regardless of which org they register
   under). A box WITH its own models still renders locally when it sees no
   worker. A queued job that waits beats an instant fake failure.

2. The capacity gate (/v1/gpu-status → the ⚠ warning) is advisory ONLY and now
   reads the fleet with a SERVICE identity (STUDIO_WORKER_TOKEN, then
   STUDIO_COMMERCE_TOKEN), user token as fallback — so every org sees true
   worker availability. Job tenancy is untouched: dispatch still enqueues under
   the user token; only the org-blind gate changed.

Tests: karma-org model-less dispatch enqueues (never local, never on POST
failure); a box with models still renders local; online worker keeps its label;
the advisory gate uses the service identity first and falls back to the user
token.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 16:12:58 -07:00
Hanzo AI c86ded1bad studio delete UX: ⌘Z undo, findable Deleted tab, one-click bulk delete + no-store library
Deletes already persist server-side (verified live: soft-delete → status 'deleted'
holds, zero reverts); the "moved to deleted then restored" was the frontend serving
a cached /v1/library on refresh. Fixes:
- /v1/library → Cache-Control: no-store, so a delete is never masked by a stale copy.
- ⌘Z / Ctrl+Z undoes the last delete (and any status change) — soft-deletes are
  reversible, so undo is just a status write-back. Dropped the bulk confirm dialog.
- 🗑 Deleted is now a top-level tab with a live count — trash is easy to find;
  per-item Recover + bulk Recover (context-aware bulk bar).
- Bulk: Select → pick many → one-click "🗑 Delete selected". Discoverable.
Verified in-browser (Playwright): delete hides the card, ⌘Z restores it, Deleted tab
filters + Recover works. v0.17.6
2026-07-17 16:09:39 -07:00
4c77c82b90 Stacks: Procreate-style gallery folders (per-org SQLite) (#26)
Full Stacks spec on the /studio home surface: create by drag (image→image) or
multi-select; layered Stack cards (cover/name/count/updated); a detail view with
add/remove/reorder/move/set-cover/rename/describe/unstack/delete; the delete
dialog defaulting to preserve; generation 'Save outputs to' routing (fix/compose/
rerun/template) with per-org auto-stack-batch setting; keyboard + touch parity
(no drag-and-drop ever required); a11y labels + live region; loading/empty/missing
states.

Backend: middleware/stacks_store.py — per-org SQLite (WAL, BEGIN IMMEDIATE) with
the spec's stacks + stack_assets join, UNIQUE(asset_id) = one Stack per asset,
tenancy structural (one file per org). Replaces the JSON whole-set /v1/stacks
store with the full REST surface. Ingest assigns membership; remove != delete;
delete-images maps to the library soft-delete. Tests: tests-unit/studio_home_test/
test_stacks.py.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 15:43:32 -07:00
a6c1130b54 studio-chat: Up next / History / Stacks queue surface with ratings + favorites (#22)
* studio-chat: Up next / History / Stacks queue surface with ratings + favorites

The studio-chat SPA's right rail becomes the full YouTube-watch-page surface,
shaped around generative flows (a card = a run in a flow):

- Up next: the live queue (GET /v1/queue/status joined to /v1/worklog) — pinned
  "Next" card + vertical cards, thumb-left with an ETA/elapsed corner badge,
  2-line prompt, node · time · status · position meta, per-card overflow menu.
  Delete (POST /v1/queue/cancel) and Add references (file pick + drag-drop →
  /upload/image → POST /v1/queue/amend) on each queued card.
- History: past runs newest-first (GET /v1/worklog +?q= search), output
  thumbnail, "New" badge on just-landed renders, version pips for runs with
  descendants → a walker over GET /v1/worklog/lineage.
- Rate 0-5 stars + a note ("What should the AI do differently next time?")
  per run — the flywheel row {path, stars, note, ts} keyed by run id.
- Favorite (heart) → offer "turn this into a template" (name it → confirm what
  it captures → POST /v1/templates with a captured flow).
- Stacks: drag one card and HOLD (~600ms) over another to form an iOS-style
  folder; stacked-cards tile + count; click = folder-open reveal; unstack to
  dissolve. Persisted per org.
- Filter chips (All · Queued · Done · Favorites · Stacks), resizable main|rail
  and Up-next|History dividers, per-section collapse, active filter — all
  persisted in localStorage keyed by org+user; "Reset layout" in the rail menu.
- Mobile (<=820px): single-scroll column, rail as the bottom section, composer
  fixed to the viewport bottom with safe-area inset; example-prompt chips; a
  44px account avatar; AA-contrast grays; real send-button state pair.

Middleware (all tenant-scoped via _org_of, one atomic per-org store each):
- GET/POST /v1/favorites (favorites.json) — the ONE favorites store; the legacy
  studio_home.html favorite toggle migrated onto it too (was ratings.favorite).
- GET/POST /v1/stacks (stacks.json) — normalized, >=2 members, bounded.
- POST /v1/library/rate: carries `path` for the flywheel; favorite removed
  (now its own store — one way).
- POST /v1/templates: accepts a `flow` payload, not only a node graph.

Tests: tests-unit/studio_home_test extended (favorites, stacks, rate stars+
notes+path, flow templates) — tenancy proven on each; added to the hanzo.yml
unit gate. web build green.

* studio: self-heal stale-asset dispatch — refresh + "pick it again", never strand

When the item a user clicked was deduped/renamed/cleaned since their page loaded,
a fix/compose/rerun dispatch 400s "unknown asset: <path>" and the raw error left
them stuck. One self-heal wherever a dispatch happens:

- Legacy studio_home.html: healStale(j) — on an unknown-asset error, close any
  open dialog, drop the stale selection/tray, re-fetch the library (load()), and
  toast "That item changed — library refreshed, pick it again." Wired into the
  fix + compose (sendRef), compose (sendCompose), and rerun (bulkFork) branches.
- studio-chat SPA rail: heal(e) — a stale run (unknown asset / not found / no such
  job / already finished) on cancel or amend refreshes the queue and shows "That
  run changed — refreshed, pick it again." instead of the raw error.

* rail: bigger card thumbnails + a visible × on every card (cancel / soft-delete)

- Thumbnails grow to ~half the card width and a taller 4:5 crop (44% on mobile)
  so the render is actually legible in Up next and History.
- Every card gets an always-visible × (fast path) beside the ⋯ overflow menu —
  ONE shared handler, no second mechanism: a queued run cancels immediately
  (POST /v1/queue/cancel); a finished result is a destructive soft-delete
  (POST /v1/library/status status=deleted) and asks first with a small inline
  confirm. History hides soft-deleted results (optimistic + reconciled from the
  org's deleted set via /v1/library) so the delete persists across reloads.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 15:28:42 -07:00
Hanzo AI a363751352 studio: dispatch resilience — kill the intermittent "failed validation" on fix
The /prompt intercept sends every render to the org's GPU worker, but a
transient fleet-check fault (/v1/machines 502 / TLS reset / timeout) made
dispatch_if_worker return False, so the render fell through to LOCAL
validation on the model-less cloud pod → the cryptic "Prompt outputs failed
validation" (unet_name '…' not in []). That's why Fix failed only *sometimes*.

- _fleet_machines: retry 3x with backoff so a transient hiccup isn't read as
  "no GPU" and bounced onto the model-less pod.
- has_local_models() + intercept: on a model-less coordinator pod a failed
  dispatch now returns a clear retryable 503 "GPU render worker is momentarily
  unavailable — please retry", never the doomed local validation. A box WITH
  models still renders locally (dev). v0.17.5
2026-07-17 15:17:26 -07:00
99919db60d gate: corrupted renders never surface as library items (#25)
Every render lands in the library through the one ingest choke point,
POST /v1/library/upload -> _index_upload. The moment that creates a NEW
asset row, a quality judgment fires out-of-band (aiohttp task, never
blocks the upload response): the just-stored image is downscaled to a
~512px JPEG and posted to the cloud vision judge (zen-vl) for a strict
PASS/FAIL on the observed corruption classes -- mottled/blotchy/speckled
skin, ghosting/double-exposure, frame-within-frame borders, glyph garbage.

A clear FAIL moves the asset draft -> flagged (a new first-class status
with deleted's visibility semantics: excluded from every default grid,
recoverable) and annotates its work-log row. Everything else -- PASS,
inconclusive, 402/429/timeout/unreachable, unreadable image -- leaves the
render a normal draft. The gate fails open: it can only ever hide clearly
broken work, never good work, and never blocks a render from landing. A
late verdict only ever transitions draft, so it can't yank curated work.

- middleware/quality_gate.py: the mechanism (judge prompt, strict verdict
  parse, downscale, fail-open run, fire-and-forget schedule). Same bearer
  the upload carried -> tenant-isolated cloud billing, one auth path.
- studio_home: _STATUSES gains flagged; _index_upload reports new rows;
  the upload route schedules the gate on a newly-surfaced render.
- worklog.add_event: annotate a completed row without touching its status.
- studio_home.html: flagged hidden from normal grids, a Flagged review
  filter, Restore -> draft.
- /v1/library/status accepts flagged (flag + restore). Sweeper preserves
  and tallies flagged like deleted (verified).

Config: STUDIO_GATE=off kills it; STUDIO_GATE_MODEL (default zen-vl);
STUDIO_GATE_URL override for local engines. No other knobs.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 15:13:30 -07:00
fac9880aaf library: hidden segments are never assets (#24)
The everything-indexes sweep pulled .thumbs/ cache files in as rows; fixing
one sent a thumb path into the dispatcher (unknown asset). One rule, both
writers: any path segment starting with a dot is excluded — forks and cache
trees alike.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 13:59:52 -07:00
bb3711eca1 manifest: taxonomy enriches, never gates (#23)
The sweeper rebuilt library.json and DROPPED every image classify() could
not parse — erasing ingest-indexed renders on each cycle (rows appeared,
then vanished minutes later). Unclassifiable images now keep a minimal row,
prior fields preserved.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 13:17:47 -07:00
Hanzo AI 86e301345f studio /studio: serve the rich home (gallery remove/edit/+ refs), not the minimal SPA
The web/dist chat SPA shadowed studio_home.html at /studio, dropping the
per-image affordances: delx (remove → POST /v1/library/status deleted),
the Fix dialog (type-and-edit → /v1/library/fix), and the + reference tray
(→ /upload/image → /v1/library/compose, up to 5 refs). Serve the rich home
explicitly; drop the dead /studio/assets route + the node web-build stage.
web/login.html still ships (server.py serves /login). v0.17.4
2026-07-17 13:06:01 -07:00
3f48ca15e0 ingest: index on dedup too — files without rows must repair (#21)
An interrupted mirror leaves bytes on disk with no library or worklog row;
the dedup branch skipped indexing, so re-ingesting repaired nothing. The
library upserts by path; the worklog now upserts by output. Recovery is a
re-POST of the same bytes.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:24:19 -07:00
fdc2da4c57 upload: 100MB cap — 4k masters run 30-60MB and belong in the library (#20)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:21:56 -07:00
9993da806e chat: surface the server's error message — a 402 says where to add credits (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:44 -07:00
b5bbbfd724 library: dotfiles are never assets (#18)
The indexer skips them; _safe_asset refuses them (one gate for file serving,
fix sources, and every other path lookup). AppleDouble forks carry image
extensions, serve resource-fork bytes, and rendered 0x0 across the library.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:02 -07:00
73abeb1109 chat: same-origin /v1/agent bridge — the cookie never leaves the host (#17)
The SPA called the gateway cross-origin; the session cookie is host-only, so
every signed-in user got 403 on their first message (401 once the cookie was
widened — the gateway takes bearers, not cookies). The bridge forwards the
caller's bearer server-side, the wallet pattern. VITE_AGENT_API still selects
direct mode.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 11:52:55 -07:00
Hanzo AI f75cb4b0cc studio-chat: keep renders reachable on mobile (library becomes a bottom strip)
On a phone (<=820px) the library rail was display:none, so a user could chat and
dispatch renders but never see them. Now the body stacks (chat fills, library
drops under it) and the rail shows as a compact scrollable bottom strip — but only
when there is something to show (jobs or assets set the `active` class), so the
empty chat state stays clean. Verified with Playwright at iPhone 14 (390x844):
empty = clean chat; with renders = 4-col strip under the composer, no overflow.
2026-07-17 09:38:42 -07:00
c2f16445f2 upload: reject dotfiles and sniff magic bytes (#16)
* upload: reject dotfiles and sniff magic bytes — the name lies, the bytes never do

AppleDouble forks (._foo.png) passed the extension check; 700+ poisoned a
library within an hour of the mirror going live. PNG/JPEG/WebP signatures
required; hidden names rejected.

* upload: size cap precedes the sniff; test payloads carry the full PNG signature

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 01:18:17 -07:00
Hanzo AI 2bf5421313 studio: serve the /v1/agent chat SPA (web/dist) at /studio
The studio-chat frontend (web/, Vite on @hanzo/ai talking to POST /v1/agent) was
repointed + committed but never served. Build it in a node stage of the Dockerfile
(-> /app/web/dist) and serve it at GET /studio, with GET /studio/assets/{name} for
the hashed bundle (immutable) and a traversal guard. Falls back to the legacy
studio_home.html when the build is absent so /studio is never a dark page. This is
the decided cutover: studio.hanzo.ai/studio becomes the /v1/agent chat app.
2026-07-17 01:13:51 -07:00
93688cac99 worklog: widen ?q= search to paths + refs, not just prompt (#15)
An ingested render row has an empty prompt, so filtering Queue & History by
filename found nothing. Widen the /v1/worklog ?q= filter to also match kind,
output_prefix, output (the landed path), and refs — so a render is findable by
its filename or source image, not only by a typed prompt. Test covers filename,
ref, and prompt matches.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 00:49:53 -07:00
be313e8068 Library ingest: POST /v1/library/upload — every render lands in the library (#13)
* Library ingest: POST /v1/library/upload — every render lands in the library

Tenant-scoped ingest so EVERY render is stored in studio.hanzo.ai, including
renders produced on a GPU node outside the job path (the node's mirror loop
POSTs here). Org via _org_of like every route.

Body: multipart (field image/file) OR raw bytes + ?name=, optional ?subpath=
(default renders). Writes orgs/<org>/output/<subpath>/<name> atomically
(tmp+replace, same pattern as the work log). Rejects traversal (basename-only
name + allowlisted subpath chars), caps size at 30MB, and skips a byte-identical
existing file (name+size match → 200 {ok, existed:true}); else 200 {ok, path}.
The manifest sidecar already indexes output/ — no other wiring.

Tests (studio_home_test): round-trip via GET /v1/library/file, dedup, tenant
isolation (org A upload never visible to org B, no cross-tenant overwrite),
subpath-traversal rejected, traversal-y name neutralized to basename, non-image
rejected, oversize rejected. 259 passed.

* Ship scripts/ whole (unbreak manifest sidecar) + index renders at ingest

(A) Regression: .dockerignore `scripts/*` + `!install_custom_nodes.sh` dropped
scripts/library_manifest.py from the image, so the build-manifest sidecar's
`[ -f ]` guard silently no-op'd and NOTHING got indexed into any org library.
Ship scripts/ whole again.

(B) Make indexing event-driven at the ingest point: POST /v1/library/upload, after
writing the asset, atomically appends the org library.json entry ({path, design?,
kind, role?, status:"draft", tags:[], updatedAt}) AND a worklog row ({kind:"render",
node, prompt:"", refs:[], parents:[], output_prefix:<path>, status:"done"}) — so
every mirrored render is instantly findable in Assets AND Queue & History with its
source node, filterable by run/flow, the moment it exists. Optional ?design= ?kind=
?role= ?node= with safe defaults (node→"upload"). Upserts by path (no dup on
re-render); the existed/dedup path skips indexing. The manifest sidecar stays the
sweeper for files that appear outside the route. _row_output resolves an ingested
render's output (its stored path) so it shows a thumbnail + opens its graph.

Tests: upload → library.json entry + worklog row appear (node, done, resolved
output), tenancy holds, dedup adds no second entry. 260 passed.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 22:19:20 -07:00
6b5597d8cb home: account menu — the chip advertised one and did nothing (#14)
cursor:pointer + title=Account with no handler. Tap now opens a menu:
identity, Manage account (hanzo.id), Sign out (/logout — existing route).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 21:52:03 -07:00
Hanzo AI dd1f9e7138 studio-chat: call /v1/agent (was /v1/chat), canonical preset field + conversation threading
The orchestrator lives at POST /v1/agent (github.com/hanzoai/agent in the cloud
binary), not /v1/chat (which ai owns as its completions route). Use the canonical
`preset` selector (not the legacy `capability` alias) and thread conversationId so
the per-org history the orchestrator persists actually continues across turns.
VITE_CHAT_API → VITE_AGENT_API.
2026-07-16 21:10:06 -07:00
86bb0a3cad Shell: Studio/Editor toggle, sidebar chat, templates, wallet, GPUs (#11)
One shared shell asset (middleware/shell.{js,css}, served at /studio/shell.*)
is loaded by BOTH views — the Studio home <script>-includes it, and the Editor
gets a single tag injected into the engine index.html by server.get_root. It
renders one chrome in both: a Studio/Editor toggle, wallet chip, GPUs badge +
panel, and a dockable right chat sidebar backed by the ONE chat path
(/v1/copilot/chat). Same-origin cookies carry the IAM session, so login/org are
inherited — no auth is rebuilt.

New tenant-scoped routes (all via _org_of):
  GET  /studio/shell.js|css
  GET  /v1/workflow?id=<worklog id>   graph from the item's output PNG (404 if
                                      not owned / not landed) — Open in Editor
  POST /v1/templates                  save {name, graph} → orgs/<org>/templates
  GET  /v1/templates                  list
  GET  /v1/templates/{name}           fetch
  POST /v1/templates/render           queue through the ONE dispatch funnel
  GET  /v1/wallet                     display-only proxy of the user token to the
                                      cloud finance balance → {org, balance, currency}

Studio home: "Advanced mode" link replaced by the shell toggle; dead chatfab CSS
removed; Templates tab lists org templates (Open in Editor + Render); work-item
context gets Open in Editor; favorite→template now writes a REAL template.

Mobile (verified 390x844 + 768x1024): chat collapses to a full-width bottom
sheet; the pill bar stays one compact line (icon-only Chat/GPUs) so all controls
stay reachable and clear the content; zero horizontal overflow.

Wallet is display + Top up link (pay.hanzo.ai) only — no card fields, no minting.
Tests extend studio_home_test for the new routes (tenant isolation + shape).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 20:44:50 -07:00
96a633d3ce render: cap startToClose at 4h — the tasks default (~1h) reaped live renders (#12)
Liveness is the 1200s heartbeat; the cap only bounds a live-but-stuck render.
Observed: activities reaped at ~68m mid-render, queue re-ran the same job six
times, results stranded on the node.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 19:31:45 -07:00
722ea7fd39 Queue transparency: which node runs it + position/ETA/elapsed (#10)
Makes the unified queue honest about WHERE and WHEN, all server-computed and
tenant-scoped (an org sees only its own jobs' positions and its own nodes).

WHERE — node identity:
- gpu_dispatch reads whatever the gpu-jobs fleet publishes (_node_label prefers
  name/hostname/label; home-lab labels spark/dbc/evo land there) — no parallel
  registry invented. dispatch_if_worker records the target node (the single online
  GPU is definitive; ambiguous 'gpu' with several) on the render_jobs row; _dispatch
  copies it onto the work-log row. Local-core jobs are 'studio pod'.
- GET /v1/nodes: the org's GPU nodes (online/offline) + the pod, for header badges.
  Fleet is token-scoped → only the caller's nodes.

WHEN — position + ETA (server-side, never client math over private data):
- worklog records queued/started/finished timestamps (mark_started when a job first
  heads its lane; mark_finished from the output file mtime). Pure functions:
  median_durations (rolling median render seconds per kind) and estimate_lanes
  (position N of M per lane; wait ≈ jobs-ahead × medians + running remaining;
  eta = wait + own median). Unknown kinds fall back to a default; labeled honestly.
- GET /v1/queue/status: this org's active jobs with {position, of, node, status,
  elapsed_seconds, wait_seconds, remaining_seconds, eta_seconds} + medians. Reads
  the org work log only; no fleet call on the hot path.

UI: 'Your work' rows and the BYO-GPU row show ⚙ node · #N/M · running <elapsed> ·
~Xm left  /  #N/M in line · ~X min est. Queue header shows per-node online/offline
badges + the median-render stat. Auto-refresh while the queue view is open (6s
positions, 20s nodes — no hammering); the running item's elapsed ticks each second
client-side; a completion toast fires when an open page sees a job land.

Tests (11 new): _node_label + online-node filtering; record-node; started/finished
+ median; median_durations per kind + ts fallback; estimate_lanes positions/ETA +
default-median. Route smoke: positions #1/2/3 with ETAs from the org's own median,
landed→finished drop, globex sees none of acme's queue, /v1/nodes maps fleet→badges.
Browser smoke: node+position+ETA render, elapsed ticks 0s→2s, median stat + pod
badge, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 16:28:30 -07:00
845ddd9dae Cancel + amend queued jobs (tenant-scoped, both realms) (#9)
Cancel and amend any of the caller's OWN queued/running renders, resolved
server-side; a prompt_id that isn't the caller's 404s (never reveals it exists).

Cancel — POST /v1/queue/cancel {prompt_id}:
- local pod queue: delete a pending item OR interrupt a running one, org-scoped
  (reuses the core prompt_queue primitives — one tenant can't touch another's).
- BYO-GPU lane: drop the render_jobs.json tracking row (per-org file → inherently
  owned); a job already rendering on the worker may still finish (the pod can't
  stop a remote process) — the UI says so honestly.
- marks the work-log row cancelled. _owns_job gates it: work-log OR org-stamped
  local-queue item OR the org's gpu tracking file; else 404.

Amend — POST /v1/queue/amend {prompt_id, instruction, paths?, uploads?}:
- honest atomic swap (queued graphs are immutable): cancel the original, then
  re-dispatch through the ONE funnel with the amended prompt + the original refs
  (kept, read from the authoritative work-log row) + new library picks/uploads.
  Records a fresh work-log row; position resets, shown honestly in the UI.

UI: Edit + Cancel on every queued/running work row and in the context panel;
Cancel on the BYO-GPU live row; the local 'Remove' now routes through the unified
cancel (dead delQueued removed). Amend reuses the Fix dialog in amend mode
(openAmend): prompt primed, current refs shown locked, add more via the same
dropzone + history picker. Errors surface in-dialog (no silent swallow).

Hardening: _queue_prompt tolerates a non-JSON downstream response (reports it
instead of 500-ing).

Tests: cancel org-scoped + gpu-row removal + cross-tenant not-owned; amend atomic
swap keeps original refs + adds new, unknown→NotOwned(404), terminal rejected (5).
Route smoke: amend swaps + re-queues, gpu cancel removes tracking, globex gets 404
on acme's job (cancel AND amend). Browser: Edit/Cancel on queued rows, amend dialog
primed with prompt + current ref, correct POST, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:24:24 -07:00
f569319988 Work history with full context + parent→child lineage/versions (#8)
Adds a per-org work log — the ONE record of every render dispatched — as the
backbone for a queue/history that never loses a job and shows what produced it.

Backend (middleware/worklog.py, one JSON per org at orgs/<org>/output/worklog.json;
same pattern as library.json/render_jobs.json; no new DB, no backfill):
- The single dispatch funnel (dispatch_fix/dispatch_compose/rerun) records one row
  at dispatch via _dispatch(): {id, ts, kind, prompt, refs, uploads, parents,
  output_prefix, params, lane, status, events}. A FAILED dispatch is recorded too
  (status=failed + reason) so the deployed 'nothing showed as queued' can't recur —
  every dispatch is visible with real status, gpu-lane or local.
- GET /v1/worklog — org's dispatched renders, reverse-chron, ?q= over prompt+kind,
  status enriched (done when the output lands). Server-resolved org only.
- GET /v1/worklog/lineage?path= — a library asset's version chain (ancestors it was
  derived from + later fixes), a pure function over this org's rows.
- _landed_output matches SaveImage's exact <prefix>_<counter>_.png (not a loose
  <prefix>*, which collapsed distinct lineage steps).

Frontend (studio_home.html):
- Queue & History → 'Your work': searchable, reverse-chron list with status badge +
  lane + prompt + thumbnail; click → context panel (prompt, reference/result thumbs,
  workflow family, params, process trail, and the Versions chain inline).
- 'Versions' affordance on every library card → the same lineage chain; click any
  version to open it.
- 'Reuse context' / 'Fix this version' open the Fix dialog primed with the item's
  prompt + references (openFixPath/primeFix) — reuse = continue from any version.

Tenant isolation: one log file per org; routes resolve org server-side; lineage is
pure over the caller's rows — cross-tenant leakage is impossible by construction.

Tests: worklog record/failed/status/cap, lineage chain + cycle-termination,
cross-tenant isolation (7). Route smoke: dispatch→done+output, 2-step lineage,
failed dispatch recorded with reason, globex sees none of acme's work. Browser
smoke: work list + status badge + context panel + Versions chain, zero console
errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:15:21 -07:00
c249ac41a4 Fix live blockers: surface dispatch errors + working '+' attach (no silent swallow) (#7)
Two user-reported live regressions on studio.hanzo.ai, root-caused at the
deployed image (sha-0d307dc, pre-#6):

1) Fix 'won't work / nothing queued' — the GPU-less prod pod fails /prompt
   validation (models absent) or the router has no GPU worker; _queue_prompt
   raised web.HTTPBadGateway(text=json.dumps(body)[:500]) — a raw 502 whose
   TRUNCATED body the dialog couldn't parse, so the error was swallowed into an
   empty 'Failed:'. Now _queue_prompt raises DispatchError carrying the REAL
   reason (_prompt_error_message), which the fix/compose routes already return as
   {"error"}. Every dialog/sender now SHOWS backend errors persistently and keeps
   the dialog open on failure — no silent swallow anywhere on fix/compose paths.

2) The composer '+' attach button did nothing (no handler). Wired it to the
   shared upload lane (POST /upload/image → org input dir, multi-tenant) with a
   visible thumbnail; added drag-and-drop + paste to the Fix dialog AND the
   composer, all through ONE shared helper (uploadImage/uploadMany/wireDrop/
   wirePaste). Upload failures are shown, never swallowed. Reference tray now
   holds library AND uploaded refs; a single uploaded photo drives a fix via the
   new dispatch_fix(upload=) base (edit a fresh photo without landing it first).

Tests: text-only fix regression lock, uploaded-base fix + traversal refusal,
_prompt_error_message extraction, and a real-aiohttp test that a non-200 /prompt
surfaces the real reason (not a 502). Browser smoke (headless Chromium on the
served page): '+' opens the chooser and uploads, drag-drop adds a ref, text-only
Fix posts {path,instruction}, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:00:40 -07:00
268857c16e Fix surface: reference images via drag-drop upload + add-from-history (#6)
The Fix action on a generated image becomes a reference-capable surface:
- base image (the clicked output) + up to 4 references
- drag-and-drop / click-to-upload image files as references, via the
  existing core /upload/image route (org-scoped input dir — the same place
  fix/compose already stage refs and the BYO-GPU collector ships from)
- add-from-history picker: attach past library generations as references
- no references -> single-image /v1/library/fix (unchanged); any reference
  -> multi-input /v1/library/compose, base image first

Backend: dispatch_compose now accepts uploaded input-dir names (uploads[])
alongside library asset paths, validated as safe existing images; the 2-5
bound is on the combined reference count. One compose path, additive.

Tests: _resolve_uploads filtering, compose upload path + single-ref
rejection; corrected stale _STATUSES vocab (includes 'deleted').

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 12:36:47 -07:00
Hanzo AI a9223e59e8 studio/web: call api.hanzo.ai/v1/chat directly — no studio proxy layer
Chat hits the cloud clients/chat orchestrator directly (credentials:'include'
carries the .hanzo.ai cookie cross-origin; handler replays it into the billed
completion). Engine calls stay same-origin. Needs gateway CORS to allow the
studio.hanzo.ai origin with credentials — infra config, no backend code.
2026-07-16 10:57:33 -07:00
Hanzo AI 10eec303fd studio: web/ — our unique product frontend (chat app on the shared Hanzo stack)
Vite+React studio-chat: say what to create, the cloud /v1/chat orchestrator drives
studio's render tools, outputs land in the library. Increment 1 (own minimal UI
wired to /v1/chat create + library/queue/gpu). Served at /studio; the ComfyUI node
editor stays separate in studio-ui (was studio-frontend). Builds clean (npm-published
@hanzo/*).
2026-07-16 09:48:39 -07:00
Hanzo AI b07530398d studio: /v1/mcp render tools (fix/compose/ask) for the cloud chat tool registry
MCP-over-HTTP (JSON-RPC 2.0) exposing the studio render pipeline as tools the
cloud /v1/chat 'create' capability drives via the tool registry. Reuses the ONE
dispatch path (studio_home.dispatch_fix/dispatch_compose, extracted from the
/v1/library/{fix,compose} routes) so an LLM-issued fix behaves identically to a
UI one. Registered per-org via cloud POST /v1/tools/servers. v0.17.3.
2026-07-16 09:01:36 -07:00
Hanzo AI 0d307dceb2 studio: responsive — home was desktop-only (0 media queries). Add tablet(≤900) + phone(≤600) breakpoints: fluid grid, wrapping controls, full-width chat sheet, bigger tap targets, always-show card tools on touch (no hover-reveal). Works mobile/tablet/laptop/desktop. v0.17.2 2026-07-16 00:21:55 -07:00
Hanzo AI 1b713f904c studio: fix queue jobs VANISHING after 30s — render-queue completion check did a fuzzy base-prefix match that collided with the SOURCE image (a fix's output prefix derives from the source name), so every job looked 'done' after 30s and disappeared. Now checks for the EXACT output file (<outPrefix>_NNNNN_.png). This is why 'the queue is not working'. v0.17.1 2026-07-15 23:19:39 -07:00
Hanzo AI c969641d7d studio: EXIT Advanced mode — always-visible '← Studio' button (fixed top-left) + 'Exit to Studio Home' in the user menu. Advanced mode (the full-screen node editor) had NO way back to Studio Home — users got stuck. v0.17.0 2026-07-15 23:11:34 -07:00
Hanzo AI b7dff20861 studio: fix 'connect a GPU' when a GPU IS connected — UI checked /v1/engines (worker-federation) but 'hanzo gpu connect' registers with the FLEET (gpu-jobs), which is what dispatch actually uses. New /v1/gpu-status reports the SAME source as dispatch (_has_online_gpu → /v1/machines); UI now shows spark connected. v0.16.9 2026-07-15 22:58:04 -07:00
Hanzo AI e696fb7480 studio: SHOW BYO-GPU renders in the queue — fix/compose dispatch to gpu-jobs (worker), NOT the pod's local /queue, so a queued fix was invisible ('didn't bring it into queue'). Now dispatch_if_worker tracks each job per-org; /v1/render-queue serves in-flight ones (drops on output-landed/age-out); live-bar + Queue tab show them with thumbnail + ETA. v0.16.8 2026-07-15 22:20:50 -07:00
Hanzo AI b3f20495f8 studio: /studio served no-store — browsers cached the SPA HTML and served a STALE page after deploys, making UI fixes (delete) look broken to users on yesterday's cached page. v0.16.7 2026-07-15 22:12:29 -07:00
Hanzo AI 179b577add studio SECURITY: org-scope the inherited core endpoints (multi-tenant release blockers from red audit) — /history + /history/{id} filtered by caller org (was cross-tenant BOLA, live in Queue&History UI); /queue GET filtered; /queue clear/delete scoped (was: any tenant's 'Clear all' wiped ALL orgs); /interrupt scoped to caller's running job; org_id path-traversal sanitization in get_org_base_path. Single-tenant (auth off) unchanged. v0.16.6 2026-07-15 21:37:33 -07:00
Hanzo AI c2336e0c1e studio: YouTube-style up-next queue rows — reference thumbnail + title + brief description (the prompt) + ETA/#-in-line; serves staged fix/compose refs via ?input=1; v0.16.5 2026-07-15 20:51:01 -07:00
Hanzo AI 0538abf199 studio: FAST thumbnails (?thumb=1 → cached 480px JPEG, fixes slow full-res grid loading) + always-visible instant 🗑 trash on every card (optimistic delete, feels instant) + tray/compose use thumbs; v0.16.4 2026-07-15 20:49:03 -07:00
Hanzo AI 7eefdcd206 studio: persistent live-queue bar (always visible, non-advanced) — shows what's Generating now + queued count + ETA, polls /queue every 5s; auto-hiding header + full-bleed immersive stage CSS + Hanzo AI chat widget scaffold; v0.16.3 2026-07-15 20:42:01 -07:00
Hanzo AI b238b8c568 studio: bump BYO-GPU render deadline 600s→1200s (heavy Qwen edit on cold GB10 exceeds 10min); v0.16.2 2026-07-15 20:18:30 -07:00
Hanzo AI e97194613c studio: '+' on each photo → reference tray for the next generation; 1 ref=fix/edit, 2-5=compose; describe + Generate from the floating tray; v0.16.1 2026-07-15 20:12:56 -07:00
Hanzo AI b31a1ea3e2 studio: YouTube-style Queue & History — Now-running + Up-next (live /queue, remove/clear) + History with 0-5 star ratings + notes-for-AI (real /v1/library/rate + ratings.json curation store) + favorite→save-as-template guided flow; v0.16.0 2026-07-15 19:58:21 -07:00
Hanzo AI dd21fd9f85 studio: multi-asset Compose (select N → describe how to combine → /v1/library/compose, multi-ref Qwen graph) + Fix/Compose now report GPU-online status clearly (Qwen models live on BYO-GPU); v0.15.9 2026-07-15 19:50:41 -07:00
Hanzo AI 109cd04a54 studio: soft-delete (recoverable, Deleted filter) + multi-select bulk (approve/publish/fork/delete) + hover-reveal tools (cleaner grid) + systematized fashion templates (CAD→ghost, outfit-switcher, product, lifestyle, CAD→3D grouped by category); v0.15.8 2026-07-15 19:22:32 -07:00
Hanzo AI 552f47b844 studio: chat-first Claude-Design home ('What should we create?' + full Hanzo template set: product/ghost/CAD-3D/video/music/voice/social/docs/paper) + org-scoped /v1/library/file (fixes blank thumbnails); v0.15.7 2026-07-15 18:33:59 -07:00
Hanzo AI b0d1b06a6f studio home: Hanzo logo top-left + clean inline org switcher/avatar (drop broken floating identity pill); v0.15.6 2026-07-15 18:30:09 -07:00
Hanzo AI 023367bfec studio home: monochrome brand (drop yellow --brand) + surface org-switcher/user/wallet identity pill; v0.15.5 2026-07-15 17:42:05 -07:00
Hanzo AI b2121abc71 chore: version 0.15.4 — rebuild Studio Home (0.15.3 image never reached GHCR) 2026-07-14 17:51:53 -07:00
hanzo-dev 0a3146e00f docs: de-tenant the manifest + home docstrings
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:06:03 -07:00
hanzo-dev 8e55ecac9a refactor: karma_manifest.py -> library_manifest.py — org-generic name for the org-generic tool (unified multi-tenant SaaS; no tenant names in code)
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:05:44 -07:00
hanzo-dev b6317999dd fix: dedupe version key from rebase resolution
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 16:52:46 -07:00
hanzo-dev 5e9f72bc16 feat(studio): Studio Home — content-forward default view; node editor becomes Advanced mode
/studio: org-scoped asset gallery over library.json (status chips draft/
approved/queued/published), per-asset workflow provenance from the embedded
PNG graph, Re-run (exact graph, fresh seed) and chat-fix (instruction ->
Qwen-edit pass on that output) both through the ONE /prompt path with the
caller's own credentials. Pipelines tab reuses the 0.15.1 deep-link opener
for Edit/Run; STUDIO_HOME_DEFAULT=1 makes / land on /studio (SPA at
/?advanced=1). 5 unit tests.

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 16:52:24 -07:00
Hanzo AI acd3bcc99c chore: version 0.15.1 — deep-link opener atop the live 0.15.0 line 2026-07-14 06:32:07 -07:00
Hanzo AI 2ea07a2678 feat(studio): deep-link opener — ?org=&workflow=&run=1 opens (and runs) a saved workflow
A gallery/console/chat link lands you on the graph ready to render: sets the
session org (so a re-run dispatches to that org's BYO-GPU and outputs to its
gallery), loads the saved workflow, and optionally auto-queues. Injected as a
frontend shim (patch_deeplink.py, step 13 of apply-branding), guarded end-to-end.
2026-07-14 06:06:24 -07:00
Hanzo Dev 55f6eee60a chore(notice): pin ComfyUI provenance; normalize copyright entity; keep GPL-3.0 2026-07-13 15:56:49 -07:00
Hanzo AI 1dba6abcbf fix(ci): launch smoke test waits 120s — boot now includes alembic + assets scan, 30s flakes at the boundary 2026-07-12 22:53:35 -07:00
Hanzo AI cc352ba658 chore: version 0.14.11 2026-07-12 22:50:42 -07:00
Hanzo AI 3bb37419b5 chore: version 0.14.10 2026-07-12 22:50:16 -07:00
Hanzo AI 5c9215afe3 fix(cloud): persist models on the tenant-data PVC
/app/models is ephemeral overlay — every model vanished on pod restart
(CheckpointLoaderSimple: 'not in []'). Bake extra_model_paths.yaml into
the image registering /app/orgs/.models (PVC) as the default model store
for all model classes; main.py auto-loads it. Downloads land durable.
2026-07-12 22:42:34 -07:00
Hanzo AI 82a8487e7b fix(test): force CPU device path before importing execution — GPU-less CI runner has no NVIDIA driver 2026-07-12 22:32:52 -07:00
Hanzo AI ef128fadcc chore: drop watchdog script — hanzo gpu connect --studio-dir supervises the render backend (one way) 2026-07-12 22:25:03 -07:00
Hanzo AI 35f4a0328c merge: zen-video headless text-to-video queue (lint-clean) 2026-07-12 22:14:12 -07:00
Hanzo AI 9978f26e25 fix(zen-video): lint — split imports, JSON to stdout without print 2026-07-12 22:14:12 -07:00
f8007e6ced feat(zen-video): headless text-to-video queue on the GB10 (Wan2.2 via Studio)
Serialized, SQLite-persisted queue fronting Hanzo Studio (ComfyUI :8188) that
turns a prompt into a real MP4 on the GB10. One GPU => one job at a time; a
single async worker drains an ordered queue and submits to Studio.

Two composable surfaces over one backend:
- OpenAI-style /v1/videos/generations (gateway-injected X-Org-Id identity, per
  generated-second billing into a usage table).
- do-ai-compatible async /v1/videos + /{id} + /{id}/content (Bearer provider
  key) — the exact Sora-style create->poll->download contract the cloud LLM
  gateway's video client drives, so the ai registry adopts spark by pointing
  the zen3-video provider at spark, no ai-side client change.

Zen video family public names (zen3-video/-fast/-pro); private Wan2.2 filenames
confined to zen_wan_workflow.py, never returned to callers. Includes the
systemd unit and studio_clean_restart.sh (breaks the Studio JIT/EADDRINUSE
crash-loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:11:05 -07:00
Hanzo AI 5ed1dacbdc fix(dispatch): route BYO-GPU jobs before coordinator validation
A GPU-less cloud pod validates ckpt_name & friends against ITS OWN (empty)
model list, rejecting every checkpoint-referencing graph with 'not in []'
before dispatch could ship it to the org's GPU box. Validation belongs where
execution happens: the dispatch attempt now runs first, and the worker
validates against its real node classes + models. Orgs with no online GPU
fall through to local validation unchanged.
2026-07-12 22:08:44 -07:00
Hanzo AI 4200c52000 fix(packs): comfyui_segment_anything under Transformers 5; installer applies scripts/patches/<pack>-*.patch
BertModelWarper now delegates to the real BertModel.forward instead of
reimplementing internals whose private signatures drift between releases;
checkpoint weights remap bert.* -> bert.bert_model.* at load. The installer
force-checkouts each pin (discarding prior patches) then applies any
scripts/patches/<pack>-*.patch, so re-runs stay idempotent and the image
build gets the same fix the GB10 venv had by hand.
2026-07-12 22:08:44 -07:00
Hanzo AI 1d67adb0fb feat(scripts): karma library manifest generator + studio watchdog (from the GB10 box)
karma_manifest.py regenerates the ONE canonical library.json index of the
karma org's rendered assets (storefront + socials queue both read it).
studio_watchdog.sh keeps the local render backend on :8188 alive unattended;
paths derive from the script location (STUDIO/STUDIO_PY overridable).
2026-07-12 22:08:44 -07:00
hanzo-devandHanzo AI 50c338ecfd feat(content): record completed renders as Assets in the content lane
A finished render becomes an `Asset` document — the DocType hanzoai/cloud
clients/content already declares (framework module "marketing") and already writes
itself when IT drives a render. Studio-initiated renders were the only ones with
no path into that record; this closes exactly that gap and adds NO second schema,
NO second lifecycle, and NO second storage plane. cloud is untouched.

  POST /v1/framework/Asset   (the already-live generic framework surface)

Same field shape clients/content writes (studio_render.go draftAsset): title,
kind, design, prompt, file, generator, workflow, source_prompt_id, render_params,
project, tags. `file` is the org-scoped output key (orgs/<org>/output/...) —
byte-identical to the key persistOrLink produces and the key Studio's own output
mirror lands, so the bytes are ALREADY persisted: this module uploads nothing and
holds no storage credential.

Written AS THE REQUESTING USER: the verified IAM access token rides to the prompt
worker on the queue's existing `sensitive` channel (never persisted into history)
and authenticates every write, so the Asset lands in that user's org with that
user's rights. No service account, no impersonation, no cross-org path.

Assets land in draft (the DocType default); the content lane's ONE state machine
(draft->in_review->approved->queued->published) owns every transition, and a human
does the promoting. Publishing is what a storefront shows, so an unattended render
can never push an image to karma.style.

Fire-and-forget on the same prompt-worker seam as billing: a render is never failed
because the lane was unreachable. Env-gated (STUDIO_CONTENT_PUBLISH); local dev
unaffected.
2026-07-12 22:05:54 -07:00
hanzo-dev 203375e5db ci: run linux jobs on hanzo-build-linux-amd64 ARC scale set (no GitHub-hosted builders)
Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-11 00:22:48 -07:00
Hanzo Dev cea869bd14 feat(hanzo_engine): music + dub nodes, textured 3D, flagship pipeline
Add HanzoMusic (POST /v1/audio/music -> ACE-Step stereo AUDIO) and HanzoDub
(POST /v1/animate -> MuseTalk lip-sync: portrait IMAGE + driving AUDIO -> frames
+ audio). Fix HanzoImageTo3D to send the now-live `texture` flag (textured GLB
via SLAT + FlexiCubes) and offer obj. Correct HanzoTTS response_format to the
engine's actual wav/pcm. Drop the torchaudio dependency: studio_audio_to_wav_bytes
now uses the stdlib wave module, and studio_audio_to_data_url feeds the dub's
driving audio.

Ship user/default/workflows/hanzo-full-generative-pipeline.json: a prompt is
expanded by Chat and fans out to Image Gen -> (textured Image-to-3D AND WAN
Text-to-Video), plus TTS narration and ACE-Step Music -- every modality in one
graph, all on the native engine.

contract_check.py drives all 8 nodes over real HTTP against a stub engine that
enforces each Rust handler's request fields and the async job submit->poll->content
protocol.
2026-07-10 16:48:53 -07:00
110 changed files with 22400 additions and 247 deletions
+4 -3
View File
@@ -23,9 +23,10 @@ venv/
.ruff_cache/
tests/
tests-unit/
scripts/*
# ...except the pack installer, which the image build invokes.
!scripts/install_custom_nodes.sh
# scripts/ ships WHOLE: the image build runs scripts/install_custom_nodes.sh and
# the studio pod runs scripts/library_manifest.py as the build-manifest sidecar
# (indexes every org's output/ into library.json). Excluding it silently no-ops
# the sidecar's `[ -f ]` guard, so nothing gets indexed — keep the tree intact.
notebooks/
*.swp
.env
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
inject:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Ensure template exists and append to PR body
uses: actions/github-script@v7
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
check-line-endings:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout code
+8 -16
View File
@@ -1,22 +1,14 @@
# Canonical CI/CD — imports the shared hanzoai/ci reusable workflow, which
# reads hanzo.yml (images + test + deploy + kms). No per-repo build logic,
# no GitHub-hosted runners (defaults to the Hanzo cloud arc pool).
# Deploy is native now — see .hanzo/workflows/deploy.yml (Hanzo Git → act_runner
# → BuildKit → ghcr.io/hanzoai/studio:<sha> → operator reconcile → hanzocd).
# GitHub is a mirror; this workflow no longer builds or deploys the app image.
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
# Target the Hanzo arc scale set by its installation name. ARC scale sets
# are matched by name, not by the [self-hosted,linux,amd64] label triple
# that ci@v1 defaults to — without this override the job never gets a
# runner and sits queued.
with:
runner: '["hanzo-build-linux-amd64"]'
secrets: inherit
sync-notice:
runs-on: ubuntu-latest
steps:
- name: Notice
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
use_prior_commit: 'true'
comment:
if: ${{ github.event.label.name == 'Run-CI-Test' }}
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
permissions:
pull-requests: write
steps:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
send-webhook:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
env:
DESKTOP_REPO_DISPATCH_TOKEN: ${{ secrets.DESKTOP_REPO_DISPATCH_TOKEN }}
steps:
+2 -2
View File
@@ -5,7 +5,7 @@ on: [push, pull_request]
jobs:
ruff:
name: Run Ruff
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
@@ -27,7 +27,7 @@ jobs:
pylint:
name: Run Pylint
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/stale@v9
with:
+1 -1
View File
@@ -14,7 +14,7 @@ on:
jobs:
build:
name: Build Test
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
strategy:
fail-fast: false
matrix:
+2 -2
View File
@@ -8,7 +8,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout Hanzo Studio
uses: actions/checkout@v4
@@ -29,7 +29,7 @@ jobs:
- name: Start Hanzo Studio server
run: |
python main.py --cpu 2>&1 | tee console_output.log &
wait-for-it --service 127.0.0.1:8188 -t 30
wait-for-it --service 127.0.0.1:8188 -t 120
working-directory: studio
- name: Check for unhandled exceptions in server log
run: |
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
generate-models:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
+1 -1
View File
@@ -12,7 +12,7 @@ on:
jobs:
update-ci-container:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
# Skip pre-releases unless manually triggered
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
update-version:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
# Don't run on fork PRs
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
+1
View File
@@ -20,6 +20,7 @@ venv*/
/web/extensions/*
!/web/extensions/logging.js.example
!/web/extensions/core/
/web/shell/node_modules/
/tests-ui/data/object_info.json
/user/
*.log
+24
View File
@@ -0,0 +1,24 @@
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: hanzo-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Build + push image
run: |
SHA="${GITHUB_SHA::8}"
buildctl-daemonless.sh build --frontend=dockerfile.v0 \
--opt context="${{ github.server_url }}/${{ github.repository }}.git#${GITHUB_SHA}" \
--opt filename=Dockerfile --opt platform=linux/amd64 \
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
--output "type=image,name=ghcr.io/hanzoai/studio:${SHA},push=true" --progress=plain
env:
GIT_AUTH_TOKEN: ${{ secrets.GIT_CLONE_TOKEN }}
- name: Deploy — declare tag to operator
run: |
for app in studio; do
kubectl -n hanzo patch app "$app" --type=merge -p "{\"spec\":{\"image\":{\"repository\":\"ghcr.io/hanzoai/studio\",\"tag\":\"${GITHUB_SHA::8}\"}}}"
done
+36
View File
@@ -5,6 +5,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgl1 \
libglib2.0-0 \
# WeasyPrint native stack (office-document PDF renderer, middleware/doc_render.py):
# pango/cairo/gdk-pixbuf + a base font. Absent → the built-in PDF fallback is used.
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -33,6 +40,35 @@ RUN chmod +x scripts/install_custom_nodes.sh && scripts/install_custom_nodes.sh
# Create directories for runtime volumes
RUN mkdir -p models output input custom_nodes user
# /app/orgs is the tenant-data PVC; /app/models is ephemeral overlay. Register
# .models on the PVC as the default model store (main.py auto-loads this file)
# so models survive pod restarts and downloads land durable.
RUN printf '%s\n' \
'hanzo:' \
' base_path: /app/orgs/.models' \
' is_default: true' \
' checkpoints: checkpoints' \
' configs: configs' \
' loras: loras' \
' vae: vae' \
' text_encoders: text_encoders' \
' diffusion_models: diffusion_models' \
' clip_vision: clip_vision' \
' style_models: style_models' \
' embeddings: embeddings' \
' diffusers: diffusers' \
' vae_approx: vae_approx' \
' controlnet: controlnet' \
' gligen: gligen' \
' upscale_models: upscale_models' \
' latent_upscale_models: latent_upscale_models' \
' hypernetworks: hypernetworks' \
' photomaker: photomaker' \
' classifiers: classifiers' \
' model_patches: model_patches' \
' audio_encoders: audio_encoders' \
> extra_model_paths.yaml
EXPOSE 8188
# Default: listen on all interfaces, CPU mode
+138 -3
View File
@@ -97,6 +97,24 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
- **Per-GPU gpu-jobs queue** (`middleware/{gpu_dispatch,studio_home}.py`, `server.py`) —
the ONE submit path onto a GPU. A model-less coordinator's `POST /prompt` enqueues to
the cloud tasks engine (`POST {STUDIO_CLOUD_API_URL}/v1/tasks/namespaces/gpu-jobs/activities`,
user bearer → org-scoped); a BYO worker (`hanzo gpu connect`) claims + runs it.
**Targeting**: the Home "Run on <gpu>" chip sets `X-Target-GPU: <identity>`;
`dispatch_if_worker` enqueues on that machine's own lane `taskQueue="gpu:<identity>"`
(namespace stays `gpu-jobs`), else the shared `gpu-jobs` lane any worker drains —
`X-Target-GPU` rides through `_queue_prompt` so fix/compose/video/rerun all honor it.
**Authoritative visible queue**: `/v1/render-queue` + `/v1/nodes` read the tasks
engine's activity list — REAL claiming `identity`, status
SCHEDULED/STARTED/COMPLETED/FAILED → queued/running/done/failed, `lastHeartbeatTime`
liveness, per-node depth — not a local shadow; `render_jobs.json` survives only as a
~2s pre-claim placeholder. **No hidden runs**: in `--worker-mode` a direct `/prompt`
POST is refused 403 unless it carries `X-Worker-Token` — the only accepted execution is
a claimed job handed in over `POST /v1/worker/execute`
(`worker_client.reject_untrusted_worker_submit`, one policy gate). Legacy push
(`prompt_router`) is OFF unless `STUDIO_LEGACY_PUSH_ROUTER=1`. Tests:
`middleware_test/{gpu_dispatch_test,worker_seam_test}.py`.
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
@@ -111,6 +129,25 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
crash-recovery across instances). Future (federation.md §6): a remote queue
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
- **Stacks** (`middleware/stacks_store.py`, `middleware/studio_home.py`): Procreate-
style gallery folders — a per-org SQLite store (`orgs/<org>/stacks.db`, WAL, every
mutation under `BEGIN IMMEDIATE`, the same `tasks_queue.py` pattern) that is ONLY an
organizational layer; assets stay the source of truth (library.json + files). Schema
= the spec's `stacks` + `stack_assets` join; `UNIQUE(asset_id)` ⇒ one Stack per
asset. Tenancy is structural (one DB file per org → cross-org 404s by construction).
REST (`/v1` house style): `GET/POST /v1/stacks`, `GET/PATCH/DELETE /v1/stacks/:id`
(`?mode=only|images`), `POST|DELETE /v1/stacks/:id/assets`, `PATCH /v1/stacks/:id/order`,
`POST /v1/stacks/:id/unstack`, `GET/PATCH /v1/stacks/settings`; registered via
injected resolvers (org/owner/workspace + the library soft-deleter) so the module
stays decoupled. Remove ≠ delete (returns to the gallery); Delete-Stack defaults to
preserving; "…and Images" maps to the library soft-delete (`status=deleted`, files
kept). Generation integration: `fix/compose/rerun/template` accept a `stack` id and
the ingest point (`_index_upload` → `stacks_store.absorb`) assigns membership when
outputs land (ALL outputs of a multi-output job); a per-org `auto_stack_batches`
setting auto-creates `"<prompt> — <date>"` Stacks. UI is the vanilla-JS home
(`studio_home.html`): layered cards, drag/multi-select/mobile-tap parity (no DnD ever
required), a11y labels + live region. Tested: `studio_home_test/test_stacks.py`
(routes, tenancy, one-per-asset, order persistence, delete/unstack, ingest→absorb).
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
execution targets for the org (`local` + registered compute_config workers) and
`PUT /v1/engines/default` sets a per-org default (stored on
@@ -182,10 +219,108 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
## Office documents (the non-GPU pipeline)
Studio's creative side is ComfyUI graphs → GPU. Office documents are the **orthogonal**
subsystem: structured content (a template's typed fields, filled by a person OR by the AI)
→ a pure-Python renderer → a real `.pdf/.docx/.xlsx/.pptx/.md`. No graph, no model, no GPU.
NOT bolted onto graphs; its own module, store, endpoints, and Home tab.
- **Renderers** (`middleware/doc_render.py`, pure — no HTTP/storage/org): one neutral IR
per archetype (`doc` prose blocks · `sheet` sheets · `deck` slides) and EXACTLY ONE
renderer per format. Adding a template = one `build(fields)->(kind,ir)`; adding a format =
one renderer. Deps imported lazily so the module loads stdlib-only. **PDF** prefers
WeasyPrint (full-CSS, monochrome print stylesheet); when its native pango/cairo stack is
absent (the light CI unit venv, model-less pods) a **built-in pure-Python PDF writer**
(base-14 Helvetica, WinAnsi/cp1252, auto-pagination, `/Info` title) produces a valid
`%PDF` — so every format renders with just the wheels. **DOCX/XLSX/PPTX** = python-docx /
openpyxl / python-pptx (real, always). Native libs in the `Dockerfile` (libpango/…).
- **Catalog + AI-fill + store + REST** (`middleware/documents.py`): ONE declarative `CATALOG`
of 22 common templates (Documents 15 → pdf/docx/md · Spreadsheets 4 → xlsx/pdf/docx/md ·
Presentations 3 → pptx/pdf/md). Each template's typed `fields` drive the form, the AI-fill
JSON, AND the renderer inputs (one schema, three uses). Registered from within
`add_studio_home_routes` with the SAME injected tenancy resolvers as `stacks_store`
(`org_of`/`owner_of`). Per-org store `orgs/<org>/documents/` (atomic `index.json` +
`<id>/doc.json`), doc.json is the source of truth, files rendered on demand at download.
Endpoints (`/v1` house style): `GET /v1/documents` (grouped catalog),
`POST /v1/documents/generate` `{templateId, description?|fields?}`,
`GET /v1/documents/library`, `GET|PATCH|DELETE /v1/documents/{id}`,
`GET /v1/documents/{id}/download?format=`.
- **AI-fill is a LAYERED ENHANCEMENT** — the structured-form path renders a valid document
with NO LLM, so a broken/unauthorized gateway can NEVER block creation (`422 needFields`
→ the UI reveals the prefilled form). It reuses the SAME working token the shipped Chat
uses: the browser sends `window.HZ.pk` (the publishable `pk-` key `_publishable`/
`_hz_config_script` emit) as the bearer to `/v1/documents/generate`; the backend forwards
it, fail-closed, in order: (1) a **server-side gateway key** (`STUDIO_DOC_TOKEN`→
`STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`); (2) else the caller's own token, forwarded
ONLY when gateway-verifiable — a publishable `pk-` key or an asymmetric/JWKS JWT
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
gateway rejects it: "unsupported signing method: HS256"), nor any secret `sk-`/`hk-` key.
Model = `STUDIO_DOC_MODEL`→`STUDIO_CHAT_MODEL`→`enso` (matches window.HZ.model).
- **UI** (`middleware/studio_home.html`, vanilla JS, monochrome/Geist): a **📄 Documents**
Home tab — searchable server-driven catalog + "Your documents" grid with Download
PDF/Word/Excel/PowerPoint. The `docdlg` dialog: describe in one line → Generate with AI
(sends window.HZ.pk), or fill the short typed form → Generate; edit fields before/after;
re-open a saved doc to edit (PATCH). The dead `doc/sheet/paper` composer KIND placeholders
were replaced with one "Office documents" card that opens the tab.
## Create composer + gateway auth (v0.18.1 — the intuitive first Create)
The "What should we create?" composer must PRODUCE something on the first ↑, and Chat must
reach the gateway. Both were fixed here.
- **Gateway auth is ONE fail-closed seam** (`middleware/gateway_auth.py`): the canonical
allowlist for handing a browser-edge bearer to api.hanzo.ai. `ai_bearer(request,
server_env=…)` = a server key first (never in page source), else the caller's own token
ONLY when the gateway can verify it — a publishable `pk-` key or an asymmetric/JWKS JWT
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
gateway rejects it → "unsupported signing method: HS256" → the old 502), nor any secret
`sk-`/`hk-`/`rk-` key. `copilot.py._resolve_target` now uses it
(`_SERVER_TOKEN_ENV=(STUDIO_COPILOT_TOKEN, STUDIO_COMMERCE_TOKEN)`), so the editor-ops
chat no longer 502s on the session token. The shipped **Chat sidebar** (`shell.js`) still
streams enso directly from the gateway with `window.HZ.pk` when present; its copilot
fallback now renders ONE friendly line on failure — never the raw backend error/JSON.
(documents.py's AI-fill has an equivalent private copy; it should adopt `gateway_auth`
once its concurrent review lands — the shared module is the going-forward one way.)
- **Text-to-image is the default create lane** (`studio_home.py`: `_image_graph` +
`dispatch_image` + `POST /v1/library/image`): the proven **Z-Image-Turbo** graph
(`z_image_turbo_bf16` / `qwen_3_4b` lumina2 / `ae` vae; EmptySD3LatentImage →
ModelSamplingAuraFlow shift 3 → KSampler res_multistep/simple 4 steps cfg 1.0 →
SaveImage), lifted from the shipped blueprint, dispatched through the ONE funnel like
video/fix. Words in → an image out; a model-less pod surfaces the real DispatchError, not
a silent nothing.
- **Composer routes ↑ by the selected model's modality** (`studio_home.html`): default
`MODEL='zen-image'` from `hz_create_model` — DECOUPLED from the chat model (`hz_model`),
so an image pick never breaks the assistant (and `shell.js chatModel()` ignores a
non-chat `hz_model`). `composerPlan()`: assistant model → hand to the ONE chat via
`window.HanzoChat.send` (exposed by shell.js); no photo → text-to-image; 1 photo → fix;
2+ → compose; video model/lane → video. `create()` ALWAYS pulses a "Preparing…"
live-bar state and, on failure, shows a friendly retry (prompt kept) — never a silent
no-op. The code model (`zen5-coder`) is hidden from the creative composer; each model has
a one-line description; the `</>` request-preview is gated behind Developers mode
(`body.hzdev`, set by the shell's Developers pill).
- **P2 friction fixes** (`studio_home.html` + `shell.css`): a first-class **Download** on
every tile AND in the viewer (PNG original · JPG/WebP client-side canvas convert, the
popover flips up near the viewport bottom); the fixed header + sticky live-bar are now
OPAQUE (were translucent → content scrolled under them); the **ARCHIVED** badge shows
ONLY for truly deleted/flagged items (active work is unbadged); on mobile the ~30 filter
pills collapse to one horizontally-scrollable row and the tab bar gets a scroll fade
affordance. The Chat empty state (shell.js) shows an assistant identity + one line + 3
tappable starters.
## Tests
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test,studio_home_test,documents_test}` —
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
tests-unit/app_test tests-unit/prompt_server_test tests-unit/studio_home_test
tests-unit/documents_test -q`. The auth/tenancy work is
covered by `middleware_test/iam_auth_test.py`,
`middleware_test/session_test.py` and
`folder_paths_test/org_scoping_test.py`.
`folder_paths_test/org_scoping_test.py`. Office documents:
`documents_test/test_documents.py` (catalog invariants, the full template×format render
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist, and the
REST surface incl. per-org isolation on a real aiohttp app). Create composer + gateway
auth: `middleware_test/gateway_auth_test.py` (the fail-closed allowlist — HS256/`sk-` refused,
`pk-`/asymmetric forwarded, server-key precedence, session-cookie path),
`middleware_test/copilot_test.py::test_resolve_target_never_forwards_hs256_session_token`,
and `studio_home_test/create_image_test.py` (the Z-Image-Turbo graph shape, `dispatch_image`
validation, and served-HTML invariants: default-model-is-image, text→image route,
Create-always-shows-a-state, first-class Download, no ARCHIVED badge on active items, opaque
bars, gated `</>`, collapsed mobile filters).
+5 -3
View File
@@ -1,19 +1,21 @@
Hanzo Studio
============
Forked from https://github.com/comfyanonymous/ComfyUI (GPL-3.0).
Hanzo Studio is a fork of ComfyUI (https://github.com/comfyanonymous/ComfyUI),
Copyright (c) ComfyUI contributors, licensed under the GNU General Public
License v3.0 (GPL-3.0).
Modifications Copyright (c) 2026 Hanzo Industries Inc, also released under the
Modifications Copyright (c) 2026 Hanzo AI, Inc., also released under the
GNU General Public License v3.0 (GPL-3.0). See the LICENSE file for the full
license text.
As required by the GPL-3.0, this fork remains under the same license as the
upstream project. The original ComfyUI copyright notices are retained.
Modifications made in this fork
-------------------------------
Modifications made in this fork (DEVIATIONS)
--------------------------------------------
- Branding: user-visible name changed from "ComfyUI" to "Hanzo Studio",
including the served page <title>, favicon, in-app logos, and display
+7 -1
View File
@@ -105,7 +105,13 @@ python3 "$BRANDING_DIR/patch_destring.py" "$STATIC_DIR"
# studio.hanzo.ai ships the modern DOM node renderer as the only node design:
# default the setting on, hide it from the Settings dialog (type:hidden), and
# drop the logo-menu switch. Idempotent; see patch_vuenodes.py.
echo "[12/12] Forcing Nodes 2.0 on (no toggle)..."
echo "[12/13] Forcing Nodes 2.0 on (no toggle)..."
python3 "$BRANDING_DIR/patch_vuenodes.py" "$STATIC_DIR"
# --- 13. Inject the deep-link opener (open + optionally run a workflow by URL) ---
# ?org=<org>&workflow=<path>&run=1 sets the session org, loads the saved workflow,
# and queues it (dispatching to the org's BYO-GPU). Idempotent; see patch_deeplink.py.
echo "[13/13] Injecting deep-link opener..."
python3 "$BRANDING_DIR/patch_deeplink.py" "$STATIC_DIR" "$BRANDING_DIR"
echo "=== Hanzo Studio branding complete ==="
+24 -23
View File
@@ -1,6 +1,7 @@
/* Hanzo Studio Copilot — black + Hanzo-purple sidebar chat.
* Reuses the theme's CSS vars (hanzo-theme.css) so it tracks the shell palette;
* hard-coded fallbacks keep it on-brand if a var is ever absent.
/* Hanzo Studio Copilot — monochrome sidebar chat.
* Tracks the shell's neutral theme vars (hanzo-theme.css) with canonical Hanzo
* design-system fallbacks: surface #0a0a0a · raised #1a1a1a · border #1f1f1f ·
* text #ededed · dim #888 · brand/CTA #fff. No color accent — monochrome.
*/
.hanzo-copilot {
display: flex;
@@ -9,7 +10,7 @@
width: 100%;
box-sizing: border-box;
background: var(--comfy-menu-bg, #0a0a0a);
color: var(--fg-color, #fff);
color: var(--fg-color, #ededed);
font-size: 13px;
}
@@ -26,8 +27,7 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--p-primary-color, #8b5cf6);
box-shadow: 0 0 8px var(--p-primary-color, #8b5cf6);
background: #ededed;
}
.hanzo-copilot .hz-messages {
@@ -50,12 +50,13 @@
white-space: normal;
}
.hanzo-copilot .hz-user .hz-bubble {
background: var(--p-button-primary-background, #7c3aed);
color: #fff;
background: #ffffff;
color: #111;
border-bottom-right-radius: 4px;
}
.hanzo-copilot .hz-assistant .hz-bubble {
background: #1a1a1f;
background: #1a1a1a;
color: var(--fg-color, #ededed);
border: 1px solid var(--border-color, #1f1f1f);
border-bottom-left-radius: 4px;
}
@@ -69,17 +70,18 @@
}
.hanzo-copilot .hz-chip {
background: transparent;
color: #c4b5fd;
border: 1px solid #2a2140;
color: #888;
border: 1px solid var(--border-color, #1f1f1f);
border-radius: 999px;
padding: 5px 10px;
font-size: 11.5px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.hanzo-copilot .hz-chip:hover {
background: rgba(139, 92, 246, 0.16);
border-color: var(--p-primary-color, #8b5cf6);
background: #1a1a1a;
border-color: #333;
color: #ededed;
}
.hanzo-copilot .hz-input {
@@ -93,7 +95,7 @@
flex: 1 1 auto;
resize: none;
background: var(--comfy-input-bg, #0a0a0a);
color: var(--fg-color, #fff);
color: var(--fg-color, #ededed);
border: 1px solid var(--border-color, #1f1f1f);
border-radius: 8px;
padding: 8px 10px;
@@ -101,11 +103,11 @@
outline: none;
}
.hanzo-copilot .hz-input textarea:focus {
border-color: var(--p-primary-color, #8b5cf6);
border-color: #888;
}
.hanzo-copilot .hz-send {
background: var(--p-button-primary-background, #7c3aed);
color: #fff;
background: #ffffff;
color: #111;
border: none;
border-radius: 8px;
padding: 9px 14px;
@@ -113,7 +115,7 @@
cursor: pointer;
transition: background 0.15s;
}
.hanzo-copilot .hz-send:hover { background: #6d28d9; }
.hanzo-copilot .hz-send:hover { background: #e4e4e7; }
.hanzo-copilot .hz-send:disabled { opacity: 0.5; cursor: default; }
/* Fallback toast (used only when the app toast service is unavailable) */
@@ -122,10 +124,9 @@
bottom: 24px;
left: 50%;
transform: translate(-50%, 12px);
background: #17171c;
color: #fff;
border: 1px solid #2a2140;
border-left: 3px solid var(--p-primary-color, #8b5cf6);
background: #1a1a1a;
color: #ededed;
border: 1px solid var(--border-color, #1f1f1f);
border-radius: 8px;
padding: 10px 16px;
font-size: 13px;
+103
View File
@@ -0,0 +1,103 @@
/* Hanzo Studio deep-link — open a saved workflow (and optionally run it) straight
* from a URL, so a link in the gallery / console / chat lands you on the graph
* ready to render.
*
* Params (query string or hash, e.g. ?org=karma&workflow=fashion/shoot_valentina.json&run=1):
* org active org to scope userdata + render output to (sets the studio
* session org cookie BEFORE loading, so a re-run dispatches to the
* org's BYO-GPU and lands the output in that org's gallery).
* workflow path under the user's workflows/ dir (e.g. "fashion/shoot_x.json").
* run "1" to auto-queue the graph once loaded (re-run in one click).
*
* Injected as the last <script> by branding/patch_deeplink.py so window.app
* exists. Guarded end-to-end: a bad param logs and no-ops, never throws.
*/
(function () {
"use strict";
var VERSION = "1";
function app() { return window.app; }
function params() {
var out = {};
var q = new URLSearchParams(window.location.search);
q.forEach(function (v, k) { out[k] = v; });
if (window.location.hash && window.location.hash.length > 1) {
var h = new URLSearchParams(window.location.hash.slice(1));
h.forEach(function (v, k) { if (out[k] == null) out[k] = v; });
}
return out;
}
function waitFor(cond, ms) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function poll() {
var v; try { v = cond(); } catch (e) { v = null; }
if (v) return resolve(v);
if (Date.now() - t0 > ms) return reject(new Error("timeout"));
setTimeout(poll, 150);
})();
});
}
function toast(msg, ok) {
var el = document.createElement("div");
el.textContent = msg;
el.style.cssText =
"position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:99999;" +
"padding:8px 14px;border-radius:8px;font:13px system-ui;color:#fff;" +
"background:" + (ok ? "#1f7a4d" : "#8a1f1f") + ";box-shadow:0 4px 16px rgba(0,0,0,.35)";
document.body.appendChild(el);
setTimeout(function () { el.style.transition = "opacity .4s"; el.style.opacity = "0"; }, 3200);
setTimeout(function () { el.remove(); }, 3700);
}
async function setOrg(org) {
// Set the studio active org so userdata + render output scope to it. Same-origin
// fetch carries cookies; the response Set-Cookie updates studio_active_org.
var r = await fetch("/v1/session/org", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ org: org }),
});
if (!r.ok) throw new Error("org switch failed (" + r.status + ")");
}
async function loadWorkflow(wf) {
var key = "workflows/" + String(wf).replace(/^\/+/, "");
var r = await fetch("/userdata/" + encodeURIComponent(key), { credentials: "same-origin" });
if (!r.ok) throw new Error("workflow not found: " + wf + " (" + r.status + ")");
var data = await r.json();
await app().loadGraphData(data);
return key;
}
async function run() {
var p = params();
if (!p.workflow && !p.org) return; // nothing to do — normal studio load
await waitFor(function () { return app() && typeof app().loadGraphData === "function"; }, 30000);
try {
if (p.org) { await setOrg(p.org); }
if (p.workflow) {
await loadWorkflow(p.workflow);
toast("Opened " + p.workflow + (p.org ? " · " + p.org : ""), true);
if (String(p.run) === "1" && typeof app().queuePrompt === "function") {
// Give the graph a tick to settle before queuing.
setTimeout(function () {
try { app().queuePrompt(0); toast("Queued render → your GPU", true); }
catch (e) { toast("Auto-run failed: " + e.message, false); }
}, 400);
}
} else if (p.org) {
toast("Switched to " + p.org, true);
}
} catch (e) {
console.warn("[Hanzo deep-link]", e);
toast(e.message, false);
}
}
run();
console.log("[Hanzo deep-link] v" + VERSION + " ready");
})();
+17
View File
@@ -110,6 +110,12 @@
}
rows.push(el("div", { class: "hzid-sep" }));
// Exit the advanced node editor back to the content-forward Studio Home.
// Without this there is NO way out of Advanced mode (the node editor is the
// full-screen ComfyUI SPA) — the user gets stuck.
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
]));
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
]));
@@ -151,6 +157,17 @@
// ------------------------------------------------------------- boot
function mount() {
if (document.getElementById("hanzo-identity")) return;
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
// Fixed top-left, above the node editor; independent of the user menu.
if (!document.getElementById("hanzo-exit-advanced")) {
var exit = el("a", {
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
document.body.appendChild(exit);
}
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
document.body.appendChild(root);
getSession().then(function (s) {
+55 -54
View File
@@ -1,11 +1,12 @@
/* Hanzo Studio — black surface + Hanzo-purple accent theme.
/* Hanzo Studio — black surface + monochrome accent theme.
* Minimal override: only background/surface/accent tokens are touched so the
* rest of the frontend's dark palette (text, node colors, borders) is intact.
* Loaded LAST in index.html so it beats PrimeVue's runtime-injected tokens;
* every declaration is !important because those tokens are injected after this
* sheet at boot. Colors chosen for WCAG AA contrast on black:
* accent #8B5CF6 on #000 -> 6.2:1 (AA normal text)
* button #ffffff on #7C3AED -> 5.25:1 (AA normal text)
* sheet at boot. Monochrome — no color accent — matching the canonical Hanzo
* design system (page #000 · surface #0a0a0a · raised #1a1a1a · border #1f1f1f ·
* text #ededed · accent/brand #ededed/#fff). Contrast on black:
* accent #ededed on #000 -> ~18:1 · button #111 on #fff -> ~18:1
*/
:root {
@@ -36,23 +37,23 @@
--p-overlay-popover-background: #0a0a0a !important;
--p-mask-background: rgba(0, 0, 0, 0.72) !important;
/* --- Hanzo purple accent --- */
--p-primary-color: #8b5cf6 !important; /* icons/links/active — AA on black */
--p-primary-contrast-color: #ffffff !important;
--p-primary-400: #a78bfa !important;
--p-primary-500: #8b5cf6 !important;
--p-primary-600: #7c3aed !important;
--p-focus-ring-color: #8b5cf6 !important;
--p-highlight-background: rgba(124, 58, 237, 0.24) !important;
--p-highlight-focus-background: rgba(124, 58, 237, 0.32) !important;
--p-highlight-color: #ede9fe !important;
/* --- Monochrome accent (near-white on black — icons/links/active) --- */
--p-primary-color: #ededed !important;
--p-primary-contrast-color: #111111 !important;
--p-primary-400: #f5f5f5 !important;
--p-primary-500: #ededed !important;
--p-primary-600: #d4d4d4 !important;
--p-focus-ring-color: #8a8a8a !important;
--p-highlight-background: rgba(255, 255, 255, 0.14) !important;
--p-highlight-focus-background: rgba(255, 255, 255, 0.20) !important;
--p-highlight-color: #ffffff !important;
/* --- Primary buttons: white on deeper violet keeps AA (5.25:1) --- */
--p-button-primary-background: #7c3aed !important;
--p-button-primary-hover-background: #6d28d9 !important;
--p-button-primary-active-background: #5b21b6 !important;
--p-button-primary-border-color: #7c3aed !important;
--p-button-primary-color: #ffffff !important;
/* --- Primary buttons: brand/CTA white on black, dark text (matches Home) --- */
--p-button-primary-background: #ffffff !important;
--p-button-primary-hover-background: #e4e4e7 !important;
--p-button-primary-active-background: #d4d4d4 !important;
--p-button-primary-border-color: #ffffff !important;
--p-button-primary-color: #111111 !important;
}
/* Pure-black canvas gutter behind the litegraph <canvas>. */
@@ -65,31 +66,31 @@ body.litegraph,
/* ── hover/active states: dark surfaces, never light (fix white-on-white) ── */
:root, :root.dark-theme {
--p-content-hover-background: #1f1f23 !important;
--p-content-hover-background: #1a1a1a !important;
--p-content-hover-color: #ffffff !important;
--p-navigation-item-hover-background: #1f1f23 !important;
--p-list-option-focus-background: #26262b !important;
--p-select-option-focus-background: #26262b !important;
--p-button-text-secondary-hover-background: #1f1f23 !important;
--p-button-text-primary-hover-background: rgba(139,92,246,.16) !important;
--p-button-secondary-hover-background: #26262b !important;
--p-togglebutton-hover-background: #26262b !important;
--p-tab-hover-background: #1f1f23 !important;
--p-menubar-item-focus-background: #26262b !important;
--p-menu-item-focus-background: #26262b !important;
--p-tree-node-hover-background: #1f1f23 !important;
--p-surface-100: #26262b !important;
--p-surface-200: #1f1f23 !important;
--p-highlight-background: rgba(139,92,246,.20) !important;
--p-navigation-item-hover-background: #1a1a1a !important;
--p-list-option-focus-background: #242424 !important;
--p-select-option-focus-background: #242424 !important;
--p-button-text-secondary-hover-background: #1a1a1a !important;
--p-button-text-primary-hover-background: rgba(255,255,255,.10) !important;
--p-button-secondary-hover-background: #242424 !important;
--p-togglebutton-hover-background: #242424 !important;
--p-tab-hover-background: #1a1a1a !important;
--p-menubar-item-focus-background: #242424 !important;
--p-menu-item-focus-background: #242424 !important;
--p-tree-node-hover-background: #1a1a1a !important;
--p-surface-100: #242424 !important;
--p-surface-200: #1a1a1a !important;
--p-highlight-background: rgba(255,255,255,.14) !important;
--p-highlight-color: #ffffff !important;
}
.side-tool-bar-container .p-button:hover,
.side-bar-button:hover,
.comfyui-button:hover {
background: #1f1f23 !important;
background: #1a1a1a !important;
color: #fff !important;
}
.p-button-text:not(.p-button-danger):hover { background: #1f1f23 !important; color: #fff !important; }
.p-button-text:not(.p-button-danger):hover { background: #1a1a1a !important; color: #fff !important; }
/* ── the H logo menu button: keep the mark visible on hover/open ── */
button:has(> .comfyui-logo):hover,
@@ -97,7 +98,7 @@ button:has(.comfyui-logo):hover,
.p-button:has(.comfyui-logo):hover,
button:has(.comfyui-logo)[aria-expanded="true"],
.comfyui-logo-menu-trigger:hover {
background: #1f1f23 !important;
background: #1a1a1a !important;
}
button:has(.comfyui-logo):hover .comfyui-logo,
button:has(.comfyui-logo):hover svg,
@@ -106,37 +107,37 @@ button:has(.comfyui-logo):hover path {
fill: #ffffff !important;
}
/* ── active/selected/checked states: never white (white-box-under-H fix) ── */
/* ── active/selected/checked states: neutral gray, never white ── */
:root, :root.dark-theme {
--p-button-secondary-active-background: #26262b !important;
--p-togglebutton-checked-background: #2a2140 !important;
--p-togglebutton-checked-color: #c4b5fd !important;
--p-navigation-item-active-background: #26262b !important;
--p-button-secondary-active-background: #242424 !important;
--p-togglebutton-checked-background: #242424 !important;
--p-togglebutton-checked-color: #ededed !important;
--p-navigation-item-active-background: #242424 !important;
--p-tab-active-background: #0a0a0a !important;
--p-button-text-primary-active-background: rgba(139,92,246,.24) !important;
--p-button-text-primary-active-background: rgba(255,255,255,.16) !important;
}
.side-tool-bar-container .p-button.p-highlight,
.side-tool-bar-container .p-togglebutton.p-togglebutton-checked,
.side-tool-bar-container [aria-pressed="true"],
.side-tool-bar-container .p-button:active,
.p-selectbutton .p-button.p-highlight {
background: #2a2140 !important;
color: #c4b5fd !important;
background: #242424 !important;
color: #ededed !important;
}
.side-tool-bar-container .p-button.p-highlight svg,
.side-tool-bar-container [aria-pressed="true"] svg { color: #c4b5fd !important; fill: currentColor !important; }
.side-tool-bar-container [aria-pressed="true"] svg { color: #ededed !important; fill: currentColor !important; }
/* ── selected sidebar icon: purple-tinted dark, never white ── */
/* ── selected sidebar icon: neutral dark, never white ── */
:root, :root.dark-theme {
--interface-panel-selected-surface: #2a2140 !important;
--interface-menu-component-surface-selected: #2a2140 !important;
--interface-panel-selected-surface: #242424 !important;
--interface-menu-component-surface-selected: #242424 !important;
}
.side-bar-button-selected {
background-color: #2a2140 !important;
color: #c4b5fd !important;
background-color: #242424 !important;
color: #ededed !important;
}
.side-bar-button-selected svg, .side-bar-button-selected i { color: #c4b5fd !important; }
.side-bar-button-selected svg, .side-bar-button-selected i { color: #ededed !important; }
.selected:not(.p-galleria-thumbnail-item) {
background-color: #2a2140 !important;
background-color: #242424 !important;
color: #e5e5e5 !important;
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Inject the Hanzo Studio deep-link opener into the prebuilt frontend.
Mirrors patch_copilot.py (same seam, same idempotency):
1. Assets -> copy hanzo-deeplink.js into static/.
2. index.html -> <script defer> as the last element before </body>, so
window.app exists when it reads the URL and opens/runs the
workflow.
Idempotent: strips any prior deeplink script (matched by filename) and re-injects
with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the JS changes.
Usage: patch_deeplink.py <static_dir> <branding_dir>
"""
import os
import re
import shutil
import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "1"
JS = "hanzo-deeplink.js"
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
def install_assets() -> None:
shutil.copyfile(os.path.join(BRANDING_DIR, JS), os.path.join(STATIC_DIR, JS))
print(f" Installed {JS}")
def inject() -> None:
index = os.path.join(STATIC_DIR, "index.html")
if not os.path.isfile(index):
print(" WARNING: index.html not found — deep-link not injected")
return
with open(index, encoding="utf-8") as f:
html = f.read()
# Strip any prior deeplink tag (any ?v=) so re-runs stay clean.
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
if "</body>" in html:
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
else: # no </body> (minified edge) — append; the script still runs
html += SCRIPT_TAG
with open(index, "w", encoding="utf-8") as f:
f.write(html)
print(f" Injected deep-link script (v{BUNDLE_VERSION}) into index.html")
if __name__ == "__main__":
install_assets()
inject()
+1 -1
View File
@@ -21,7 +21,7 @@ import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "3"
BUNDLE_VERSION = "4"
JS = "hanzo-identity.js"
CSS = "hanzo-identity.css"
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
+32 -11
View File
@@ -27,11 +27,13 @@ server keeps running.
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` (streams) | model + prompt -> `text` |
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + width/height -> `IMAGE` |
| **Hanzo TTS (Engine)** | `POST /v1/audio/speech` | text -> `AUDIO` |
| **Hanzo Music (Engine)** | `POST /v1/audio/music` | prompt -> `AUDIO` |
| **Hanzo ASR (Engine)** | `POST /v1/audio/transcriptions` | `AUDIO` -> `text` |
| **Hanzo Engine Request** | any `/v1` path | method + path + JSON -> `response` |
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt -> `caption` |
| **Hanzo Text to Video (Engine)** | async `/v1/videos` | prompt -> `IMAGE` frames |
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` -> mesh path |
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` (+`texture`) -> mesh path |
| **Hanzo Dub / Lip-Sync (Engine)** | `POST /v1/animate` | `IMAGE` + `AUDIO` -> `IMAGE` frames + `AUDIO` |
| **Hanzo Save Text** | -- | `text` -> writes `output/*.txt` |
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
@@ -39,21 +41,40 @@ server keeps running.
from `/v1/models`; `default` always routes to the loaded model.
- **Image Gen** sends the engine's `width`/`height`/`n` schema and decodes the
`b64_json` (or `url`) response into a Studio `IMAGE`.
- **TTS/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`) and
compose with the built-in Load/Save/Preview Audio nodes.
- **TTS/Music/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`)
and compose with the built-in Load/Save/Preview Audio nodes. TTS and Music
serve `wav`/`pcm` (the engine's synchronous audio formats); Music is stereo
44.1 kHz ACE-Step.
- **Engine Request** is the forward-compatible escape hatch: reach any endpoint
(music, dub, ...) the day it ships, then promote it to a dedicated node here.
the day it ships, then promote it to a dedicated node here.
- **Image to 3D** exposes the `texture` flag: on (default) runs the full
SLAT + FlexiCubes stage for a textured GLB; off returns the coarse mesh.
- **Text to Video / Image to 3D** use the engine's async job protocol
(`submit -> poll -> fetch content`) and report progress.
(`submit -> poll -> fetch content`) and report progress. **Dub** is
synchronous: it posts the portrait `IMAGE` + driving `AUDIO` and returns the
animated frames plus the audio (mux them with a video-combine node).
## Requirements
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
(always), plus `av`/`torchaudio` (loaded lazily, for the audio/video nodes).
No extra install.
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
encode/decode uses the stdlib `wave` module. No extra install.
## Example
## Verification
`user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
prompt, Image Gen renders it, the image and the prompt text are saved. All
compute runs on the native engine.
`contract_check.py` drives every node through real HTTP against a stub engine
that enforces the exact request fields each Rust handler deserializes and the
async job protocol (submit -> poll -> content):
```bash
python custom_nodes/hanzo_engine/contract_check.py
```
## Examples
- `user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
prompt, Image Gen renders it, image + prompt are saved.
- `user/default/workflows/hanzo-full-generative-pipeline.json` -- the flagship
multimodal pipeline: Chat expands a prompt, which fans out to Image Gen ->
(textured Image-to-3D **and** WAN Text-to-Video), plus TTS narration and
ACE-Step Music. One graph, every modality, all on the native engine.
+17 -3
View File
@@ -293,12 +293,26 @@ def audio_bytes_to_studio(data: bytes) -> dict:
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
"""Encode a Studio AUDIO dict to WAV bytes for multipart upload (ASR)."""
import torchaudio
"""Encode a Studio AUDIO dict ({waveform:[B,C,T], sample_rate}) to 16-bit PCM
WAV bytes for multipart upload (ASR) or a data URL (dub). Stdlib `wave` only."""
import wave
wav = audio["waveform"]
if wav.dim() == 3: # [B,C,T] -> [C,T]
wav = wav[0]
arr = wav.detach().cpu().float().clamp_(-1.0, 1.0).numpy() # [C,T]
pcm16 = (arr * 32767.0).astype(np.int16)
interleaved = pcm16.T.reshape(-1).tobytes() # [T,C] interleaved
buf = io.BytesIO()
torchaudio.save(buf, wav.cpu(), int(audio["sample_rate"]), format="wav")
with wave.open(buf, "wb") as w:
w.setnchannels(arr.shape[0])
w.setsampwidth(2)
w.setframerate(int(audio["sample_rate"]))
w.writeframes(interleaved)
return buf.getvalue()
def studio_audio_to_data_url(audio: dict) -> str:
"""Encode a Studio AUDIO dict to a `data:audio/wav` URL (driving audio for
/v1/animate, which takes audio as an http/file/data URL)."""
return "data:audio/wav;base64," + base64.b64encode(studio_audio_to_wav_bytes(audio)).decode("ascii")
+212
View File
@@ -0,0 +1,212 @@
"""Contract test: drive every Hanzo Engine node through REAL HTTP against a stub
engine that enforces the exact request fields the Rust handlers deserialize
(ThreeDGenerationRequest, VideoGenerationRequest, AnimateRequest, Speech/Music
requests) and the async-job protocol (submit -> poll -> content). This is a real
request/response cycle over sockets + the requests lib + client.run_job polling,
not a monkeypatched mock. Run: python contract_check.py
"""
# ruff: noqa: T201 (a CLI harness prints its results)
import base64
import io
import json
import os
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import numpy as np
import torch
# Stub out the ComfyUI-side modules the node pack imports so nodes.py loads.
fp = types.ModuleType("folder_paths")
fp.get_output_directory = lambda: "/tmp"
fp.get_save_image_path = lambda pre, d: (d, pre, 1, "", "")
sys.modules["folder_paths"] = fp
# Import the pack as a package so its `from . import client` resolves.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from hanzo_engine import nodes # noqa: E402
RECORDED = {} # path -> last JSON/body seen
def _wav_bytes(seconds=0.1, sr=44100, ch=2):
import wave
n = int(seconds * sr)
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(ch)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b"\x00\x00" * n * ch)
return buf.getvalue()
def _png_b64():
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _mp4_bytes(frames=3, w=16, h=16):
import av
buf = io.BytesIO()
container = av.open(buf, mode="w", format="mp4")
stream = container.add_stream("libx264", rate=25)
stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
for i in range(frames):
arr = np.full((h, w, 3), i * 20 % 255, dtype=np.uint8)
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in stream.encode(frame):
container.mux(pkt)
for pkt in stream.encode():
container.mux(pkt)
container.close()
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype="application/json"):
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self):
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n) if n else b"{}"
try:
return json.loads(raw)
except ValueError:
return {"_raw": raw}
def do_GET(self):
p = self.path
if p == "/v1/models":
return self._send(200, {"data": [{"id": "stub-model"}]})
if p.startswith("/v1/3d/") and p.endswith("/content"):
return self._send(200, base64.b64decode(_PLACEHOLDER_GLB_B64), "model/gltf-binary")
if p.startswith("/v1/videos/") and p.endswith("/content"):
return self._send(200, _mp4_bytes(), "video/mp4")
if p.startswith("/v1/3d/") or p.startswith("/v1/videos/"):
# poll: report completed immediately
return self._send(200, {"id": p.split("/")[3], "status": "completed", "progress": 1.0})
return self._send(404, {"message": "no"})
def do_POST(self):
try:
self._do_POST()
except Exception as exc: # surface stub-side errors to the client
self._send(500, {"stub_error": repr(exc)})
def _do_POST(self):
p = self.path
# multipart (ASR) is not JSON
if p == "/v1/audio/transcriptions":
n = int(self.headers.get("Content-Length", 0))
_ = self.rfile.read(n)
RECORDED[p] = {"multipart": True}
return self._send(200, {"text": "stub transcript"})
body = self._body_json()
RECORDED[p] = body
if p == "/v1/chat/completions":
return self._send(200, {"choices": [{"message": {"content": "expanded prompt"}}]})
if p == "/v1/images/generations":
return self._send(200, {"data": [{"b64_json": _png_b64()}]})
if p == "/v1/audio/speech":
assert "input" in body and "model" in body, "speech needs input+model"
return self._send(200, _wav_bytes(ch=1, sr=24000), "audio/wav")
if p == "/v1/audio/music":
assert "input" in body and "model" in body, "music needs input+model"
return self._send(200, _wav_bytes(ch=2, sr=44100), "audio/wav")
if p == "/v1/animate":
assert set(body) >= {"model", "audio", "visual"}, "animate needs model+audio+visual"
assert body["visual"].startswith("data:image/"), "visual must be an image data url"
assert body["audio"].startswith("data:audio/"), "audio must be an audio data url"
return self._send(200, _mp4_bytes(), "video/mp4")
if p == "/v1/videos":
assert "prompt" in body, "video needs prompt"
return self._send(202, {"id": "vid-1", "status": "queued"})
if p == "/v1/3d":
assert "image" in body, "3d needs image"
return self._send(202, {"id": "threed-1", "status": "queued", "progress": 0.0})
return self._send(404, {"message": "no"})
# A 1-triangle binary glTF is overkill; the node just writes bytes to disk, so any
# non-empty payload proves the content fetch + save path. Use a tiny placeholder.
_PLACEHOLDER_GLB_B64 = base64.b64encode(b"glTF\x02\x00\x00\x00stub-mesh").decode("ascii")
def main():
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
import os
os.environ["HANZO_ENGINE_URL"] = f"http://127.0.0.1:{port}"
img = torch.rand(1, 8, 8, 3)
audio = {"waveform": torch.zeros(1, 2, 4410), "sample_rate": 44100}
passed = []
# Chat (non-stream path)
(text,) = nodes.HanzoChat().generate(model="default", prompt="a red dragon", stream=False)["result"]
assert text == "expanded prompt", text
assert RECORDED["/v1/chat/completions"]["messages"][-1]["content"] == "a red dragon"
passed.append("HanzoChat -> /v1/chat/completions")
# ImageGen
(imgs,) = nodes.HanzoImageGen().generate("a red dragon", 64, 64, n=1)
assert imgs.shape[-1] == 3
passed.append("HanzoImageGen -> /v1/images/generations")
# TTS
(aud,) = nodes.HanzoTTS().speak("hello world", response_format="wav")
assert "waveform" in aud and aud["sample_rate"] == 24000
passed.append("HanzoTTS -> /v1/audio/speech")
# Music (NEW)
(music,) = nodes.HanzoMusic().compose("epic orchestral", response_format="wav")
assert music["sample_rate"] == 44100 and music["waveform"].shape[1] == 2, music["waveform"].shape
assert RECORDED["/v1/audio/music"]["input"] == "epic orchestral"
passed.append("HanzoMusic -> /v1/audio/music (input field, stereo 44.1k)")
# ASR
(tr,) = nodes.HanzoASR().transcribe(audio)
assert tr == "stub transcript"
passed.append("HanzoASR -> /v1/audio/transcriptions")
# ImageTo3D (texture flag fix) -- async job submit+poll+content
out = nodes.HanzoImageTo3D().generate(img, "glb", True, 0, 4)
assert RECORDED["/v1/3d"]["texture"] is True, "texture flag not sent"
assert RECORDED["/v1/3d"]["format"] == "glb"
assert out["result"][0].endswith(".glb")
passed.append("HanzoImageTo3D -> /v1/3d (texture=true sent; job submit->poll->content)")
# TextToVideo -- async job
(frames,) = nodes.HanzoTextToVideo().generate("a red dragon flying", 3, 16, 16, 4)
assert frames.dim() == 4 and frames.shape[0] >= 1
assert RECORDED["/v1/videos"]["num_frames"] == 3
passed.append("HanzoTextToVideo -> /v1/videos (job submit->poll->content)")
# Dub (NEW) -- synchronous mp4
(dframes, daudio) = nodes.HanzoDub().dub(img, audio, fps=25.0)
assert dframes.dim() == 4
assert daudio is audio
passed.append("HanzoDub -> /v1/animate (image+audio data urls; frames+audio out)")
srv.shutdown()
print("\n".join(f"PASS {p}" for p in passed))
print(f"\nALL {len(passed)} NODE CONTRACT CHECKS PASSED against a live HTTP engine stub.")
if __name__ == "__main__":
main()
+81 -6
View File
@@ -8,11 +8,13 @@ http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
HanzoChat -> POST /v1/chat/completions (prompt -> text)
HanzoImageGen -> POST /v1/images/generations (prompt -> IMAGE)
HanzoTTS -> POST /v1/audio/speech (text -> AUDIO)
HanzoMusic -> POST /v1/audio/music (prompt -> AUDIO)
HanzoASR -> POST /v1/audio/transcriptions (AUDIO -> text)
HanzoEngineRequest -> POST|GET <any /v1 path> (JSON -> response)
HanzoVisionCaption -> POST /v1/chat/completions (vision) (IMAGE -> text)
HanzoTextToVideo -> async /v1/videos (prompt -> frames)
HanzoImageTo3D -> async /v1/3d (IMAGE -> mesh path)
HanzoDub -> POST /v1/animate (IMAGE+AUDIO -> frames)
HanzoSaveText -> writes a STRING to output/*.txt
The generic HanzoEngineRequest is the forward-compatible escape hatch: a new
@@ -129,7 +131,7 @@ class HanzoTTS:
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "flac", "mp3", "opus", "aac"], {"default": "wav"}),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
@@ -302,15 +304,17 @@ class HanzoTextToVideo:
class HanzoImageTo3D:
"""Image-to-3D via the async job /v1/3d (native Rust). Saves the GLB/PLY to
the output dir and returns its path for a 3D-viewer node."""
"""Image-to-3D via the async job /v1/3d (native Rust TRELLIS/Pixal3D). Saves
the mesh to the output dir and returns its path for a 3D-viewer node. Enable
`texture` for the full SLAT + FlexiCubes stage (textured GLB vs coarse mesh)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"format": (["glb", "ply"], {"default": "glb"}),
"format": (["glb", "ply", "obj"], {"default": "glb"}),
"texture": ("BOOLEAN", {"default": True}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
},
@@ -322,10 +326,11 @@ class HanzoImageTo3D:
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, image, format, seed, steps):
def generate(self, image, format, texture, seed, steps):
raw = client.run_job(
"3d",
{"image": client.tensor_to_data_url(image), "format": format, "seed": seed, "steps": steps},
{"image": client.tensor_to_data_url(image), "format": format,
"texture": texture, "seed": seed, "steps": steps},
job_timeout=900,
)
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
@@ -337,6 +342,72 @@ class HanzoImageTo3D:
return {"ui": {"text": [path]}, "result": (path,)}
class HanzoMusic:
"""Text/tag/lyric prompt -> music via /v1/audio/music (native Rust ACE-Step).
Returns a Studio AUDIO you can save/preview with the built-in audio nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True,
"default": "epic cinematic orchestral, driving percussion, brass swells"}),
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "compose"
CATEGORY = CATEGORY
def compose(self, prompt, model="default", response_format="wav"):
raw = client.post_bytes(
"/v1/audio/music",
{"model": model, "input": prompt, "response_format": response_format},
timeout=900,
)
return (client.audio_bytes_to_studio(raw),)
class HanzoDub:
"""Lip-sync / avatar dub via /v1/animate (native Rust MuseTalk). Drives a
portrait IMAGE (or the first video frame) with an AUDIO track and returns the
animated frames as an IMAGE batch plus the driving audio, so a video-combine
node can mux them."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"audio": ("AUDIO",),
},
"optional": {
"model": (_model_combo(),),
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 60.0, "step": 1.0}),
},
}
RETURN_TYPES = ("IMAGE", "AUDIO")
RETURN_NAMES = ("frames", "audio")
FUNCTION = "dub"
CATEGORY = CATEGORY
def dub(self, image, audio, model="default", fps=25.0):
payload = {
"model": model,
"audio": client.studio_audio_to_data_url(audio),
"visual": client.tensor_to_data_url(image),
"fps": fps,
}
raw = client.post_bytes("/v1/animate", payload, timeout=1800)
return (client.mp4_bytes_to_image_batch(raw), audio)
class HanzoSaveText:
"""Write a STRING to output/*.txt (image/caption pairs for dataset prep)
and preview it in the node."""
@@ -372,11 +443,13 @@ NODE_CLASS_MAPPINGS = {
"HanzoChat": HanzoChat,
"HanzoImageGen": HanzoImageGen,
"HanzoTTS": HanzoTTS,
"HanzoMusic": HanzoMusic,
"HanzoASR": HanzoASR,
"HanzoEngineRequest": HanzoEngineRequest,
"HanzoVisionCaption": HanzoVisionCaption,
"HanzoTextToVideo": HanzoTextToVideo,
"HanzoImageTo3D": HanzoImageTo3D,
"HanzoDub": HanzoDub,
"HanzoSaveText": HanzoSaveText,
}
@@ -384,10 +457,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"HanzoChat": "Hanzo Chat (Engine)",
"HanzoImageGen": "Hanzo Image Gen (Engine)",
"HanzoTTS": "Hanzo TTS (Engine)",
"HanzoMusic": "Hanzo Music (Engine)",
"HanzoASR": "Hanzo ASR (Engine)",
"HanzoEngineRequest": "Hanzo Engine Request",
"HanzoVisionCaption": "Hanzo Vision Caption (Engine)",
"HanzoTextToVideo": "Hanzo Text to Video (Engine)",
"HanzoImageTo3D": "Hanzo Image to 3D (Engine)",
"HanzoDub": "Hanzo Dub / Lip-Sync (Engine)",
"HanzoSaveText": "Hanzo Save Text",
}
+5 -1
View File
@@ -149,7 +149,11 @@ class CacheSet:
}
return result
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo")
# Secrets that ride with a queued prompt but must never be persisted into history.
# `iam_token` is the requesting user's own verified IAM access token: the prompt
# worker records the finished render in the content lane as that user
# (middleware/content_publish.py).
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo", "iam_token")
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
is_v3 = issubclass(class_def, _StudioNodeInternal)
+15 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import time
import mimetypes
import logging
@@ -94,6 +95,19 @@ def get_org_id() -> str | None:
return _org_id
_ORG_ID_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
def _safe_org_id(oid: str) -> str:
"""The single choke point where an org value becomes a filesystem path — so it
is also the one place to reject traversal. An org id must be a plain slug; `.`,
`..`, `/`, `\\`, or any other character is refused so a malformed/hostile claim
can never escape the orgs/ tree (defense-in-depth; org id is IAM-derived today)."""
if oid in (".", "..") or not _ORG_ID_RE.match(oid):
raise ValueError(f"invalid org_id for path scoping: {oid!r}")
return oid
def get_org_base_path(org_id: str | None = None) -> str:
"""
Get the org-scoped base path. If multi-tenant is enabled and org_id is set,
@@ -101,7 +115,7 @@ def get_org_base_path(org_id: str | None = None) -> str:
"""
oid = org_id or _org_id
if _multi_tenant and oid:
org_path = os.path.join(base_path, "orgs", oid)
org_path = os.path.join(base_path, "orgs", _safe_org_id(oid))
os.makedirs(org_path, exist_ok=True)
return org_path
return base_path
+3 -1
View File
@@ -23,7 +23,9 @@ test:
tests-unit/folder_paths_test \
tests-unit/middleware_test \
tests-unit/app_test \
tests-unit/prompt_server_test
tests-unit/prompt_server_test \
tests-unit/studio_home_test \
tests-unit/documents_test
# Deploy: roll the freshly-built image onto the studio Service CR.
deploy:
+42 -6
View File
@@ -245,6 +245,7 @@ def prompt_worker(q, server_instance):
# Import metrics + billing for prompt tracking
from middleware import metrics_middleware
from middleware import billing_middleware
from middleware import content_publish
enable_billing = getattr(args, "enable_billing", False)
enable_metrics = getattr(args, "enable_metrics", False)
@@ -307,6 +308,26 @@ def prompt_worker(q, server_instance):
server_instance.loop,
)
# Content: record each output artifact as a draft Asset in the content
# lane (clients/content, module "marketing") — the SAME DocType that
# lane writes when it drives a render, so a studio-initiated render lands
# in the same place. Written as the requesting user via their IAM token
# from `sensitive`, so it lands in their org. Assets land draft; a human
# moves them through the lifecycle, and published is what a storefront
# shows. Fire-and-forget: a render is never failed if the lane is down.
if content_publish.enabled() and e.success:
asyncio.run_coroutine_threadsafe(
content_publish.publish_render(
org_id=extra_data.get("org_id") or "default",
prompt_id=prompt_id,
workflow=item[2],
history_result=e.history_result,
iam_token=extra_data.get("iam_token"),
extra_data=extra_data,
),
server_instance.loop,
)
# Log Time in a more readable way after 10 minutes
if execution_time > 600:
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
@@ -430,17 +451,32 @@ def start_studio(asyncio_loop=None):
cuda_malloc_warning()
setup_database()
prompt_server.add_routes()
hijack_progress(prompt_server)
# In worker mode, add worker execution routes and register with coordinator
# In worker mode, register the worker execution routes BEFORE add_routes() mounts
# the route table — otherwise POST /v1/worker/execute is appended after the SPA
# catch-all is already mounted and never registers, so the catch-all answers it
# (GET/HEAD only) and the coordinator seam 405s (no hidden-run hole, but no renders).
if args.worker_mode:
from middleware.worker_client import add_worker_routes
from middleware.worker_client import add_worker_routes, WORKER_TOKEN
# FAIL CLOSED: a worker box executes jobs the coordinator hands it over a
# secret-authenticated seam. Without STUDIO_WORKER_TOKEN that seam (and the
# worker-mode /prompt gate) would accept un-tokened callers — silently
# reopening the hidden-run hole. Refuse to start rather than run wide open.
if not WORKER_TOKEN:
sys.exit("worker-mode requires STUDIO_WORKER_TOKEN (coordinator shared secret) — refusing to start")
add_worker_routes(prompt_server.routes, prompt_server)
logging.info("Worker mode enabled — worker_id=%s coordinator=%s",
args.worker_id, args.coordinator_url)
threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,)).start()
prompt_server.add_routes()
hijack_progress(prompt_server)
_worker_thread = threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,))
_worker_thread.start()
# Exposed so /ready can gate on the render worker actually being alive: if this
# daemon thread dies (an unhandled exception in the executor), the queue still
# accepts /prompt but nothing runs — readiness must fail so the pod leaves rotation
# and a roll never promotes a wedged pod.
prompt_server.prompt_worker_thread = _worker_thread
if args.quick_test_for_ci:
exit(0)
+63
View File
@@ -0,0 +1,63 @@
"""Colour fidelity for Fix outputs.
A Qwen-Image-Edit fix runs at denoise 1.0 — it re-paints EVERY pixel, so the
render's global white-balance / exposure / saturation drift off the source, and
that drift COMPOUNDS across fix-of-a-fix. This restores the render's global colour
statistics to its source (Reinhard mean/std transfer, per RGB channel): the local
edit stays, the global cast goes. Matching an already-correct image to its source
is the identity, so a render that did not drift is left untouched.
Applied POD-SIDE when a BYO-GPU worker's finished render is ingested
(server.upload_output). It needs NO node on the GPU worker and touches no render
graph, so it can never make a render fail. Fail-safe throughout: any error, missing
source, or size mismatch leaves the raw bytes exactly as uploaded.
"""
import os
import numpy as np
from PIL import Image
# Fraction of the correction to apply. <1.0 leaves headroom for an INTENTIONAL
# global colour change in the instruction: the drift being corrected is a global
# cast, while a normal edit is local and barely moves the whole-frame statistics,
# so a moderate-high default corrects drift while an intended recolour mostly
# survives. Tunable without a rebuild via STUDIO_COLORMATCH_STRENGTH.
STRENGTH = float(os.environ.get("STUDIO_COLORMATCH_STRENGTH", "0.85"))
def color_match(target: Image.Image, reference: Image.Image, strength: float = STRENGTH) -> Image.Image:
"""Pull ``target``'s global colour balance onto ``reference``'s — a per-channel
Reinhard mean/std transfer in RGB. target-to-itself is the identity."""
t = np.asarray(target.convert("RGB"), np.float32)
ref = reference.convert("RGB")
if ref.size != target.size:
ref = ref.resize(target.size, Image.LANCZOS)
r = np.asarray(ref, np.float32)
out = t.copy()
for c in range(3):
tm, ts = t[..., c].mean(), t[..., c].std() + 1e-5
rm, rs = r[..., c].mean(), r[..., c].std() + 1e-5
out[..., c] = (t[..., c] - tm) * (rs / ts) + rm
out = t + (out - t) * float(strength)
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
def match_in_place(out_path: str, src_path: str, strength: float = STRENGTH) -> bool:
"""Colour-match the fix render at ``out_path`` to its source, atomically in
place (tmp + replace). Returns True if rewritten, False (no-op) on any error or
missing input — never raises."""
try:
if strength <= 0 or not (os.path.isfile(out_path) and os.path.isfile(src_path)):
return False
with Image.open(out_path) as o, Image.open(src_path) as s:
matched = color_match(o, s, strength)
# Tmp keeps a real image extension so PIL can infer the format; pass it
# explicitly too (the ".cm.tmp" suffix alone is unknown to PIL).
ext = os.path.splitext(out_path)[1].lower()
fmt = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(ext, "PNG")
tmp = f"{out_path}.cm.tmp{ext or '.png'}"
matched.save(tmp, format=fmt)
os.replace(tmp, out_path)
return True
except Exception:
return False
+189
View File
@@ -0,0 +1,189 @@
"""
Publish a completed Studio render into the Hanzo content lane.
THE one writer, and it writes NOTHING new. A finished render becomes an `Asset`
document — the DocType hanzoai/cloud `clients/content` already declares (framework
module "marketing") and already writes itself when IT drives a render
(clients/content/studio_render.go). Studio-initiated renders were the only ones
with no path into that record; this closes exactly that gap and adds no second
schema, no second lifecycle, and no second storage plane.
POST /v1/framework/Asset (the already-live generic framework surface)
Same shape `clients/content` writes (studio_render.go `draftAsset`):
title, kind, design, prompt, file, generator, workflow,
source_prompt_id, render_params, project, tags
`file` is the object key under the org's studio output prefix —
``orgs/<org>/output/<subfolder>/<filename>`` — byte-identical to the key
`persistOrLink` produces, and the same key Studio's own output mirror already
lands. So the bytes are ALREADY persisted by Studio: this module uploads nothing
and holds no storage credential. The record points at what exists.
Curation is the content lane's ONE state machine (clients/content/lifecycle.go):
draft → in_review → approved → queued → published
An asset lands in `draft` (the DocType's default) and a human moves it. Publishing
is what puts an image in front of customers, so an unattended render must never do
it. The storefront reads `published`.
Auth is the requesting user's OWN verified IAM access token, carried to the prompt
worker on the queue's existing `sensitive` channel (execution.SENSITIVE_EXTRA_DATA_KEYS)
so it is never persisted into history. The Asset is written AS that user, into that
user's org — the framework derives the tenant from the token's `owner` claim. No
service account, no impersonation, no cross-org write path.
Fire-and-forget: a publish failure only logs. A render is NEVER failed for it.
Env-gated; absent locally = no-op.
STUDIO_CLOUD_API_URL Hanzo Cloud base URL (default https://api.hanzo.ai)
STUDIO_CONTENT_PUBLISH "1"/"true" to enable
"""
import logging
import os
import posixpath
import aiohttp
CLOUD_URL = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai")
# One Asset row per artifact; small JSON writes.
_TIMEOUT = aiohttp.ClientTimeout(total=float(os.environ.get("STUDIO_CONTENT_TIMEOUT", "15")))
# clients/content declares Asset.kind as a Select; anything else would 422.
KINDS = ("ecom", "product", "lifestyle", "hover", "hero", "thumbnail")
DEFAULT_KIND = "product"
def enabled() -> bool:
"""True when render publishing is switched on. Absent locally = no-op."""
return os.environ.get("STUDIO_CONTENT_PUBLISH", "").strip().lower() in ("1", "true", "yes")
def artifacts(history_result: dict | None) -> list[dict]:
"""
The media artifacts a completed render produced, as Studio's own output refs
({filename, subfolder, type}). Mirrors billing_middleware.count_outputs — the
same shape Studio already returns to the UI, so there is no second notion of
"what a render produced". Previews (type != "output") are not artifacts.
"""
out: list[dict] = []
for node_output in (history_result or {}).get("outputs", {}).values():
if not isinstance(node_output, dict):
continue
for values in node_output.values():
if not isinstance(values, list):
continue
for v in values:
if isinstance(v, dict) and v.get("filename") and v.get("type") == "output":
out.append(v)
return out
def object_key(org_id: str, ref: dict) -> str:
"""
The asset's key under the org's studio output prefix. Byte-identical to the key
clients/content builds in persistOrLink (orgs/<org>/output/...), which is also
where Studio's own output mirror lands — ONE key convention, so the record and
the bytes can never disagree.
"""
return posixpath.join(
"orgs", org_id, "output", (ref.get("subfolder") or "").strip("/"), ref["filename"]
).replace("//", "/")
def kind_of(extra_data: dict) -> str:
"""The Asset.kind Select value. Anything off-list would be refused by the engine."""
k = str(extra_data.get("kind") or "").strip().lower()
return k if k in KINDS else DEFAULT_KIND
def _model(workflow: dict | None) -> str:
"""Best-effort model name off the graph's first checkpoint/UNet loader."""
for node in (workflow or {}).values():
if not isinstance(node, dict):
continue
if "Loader" not in str(node.get("class_type", "")):
continue
for v in (node.get("inputs") or {}).values():
if isinstance(v, str) and v.endswith((".safetensors", ".ckpt", ".gguf", ".sft")):
return v
return ""
def asset_doc(*, org_id: str, prompt_id: str, workflow: dict, ref: dict, extra_data: dict) -> dict:
"""
One artifact as an Asset document — the same field set clients/content writes.
`status` is omitted on purpose: the DocType defaults it to draft, and the
lifecycle hook owns every transition from there.
"""
model = _model(workflow)
return {
"title": ref["filename"],
"kind": kind_of(extra_data),
"design": str(extra_data.get("design") or "").strip(),
"role": str(extra_data.get("role") or "").strip(),
"prompt": str(extra_data.get("prompt") or "").strip(),
"file": object_key(org_id, ref),
"generator": model or "studio",
"workflow": model or "studio",
"source_prompt_id": prompt_id,
# The graph that reproduces this artifact — the reproducibility record.
"render_params": workflow or {},
"project": str(extra_data.get("project") or "").strip(),
"tags": str(extra_data.get("tags") or "").strip(),
}
async def publish_render(
*,
org_id: str,
prompt_id: str,
workflow: dict,
history_result: dict | None,
iam_token: str | None,
extra_data: dict | None = None,
) -> int:
"""
Publish a completed render's artifacts as draft Assets. Returns how many landed.
Never raises: the caller is the prompt worker, and a render must not fail
because the content lane was unreachable.
"""
if not enabled():
return 0
if not iam_token:
# No verified user principal on this execution. We refuse to invent one:
# writing via a service account would be a cross-org write path.
logging.warning("content_publish: no IAM token on prompt %s — not published", prompt_id)
return 0
refs = artifacts(history_result)
if not refs:
return 0
extra = extra_data or {}
url = f"{CLOUD_URL.rstrip('/')}/v1/framework/Asset"
headers = {"Authorization": f"Bearer {iam_token}"}
landed = 0
try:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
for ref in refs:
doc = asset_doc(
org_id=org_id, prompt_id=prompt_id, workflow=workflow,
ref=ref, extra_data=extra,
)
async with session.post(url, json=doc, headers=headers) as resp:
if resp.status in (200, 201):
landed += 1
else:
logging.warning(
"content_publish: POST /v1/framework/Asset -> %s: %s",
resp.status, (await resp.text())[:200],
)
except Exception as e: # unreachable cloud / timeout — a render is never failed for this
logging.warning("content_publish: prompt %s not published: %s", prompt_id, e)
return landed
+18 -11
View File
@@ -17,7 +17,7 @@ Env (all optional):
STUDIO_COPILOT_URL chat-completions URL. Default: the local engine at
http://127.0.0.1:1234/v1/chat/completions if that port
is open, else https://api.hanzo.ai/v1/chat/completions.
STUDIO_COPILOT_MODEL model id (default "zen").
STUDIO_COPILOT_MODEL model id (default "enso" — the Hanzo flagship assistant).
STUDIO_COPILOT_TOKEN bearer for the gateway. Falls back to
STUDIO_COMMERCE_TOKEN, then any bearer on the request.
@@ -62,6 +62,11 @@ _OP_REQUIRED = {
_GATEWAY_URL = "https://api.hanzo.ai/v1/chat/completions"
_LOCAL_URL = "http://127.0.0.1:1234/v1/chat/completions"
# Server-side gateway keys, in precedence order (never in page source). When none is set
# the caller's own token is forwarded ONLY when the gateway can verify it (pk-/asymmetric
# JWT) — see gateway_auth. This is why a browser HS256 session token no longer 502s.
_SERVER_TOKEN_ENV = ("STUDIO_COPILOT_TOKEN", "STUDIO_COMMERCE_TOKEN")
# Single tool: the model calls it to both reply and edit the graph.
_EDIT_TOOL = {
"type": "function",
@@ -142,17 +147,19 @@ class CopilotError(Exception):
def _resolve_target(request) -> tuple[str, str, str]:
"""(url, model, token) for this request."""
"""(url, model, bearer_token) for this request.
The bearer is chosen by the ONE fail-closed gateway allowlist (``gateway_auth``): a
server-side key first, else the caller's own token ONLY when the gateway can verify it
(a publishable ``pk-`` key or an asymmetric/JWKS JWT). A symmetric HS256 hanzo.id
session token is NEVER forwarded — the gateway rejects it ("unsupported signing method:
HS256"), which was the 502 that made Chat show a raw error. ``token`` is the bare value
(no ``Bearer `` prefix) since ``_post_chat`` adds it; "" when none is usable."""
from middleware import gateway_auth
url = os.environ.get("STUDIO_COPILOT_URL", "").strip() or _DEFAULT_URL
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "zen"
token = (
os.environ.get("STUDIO_COPILOT_TOKEN", "").strip()
or os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
)
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "enso"
bearer = gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV)
token = bearer[7:] if bearer[:7].lower() == "bearer " else bearer
return url, model, token
+887
View File
@@ -0,0 +1,887 @@
"""Office-document renderers — the NON-GPU pipeline.
Studio's creative side is ComfyUI graphs → GPU. Office documents are the opposite:
structured content (a template's typed fields, filled by a person or by the AI) →
a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md file. No graph, no model,
no GPU.
One and only one way, kept DRY by a neutral intermediate representation (IR):
* a template maps its fields to ONE of three archetype IRs —
- ``doc`` : a list of prose blocks (title/heading/paragraph/table/…) → PDF · DOCX · MD
- ``sheet`` : a list of sheets (columns + rows + totals) → XLSX · PDF · DOCX · MD
- ``deck`` : a list of slides (title + bullets + notes) → PPTX · PDF · MD
* each output format has EXACTLY ONE renderer that consumes the IR.
So adding a template = write one ``build(fields) -> (kind, ir)``; adding a format =
write one renderer. The renderers never know about templates, HTTP, storage, or org
scoping — they are pure ``(ir) -> bytes``.
Dependencies are imported LAZILY inside each renderer so this module imports with only
the standard library present (unit tests, light pods). PDF prefers WeasyPrint (full CSS
fidelity); when its native stack (pango/cairo) is absent it falls back to a self-contained
pure-Python PDF writer that still emits a valid, readable, monochrome document — the
``weasyprint``-absent fallback. DOCX/XLSX/PPTX are pure-Python (python-docx / openpyxl /
python-pptx) and always render for real.
"""
from __future__ import annotations
import html as _html
import io
import zlib
from typing import Any
# ── Public surface ────────────────────────────────────────────────────────────────
FORMATS = ("pdf", "docx", "xlsx", "pptx", "md")
CONTENT_TYPE = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"md": "text/markdown; charset=utf-8",
}
# Which formats each archetype can produce. The catalog's per-template ``formats`` is
# always a subset of its archetype's capability — the render dispatch enforces both.
KIND_FORMATS = {
"doc": ("pdf", "docx", "md"),
"sheet": ("xlsx", "pdf", "docx", "md"),
"deck": ("pptx", "pdf", "md"),
}
def content_type(fmt: str) -> str:
return CONTENT_TYPE[fmt]
def render(kind: str, ir: dict, fmt: str) -> bytes:
"""Render an archetype IR to ``fmt``, returning the file bytes.
Raises ``ValueError`` for an unsupported (kind, fmt) pair — the one place the
dispatch table lives, so a caller can never silently get the wrong renderer.
"""
if fmt not in KIND_FORMATS.get(kind, ()): # pragma: no cover - guarded by callers
raise ValueError(f"{kind!r} cannot render {fmt!r}")
if kind == "doc":
return _render_doc(ir, fmt)
if kind == "sheet":
return _render_sheet(ir, fmt)
if kind == "deck":
return _render_deck(ir, fmt)
raise ValueError(f"unknown archetype {kind!r}") # pragma: no cover
def weasyprint_available() -> bool:
"""True when the high-fidelity PDF path (WeasyPrint + native pango/cairo) is usable.
Diagnostic only — the renderer falls back automatically."""
return _weasy_pdf("<html><body>probe</body></html>") is not None
# ── IR block constructors — the shared vocabulary of the ``doc`` archetype ─────────
# Blocks are plain dicts (JSON-friendly, no classes to import). A template's build()
# assembles a list of them; four renderers consume the same list.
def title(text: str, sub: str | None = None) -> dict:
return {"t": "title", "text": text or "", "sub": sub or ""}
def heading(text: str, level: int = 2) -> dict:
return {"t": "h", "text": text or "", "level": max(1, min(3, int(level)))}
def para(text: str) -> dict:
return {"t": "p", "text": text or ""}
def keyvals(pairs: list, cols: int = 1) -> dict:
return {"t": "kv", "pairs": [[str(k), str(v)] for k, v in pairs if v not in (None, "")], "cols": cols}
def party(left: dict, right: dict | None = None) -> dict:
"""Two facing address/identity blocks (invoice from/to, letter sender/recipient)."""
def _side(s):
s = s or {}
return {"title": str(s.get("title", "")), "lines": [str(x) for x in (s.get("lines") or []) if str(x).strip()]}
return {"t": "party", "left": _side(left), "right": _side(right)}
def table(headers: list, rows: list, align: list | None = None, foot: list | None = None) -> dict:
return {
"t": "table",
"headers": [str(h) for h in headers],
"rows": [[("" if c is None else str(c)) for c in r] for r in rows],
"align": align or [],
"foot": [[("" if c is None else str(c)) for c in r] for r in (foot or [])],
}
def bullets(items: list) -> dict:
return {"t": "ul", "items": [str(i) for i in items if str(i).strip()]}
def rule() -> dict:
return {"t": "hr"}
def spacer(h: int = 12) -> dict:
return {"t": "sp", "h": int(h)}
def signature(label: str) -> dict:
return {"t": "sign", "label": label or ""}
def doc_ir(title_text: str, blocks: list, *, meta: dict | None = None) -> dict:
return {"title": title_text or "Document", "blocks": [b for b in blocks if b], "meta": meta or {}}
# ── doc archetype: PDF · DOCX · MD ────────────────────────────────────────────────
def _render_doc(ir: dict, fmt: str) -> bytes:
if fmt == "pdf":
html = _doc_html(ir)
pdf = _weasy_pdf(html)
return pdf if pdf is not None else _builtin_pdf(ir)
if fmt == "docx":
return _doc_docx(ir)
if fmt == "md":
return _doc_md(ir).encode("utf-8")
raise ValueError(fmt) # pragma: no cover
# Monochrome print stylesheet — Hanzo's design-system look on paper: near-black ink on
# white, a clean sans stack, thin hairline rules, generous margins. Deliberately light
# (this is the paper, previewed as paper even under a dark UI).
_PRINT_CSS = """
@page { size: Letter; margin: 22mm 20mm; }
* { box-sizing: border-box; }
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, "Segoe UI", sans-serif;
color: #16171a; font-size: 10.5pt; line-height: 1.5; margin: 0; }
h1.doctitle { font-size: 22pt; letter-spacing: -.01em; margin: 0 0 2pt; font-weight: 700; }
.docsub { color: #6b6f76; font-size: 10.5pt; margin: 0 0 14pt; }
h2 { font-size: 13pt; font-weight: 700; margin: 20pt 0 6pt; letter-spacing:-.005em; }
h3 { font-size: 11pt; font-weight: 700; margin: 14pt 0 4pt; }
h4 { font-size: 10.5pt; font-weight: 700; margin: 12pt 0 3pt; text-transform: uppercase; letter-spacing: .06em; color:#4a4d53; }
p { margin: 0 0 8pt; }
.hr { border: 0; border-top: 1px solid #d9dbdf; margin: 14pt 0; }
.kv { width: 100%; border-collapse: collapse; margin: 0 0 8pt; }
.kv td { padding: 2pt 0; vertical-align: top; }
.kv td.k { color: #6b6f76; width: 34%; padding-right: 10pt; }
.kv.two { }
.parties { display: flex; gap: 26pt; margin: 6pt 0 16pt; }
.parties .side { flex: 1; }
.parties .side .ttl { text-transform: uppercase; letter-spacing: .06em; font-size: 8.5pt; color: #6b6f76; margin: 0 0 3pt; }
.parties .side .ln { margin: 0; }
table.grid { width: 100%; border-collapse: collapse; margin: 8pt 0 12pt; font-size: 10pt; }
table.grid th { text-align: left; font-weight: 700; border-bottom: 1.4px solid #16171a; padding: 6pt 8pt 6pt 0; }
table.grid td { border-bottom: 1px solid #e6e8eb; padding: 6pt 8pt 6pt 0; vertical-align: top; }
table.grid th.num, table.grid td.num { text-align: right; padding-right: 0; }
table.grid tfoot td { border-bottom: 0; border-top: 1.4px solid #16171a; font-weight: 700; padding-top: 7pt; }
ul { margin: 0 0 8pt; padding-left: 16pt; }
li { margin: 0 0 3pt; }
.sign { margin-top: 34pt; }
.sign .line { border-top: 1px solid #16171a; width: 58%; padding-top: 4pt; color: #6b6f76; font-size: 9pt; }
"""
def _doc_html(ir: dict) -> str:
"""The IR as one self-contained HTML document for WeasyPrint. Built with stdlib
``html.escape`` at every value boundary — no template engine, so there is no
template-injection surface and no extra dependency; the structural HTML is ours,
every field value is escaped."""
body = _doc_html_body(ir) # already-escaped, structural HTML
doc_title = _html.escape(ir.get("title") or "Document")
return (
"<!doctype html><html><head><meta charset='utf-8'><title>" + doc_title
+ "</title><style>" + _PRINT_CSS + "</style></head><body>" + body + "</body></html>"
)
def _e(s: Any) -> str:
return _html.escape("" if s is None else str(s))
def _doc_html_body(ir: dict) -> str:
out: list[str] = []
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
out.append(f"<h1 class='doctitle'>{_e(b['text'])}</h1>")
if b.get("sub"):
out.append(f"<div class='docsub'>{_e(b['sub'])}</div>")
elif t == "h":
tag = {1: "h2", 2: "h3", 3: "h4"}[b.get("level", 2)]
out.append(f"<{tag}>{_e(b['text'])}</{tag}>")
elif t == "p":
out.append(f"<p>{_e(b['text']).replace(chr(10), '<br>')}</p>")
elif t == "kv":
cls = "kv two" if b.get("cols") == 2 else "kv"
rows = "".join(f"<tr><td class='k'>{_e(k)}</td><td>{_e(v)}</td></tr>" for k, v in b["pairs"])
out.append(f"<table class='{cls}'>{rows}</table>")
elif t == "party":
def side(s):
lns = "".join(f"<p class='ln'>{_e(x)}</p>" for x in s["lines"])
ttl = f"<p class='ttl'>{_e(s['title'])}</p>" if s["title"] else ""
return f"<div class='side'>{ttl}{lns}</div>"
out.append(f"<div class='parties'>{side(b['left'])}{side(b['right'])}</div>")
elif t == "table":
out.append(_html_table(b))
elif t == "ul":
out.append("<ul>" + "".join(f"<li>{_e(i)}</li>" for i in b["items"]) + "</ul>")
elif t == "hr":
out.append("<hr class='hr'>")
elif t == "sp":
out.append(f"<div style='height:{int(b.get('h', 12))}pt'></div>")
elif t == "sign":
out.append(f"<div class='sign'><div class='line'>{_e(b['label'])}</div></div>")
return "".join(out)
def _num_cols(b: dict) -> set:
return {i for i, a in enumerate(b.get("align", [])) if a == "num"}
def _html_table(b: dict) -> str:
nums = _num_cols(b)
th = "".join(f"<th class='{'num' if i in nums else ''}'>{_e(h)}</th>" for i, h in enumerate(b["headers"]))
body = "".join(
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
for r in b["rows"]
)
foot = ""
if b.get("foot"):
foot = "<tfoot>" + "".join(
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
for r in b["foot"]
) + "</tfoot>"
return f"<table class='grid'><thead><tr>{th}</tr></thead><tbody>{body}</tbody>{foot}</table>"
_WEASY: Any = ... # unset sentinel; set to the module or False on first probe
def _weasyprint():
"""The weasyprint module if importable with its native stack, else False. Probed
once and cached — the import is expensive and, when pango/cairo are absent, prints
its own diagnostics, so we do it exactly once and swallow that chatter."""
global _WEASY
if _WEASY is ...:
import contextlib
import os as _os
try:
with open(_os.devnull, "w") as dn, contextlib.redirect_stderr(dn), contextlib.redirect_stdout(dn):
import weasyprint # type: ignore
_WEASY = weasyprint
except Exception:
_WEASY = False
return _WEASY
def _weasy_pdf(html: str) -> bytes | None:
"""WeasyPrint render, or None when the library or its native stack is unavailable —
the caller then uses the built-in fallback. Fully guarded so a pod without
pango/cairo simply falls back."""
wp = _weasyprint()
if not wp:
return None
try:
return wp.HTML(string=html).write_pdf()
except Exception: # pragma: no cover - native failure at render time
return None
# ── DOCX (python-docx) ────────────────────────────────────────────────────────────
def _doc_docx(ir: dict) -> bytes:
from docx import Document
from docx.shared import Pt, RGBColor
INK = RGBColor(0x16, 0x17, 0x1A)
DIM = RGBColor(0x6B, 0x6F, 0x76)
d = Document()
for s in d.styles:
try:
if s.font is not None and s.font.name is None:
s.font.name = "Helvetica"
except Exception:
pass
normal = d.styles["Normal"]
normal.font.name = "Helvetica"
normal.font.size = Pt(10.5)
normal.font.color.rgb = INK
def _runcolor(p, color=INK):
for r in p.runs:
r.font.color.rgb = color
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
p = d.add_paragraph()
run = p.add_run(b["text"])
run.bold = True
run.font.size = Pt(22)
run.font.color.rgb = INK
if b.get("sub"):
sp = d.add_paragraph()
sr = sp.add_run(b["sub"])
sr.font.size = Pt(10.5)
sr.font.color.rgb = DIM
elif t == "h":
p = d.add_paragraph()
run = p.add_run(b["text"])
run.bold = True
run.font.size = Pt({1: 13, 2: 11, 3: 10.5}[b.get("level", 2)])
run.font.color.rgb = INK if b.get("level", 2) < 3 else DIM
elif t == "p":
p = d.add_paragraph(b["text"])
_runcolor(p)
elif t == "kv":
tbl = d.add_table(rows=0, cols=2)
for k, v in b["pairs"]:
cells = tbl.add_row().cells
kr = cells[0].paragraphs[0].add_run(k)
kr.font.color.rgb = DIM
kr.font.size = Pt(10)
vr = cells[1].paragraphs[0].add_run(v)
vr.font.size = Pt(10)
vr.font.color.rgb = INK
elif t == "party":
tbl = d.add_table(rows=1, cols=2)
for cell, side in ((tbl.rows[0].cells[0], b["left"]), (tbl.rows[0].cells[1], b["right"])):
if side["title"]:
tr = cell.paragraphs[0].add_run(side["title"].upper())
tr.bold = True
tr.font.size = Pt(8.5)
tr.font.color.rgb = DIM
for ln in side["lines"]:
lp = cell.add_paragraph()
lr = lp.add_run(ln)
lr.font.size = Pt(10)
lr.font.color.rgb = INK
elif t == "table":
_docx_table(d, b, INK, DIM, Pt)
elif t == "ul":
for it in b["items"]:
p = d.add_paragraph(it, style="List Bullet")
_runcolor(p)
elif t == "hr":
d.add_paragraph("_" * 48).runs[0].font.color.rgb = DIM
elif t == "sp":
d.add_paragraph()
elif t == "sign":
d.add_paragraph()
p = d.add_paragraph("__________________________")
p.runs[0].font.color.rgb = INK
lp = d.add_paragraph()
lr = lp.add_run(b["label"])
lr.font.size = Pt(9)
lr.font.color.rgb = DIM
buf = io.BytesIO()
d.save(buf)
return buf.getvalue()
def _docx_table(d, b, INK, DIM, Pt) -> None:
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
nums = _num_cols(b)
from docx.enum.text import WD_ALIGN_PARAGRAPH
tbl = d.add_table(rows=1, cols=len(headers))
tbl.style = "Light Grid Accent 1" if "Light Grid Accent 1" in [s.name for s in d.styles] else "Table Grid"
for i, h in enumerate(headers):
cell = tbl.rows[0].cells[i]
run = cell.paragraphs[0].add_run(h)
run.bold = True
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
for r in rows:
cells = tbl.add_row().cells
for i, c in enumerate(r):
run = cells[i].paragraphs[0].add_run(c)
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
for r in foot:
cells = tbl.add_row().cells
for i, c in enumerate(r):
run = cells[i].paragraphs[0].add_run(c)
run.bold = True
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
# ── Markdown ──────────────────────────────────────────────────────────────────────
def _doc_md(ir: dict) -> str:
out: list[str] = []
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
out.append(f"# {b['text']}")
if b.get("sub"):
out.append(f"*{b['sub']}*")
elif t == "h":
out.append(("#" * (b.get("level", 2) + 1)) + " " + b["text"])
elif t == "p":
out.append(b["text"])
elif t == "kv":
out.extend(f"- **{k}:** {v}" for k, v in b["pairs"])
elif t == "party":
for side in (b["left"], b["right"]):
if side["title"]:
out.append(f"**{side['title']}**")
out.extend(side["lines"])
out.append("")
elif t == "table":
out.append(_md_table(b["headers"], b["rows"] + (b.get("foot") or [])))
elif t == "ul":
out.extend(f"- {i}" for i in b["items"])
elif t == "hr":
out.append("---")
elif t == "sign":
out.append("")
out.append("\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_")
out.append(b["label"])
out.append("")
return "\n".join(out).strip() + "\n"
def _md_table(headers: list, rows: list) -> str:
def row(cells):
return "| " + " | ".join(str(c).replace("|", "\\|") for c in cells) + " |"
lines = [row(headers), "| " + " | ".join("---" for _ in headers) + " |"]
lines += [row(r + [""] * (len(headers) - len(r))) for r in rows]
return "\n".join(lines)
# ── Built-in PDF fallback — a valid, monochrome, paginated PDF with zero native deps ─
# Base-14 Helvetica (no embedding needed), WinAnsi text, absolute-positioned lines,
# automatic pagination and word wrap, simple heading/table/bullet layout. Not as rich
# as WeasyPrint, but a genuine, readable document that always passes %PDF validation.
_PAGE_W, _PAGE_H = 612.0, 792.0
_ML, _MR, _MT, _MB = 54.0, 54.0, 60.0, 56.0
_CONTENT_W = _PAGE_W - _ML - _MR
# Coarse Helvetica advance widths (per 1pt), enough to wrap without overflow.
_AVG = 0.50
def _text_w(s: str, size: float, bold: bool = False) -> float:
return len(s) * size * (_AVG + (0.02 if bold else 0.0))
def _wrap(text: str, size: float, width: float, bold: bool = False) -> list[str]:
lines: list[str] = []
for raw in str(text).split("\n"):
words, cur = raw.split(), ""
for w in words:
trial = w if not cur else cur + " " + w
if _text_w(trial, size, bold) <= width or not cur:
cur = trial
else:
lines.append(cur)
cur = w
lines.append(cur)
return lines or [""]
def _pdf_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _pdf_enc(s: str) -> str:
# WinAnsi/cp1252 so currency/quotes/dashes survive; unmappable → '?'.
return _pdf_escape(s.encode("cp1252", "replace").decode("cp1252"))
class _PDFPager:
"""Accumulates absolute-positioned text ops across auto-paginated pages."""
def __init__(self):
self.pages: list[list[str]] = [[]]
self.y = _PAGE_H - _MT
def _page(self) -> list[str]:
return self.pages[-1]
def _newpage(self):
self.pages.append([])
self.y = _PAGE_H - _MT
def space(self, h: float):
self.y -= h
if self.y < _MB:
self._newpage()
def line(self, text: str, size: float, *, bold=False, gray=False, indent=0.0, leading=1.42, gap=0.0):
if gap:
self.y -= gap
wrapped = _wrap(text, size, _CONTENT_W - indent, bold)
for ln in wrapped:
if self.y - size < _MB:
self._newpage()
font = "F2" if bold else "F1"
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
self._page().append(
f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + indent:.2f} {self.y - size:.2f} Tm ({_pdf_enc(ln)}) Tj ET\n"
)
self.y -= size * leading
def cols(self, cells: list[tuple[str, float, bool]], xs: list[float], size: float, *, bold=False, gray=False, gap=0.0):
"""One row of already-fitted cell strings at column x-offsets."""
if gap:
self.y -= gap
if self.y - size < _MB:
self._newpage()
font = "F2" if bold else "F1"
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
y = self.y - size
for (text, colw, right), x in zip(cells, xs):
s = _fit(text, size, colw, bold)
tx = x + (colw - _text_w(s, size, bold)) if right else x
self._page().append(f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + tx:.2f} {y:.2f} Tm ({_pdf_enc(s)}) Tj ET\n")
self.y -= size * 1.55
def hline(self, weight: float = 0.6, gray=True):
self.y -= 4
if self.y < _MB:
self._newpage()
col = "0.85 0.86 0.88 RG\n" if gray else "0.086 0.09 0.102 RG\n"
self._page().append(f"{col}{weight:.2f} w {_ML:.2f} {self.y:.2f} m {_PAGE_W - _MR:.2f} {self.y:.2f} l S\n")
self.y -= 6
def _fit(text: str, size: float, width: float, bold: bool) -> str:
s = str(text)
if _text_w(s, size, bold) <= width:
return s
while s and _text_w(s + "", size, bold) > width:
s = s[:-1]
return (s + "") if s else ""
def _builtin_pdf(ir: dict) -> bytes:
p = _PDFPager()
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
p.line(b["text"], 21, bold=True, gap=2)
if b.get("sub"):
p.line(b["sub"], 10.5, gray=True, gap=1)
p.space(8)
elif t == "h":
size = {1: 13.5, 2: 11.5, 3: 10.5}[b.get("level", 2)]
p.line(b["text"], size, bold=True, gray=b.get("level", 2) == 3, gap=10)
p.space(2)
elif t == "p":
p.line(b["text"], 10.5, gap=2)
p.space(4)
elif t == "kv":
for k, v in b["pairs"]:
kw = _CONTENT_W * 0.32
p.cols([(k, kw, False), (v, _CONTENT_W - kw, False)], [0, kw], 10, gray=False)
elif t == "party":
half = _CONTENT_W / 2 - 8
left, right = b["left"], b["right"]
p.space(4)
if left["title"] or right["title"]:
p.cols([(left["title"].upper(), half, False), (right["title"].upper(), half, False)],
[0, _CONTENT_W / 2 + 8], 8.5, bold=True, gray=True)
for i in range(max(len(left["lines"]), len(right["lines"]))):
lt = left["lines"][i] if i < len(left["lines"]) else ""
rt = right["lines"][i] if i < len(right["lines"]) else ""
p.cols([(lt, half, False), (rt, half, False)], [0, _CONTENT_W / 2 + 8], 10)
p.space(8)
elif t == "table":
_builtin_table(p, b)
elif t == "ul":
for it in b["items"]:
for j, ln in enumerate(_wrap(it, 10.5, _CONTENT_W - 16)):
p.line(("" if j == 0 else " ") + ln, 10.5, indent=8, leading=1.35)
p.space(4)
elif t == "hr":
p.hline()
elif t == "sp":
p.space(int(b.get("h", 12)))
elif t == "sign":
p.space(28)
p.hline(weight=0.9, gray=False)
p.line(b["label"], 9, gray=True)
return _assemble_pdf(p.pages, ir.get("title") or "Document")
def _builtin_table(p: _PDFPager, b: dict) -> None:
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
nums = _num_cols(b)
n = len(headers)
# First column takes remaining width; numeric/short columns get a fixed slice.
fixed = min(_CONTENT_W * 0.22, 120)
other = fixed if n > 1 else _CONTENT_W
first = _CONTENT_W - other * (n - 1)
widths = [first] + [other] * (n - 1)
xs, acc = [], 0.0
for w in widths:
xs.append(acc)
acc += w
cells = [(h, widths[i] - 8, i in nums) for i, h in enumerate(headers)]
p.space(6)
p.cols(cells, xs, 9.5, bold=True, gap=2)
p.hline(weight=1.2, gray=False)
for r in rows:
cells = [((r[i] if i < len(r) else ""), widths[i] - 8, i in nums) for i in range(n)]
p.cols(cells, xs, 9.5)
if foot:
p.hline(weight=1.2, gray=False)
for fr in foot:
cells = [((fr[i] if i < len(fr) else ""), widths[i] - 8, i in nums) for i in range(n)]
p.cols(cells, xs, 9.5, bold=True)
p.space(6)
def _assemble_pdf(pages: list[list[str]], title_text: str) -> bytes:
"""Serialize positioned-text pages into a valid PDF 1.7 with an xref table.
Object numbers are planned up front so every cross-reference is known before a
single byte is written — no insert/renumber games:
1 = Catalog · 2 = Pages · 3 = F1 · 4 = F2 ·
then per page i: content = 5+2i, page = 6+2i.
"""
n_pages = max(1, len(pages))
page_nos = [6 + 2 * i for i in range(n_pages)]
content_nos = [5 + 2 * i for i in range(n_pages)]
objs: dict[int, bytes] = {}
objs[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{no} 0 R" for no in page_nos).encode()
objs[2] = b"<< /Type /Pages /Kids [" + kids + b"] /Count %d >>" % n_pages
objs[3] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
objs[4] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
for i in range(n_pages):
ops = pages[i] if i < len(pages) else []
# cp1252 so WinAnsi glyphs (€, curly quotes, en/em dash) survive as their byte
# values; the fonts declare /WinAnsiEncoding so those bytes map to the right glyph.
stream = ("".join(ops)).encode("cp1252", "replace")
comp = zlib.compress(stream)
objs[content_nos[i]] = (
b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(comp) + comp + b"\nendstream")
objs[page_nos[i]] = (
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] "
b"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>"
% (int(_PAGE_W), int(_PAGE_H), content_nos[i]))
info_no = max(objs) + 1
objs[info_no] = (b"<< /Title (" + _pdf_enc(title_text).encode("cp1252", "replace")
+ b") /Producer (Hanzo Studio) >>")
count = max(objs)
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = [0] * (count + 1)
for i in range(1, count + 1):
offsets[i] = len(out)
out += b"%d 0 obj\n" % i + objs[i] + b"\nendobj\n"
xref_pos = len(out)
out += b"xref\n0 %d\n" % (count + 1)
out += b"0000000000 65535 f \n"
for i in range(1, count + 1):
out += b"%010d 00000 n \n" % offsets[i]
out += b"trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
count + 1, info_no, xref_pos)
return bytes(out)
# ── sheet archetype: XLSX · PDF · DOCX · MD ───────────────────────────────────────
# SheetIR = {"title", "sheets": [{"name", "columns": [{"key","label","width","fmt"}],
# "rows": [dict], "totals": [dict]}]}. fmt ∈ {"money","int","pct","date",None}.
def sheet_ir(title_text: str, sheets: list) -> dict:
return {"title": title_text or "Spreadsheet", "sheets": sheets}
def _render_sheet(ir: dict, fmt: str) -> bytes:
if fmt == "xlsx":
return _sheet_xlsx(ir)
# Secondary formats reuse the doc archetype by turning each sheet into a table block.
d = _sheet_as_doc(ir)
return _render_doc(d, fmt)
_XLSX_FMT = {"money": "#,##0.00", "int": "#,##0", "pct": "0.0%", "date": "yyyy-mm-dd"}
def _xlsx_safe(v):
"""OWASP formula-injection guard (CWE-1236). openpyxl turns any string cell
starting with '=' into a LIVE formula; '+','-','@','\\t','\\r' are the other
DDE/HYPERLINK triggers. These XLSX outputs (budgets, expense reports) are made
to be shared, so a text cell that begins with a trigger char is coerced to text
with a leading apostrophe — inert in Excel/Sheets, invisible to the reader.
Numeric cells (int/float) are untouched: openpyxl only weaponizes strings."""
if isinstance(v, str) and v[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + v
return v
def _sheet_xlsx(ir: dict) -> bytes:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
wb = Workbook()
wb.remove(wb.active)
ink = "16171A"
head_fill = PatternFill("solid", fgColor="F0F1F3")
head_font = Font(name="Helvetica", bold=True, color=ink, size=10)
base_font = Font(name="Helvetica", color=ink, size=10)
bot = Border(bottom=Side(style="thin", color="16171A"))
thin = Border(bottom=Side(style="hair", color="D9DBDF"))
for sh in ir.get("sheets", []):
ws = wb.create_sheet(title=(sh.get("name") or "Sheet")[:31])
cols = sh.get("columns", [])
for ci, col in enumerate(cols, start=1):
c = ws.cell(row=1, column=ci, value=col.get("label", col.get("key", "")))
c.font = head_font
c.fill = head_fill
c.border = bot
c.alignment = Alignment(horizontal="right" if col.get("fmt") in ("money", "int", "pct") else "left")
ws.column_dimensions[c.column_letter].width = col.get("width", 18)
r = 2
for row in sh.get("rows", []):
for ci, col in enumerate(cols, start=1):
v = row.get(col["key"])
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
c.font = base_font
c.border = thin
nf = _XLSX_FMT.get(col.get("fmt"))
if nf:
c.number_format = nf
r += 1
for total in sh.get("totals", []):
for ci, col in enumerate(cols, start=1):
v = total.get(col["key"])
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
c.font = Font(name="Helvetica", bold=True, color=ink, size=10)
c.border = bot
nf = _XLSX_FMT.get(col.get("fmt"))
if nf and isinstance(v, (int, float)):
c.number_format = nf
r += 1
ws.freeze_panes = "A2"
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _fmt_cell(v: Any, fmt: str | None) -> str:
if v is None or v == "":
return ""
if fmt == "money" and isinstance(v, (int, float)):
return f"{v:,.2f}"
if fmt == "int" and isinstance(v, (int, float)):
return f"{int(v):,}"
if fmt == "pct" and isinstance(v, (int, float)):
return f"{v * 100:.1f}%"
return str(v)
def _sheet_as_doc(ir: dict) -> dict:
blocks = [title(ir.get("title") or "Spreadsheet")]
for sh in ir.get("sheets", []):
cols = sh.get("columns", [])
if len(ir.get("sheets", [])) > 1:
blocks.append(heading(sh.get("name") or "Sheet", 2))
headers = [c.get("label", c.get("key", "")) for c in cols]
align = ["num" if c.get("fmt") in ("money", "int", "pct") else "" for c in cols]
rows = [[_fmt_cell(row.get(c["key"]), c.get("fmt")) for c in cols] for row in sh.get("rows", [])]
foot = [[_fmt_cell(t.get(c["key"]), c.get("fmt")) for c in cols] for t in sh.get("totals", [])]
blocks.append(table(headers, rows, align=align, foot=foot))
return doc_ir(ir.get("title") or "Spreadsheet", blocks)
# ── deck archetype: PPTX · PDF · MD ───────────────────────────────────────────────
# DeckIR = {"title", "slides": [{"layout": "title"|"bullets"|"section",
# "title", "subtitle", "bullets": [str], "notes"}]}.
def deck_ir(title_text: str, slides: list) -> dict:
return {"title": title_text or "Presentation", "slides": slides}
def _render_deck(ir: dict, fmt: str) -> bytes:
if fmt == "pptx":
return _deck_pptx(ir)
return _render_doc(_deck_as_doc(ir), fmt)
def _deck_pptx(ir: dict) -> bytes:
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
INK = RGBColor(0x16, 0x17, 0x1A)
DIM = RGBColor(0x6B, 0x6F, 0x76)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
def _tb(slide, left, top, width, height):
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
box.text_frame.word_wrap = True
return box.text_frame
for sd in ir.get("slides", []):
slide = prs.slides.add_slide(blank)
layout = sd.get("layout", "bullets")
if layout in ("title", "section"):
tf = _tb(slide, 0.9, 2.6 if layout == "title" else 2.9, 11.5, 2.0)
para0 = tf.paragraphs[0]
run = para0.add_run()
run.text = sd.get("title", "")
run.font.size = Pt(40 if layout == "title" else 30)
run.font.bold = True
run.font.color.rgb = INK
if sd.get("subtitle"):
sp = tf.add_paragraph()
sr = sp.add_run()
sr.text = sd["subtitle"]
sr.font.size = Pt(18)
sr.font.color.rgb = DIM
else:
tf = _tb(slide, 0.9, 0.7, 11.5, 1.1)
run = tf.paragraphs[0].add_run()
run.text = sd.get("title", "")
run.font.size = Pt(28)
run.font.bold = True
run.font.color.rgb = INK
body = _tb(slide, 0.9, 2.0, 11.5, 4.8)
first = True
for b in sd.get("bullets", []):
p = body.paragraphs[0] if first else body.add_paragraph()
first = False
r = p.add_run()
r.text = str(b)
r.font.size = Pt(18)
r.font.color.rgb = INK
p.space_after = Pt(8)
if sd.get("notes"):
slide.notes_slide.notes_text_frame.text = sd["notes"]
buf = io.BytesIO()
prs.save(buf)
return buf.getvalue()
def _deck_as_doc(ir: dict) -> dict:
blocks = [title(ir.get("title") or "Presentation")]
for i, sd in enumerate(ir.get("slides", []), start=1):
blocks.append(heading(f"{i}. {sd.get('title', '')}".strip(". "), 2))
if sd.get("subtitle"):
blocks.append(para(sd["subtitle"]))
if sd.get("bullets"):
blocks.append(bullets(sd["bullets"]))
if sd.get("notes"):
blocks.append(para(sd["notes"]))
return doc_ir(ir.get("title") or "Presentation", blocks)
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
"""Gateway auth — the ONE fail-closed allowlist for handing a browser-edge bearer to
the Hanzo AI gateway (api.hanzo.ai/v1/chat/completions).
The problem it closes: the browser session is a *symmetric* HS256 hanzo.id token. The
gateway verifies bearers against JWKS and refuses HS256 ("unsupported signing method:
HS256") — so forwarding the raw session token produces a 502, not a chat. Secret keys
(``sk-``/``hk-``/``rk-``) must never leave the server, and a publishable ``pk-`` key is
the one client-safe family the gateway accepts from the edge.
So the rule, fail-closed, in ONE place every gateway caller reuses:
1. a server-side gateway key (configured, never in page source) wins;
2. else the caller's own token — forwarded ONLY when the gateway can verify it: a
publishable ``pk-`` key, or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA);
3. an HS256 session token or any secret key is refused "" (the caller then degrades
honestly instead of emitting a 502).
This is the same shape the office-documents AI-fill proved (documents.py); it lives here
so copilot and any future gateway caller share exactly one implementation.
"""
from __future__ import annotations
import base64
import json
import os
# Secret key families — must never reach the gateway from the browser edge, nor appear
# in page source. Matched case-insensitively on the bare token.
_SECRET_PREFIXES = ("sk-", "sk_", "hk-", "hk_", "rk-", "rk_")
def jwt_alg(tok: str) -> str:
"""The ``alg`` from a JWT header, or "" if not a decodable JWT. Distinguishes a
JWKS-verifiable asymmetric token (gateway-acceptable) from an HS256 session token."""
try:
h = tok.split(".", 1)[0]
h += "=" * (-len(h) % 4)
alg = json.loads(base64.urlsafe_b64decode(h.encode())).get("alg", "")
# A crafted header can make alg a non-string (null/list/number/dict); the
# contract is "the alg string or ''", so a non-string is not a usable alg
# (else `alg[:2]`/`alg.startswith` below would raise → a self-inflicted 500).
return alg if isinstance(alg, str) else ""
except Exception:
return ""
def forwardable_bearer(request) -> str:
"""The caller's own token to forward to the gateway, or "" — fail-closed.
Reuses the app's one token path (``gpu_dispatch._bearer``: Authorization header or the
browser session cookie), then applies the strict allowlist:
* a publishable ``pk-`` key allowed;
* a JWT allowed ONLY when asymmetric/JWKS-verifiable (RS/ES/PS/EdDSA); a symmetric
HS256 hanzo.id session token is refused (the gateway rejects it, so forwarding it
only produces log noise and a 502);
* a secret ``sk-``/``hk-``/``rk-`` key refused outright.
So a secret can never reach the gateway from the browser edge and none is in page
source. Returns a full ``Bearer <tok>`` value, or "".
"""
from middleware.gpu_dispatch import _bearer # lazy: keeps this module import light
raw = _bearer(request)
if not raw:
return ""
tok = raw[7:].strip() if raw[:7].lower() == "bearer " else raw.strip()
if not tok or tok.lower().startswith(_SECRET_PREFIXES):
return ""
if tok.startswith(("pk-", "pk_")):
return "Bearer " + tok
if tok.count(".") == 2:
# Exact JWS asymmetric set — a prefix match ("RS"/"Ed") also admitted non-JWS
# names like RSA-OAEP / "Edwina"; `alg in <tuple>` is crash-safe for any type.
if jwt_alg(tok) in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
"PS256", "PS384", "PS512", "EdDSA"):
return "Bearer " + tok
return ""
def server_token(env_names) -> str:
"""The first configured server-side gateway key from ``env_names``, as a full
``Bearer <tok>`` value, or "". Server-side only never emitted to page source."""
for env in env_names:
v = os.environ.get(env, "").strip()
if v:
return v if v[:7].lower() == "bearer " else "Bearer " + v
return ""
def ai_bearer(request, *, server_env) -> str:
"""The bearer to send to the gateway for this request, or "" when none is usable.
A server-side gateway key wins (the configured, working path); otherwise the caller's
own gateway-verifiable token via the fail-closed allowlist. Returns ``Bearer <tok>``
or "". Callers that need the bare token can strip the 7-char ``Bearer `` prefix."""
return server_token(server_env) or forwardable_bearer(request)
+283 -11
View File
@@ -8,13 +8,23 @@ shared keys. Falls back to the in-pod queue when there is no live worker.
"""
import base64
import json
import logging
import os
import time
import urllib.error
import urllib.request
import folder_paths
log = logging.getLogger("studio.gpu_dispatch")
CLOUD = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai").rstrip("/")
JOBS_NS = os.environ.get("STUDIO_GPU_JOBS_NS", "gpu-jobs")
# The ComfyUI output-node class types — the ONE Python list, defined in this base
# module (no studio_home dependency) so studio_home can import it without a cycle.
# The client mirror lives in studio_home.html (a separate JS runtime).
SAVE_NODES = ("SaveImage", "SaveVideo", "SaveWEBM")
# Public base of THIS studio deployment — where the BYO-GPU worker returns finished
# outputs (POST /upload/output → orgs/{org}/output → gallery). The worker uploads
# here with the user's IAM token; no S3/rclone credentials ever touch the box.
@@ -76,27 +86,235 @@ def _bearer(request) -> str:
return f"Bearer {tok}" if tok else ""
# Identity keys a fleet machine might carry, most human-friendly first. We READ
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
# `name`/`hostname`); we never invent a parallel registry.
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
def _node_label(m: dict) -> str:
for k in _NODE_KEYS:
v = m.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return "gpu"
def _fleet_machines(tok: str) -> list:
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
the caller's org, so this is inherently tenant-isolated. Retried a few times so a
transient edge fault (502, TLS reset, timeout) does NOT read as "no GPU" and
bounce the render onto the model-less pod the cause of intermittent
"Prompt outputs failed validation"."""
last = None
for attempt in range(3):
try:
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
ms = d.get("machines", [])
return ms if isinstance(ms, list) else []
except Exception as e: # any transient fault (HTTP/TLS/timeout/non-JSON) is retryable
last = e
if attempt < 2:
time.sleep(0.4 * (attempt + 1))
raise last
def _online_gpu_nodes(tok: str) -> list:
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
def _has_online_gpu(tok: str) -> bool:
"""True if the caller's org has an online GPU machine in the cloud fleet."""
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
return any(m.get("status") == "online" and m.get("gpu") for m in d.get("machines", []))
return bool(_online_gpu_nodes(tok))
# Server credentials that read the WHOLE fleet, not just the caller-org's slice like
# a user token. KMS-sourced (the SAME envs worker/commerce already use); consulted
# ONLY by the advisory capacity gate below — never by a render decision.
_SERVICE_TOKEN_ENVS = ("STUDIO_WORKER_TOKEN", "STUDIO_COMMERCE_TOKEN")
def _service_token() -> str:
"""A service bearer for a fleet read, or "" if none is configured."""
for env in _SERVICE_TOKEN_ENVS:
v = os.environ.get(env, "").strip()
if v:
return v if v.lower().startswith("bearer ") else f"Bearer {v}"
return ""
def has_online_gpu_advisory(user_tok: str) -> bool:
"""Fleet availability for the UI capacity gate ONLY (the ⚠ warning + the
/v1/gpu-status badge) NEVER a render decision. Reads with a SERVICE identity
first so every org sees the TRUE worker availability: a BYO box is often
registered under a sibling org and is invisible to an org-scoped user-token read,
which made a karma session believe no GPU existed. Falls back to the user token
when no service credential is set (local dev). Job tenancy is untouched dispatch
still enqueues under the user token."""
for tok in (_service_token(), user_tok):
if not tok:
continue
try:
if _has_online_gpu(tok):
return True
except Exception:
continue
return False
# Model-loader class → (the widget naming the file, the folder_paths list it lives in).
# Covers the loaders the studio graphs use: Qwen fix/compose (UNET+CLIP+VAE) and the
# SD-family templates (checkpoint). Enough to answer "can THIS box load THIS graph".
_LOADERS = {
"UNETLoader": ("unet_name", "diffusion_models"),
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
"CheckpointLoader": ("ckpt_name", "checkpoints"),
"ImageOnlyCheckpointLoader": ("ckpt_name", "checkpoints"),
"CLIPLoader": ("clip_name", "text_encoders"),
"VAELoader": ("vae_name", "vae"),
}
def has_models_for(prompt: dict) -> bool:
"""True only if THIS instance has EVERY model file the graph actually references
(its UNET / checkpoint / CLIP / VAE loader inputs). The decision is per-GRAPH, not
"any model present": a cloud coordinator may carry a stray checkpoint (e.g. an SD1.5
stopgap) yet lack the Qwen edit models a fix/compose graph needs validating THAT
locally is the cryptic "not in []" / "Prompt outputs failed validation". So a box
renders locally only a graph it can actually load; every other graph goes to the
fleet (or a retryable 503). Errs toward the fleet (returns False) on any doubt. A
worker node (worker_mode) never reaches this path."""
try:
for node in (prompt or {}).values():
if not isinstance(node, dict):
continue
spec = _LOADERS.get(node.get("class_type", ""))
if not spec:
continue
name = (node.get("inputs") or {}).get(spec[0])
if not isinstance(name, str) or not name:
continue
try:
have = folder_paths.get_filename_list(spec[1])
except Exception:
have = []
if name not in have:
return False # a model this graph needs is absent → cannot render here
return True
except Exception:
return False
# Retry ladder across the cloud tasks backend's deploy gap. `/v1/tasks/*` is served
# by the single-pod `cloud` binary (strategy Recreate on an RWO SQLite volume), so
# every cloud deploy leaves a ~30s window where the ingress (Traefik) has zero Ready
# endpoints and returns 503 "no available server" for the enqueue. With CI shipping
# cloud continuously, an enqueue can land in that gap and instant-fail the render with
# "GPU render worker is momentarily unavailable". The gap is transient and the request
# never reached the tasks engine (empty backend pool → Traefik rejects it), so we
# retry across it. ~32s total covers a same-node Recreate.
_ENQUEUE_RETRY_DELAYS = (2, 4, 6, 8, 12)
def _enqueue_with_retry(req) -> None:
"""PUT the enqueue, retrying ONLY on 503 / connection-refused — both mean the
request never reached the backend, so re-sending the same idempotent activityId
(== prompt_id) cannot double-enqueue. Any other outcome (4xx auth/validation, a
timeout that may have been processed) raises to the caller's normal fallback, so
we never risk a double render on an ambiguous failure."""
last: Exception | None = None
for i in range(len(_ENQUEUE_RETRY_DELAYS) + 1):
try:
urllib.request.urlopen(req, timeout=90).read()
return
except urllib.error.HTTPError as e:
if e.code != 503:
raise # 4xx/other: retrying won't help — fall back
last = e # 503 empty-pool: deploy gap, retriable
except urllib.error.URLError as e:
# Connection refused/reset to the backend during the gap is retriable;
# a genuine timeout (request may have been served) is NOT — don't double-send.
if isinstance(e.reason, TimeoutError):
raise
last = e
if i < len(_ENQUEUE_RETRY_DELAYS):
time.sleep(_ENQUEUE_RETRY_DELAYS[i])
if last:
raise last
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
"""If the caller's org has an online GPU node in the fleet, enqueue a
studio.render job to gpu-jobs and return True. Return False to run in-pod.
Never raises any failure falls back to local."""
"""Enqueue this render to the shared GPU lane (gpu-jobs) and return True; return
False only to run it in-pod.
A model-less cloud coordinator NEVER renders locally it ALWAYS enqueues and
lets the job WAIT for a worker, even when the org-scoped fleet read shows none.
BYO workers can be registered under a sibling org while draining the SHARED
gpu-jobs queue, so an org-blind read (e.g. an org whose GPUs live under a peer
org) must not bounce the render onto the GPU-less pod that self-POST is what
died with an instant "Prompt outputs failed validation". A queued job that waits
for a worker beats an instant fake failure. A box WITH its own models still
renders locally when it sees no online worker (local dev / a worker node).
Never raises a genuine enqueue failure falls back, and server.py then returns a
retryable 503 on a model-less pod rather than a doomed local validation."""
try:
tok = _bearer(request)
if not tok or not _has_online_gpu(tok):
if not tok:
return False
# Per-GPU targeting: the UI's "Run on <gpu>" affordance sets X-Target-GPU to a
# machine identity. A targeted render is enqueued on THAT machine's own lane
# ("gpu:<identity>") which only that worker claims; an untargeted render rides
# the shared "gpu-jobs" lane any worker drains. The NAMESPACE is "gpu-jobs"
# either way — targeting is a taskQueue, not a second queue. One enqueue path.
target = (request.headers.get("X-Target-GPU") or "").strip()
online = _online_gpu_nodes(tok)
# Honor a target ONLY if it is one of the caller-org's OWN online GPUs. This
# blocks a forged lane AND sanitizes the value before it becomes a taskQueue
# (and later a queue-row label): an unknown/offline target falls back to the
# shared lane rather than enqueuing onto a "gpu:<bogus>" lane no worker will
# ever claim (a 30-min schedule-to-start hang) — and a "<img onerror>" target
# never reaches the queue. Cross-org is already impossible (per-org shard); this
# is defense in depth.
if target and target not in {_node_label(m) for m in online}:
target = ""
task_queue = f"gpu:{target}" if target else JOBS_NS
# No visible worker AND this box can load THIS graph → render locally. No
# visible worker on a box that lacks the graph's models → still enqueue and
# wait (never a doomed local validation), even with a stray unrelated model.
# A validated target ALWAYS enqueues (the user asked for that online GPU).
if not target and not online and has_models_for(prompt):
return False
# Which node runs it: the explicit target; else a single visible GPU is
# definitive; with several — or an org-blind read that saw none — the claimer
# is decided at pick-up: "gpu".
node = target or (_node_label(online[0]) if len(online) == 1 else "gpu")
staged = _collect_input_images(prompt, org_id)
body = json.dumps({
"activityId": prompt_id,
"runId": prompt_id,
"activityType": {"name": "studio.render"},
"taskQueue": JOBS_NS,
"heartbeatTimeout": "600s",
"taskQueue": task_queue,
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
# sample) can exceed 600s; the worker heartbeats each step, so this is
# the ceiling for a genuinely long single render, not idle slack.
"heartbeatTimeout": "1200s",
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
# the queue re-ran them for hours.
"startToCloseTimeout": "14400s",
# Stale jobs EXPIRE instead of waiting forever for a worker. If no GPU
# picks this up within 30 min it is dropped — so a worker outage can't
# leave a backlog that silently replays (re-billing old renders) the moment
# a worker reconnects. A live user re-runs; nothing lingers unbounded.
"scheduleToStartTimeout": "1800s",
# ONE attempt, no auto-retry. A GPU render is expensive and often
# deterministic — re-running a failed/crashed render (e.g. spark OOM mid-
# sample) just re-burns money on the SAME output ("the same reject over and
# over"). A failure surfaces to the user, who re-runs deliberately. This is
# the cap that the startToCloseTimeout alone did NOT provide.
"retryPolicy": {"maximumAttempts": 1},
"input": {
"prompt": prompt,
"org": org_id,
@@ -104,7 +322,7 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
# Uploaded inputs live in orgs/{org}/input on THIS pod; the worker
# renders on its own disk and cannot read them — ship them along so
# LoadImage resolves there.
"inputs": _collect_input_images(prompt, org_id),
"inputs": staged,
},
}).encode()
req = urllib.request.Request(
@@ -112,7 +330,61 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
data=body,
headers={"Authorization": tok, "Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=15).read()
# 90s, not 15s: a fix/compose with a large (e.g. 4k) reference inlines tens of
# MB, and 15s wasn't enough to PUT the enqueue body — it timed out, dispatch
# fell back, and the model-less pod returned "GPU worker unavailable" on a
# perfectly good render. The enqueue itself is quick; this only covers upload.
# Retry across the cloud tasks backend's deploy gap (see _enqueue_with_retry).
_enqueue_with_retry(req)
_track_dispatched(org_id, prompt_id, prompt, node=node)
# Correlated by pid (the job id) with studio.home's build/queue lines; names are
# content-addressed checksums, so this pins the exact source bytes shipped — no
# signed URLs, tokens or image data are logged.
log.info("studio.trace gpu.publish org=%s pid=%s node=%s inputs=%s refs=%s",
org_id, prompt_id, node, len(staged),
",".join(sorted(s["name"] for s in staged)) or None)
return True
except Exception:
return False
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
/queue so without this the user's fix/compose 'never appears in the queue'.
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
their output lands in the library or they age out. Never raises."""
try:
import time
import folder_paths
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
full_prefix, refs = "render", []
for n in prompt.values():
if not isinstance(n, dict):
continue
ct = n.get("class_type", "")
if ct in SAVE_NODES:
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
elif "LoadImage" in ct:
im = n.get("inputs", {}).get("image")
if im:
refs.append(im)
out_dir = folder_paths.get_org_output_directory(org_id)
os.makedirs(out_dir, exist_ok=True)
jf = os.path.join(out_dir, "render_jobs.json")
try:
jobs = json.loads(open(jf).read())
except Exception:
jobs = []
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
# collides with the source image and makes the job vanish after 30s.
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
"ts": int(time.time()), "status": "rendering"})
tmp = jf + ".tmp"
open(tmp, "w").write(json.dumps(jobs))
os.replace(tmp, jf)
except Exception:
pass
+115 -13
View File
@@ -9,10 +9,13 @@ signing keys are cached. Signature (RS256 by default), ``exp``, ``iss`` and
tenancy / rate-limit / billing paths.
Browser sessions use the standard OIDC Authorization Code flow: an
unauthenticated HTML request is redirected to the IAM ``authorize`` endpoint;
IAM bounces back to ``/callback`` which exchanges the code for a token and sets
an httpOnly session cookie. No frontend changes are required the prebuilt SPA
just rides the cookie.
unauthenticated browser navigation is redirected (302) to ``/login`` the one
server-owned entry point which starts the flow at the IAM ``authorize``
endpoint; IAM bounces back to ``/callback`` which exchanges the code for a token
and sets an httpOnly session cookie. A programmatic API/XHR/JSON request instead
gets a 401 (it handles its own re-auth), so the SPA's ``fetch`` calls never
follow a redirect and parse HTML as JSON. No frontend changes are required the
prebuilt SPA just rides the cookie.
Localhost connections bypass auth (single-user dev). Static assets, health
checks and the worker coordinator endpoints are exempt.
@@ -94,6 +97,93 @@ def _is_public_path(path: str) -> bool:
))
def _is_api_request(request: web.Request) -> bool:
"""A programmatic API/XHR/JSON caller (answered with 401) as opposed to a
top-level browser navigation (answered with a 302 into the login flow).
A human who types a URL or follows a link must never be shown a raw 401, so
an *ambiguous* non-browser client (plain ``curl``, ``Accept: */*``, no
fetch-metadata) counts as a navigation and is redirected. Only a request that
self-identifies as fetch/XHR/JSON is refused otherwise the SPA's own
``fetch`` calls would follow the redirect to the IdP and parse HTML as JSON.
"""
# Fetch-metadata (every evergreen browser stamps it): a user navigation is
# ``Sec-Fetch-Mode: navigate``; ``fetch()``/XHR carry cors|same-origin|no-cors.
mode = request.headers.get("Sec-Fetch-Mode", "").lower()
if mode:
return mode != "navigate"
# Legacy AJAX marker (jQuery / axios / htmx).
if request.headers.get("X-Requested-With", "").lower() == "xmlhttprequest":
return True
# Content negotiation: an explicit JSON preference with no HTML acceptance.
accept = request.headers.get("Accept", "")
return "application/json" in accept and "text/html" not in accept
def _safe_next(raw: str | None) -> str:
"""A same-origin return path, or ``/``. Rejects absolute URLs, protocol- and
backslash-relative forms (open-redirect vectors) and the login route itself
(which would loop)."""
if not raw or not raw.startswith("/") or raw[:2] in ("//", "/\\"):
return "/"
if raw == "/login" or raw.startswith(("/login?", "/login/")):
return "/"
return raw
def _login_redirect(request: web.Request) -> web.Response:
"""Send an unauthenticated browser to the ONE server-owned login entry point
(``/login``), preserving where it was headed via ``?next`` so the OIDC round
trip returns it there. The authorize-URL construction lives in ``/login``
(``handle_login``) alone this gate only decides *who* is sent there.
FRAMED navigations (the console embeds Studio in an iframe) must NOT be
302'd into the OIDC dance: the IdP's login/error pages send
``frame-ancestors 'none'`` and the frame dies on a browser error page.
Instead the frame gets a tiny same-origin CONNECT card sign in once
top-level (silent when the IdP session exists), the card polls the session
and reloads the frame into the real app."""
if request.headers.get("Sec-Fetch-Dest", "").lower() in ("iframe", "frame"):
return _embed_connect_card(request)
return web.HTTPFound("/login?" + urlencode({"next": str(request.rel_url)}))
def _embed_connect_card(request: web.Request) -> web.Response:
"""The same-origin sign-in card an unauthenticated FRAME renders (never the
IdP its pages refuse framing). Self-contained inline HTML; the button is
the user gesture that opens ``/login`` top-level, and the card polls
``/v1/session`` until the session lands, then reloads the frame."""
login = "/login?" + urlencode({"next": _safe_next(str(request.rel_url))})
html = (
"<!doctype html><html><head><meta charset='utf-8'><title>Hanzo Studio</title>"
"<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;"
"background:#0e0e12;color:#ececf1;font:15px/1.5 Inter,system-ui,sans-serif}"
".c{max-width:380px;text-align:center;padding:32px}"
"h1{font-size:18px;margin:0 0 8px}p{color:#8a8a95;font-size:13px;margin:0 0 20px}"
"button{background:#f4f4f6;color:#111;border:none;border-radius:10px;padding:10px 18px;"
"font:600 14px Inter,system-ui,sans-serif;cursor:pointer}</style></head><body>"
"<div class='c'><h1>Connect Hanzo Studio</h1>"
"<p>Sign in once to use Studio here — with a live Hanzo ID session it completes instantly, "
"then this panel loads by itself.</p>"
"<button id='go'>Sign in to Studio</button></div>"
"<script>document.getElementById('go').onclick=function(){"
f"window.open({login!r},'_blank','noopener');"
"};setInterval(function(){fetch('/v1/session',{credentials:'same-origin'})"
".then(function(r){return r.ok?r.json():null})"
".then(function(s){if(s&&s.authenticated){location.reload()}}).catch(function(){})},2000);"
"</script></body></html>"
)
return web.Response(
text=html,
content_type="text/html",
headers={
"Cache-Control": "no-store",
# Only our own consoles may frame this card (same site).
"Content-Security-Policy": "frame-ancestors 'self' https://*.hanzo.ai",
},
)
def _cache_key(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()[:32]
@@ -339,7 +429,9 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
Authorization Code flow (PKCE + signed state + ``/callback``) is built
server-side there is exactly one way to start a login, and the client
never hand-rolls an authorize URL. Already-authenticated callers skip
straight to the app; everyone else is bounced to IAM and returns to ``/``.
straight to the app; everyone else is bounced to IAM and returns to the
sanitized ``?next`` target (default ``/``) this is where the auth gate
sends an unauthenticated browser navigation, carrying its original path.
"""
token = None
auth_header = request.headers.get("Authorization", "")
@@ -353,7 +445,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return web.HTTPFound("/")
except Exception:
pass # IAM/JWKS hiccup — fall through and re-authenticate cleanly.
return _authorize_redirect(request, target="/")
return _authorize_redirect(request, target=_safe_next(request.query.get("next")))
async def handle_callback(request: web.Request) -> web.Response:
"""Exchange the authorization code for a token and set the session cookie."""
@@ -428,12 +520,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
if not token and path == "/ws":
token = request.rel_url.query.get("token")
wants_html = "text/html" in request.headers.get("Accept", "")
# A browser navigation is redirected into the login flow (302 → /login,
# carrying ?next); a programmatic API/XHR/JSON caller gets a 401 it can
# handle itself. One predicate decides, one way, for both no-token and
# bad-token cases below.
api = _is_api_request(request)
if not token:
if wants_html:
return _authorize_redirect(request)
return web.json_response({"error": "Authentication required", "iam_url": iam_url}, status=401)
if api:
return web.json_response({"error": "Authentication required", "iam_url": iam_url}, status=401)
return _login_redirect(request)
try:
user = await _validate(token)
@@ -441,11 +537,17 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return web.json_response({"error": "Authentication service unavailable"}, status=503)
if user is None:
if wants_html:
return _authorize_redirect(request)
return web.json_response({"error": "Invalid or expired token"}, status=401)
if api:
return web.json_response({"error": "Invalid or expired token"}, status=401)
return _login_redirect(request)
request["iam_user"] = _with_active_org(user, request.cookies.get(_ACTIVE_ORG_COOKIE))
# The verified raw access token. Studio records a completed render in the
# content lane AS THE REQUESTING USER (middleware/content_publish.py), so it
# lands in that user's org with that user's rights — no service account, no
# impersonation. Carried to the worker over the queue's `sensitive` channel,
# so it is never persisted into history.
request["iam_token"] = token
return await handler(request)
# Expose the login + callback handlers so server.py can register the routes.
+187
View File
@@ -0,0 +1,187 @@
"""MCP-over-HTTP for Hanzo Studio — the studio render pipeline as a Model Context
Protocol server so the cloud chat `create` capability can drive it.
POST /v1/mcp speaks JSON-RPC 2.0 (the MCP wire format) and exposes the SAME render
dispatch the /v1/library/{fix,compose} routes use ``studio_home.dispatch_fix`` /
``studio_home.dispatch_compose`` as MCP tools. So a fix/compose an LLM issues
behaves identically to one the Studio UI issues: one dispatch path, no second
pipeline. Auth and org ride the existing IAM middleware exactly like every other
/v1/* route (org = ``studio_home._org_of(request)``); nothing is registered here
that the global auth layer does not already gate.
Methods (JSON-RPC 2.0 envelope: {jsonrpc,id,result|error}):
initialize -> {protocolVersion, serverInfo, capabilities:{tools:{}}}
tools/list -> the fix / compose / ask tools + JSON schemas
tools/call -> dispatch fix|compose|ask and return MCP tool-result content
(the render prompt_id / output_prefix). A DispatchError
becomes an isError tool-result, never a 500. An unknown
METHOD is a JSON-RPC method-not-found error.
Registration: the ONE recipe
An operator/admin registers THIS studio MCP server per-org, once, through the
cloud tool registry the studio public URL with the /v1/mcp path:
POST https://api.hanzo.ai/v1/tools/servers
{ "name": "studio",
"url": "https://studio.hanzo.ai/v1/mcp",
"org": "<org>" }
Cloud then calls tools/list here and exposes the returned tools to /v1/chat's
`create` capability as SourceMCP tools. There is no per-tool wiring in cloud: the
tool set is whatever tools/list returns, so adding a studio tool is a one-line
change to _TOOLS here it flows to chat with no cloud deploy.
"""
from __future__ import annotations
import json
from aiohttp import web
from middleware import studio_home
from studio_version import __version__
# The MCP revision this server implements. Reported in `initialize`.
_PROTOCOL_VERSION = "2025-06-18"
# JSON-RPC 2.0 error codes (only the ones we emit).
_PARSE_ERROR = -32700
_INVALID_REQUEST = -32600
_METHOD_NOT_FOUND = -32601
# The studio render tools. `inputSchema` is the JSON Schema cloud advertises to
# the model; the dispatch functions validate again (instruction length, asset
# existence), so the schema is a hint, not the trust boundary.
_TOOLS = [
{
"name": "fix",
"description": (
"Edit one library asset with an instruction (Qwen image-edit). "
"Returns the render prompt_id and output_prefix."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "library-relative path of the asset to edit"},
"instruction": {"type": "string", "description": "the edit to apply (3-500 chars)"},
},
"required": ["path", "instruction"],
},
},
{
"name": "compose",
"description": (
"Combine 2-5 library assets into a new output per an instruction. "
"Returns the render prompt_id and output_prefix."
),
"inputSchema": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"minItems": 2,
"maxItems": 5,
"description": "2-5 library-relative asset paths to combine",
},
"instruction": {"type": "string", "description": "how to combine them (3-500 chars)"},
},
"required": ["paths", "instruction"],
},
},
{
"name": "ask",
"description": (
"Clarifier no-op: echo a question back to the user when the request is "
"ambiguous. Produces no render."
),
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "the clarifying question to ask"},
},
"required": ["question"],
},
},
]
def _result(id_, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": id_, "result": result}
def _error(id_, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
def _content(payload: dict, is_error: bool = False) -> dict:
"""An MCP tool result: text content parts + optional isError flag. The text is
the JSON dispatch payload (prompt_id / output_prefix) or the error message."""
r = {"content": [{"type": "text", "text": json.dumps(payload)}]}
if is_error:
r["isError"] = True
return r
async def _call_tool(request: web.Request, server, name: str, arguments: dict):
"""Dispatch one tool call to the shared render path. Returns the MCP
tool-result, or None for an unknown tool name (caller maps that to isError)."""
org = studio_home._org_of(request)
if name == "fix":
out = await studio_home.dispatch_fix(
request, server, org, arguments.get("path", ""), arguments.get("instruction", ""))
return _content(out)
if name == "compose":
out = await studio_home.dispatch_compose(
request, server, org, arguments.get("paths") or [], arguments.get("instruction", ""))
return _content(out)
if name == "ask":
return _content({"ok": True, "question": str(arguments.get("question", "")).strip()})
return None
async def _handle(request: web.Request, server, msg) -> dict | None:
"""Process one JSON-RPC 2.0 message. Returns the response envelope, or None
for a notification (a message with no ``id`` no response is sent)."""
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
return _error(None, _INVALID_REQUEST, "invalid JSON-RPC 2.0 request")
is_notification = "id" not in msg
id_ = msg.get("id")
method = msg.get("method")
params = msg.get("params") or {}
if method == "initialize":
result = {
"protocolVersion": _PROTOCOL_VERSION,
"serverInfo": {"name": "hanzo-studio", "version": __version__},
"capabilities": {"tools": {"listChanged": False}},
}
elif method == "tools/list":
result = {"tools": _TOOLS}
elif method == "tools/call":
name = params.get("name")
arguments = params.get("arguments") or {}
try:
result = await _call_tool(request, server, name, arguments)
except studio_home.DispatchError as e:
result = _content({"error": str(e)}, is_error=True)
if result is None:
result = _content({"error": f"unknown tool: {name}"}, is_error=True)
else:
return None if is_notification else _error(id_, _METHOD_NOT_FOUND, f"unknown method: {method}")
return None if is_notification else _result(id_, result)
def add_mcp_routes(routes: web.RouteTableDef, server) -> None:
"""Register POST /v1/mcp — the studio MCP-over-HTTP endpoint."""
@routes.post("/v1/mcp")
async def mcp_endpoint(request: web.Request):
try:
msg = await request.json()
except Exception:
return web.json_response(_error(None, _PARSE_ERROR, "invalid JSON"), status=400)
resp = await _handle(request, server, msg)
if resp is None: # notification: acknowledge with no body
return web.Response(status=202)
return web.json_response(resp)
+286
View File
@@ -0,0 +1,286 @@
"""Render quality gate — the ONE guard that keeps corrupted renders out of the library.
Every render lands through ``POST /v1/library/upload`` ``studio_home._index_upload``.
The moment a NEW asset row is created there, this fires an out-of-band judgment
(never blocks the upload response): downscale the just-stored image, ask a cloud
vision model to answer PASS/FAIL, and on a clear FAIL move the asset's library
status ``draft flagged`` so it drops out of every default grid the same
visibility semantics as ``deleted``, but recoverable from the UI's Flagged view.
It FAILS OPEN by construction. A PASS, an inconclusive reply, a 402/429, a timeout,
an unreachable judge, an unreadable image anything that is not an unambiguous
FAIL leaves the render a normal ``draft``. The gate can only ever hide clearly
broken work; it can never hide good work by accident, and it never blocks a render
from landing.
Env (all optional):
STUDIO_GATE "off"/"0"/"false"/"no" disables the gate entirely. Default on.
STUDIO_GATE_MODEL vision model id. Default "zen-vl" (the cloud's VL model).
STUDIO_GATE_URL full chat-completions URL override, e.g. a local engine at
http://127.0.0.1:1234/v1/chat/completions. Default = the
cloud gateway (gpu_dispatch.CLOUD) + /v1/chat/completions.
One auth path: the judgment carries the SAME bearer the upload carried, so the
cloud derives org + billing from it no shared keys, tenant-isolated like dispatch.
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
import os
import re
import time
from pathlib import Path
import aiohttp
from middleware import worklog
from middleware.gpu_dispatch import CLOUD, _bearer
log = logging.getLogger("studio.gate")
# The strict binary judge. The model is told to answer EXACTLY PASS or FAIL; the
# failure classes are the observed corruption modes of the render pipeline.
PROMPT = (
"You are a render QA gate for fashion imagery. Look at the image. Answer exactly "
"PASS or FAIL. FAIL if: skin texture is corrupted (mottled, blotchy, speckled, "
"noise-stippled), the image has ghosting/double-exposure, a frame-within-frame/"
"poster border, or glyph/pixel-garbage regions. Otherwise PASS."
)
# The motion judge — the SAME strict binary contract as PROMPT, but the failure classes
# are the observed corruption modes of the video pipeline (it is shown start/mid/end frames).
MOTION = (
"You are a render QA gate for generated video, shown frames sampled across its "
"duration. Answer exactly PASS or FAIL. FAIL if: frame-to-frame flicker, "
"identity/clothing morphing between frames, frozen/static motion where movement is "
"expected, temporal ghosting or trails, or glyph/pixel-garbage regions. Otherwise PASS."
)
_OFF = ("off", "0", "false", "no")
_FAIL_RE = re.compile(r"\bfail\b", re.I)
_PASS_RE = re.compile(r"\bpass\b", re.I)
# Keep a reference to in-flight tasks so a fire-and-forget judgment is not
# garbage-collected before it runs (per asyncio.ensure_future docs).
_TASKS: set = set()
def enabled() -> bool:
return os.environ.get("STUDIO_GATE", "").strip().lower() not in _OFF
def _model() -> str:
return os.environ.get("STUDIO_GATE_MODEL", "").strip() or "zen-vl"
def _url() -> str:
return os.environ.get("STUDIO_GATE_URL", "").strip() or (CLOUD + "/v1/chat/completions")
def parse_verdict(reply: str) -> str:
""""pass" | "fail" | "inconclusive" from the judge's reply. Case-insensitive
PASS/FAIL as a whole token (so "FAIL — mottled skin" reads as fail but "failure"
does not spuriously flag). A reply carrying BOTH tokens, or NEITHER, is
inconclusive and inconclusive fails open."""
fails = [m.start() for m in _FAIL_RE.finditer(reply or "")]
passes = [m.start() for m in _PASS_RE.finditer(reply or "")]
# The LAST token wins: a reasoning judge weighs both words before concluding
# ("is this PASS or FAIL... verdict: FAIL") — co-occurrence is deliberation,
# position is the verdict.
if fails and (not passes or fails[-1] > passes[-1]):
return "fail"
if passes and (not fails or passes[-1] > fails[-1]):
return "pass"
return "inconclusive"
def _encode(im) -> str | None:
"""A PIL image as a ~512px JPEG, base64 — the ONE frame-encode contract, small
enough for a fast vision call. Returns None on any failure."""
try:
im = im.convert("RGB")
im.thumbnail((512, 512))
buf = io.BytesIO()
im.save(buf, "JPEG", quality=80)
return base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
def _downscale(path: Path) -> str | None:
"""The stored image as a ~512px JPEG, base64. None if it can't be read (→ fail open)."""
try:
from PIL import Image
return _encode(Image.open(path))
except Exception:
return None
def _video_frame(path: str, t: float) -> str | None:
"""One ~512px JPEG (base64) decoded at ``t`` seconds — seek then take the next frame.
A fresh container per frame keeps the decode robust (no abandoned-generator state)."""
try:
import av
with av.open(path) as c:
st = c.streams.video[0]
if t > 0 and st.time_base:
c.seek(int(t / st.time_base), stream=st)
for frame in c.decode(st):
return _encode(frame.to_image())
except Exception:
return None
return None
def _frames(path: Path) -> list[str] | None:
"""Up to three ~512px JPEG frames (base64) to judge, or None when unreadable. A video
(.mp4/.webm) is sampled at start / middle / end so the motion judge sees temporal
coherence; any other file is the single downscaled image. None the caller decides
(an image fails open; a video is marked ungated, never a silent pass)."""
if path.suffix.lower() in (".mp4", ".webm"):
try:
import av
with av.open(str(path)) as probe:
st = probe.streams.video[0]
if st.duration and st.time_base:
dur = float(st.duration * st.time_base)
elif probe.duration:
dur = float(probe.duration) / 1_000_000
else:
dur = 0.0
targets = [0.0, dur / 2, dur] if dur > 0 else [0.0]
out = [b for b in (_video_frame(str(path), t) for t in targets) if b]
return out or None
except Exception:
return None
b = _downscale(path)
return [b] if b else None
def _reply_text(data: dict) -> str:
"""The assistant text from an OpenAI-style completion, tolerating a content
string, the content-parts array some gateways return, and REASONING models
whose verdict lands in reasoning_content while content comes back null (a
tiny max_tokens made zen-vl spend the whole budget thinking every verdict
parsed empty and the gate failed open, silently passing corrupted renders)."""
try:
msg = data["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return ""
content = msg.get("content") or ""
if isinstance(content, list):
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
if not str(content).strip():
content = msg.get("reasoning_content") or ""
return str(content)
async def judge(images: list[str], bearer: str, prompt: str) -> str | None:
"""Ask the vision judge to score N frames against ``prompt`` (PROMPT for a still image,
MOTION for a video's sampled frames) — one text part + N image parts. Returns its reply
text, or None on ANY error (non-200, bad JSON, timeout, unreachable) caller fails open."""
content = [{"type": "text", "text": prompt}]
for b64 in images:
content.append({"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64}})
payload = {
"model": _model(),
"messages": [{"role": "user", "content": content}],
"temperature": 0,
"max_tokens": 768, # reasoning models think before the verdict — 8 starved them into fail-open
}
headers = {"Content-Type": "application/json"}
if bearer:
headers["Authorization"] = bearer
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=45)) as s:
async with s.post(_url(), json=payload, headers=headers) as r:
if r.status != 200:
log.warning("gate: judge HTTP %s", r.status)
return None
return _reply_text(await r.json())
except Exception as e:
log.warning("gate: judge unreachable: %s", e)
return None
def _flag(org: str, root: Path, rel: str, reply: str) -> bool:
"""Move a still-``draft`` asset to ``flagged`` and record it on its work-log row.
Only ``draft`` transitions a late verdict must never yank a render a human has
since approved/published (or one already flagged/deleted). One writer of
library.json (studio_home's helpers). Returns True if the asset was flagged."""
from middleware import studio_home as sh # lazy: avoid an import cycle
lib = sh._load_library(root)
for a in lib.get("assets", []):
if a.get("path") == rel and a.get("status") == "draft":
a["status"] = "flagged"
a["updatedAt"] = int(time.time())
sh._save_library(root, lib)
worklog.add_event(org, rel, "flagged",
"quality gate: " + (reply or "").strip()[:80])
log.info("gate: FLAGGED %s/%s", org, rel)
return True
return False
def _skip(org: str, root: Path, rel: str) -> None:
"""A video whose frames could not be sampled is marked visibly UNGATED — NEVER a
silent pass: set the asset row's ``gate`` field to ``skipped`` (the card renders an
'ungated' badge from it) and log an ``ungated`` work-log event. Same single-writer
library pattern as ``_flag``; leaves status untouched."""
from middleware import studio_home as sh # lazy: avoid an import cycle
lib = sh._load_library(root)
hit = False
for a in lib.get("assets", []):
if a.get("path") == rel:
a["gate"] = "skipped"
a["updatedAt"] = int(time.time())
hit = True
if hit:
sh._save_library(root, lib)
worklog.add_event(org, rel, "ungated", "gate could not sample frames")
log.info("gate: UNGATED %s/%s", org, rel)
async def run(org: str, root: Path, rel: str, bearer: str) -> None:
"""Judge one just-stored render — a still image against PROMPT, a video against MOTION
over its sampled frames and flag it on a clear FAIL. A video whose frames can't be
sampled is marked UNGATED (never a silent pass); an unreadable image fails open. Never
raises a background task that fails open on everything else."""
try:
p = Path(root) / rel
if not p.is_file():
return
video = p.suffix.lower() in (".mp4", ".webm")
frames = _frames(p)
if frames is None:
if video:
_skip(org, root, rel) # honest: an ungated video is badged, never passed silently
else:
log.info("gate: unreadable %s/%s — pass (fail-open)", org, rel)
return
reply = await judge(frames, bearer, MOTION if video else PROMPT)
verdict = parse_verdict(reply or "")
if verdict == "fail":
_flag(org, root, rel, reply or "")
else:
log.info("gate: %s %s/%s", verdict, org, rel)
except Exception as e: # a background task must never surface an exception
log.info("gate: error %s/%s: %s — pass (fail-open)", org, rel, e)
def schedule(request, org: str, root: Path, rel: str) -> None:
"""Fire the judgment out-of-band. Captures the caller's bearer NOW (the request
is gone by the time the task runs) and schedules ``run`` on the event loop.
Never blocks the upload response; never raises."""
if not enabled():
return
try:
bearer = _bearer(request)
task = asyncio.ensure_future(run(org, root, rel, bearer))
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
except Exception as e:
log.info("gate: schedule failed %s/%s: %s", org, rel, e)
+100
View File
@@ -0,0 +1,100 @@
/* Hanzo Studio shell the ONE shared chrome, loaded by BOTH the Studio home
* (middleware/studio_home.html) and the Editor (injected into the engine's
* index.html by server.get_root). Self-contained dark palette + `hz` id/class
* prefix so it never collides with the editor's own theme. */
#hzbar{position:fixed;bottom:18px;right:18px;z-index:2147483000;display:flex;align-items:center;gap:8px;
flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);
font:400 14px/1.4 Inter,system-ui,-apple-system,sans-serif}
#hzbar button,#hzbar a{font:inherit}
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
.hz-pill:hover{border-color:#333333}
.hz-pill .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;flex:0 0 auto}
.hz-pill.on .hz-dot{background:#3ddc84}
.hz-pill.hz-chat{background:#ffffff;color:#111;border-color:#ffffff;font-weight:600}
.hz-topup{color:#888888;text-decoration:none;font-size:.82em;border-left:1px solid #1f1f1f;padding-left:7px}
.hz-topup:hover{text-decoration:underline}
/* right-docked chat sidebar (both views) */
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0a0a0a;color:#ededed;
border-left:1px solid #1f1f1f;z-index:2147483001;display:flex;flex-direction:column;
transform:translateX(100%);transition:transform .22s ease;box-shadow:-14px 0 44px #0007}
#hzchat.on{transform:translateX(0)}
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #1f1f1f;flex-wrap:wrap}
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#242424;display:flex;align-items:center;
justify-content:center;font-size:.72rem;font-weight:600;flex:0 0 auto}
#hzchat .hz-h .hz-who{font-size:.82rem;min-width:0}
#hzchat .hz-h .hz-who b{display:block;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#hzchat .hz-h .hz-who span{color:#888888;font-size:.72rem}
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#888888;font-size:1.3rem;line-height:1;background:none;border:none}
#hzmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.hz-m{max-width:88%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.hz-m.u{align-self:flex-end;background:#ffffff;color:#111;border-bottom-right-radius:3px}
.hz-m.a{align-self:flex-start;background:#1a1a1a;border:1px solid #1f1f1f;border-bottom-left-radius:3px}
.hz-m.sys{align-self:center;color:#888888;font-size:.78rem;background:none}
/* chat empty state — assistant identity + one line + 3 tappable starters */
.hz-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;margin:auto 0;padding:8px 6px}
.hz-empty .hz-eav{width:40px;height:40px;border-radius:50%;background:#1a1a1a;border:1px solid #1f1f1f;display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:#ededed}
.hz-empty .hz-etitle{font-size:.95rem;font-weight:600;color:#ededed}
.hz-empty .hz-esub{font-size:.8rem;color:#888888;line-height:1.45;max-width:280px}
.hz-empty .hz-starters{display:flex;flex-direction:column;gap:7px;width:100%;margin-top:8px}
.hz-empty .hz-starter{background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;border-radius:10px;padding:9px 12px;font:inherit;font-size:.82rem;text-align:left;cursor:pointer}
.hz-empty .hz-starter:hover{border-color:#333333;background:#171717}
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #1f1f1f}
#hzform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
#hzform button{background:#ffffff;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
#hzchat .hz-pw{padding:8px 12px 11px;text-align:center;color:#666666;font-size:.68rem;letter-spacing:.02em;border-top:1px solid #171717}
#hzchat .hz-pw b{color:#888888;font-weight:600}
.hz-m.a.streaming::after{content:"▍";color:#888888;margin-left:1px;animation:hzblink 1s steps(1) infinite}
@keyframes hzblink{50%{opacity:0}}
/* GPUs panel (both views) */
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#1a1a1a;
color:#ededed;border:1px solid #1f1f1f;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
#hzgpus.on{display:block}
#hzgpus h4{font-size:.82rem;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:7px}
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1f1f1f;font-size:.8rem}
.hz-node:first-of-type{border-top:none}
.hz-node .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;margin-top:5px;flex:0 0 auto}
.hz-node.on .hz-dot{background:#3ddc84}
.hz-node .hz-nn{flex:1;min-width:0}
.hz-node .hz-nn b{font-weight:500}
.hz-node .hz-nsub{color:#888888;font-size:.72rem;margin-top:2px}
/* Tablet: a touch narrower sidebar; the pill bar + panels already fit. */
@media (max-width:900px){#hzchat{width:340px}}
/* Phone: chat becomes a full-width bottom sheet (same pattern the old chatpanel
used); the pill bar wraps upward from the bottom-right so the toggle, wallet
and GPUs badge all stay reachable; the GPUs panel spans the width. */
@media (max-width:600px){
#hzbar{bottom:14px;right:14px;gap:7px}
.hz-pill{padding:7px 11px}
.hz-pill .hz-lbl{display:none} /* icon-only Chat/GPUs → one line, clears content */
#hzchat{top:auto;bottom:0;width:100vw;max-width:100vw;height:85vh;
border-left:none;border-top:1px solid #1f1f1f;border-radius:16px 16px 0 0;
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
#hzchat.on{transform:translateY(0)}
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
}
/* Developers workbench the bottom drawer (Queue · Logs · Shell), both views.
Sits UNDER the pill bar's z so the pills stay clickable above it. */
#hzwb{position:fixed;left:0;right:0;bottom:0;height:44vh;min-height:260px;background:#0a0a0a;color:#ededed;
border-top:1px solid #1f1f1f;z-index:2147482999;display:none;flex-direction:column;
box-shadow:0 -14px 44px #0007;font:400 13px/1.45 Inter,system-ui,-apple-system,sans-serif}
#hzwb.on{display:flex}
.hzwb-h{display:flex;align-items:center;gap:4px;padding:7px 10px;border-bottom:1px solid #1f1f1f;flex:0 0 auto}
.hzwb-tab{background:none;border:none;color:#888888;font:inherit;font-weight:600;padding:6px 11px;border-radius:8px;cursor:pointer}
.hzwb-tab:hover{color:#ededed}
.hzwb-tab.on{background:#1a1a1a;color:#ededed}
.hzwb-sp{flex:1}
.hzwb-ic{background:none;border:none;color:#888888;font:inherit;font-size:1.05rem;line-height:1;padding:5px 9px;cursor:pointer;border-radius:8px}
.hzwb-ic:hover{color:#ededed;background:#1a1a1a}
.hzwb-b{flex:1;min-height:0;overflow:auto;padding:12px 14px}
.hzwb-row{padding:2px 0}
.hzwb-dim{color:#888888}
.hzwb-cmd{color:#a3a3a3;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;margin-top:8px}
.hzwb-out{color:#ededed;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;
white-space:pre-wrap;word-break:break-word;margin:4px 0 0;padding:0}
.hzwb-out.err{color:#ff7a70}
#hzwbform{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid #1f1f1f;flex:0 0 auto}
#hzwbform .hzwb-ps{color:#888888;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
#hzwbform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:8px 11px;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;outline:none}
@media (max-width:600px){#hzwb{height:70vh}}
+454
View File
@@ -0,0 +1,454 @@
/* Hanzo Studio shell the ONE shared chrome for BOTH views.
*
* Studio home (/studio) includes this via <script src="/studio/shell.js">
* Editor (/ ?advanced=1) gets the same tag injected into the engine's
* index.html by server.get_root
*
* It renders (identically in both views): a Studio/Editor toggle, a wallet chip,
* a GPUs badge + panel, and a dockable right chat SIDEBAR backed by the ONE chat
* path (POST /v1/copilot/chat). Same-origin cookies carry the IAM session, so org
* + login are inherited no auth is rebuilt here. In the editor it also honours
* ?load=<worklog id> / ?template=<name> to open a graph, and offers Save as
* template. Guarded end to end: any failure logs and no-ops, never throws. */
(function () {
"use strict";
if (window.__hzShell) return;
window.__hzShell = 1;
var STUDIO = location.pathname === "/studio" || location.pathname === "/studio/";
// Hanzo AI chat config, injected server-side as window.HZ. `pk` is a PUBLISHABLE
// key (NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY) — safe in the client, scoped to chat
// completions; NEVER a server secret. Empty pk ⇒ the secure same-origin copilot
// path (cookie-authed) is used instead. `model` follows the composer's pick
// (hz_model) so the Chat sidebar and the composer share one model.
var HZ = window.HZ || {};
var AI_BASE = (HZ.aiBase || "https://api.hanzo.ai").replace(/\/+$/, "");
var PK = HZ.pk || "";
var USE_STREAM = STUDIO && !!PK; // direct enso streaming on the home; the editor keeps the copilot (graph ops)
// The Chat model is always an ASSISTANT model. The composer stores its CREATE model
// separately (hz_create_model), so an image pick can never leak here; and a stale
// hz_model that isn't a chat model is ignored (falls back to the enso assistant).
function chatModel() { try { var m = localStorage.hz_model; if (m && /^(enso|zen5|zen-vl)/.test(m)) return m; } catch (_) {} return HZ.model || "enso"; }
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, attrs, html) {
var e = document.createElement(tag);
if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
if (html != null) e.innerHTML = html;
return e;
};
var esc = function (s) { return String(s == null ? "" : s).replace(/[<>&]/g, function (c) { return { "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]; }); };
async function getJSON(url, opt) {
var r = await fetch(url, Object.assign({ credentials: "same-origin" }, opt || {}));
var j = {}; try { j = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, body: j };
}
// ── chrome: bottom-right pill cluster + docked chat sidebar + GPUs panel ──────
var bar = el("div", { id: "hzbar" });
var toggle = el("a", { class: "hz-pill", href: STUDIO ? "/?advanced=1" : "/studio",
title: STUDIO ? "Open the node editor" : "Back to Studio" }, STUDIO ? "⇄ Editor" : "⇄ Studio");
var tmplBtn = STUDIO ? null : el("button", { class: "hz-pill", title: "Save the current graph as a reusable template" }, "★ Template");
var wallet = el("a", { class: "hz-pill", id: "hzwallet", href: "https://pay.hanzo.ai", target: "_blank", rel: "noopener",
title: "Wallet — top up at pay.hanzo.ai" }, "wallet …");
var gpus = el("button", { class: "hz-pill", id: "hzgpubtn", title: "GPUs" }, '<span class="hz-dot"></span><span class="hz-lbl">GPUs</span>');
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span class="hz-lbl"> Chat</span>');
var devBtn = el("button", { class: "hz-pill", id: "hzdevbtn", title: "Developers workbench — queue, logs, and a read-only API shell" },
'&gt;_<span class="hz-lbl"> Developers</span>');
bar.appendChild(devBtn); bar.appendChild(toggle);
if (tmplBtn) bar.appendChild(tmplBtn);
bar.appendChild(wallet); bar.appendChild(gpus); bar.appendChild(chatBtn);
var gpuPanel = el("div", { id: "hzgpus" }, '<h4>GPUs</h4><div id="hznodes"></div>');
var chat = el("aside", { id: "hzchat" });
chat.innerHTML =
'<div class="hz-h"><span class="hz-av" id="hzav">·</span>' +
'<span class="hz-who"><b id="hzuser">…</b><span id="hzorg"></span></span>' +
'<button class="hz-x" id="hzclose" title="Close">×</button></div>' +
'<div id="hzmsgs"></div>' +
'<form id="hzform"><input id="hzin" placeholder="Ask Hanzo AI…" autocomplete="off"><button type="submit">Send</button></form>' +
'<div class="hz-pw">Powered by <b>Hanzo&nbsp;AI</b></div>';
function mount() {
document.body.appendChild(bar);
document.body.appendChild(gpuPanel);
document.body.appendChild(chat);
document.body.appendChild(wb);
wire(); wireWb();
setDev(devOn()); // developer controls (the composer's </>) follow this ONE flag
loadSession(); loadWallet(); loadGpus();
if (!STUDIO) editorBoot();
}
// Developers mode — a persistent, page-spanning flag. `body.hzdev` reveals dev-only
// controls (e.g. the composer's </> request preview) via CSS; opening the Developers
// workbench turns it on. One flag, no per-control JS coupling.
function devOn() { try { return localStorage.hz_dev === "1"; } catch (_) { return false; } }
function setDev(on) { try { if (on) localStorage.hz_dev = "1"; } catch (_) {} if (document.body) document.body.classList.toggle("hzdev", !!on); }
// ── chat: the ONE path — POST /v1/copilot/chat; ops applied via the editor's
// existing applier when present (no second chat mechanism) ────────────────────
var HISTORY = [];
function addMsg(role, text) {
var e = $("hzmsgs").querySelector(".hz-empty"); if (e) e.remove(); // first message clears the welcome
var m = el("div", { class: "hz-m " + role }, esc(text));
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
return m;
}
// Empty state: who the assistant is, one line on what it does, and 3 tappable starters —
// so an opened, empty chat is never a blank box. Editor vs home get fitting starters.
function chatStarters() {
return STUDIO
? ["A poster for a jazz night", "Ideas for a product launch image", "How do I make a logo transparent?"]
: ["What does this workflow do?", "Add a 4x upscale to the output", "Make the image sharper"];
}
function chatEmptyState() {
var box = $("hzmsgs"); if (!box || HISTORY.length || box.querySelector(".hz-empty")) return;
var wrap = el("div", { class: "hz-empty" });
wrap.innerHTML =
'<div class="hz-eav">✦</div><div class="hz-etitle">Hanzo&nbsp;Assistant</div>' +
'<div class="hz-esub">' + (STUDIO
? "Brainstorm ideas, sharpen a prompt, or ask how to make something."
: "Ask me to change your workflow, or how any node works.") + '</div>' +
'<div class="hz-starters">' + chatStarters().map(function (s) {
return '<button class="hz-starter" type="button">' + esc(s) + "</button>";
}).join("") + "</div>";
box.appendChild(wrap);
wrap.querySelectorAll(".hz-starter").forEach(function (b) {
b.onclick = function () { $("hzin").value = ""; send(b.textContent).catch(function () {}); };
});
}
function openChat() { chat.classList.add("on"); if (!HISTORY.length) chatEmptyState(); var i = $("hzin"); if (i) i.focus(); }
async function graphContext() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") return {};
try {
var p = await app.graphToPrompt();
var ctx = { graph_json: p.output || {} };
try { ctx.catalog = { types: Object.keys((window.LiteGraph && window.LiteGraph.registered_node_types) || {}) }; } catch (_) {}
return ctx;
} catch (_) { return {}; }
}
// The ONE chat send. Home + a publishable key ⇒ stream enso from the gateway
// (api.hanzo.ai/v1/chat/completions, OpenAI-compatible SSE). Otherwise the secure
// same-origin copilot path (cookie-authed; also carries graph ops in the editor).
// Same transcript UI either way.
function send(text) {
HISTORY.push({ role: "user", content: text });
addMsg("u", text);
return USE_STREAM ? sendStream() : sendCopilot();
}
// Pure SSE helpers (no DOM) — decomplected so the stream loop and the unit tests
// share ONE parser. `sseLines()` returns a stateful splitter that holds a partial
// line across chunk boundaries; `sseDelta(line)` maps one line to its content
// delta (or null for keep-alives, `[DONE]`, non-data lines, or malformed JSON).
function sseLines() {
var buf = "";
return function (chunk) {
buf += chunk;
var out = [], nl;
while ((nl = buf.indexOf("\n")) >= 0) { out.push(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
return out;
};
}
function sseDelta(line) {
line = line.replace(/\r$/, "").trim();
if (line.slice(0, 5) !== "data:") return null;
var d = line.slice(5).trim();
if (!d || d === "[DONE]") return null;
try {
var j = JSON.parse(d);
var delta = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
return delta || null;
} catch (_) { return null; }
}
// Home + publishable key: stream enso from the gateway. On a TRANSPORT failure
// (fetch throws, non-2xx, or no body — e.g. a PK provisioned before the gateway
// allows CORS/Authorization for this origin) degrade to the same-origin copilot
// instead of dead-ending, so Chat never hard-breaks. A partial stream that drops
// mid-flight keeps whatever text already arrived.
async function sendStream() {
var box = addMsg("a", ""); box.classList.add("streaming");
var acc = "";
try {
var res = await fetch(AI_BASE + "/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + PK },
body: JSON.stringify({ model: chatModel(), messages: HISTORY.slice(-20), stream: true }),
});
if (!res.ok || !res.body) { box.remove(); return sendCopilot(); }
var reader = res.body.getReader(), dec = new TextDecoder(), feed = sseLines();
for (;;) {
var rd = await reader.read(); if (rd.done) break;
var lines = feed(dec.decode(rd.value, { stream: true }));
for (var k = 0; k < lines.length; k++) {
var delta = sseDelta(lines[k]);
if (delta) { acc += delta; box.textContent = acc; $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight; }
}
}
box.classList.remove("streaming");
if (acc) HISTORY.push({ role: "assistant", content: acc }); else box.textContent = "(no response)";
} catch (e) {
box.classList.remove("streaming");
if (acc) HISTORY.push({ role: "assistant", content: acc });
else { box.remove(); return sendCopilot(); }
}
}
async function sendCopilot() {
var pending = addMsg("sys", "…");
var payload = Object.assign({ model: chatModel(), messages: HISTORY.slice(-20) }, await graphContext());
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
pending.remove();
// Only ever render the model's reply. A backend error (e.g. the gateway unreachable)
// becomes ONE friendly line — never the raw error string or JSON in a chat bubble.
var reply = r.body && r.body.reply;
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
else if (!r.ok) { addMsg("sys", "Hanzo AI is unavailable right now — please try again in a moment."); }
var ops = (r.body && r.body.ops) || [];
if (ops.length && window.HanzoCopilot && typeof window.HanzoCopilot.applyOps === "function") {
try { window.HanzoCopilot.applyOps(ops); } catch (e) { addMsg("sys", "couldn't apply changes: " + e.message); }
} else if (ops.length && STUDIO) {
addMsg("sys", "Open the Editor to apply " + ops.length + " graph change" + (ops.length > 1 ? "s" : "") + ".");
}
}
// ── Developers workbench: a bottom drawer (Queue · Logs · Shell), both views ──
// The same Stripe-Workbench footer-dock pattern the Cloud console ships: real
// queue/log data or an honest empty, and a READ-ONLY same-origin API shell
// (GET only — mutations belong in the product UI). Guarded: failures render as
// output lines, never throw.
var wb = el("div", { id: "hzwb" });
wb.innerHTML =
'<div class="hzwb-h">' +
'<button class="hzwb-tab on" data-t="queue">Queue</button>' +
'<button class="hzwb-tab" data-t="logs">Logs</button>' +
'<button class="hzwb-tab" data-t="shell">Shell</button>' +
'<span class="hzwb-sp"></span>' +
'<button class="hzwb-ic" id="hzwbre" title="Refresh">⟳</button>' +
'<button class="hzwb-ic" id="hzwbx" title="Close">×</button></div>' +
'<div class="hzwb-b" id="hzwbbody"></div>' +
'<form id="hzwbform" hidden><span class="hzwb-ps">$</span>' +
'<input id="hzwbin" placeholder="GET /api/queue — read-only, same origin" autocomplete="off" spellcheck="false"></form>';
var WBTAB = "queue";
function wbOpen(t) { if (t) WBTAB = t; wb.classList.add("on"); wbRender(); }
function wbClose() { wb.classList.remove("on"); }
function wbEsc(s, max) {
s = String(s == null ? "" : s);
if (max && s.length > max) s = s.slice(0, max) + "\n… (" + (s.length - max) + " more characters truncated)";
return esc(s);
}
// GET-only same-origin command: `GET /api/queue` | `/v1/nodes` | `internal/logs/raw`.
function wbParse(line) {
var w = line.trim().split(/\s+/).filter(Boolean);
if (!w.length) return { error: "Enter a path — e.g. GET /api/queue" };
var m = w[0].toUpperCase();
if (/^(POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(m)) return { error: "The workbench shell is read-only — only GET is supported." };
if (m === "GET") w = w.slice(1);
if (w.length !== 1) return { error: "One path per command — e.g. GET /api/queue" };
var p = w[0];
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(p)) return { error: "Same-origin paths only — /api, /v1 or /internal." };
p = p.replace(/^\/+/, "");
if (!/^(api|v1|internal)\//.test(p) || /\.\.|\/\//.test(p) || !/^[A-Za-z0-9\-_./?=&%,:+]+$/.test(p)) {
return { error: "Same-origin paths only — /api, /v1 or /internal." };
}
return { path: "/" + p };
}
async function wbRun(line) {
var out = $("hzwbbody");
var cmd = el("div", { class: "hzwb-cmd" }, "$ " + esc(line));
out.appendChild(cmd);
var parsed = wbParse(line);
var res = el("pre", { class: "hzwb-out" });
if (parsed.error) { res.classList.add("err"); res.textContent = parsed.error; }
else {
try {
var r = await fetch(parsed.path, { credentials: "same-origin" });
var text = await r.text();
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
if (!r.ok) { res.classList.add("err"); res.innerHTML = wbEsc("HTTP " + r.status + "\n" + text, 4000); }
else res.innerHTML = wbEsc(text, 20000);
} catch (e) { res.classList.add("err"); res.textContent = "request failed: " + e.message; }
}
out.appendChild(res); out.scrollTop = out.scrollHeight;
}
async function wbQueue() {
var out = $("hzwbbody");
out.innerHTML = '<div class="hzwb-dim">Loading queue…</div>';
var q = await getJSON("/api/queue"), g = await getJSON("/v1/render-queue"), h = await getJSON("/api/history?max_items=8");
var run = (q.body && q.body.queue_running || []).length, pen = (q.body && q.body.queue_pending || []).length;
var jobs = (g.body && g.body.jobs) || [];
var hist = h.body && typeof h.body === "object" ? Object.keys(h.body) : [];
var html = '<div class="hzwb-row"><b>Local engine</b> — ' + run + " running · " + pen + " pending" +
' &nbsp;·&nbsp; <b>GPU jobs</b> — ' + jobs.length + "</div>";
jobs.slice(0, 8).forEach(function (j) {
html += '<div class="hzwb-row hzwb-dim">' + esc((j.status || "queued") + " · " + (j.node || j.identity || "gpu") + " · " + String(j.prompt || j.kind || j.id || "").slice(0, 60)) + "</div>";
});
if (hist.length) {
html += '<div class="hzwb-row" style="margin-top:8px"><b>Recent history</b></div>';
hist.slice(-8).reverse().forEach(function (k) {
var it = h.body[k] || {}, st = (it.status && (it.status.status_str || (it.status.completed ? "success" : ""))) || "—";
var outs = it.outputs ? Object.keys(it.outputs).length : 0;
html += '<div class="hzwb-row hzwb-dim">' + esc(k.slice(0, 8) + " · " + st + " · " + outs + " output node" + (outs === 1 ? "" : "s")) + "</div>";
});
}
if (!run && !pen && !jobs.length && !hist.length) html += '<div class="hzwb-dim">Queue is empty — nothing has run yet.</div>';
out.innerHTML = html;
}
async function wbLogs() {
var out = $("hzwbbody");
out.innerHTML = '<div class="hzwb-dim">Loading logs…</div>';
var r = await getJSON("/internal/logs/raw");
var entries = (r.body && r.body.entries) || [];
if (!r.ok) { out.innerHTML = '<div class="hzwb-dim">Logs unavailable (HTTP ' + r.status + ").</div>"; return; }
if (!entries.length) { out.innerHTML = '<div class="hzwb-dim">No log lines yet.</div>'; return; }
out.innerHTML = '<pre class="hzwb-out">' + entries.slice(-250).map(function (e2) {
return wbEsc(String(e2.t || "").slice(11, 19) + " " + String(e2.m || "").replace(/\s+$/, ""));
}).join("\n") + "</pre>";
out.scrollTop = out.scrollHeight;
}
function wbRender() {
wb.querySelectorAll(".hzwb-tab").forEach(function (b) { b.classList.toggle("on", b.dataset.t === WBTAB); });
$("hzwbform").hidden = WBTAB !== "shell";
$("hzwbre").hidden = WBTAB === "shell";
if (WBTAB === "queue") wbQueue().catch(function () {});
else if (WBTAB === "logs") wbLogs().catch(function () {});
else {
var out = $("hzwbbody");
if (!out.querySelector(".hzwb-cmd")) out.innerHTML = '<div class="hzwb-dim">Read-only API shell — try <b>GET /api/queue</b>, <b>/v1/nodes</b> or <b>/internal/logs/raw</b>. Runs as you, same origin.</div>';
$("hzwbin").focus();
}
}
function wireWb() {
devBtn.onclick = function () { setDev(true); wb.classList.contains("on") ? wbClose() : wbOpen(); };
$("hzwbx").onclick = wbClose;
$("hzwbre").onclick = function () { wbRender(); };
wb.querySelectorAll(".hzwb-tab").forEach(function (b) {
b.onclick = function () { WBTAB = b.dataset.t; $("hzwbbody").innerHTML = ""; wbRender(); };
});
$("hzwbform").onsubmit = function (e) {
e.preventDefault();
var v = $("hzwbin").value.trim(); if (!v) return;
$("hzwbin").value = "";
wbRun(v).catch(function () {});
};
}
function wire() {
chatBtn.onclick = function () { if (chat.classList.contains("on")) chat.classList.remove("on"); else openChat(); };
$("hzclose").onclick = function () { chat.classList.remove("on"); };
$("hzform").onsubmit = function (e) {
e.preventDefault();
var v = $("hzin").value.trim(); if (!v) return;
$("hzin").value = ""; send(v).catch(function (err) { addMsg("sys", "error: " + err.message); });
};
gpus.onclick = function () { gpuPanel.classList.toggle("on"); if (gpuPanel.classList.contains("on")) loadGpus(); };
if (tmplBtn) tmplBtn.onclick = saveTemplate;
}
// ── session · wallet · GPUs ───────────────────────────────────────────────────
async function loadSession() {
var r = await getJSON("/v1/session"); var s = r.body || {};
var nm = (s.user || s.email || "You").trim();
$("hzav").textContent = (nm[0] || "·").toUpperCase();
$("hzuser").textContent = nm;
$("hzorg").textContent = s.org ? ("org · " + s.org) : "";
}
function money(n, cur) {
if (n == null || isNaN(n)) return "—";
var sym = (cur || "").toLowerCase() === "usd" ? "$" : "";
return sym + Number(n).toFixed(2) + (sym ? "" : " " + (cur || "").toUpperCase());
}
async function loadWallet() {
var r = await getJSON("/v1/wallet"); var w = r.body || {};
wallet.innerHTML = esc(money(w.balance, w.currency)) + '<span class="hz-topup">Top up ↗</span>';
wallet.title = "Wallet" + (w.org ? " · " + w.org : "") + " — top up at pay.hanzo.ai";
}
async function loadGpus() {
var nres = await getJSON("/v1/nodes"); var qres = await getJSON("/v1/queue/status");
var nodes = (nres.body && nres.body.nodes) || [];
var pod = (nres.body && nres.body.pod) || null;
if (pod) nodes = nodes.concat([pod]);
var items = (qres.body && qres.body.items) || {};
var byNode = {};
Object.keys(items).forEach(function (id) {
var it = items[id]; var k = it.node || "gpu";
(byNode[k] = byNode[k] || { running: null, depth: 0 });
byNode[k].depth++;
if (it.status === "running") byNode[k].running = it.prompt || it.kind || "a render";
});
var online = nodes.filter(function (n) { return n.online; }).length;
gpus.innerHTML = '<span class="hz-dot"></span><span class="hz-lbl">GPUs </span>' + online + "/" + nodes.length;
gpus.classList.toggle("on", online > 0);
$("hznodes").innerHTML = nodes.length ? nodes.map(function (n) {
var s = byNode[n.name] || { running: null, depth: 0 };
// Authoritative depth from /v1/nodes (gpu-jobs activities) when present; the
// running-render label still comes from the org's live queue/status.
var depth = (typeof n.depth === "number") ? n.depth : s.depth;
var sub = (n.online ? "online" : "offline") +
(s.running ? " · running: " + esc(String(s.running).slice(0, 40)) : (n.online ? " · idle" : "")) +
" · queue " + depth;
return '<div class="hz-node ' + (n.online ? "on" : "") + '"><span class="hz-dot"></span>' +
'<div class="hz-nn"><b>' + esc(n.name) + '</b><div class="hz-nsub">' + sub + "</div></div></div>";
}).join("") : '<div class="hz-nsub">No GPU nodes connected. Run <b>hanzo gpu connect</b> on a box to add one.</div>';
}
// ── editor-only: open a graph (?load / ?template), Save as template ────────────
function waitForApp(ms) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function poll() {
if (window.app && typeof window.app.loadGraphData === "function") return resolve(window.app);
if (Date.now() - t0 > ms) return reject(new Error("editor not ready"));
setTimeout(poll, 150);
})();
});
}
function editorLoad(g) {
var app = window.app; if (!app || !g) return false;
if (g.workflow && typeof app.loadGraphData === "function") { app.loadGraphData(g.workflow); return true; }
if (g.output && typeof app.loadApiJson === "function") { app.loadApiJson(g.output, "hanzo"); return true; }
if ((g.nodes || g.last_node_id != null) && typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
if (typeof app.loadApiJson === "function") { app.loadApiJson(g, "hanzo"); return true; }
if (typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
return false;
}
async function editorBoot() {
var q = new URLSearchParams(location.search);
var load = q.get("load"), template = q.get("template");
if (!load && !template) return;
try {
await waitForApp(30000);
var url = load ? "/v1/workflow?id=" + encodeURIComponent(load) : "/v1/templates/" + encodeURIComponent(template);
var r = await getJSON(url);
if (!r.ok) { addMsg("sys", "couldn't open: " + ((r.body && r.body.error) || r.status)); return; }
editorLoad(r.body.graph || r.body);
} catch (e) { console.warn("[hz shell]", e); }
}
async function saveTemplate() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") { alert("Open a workflow in the editor first."); return; }
var name = prompt("Save this graph as a template named:");
if (!name || !name.trim()) return;
var g;
try { g = await app.graphToPrompt(); } catch (e) { alert("Couldn't read the graph: " + e.message); return; }
var r = await getJSON("/v1/templates", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), graph: { workflow: g.workflow, output: g.output } }) });
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
}
// The ONE public chat entry, so other surfaces (the composer's assistant-intent ↑) reuse
// this exact sidebar + transport instead of building a second chat. `send` opens the
// panel and sends; an optional assistant model is remembered for the sidebar.
window.HanzoChat = {
open: openChat,
send: function (text, model) {
if (model) { try { localStorage.hz_model = model; } catch (_) {} }
openChat();
if (text && text.trim()) send(text.trim()).catch(function () {});
},
};
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount);
})();
+598
View File
@@ -0,0 +1,598 @@
"""Stacks — Procreate-style organizational folders for the Studio gallery.
A Stack groups library assets into a named folder (drag one image onto another,
or multi-select Stack). Stacks are ONLY an organizational layer: the assets
themselves stay exactly where they are (library.json rows + files), and lineage,
jobs and storage remain the source of truth. Removing an image from a Stack
returns it to the gallery; it is never a delete. Deleting a Stack defaults to
preserving every image.
Backed by ONE per-org SQLite file (stdlib sqlite3, WAL) at ``orgs/<org>/stacks.db``
the proven ``tasks_queue.py`` pattern: WAL + NORMAL + busy_timeout, and every
mutation runs under ``BEGIN IMMEDIATE`` so create / add / move / remove / reorder /
delete are atomic. Tenancy is structural: a different org is a different FILE, so
a stack from another org is unreachable by construction (every route 404s across
orgs). One stack per asset is enforced by ``UNIQUE(asset_id)`` on the membership
table a membership row exists only while the asset lives in a (non-deleted)
Stack, so "one non-deleted Stack per asset" falls out of the constraint.
The schema is exactly the spec's ``stacks`` + ``stack_assets`` join, plus two
small sidecar tables that keep generation-integration and the per-org setting in
the same tenant-scoped file:
stack_dest pending "outputs of this render → this Stack" routing (prefixid)
settings per-org key/value (e.g. auto_stack_batches)
Standard library only no new dependency.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import threading
import time
import uuid
from pathlib import Path
from aiohttp import web
import folder_paths
_UNTITLED = "Untitled Stack"
_MAX_NAME = 80
_MAX_DESC = 2000
_MAX_ASSET = 512
_MAX_ASSETS = 2000 # a Stack is a folder, not a database — bound it
_DEST_TTL = 6 * 3600.0 # a render lands within minutes; prune stale routing
_SCHEMA = """
CREATE TABLE IF NOT EXISTS stacks (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
workspace_id TEXT,
owner_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cover_asset_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS stack_assets (
id TEXT PRIMARY KEY,
stack_id TEXT NOT NULL,
asset_id TEXT NOT NULL UNIQUE, -- one Stack per asset
position INTEGER NOT NULL,
added_by TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_stack_assets_stack ON stack_assets(stack_id, position);
CREATE TABLE IF NOT EXISTS stack_dest (
prefix TEXT PRIMARY KEY,
stack_id TEXT NOT NULL,
added_by TEXT,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
_conns: dict[str, sqlite3.Connection] = {}
_locks: dict[str, threading.Lock] = {}
_registry_lock = threading.Lock()
def _db_path(org: str) -> Path:
"""``orgs/<org>/stacks.db`` — a per-org sibling of the output tree that holds
library.json/worklog.json/templates, so a Stack DB is tenant-scoped exactly
like every other per-org file (same resolution as ``_templates_root``)."""
out = Path(folder_paths.get_org_output_directory(org))
base = out.parent if out.parent.name == org else out
base.mkdir(parents=True, exist_ok=True)
return base / "stacks.db"
def _conn(org: str) -> tuple[sqlite3.Connection, threading.Lock]:
"""The org's connection + its write lock, opened once and reused (one durable
handle per tenant file, like the render queue)."""
path = str(_db_path(org))
with _registry_lock:
c = _conns.get(path)
if c is None:
os.makedirs(os.path.dirname(path), exist_ok=True)
c = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute("PRAGMA busy_timeout=5000")
c.executescript(_SCHEMA)
_conns[path] = c
_locks[path] = threading.Lock()
return c, _locks[path]
def _now() -> int:
return int(time.time())
def _clean_ids(raw) -> list[str]:
"""A client asset-id list → safe, de-duped, order-preserving, bounded."""
out: list[str] = []
seen: set[str] = set()
for x in (raw if isinstance(raw, list) else []):
if not isinstance(x, str):
continue
s = x.strip()[:_MAX_ASSET]
if s and s not in seen:
seen.add(s)
out.append(s)
if len(out) >= _MAX_ASSETS:
break
return out
def _row(stack: sqlite3.Row) -> dict:
return {
"id": stack["id"],
"organizationId": stack["organization_id"],
"workspaceId": stack["workspace_id"],
"ownerId": stack["owner_id"],
"name": stack["name"],
"description": stack["description"] or "",
"coverAssetId": stack["cover_asset_id"],
"createdAt": stack["created_at"],
"updatedAt": stack["updated_at"],
"archivedAt": stack["archived_at"],
}
# ── membership primitives (all run inside a caller-held BEGIN IMMEDIATE) ─────────
def _detach(c: sqlite3.Connection, asset_id: str, now: int) -> None:
"""Remove an asset from whatever Stack currently holds it, keeping that Stack's
cover valid the move-between-Stacks and create-steals-asset invariant. Does
NOT touch the asset itself (files/library rows are never Stacks' concern)."""
row = c.execute("SELECT stack_id FROM stack_assets WHERE asset_id=?", (asset_id,)).fetchone()
if row is None:
return
src = row["stack_id"]
c.execute("DELETE FROM stack_assets WHERE asset_id=?", (asset_id,))
cover = c.execute("SELECT cover_asset_id FROM stacks WHERE id=?", (src,)).fetchone()
if cover and cover["cover_asset_id"] == asset_id:
nxt = c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position LIMIT 1",
(src,)).fetchone()
c.execute("UPDATE stacks SET cover_asset_id=?, updated_at=? WHERE id=?",
(nxt["asset_id"] if nxt else None, now, src))
else:
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, src))
def _append(c: sqlite3.Connection, stack_id: str, asset_ids: list[str], added_by: str, now: int) -> None:
"""Append assets to a Stack in order, each first detached from its old Stack."""
nextpos = c.execute("SELECT COALESCE(MAX(position)+1,0) AS n FROM stack_assets WHERE stack_id=?",
(stack_id,)).fetchone()["n"]
for aid in asset_ids:
_detach(c, aid, now)
c.execute("INSERT INTO stack_assets(id, stack_id, asset_id, position, added_by, created_at)"
" VALUES(?,?,?,?,?,?)",
(uuid.uuid4().hex, stack_id, aid, nextpos, added_by, now))
nextpos += 1
def _members(c: sqlite3.Connection, stack_id: str) -> list[dict]:
rows = c.execute("SELECT asset_id, position, added_by, created_at FROM stack_assets"
" WHERE stack_id=? ORDER BY position", (stack_id,)).fetchall()
return [{"assetId": r["asset_id"], "position": r["position"],
"addedBy": r["added_by"], "createdAt": r["created_at"]} for r in rows]
# ── public API (each opens its own BEGIN IMMEDIATE; tenancy = the org's file) ────
def create(org: str, name: str, asset_ids, *, owner: str = "", workspace: str | None = None) -> dict:
name = (name or "").strip()[:_MAX_NAME] or _UNTITLED
ids = _clean_ids(asset_ids)
sid = uuid.uuid4().hex
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
c.execute("INSERT INTO stacks(id, organization_id, workspace_id, owner_id, name,"
" description, cover_asset_id, created_at, updated_at)"
" VALUES(?,?,?,?,?,?,?,?,?)",
(sid, org, workspace, owner, name, "", ids[0] if ids else None, now, now))
_append(c, sid, ids, owner, now)
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return get(org, sid)
def get(org: str, sid: str) -> dict | None:
c, _ = _conn(org)
row = c.execute("SELECT * FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
return None
out = _row(row)
out["assets"] = _members(c, sid)
out["count"] = len(out["assets"])
return out
def list_stacks(org: str, *, include_archived: bool = True) -> list[dict]:
"""All of the org's live Stacks, newest-touched first, each with its member
asset ids (the gallery hides stacked assets from the loose grid and draws the
layered thumbnail from the cover + first members)."""
c, _ = _conn(org)
q = "SELECT * FROM stacks WHERE organization_id=? AND deleted_at IS NULL"
if not include_archived:
q += " AND archived_at IS NULL"
q += " ORDER BY updated_at DESC"
out = []
for row in c.execute(q, (org,)).fetchall():
s = _row(row)
members = _members(c, row["id"])
s["assets"] = members
s["assetIds"] = [m["assetId"] for m in members]
s["count"] = len(members)
out.append(s)
return out
def patch(org: str, sid: str, fields: dict) -> dict | None:
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
c.execute("COMMIT")
return None
updates: list[tuple[str, object]] = []
if "name" in fields:
updates.append(("name", (str(fields.get("name") or "").strip()[:_MAX_NAME]) or _UNTITLED))
if "description" in fields:
updates.append(("description", str(fields.get("description") or "")[:_MAX_DESC]))
if "cover" in fields or "coverAssetId" in fields:
cover = str(fields.get("cover") or fields.get("coverAssetId") or "").strip()[:_MAX_ASSET]
# A cover must be a member — else the card would show a ghost.
member = c.execute("SELECT 1 FROM stack_assets WHERE stack_id=? AND asset_id=?",
(sid, cover)).fetchone()
if cover and member is None:
raise ValueError("cover must be an asset in this Stack")
updates.append(("cover_asset_id", cover or None))
if "archived" in fields:
updates.append(("archived_at", now if fields.get("archived") else None))
updates.append(("updated_at", now))
cols = ", ".join(f"{col}=?" for col, _ in updates)
args = [val for _, val in updates] + [sid, org]
c.execute(f"UPDATE stacks SET {cols} WHERE id=? AND organization_id=?", args)
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return get(org, sid)
def add_assets(org: str, sid: str, asset_ids, *, added_by: str = "") -> dict | None:
ids = _clean_ids(asset_ids)
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
row = c.execute("SELECT cover_asset_id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
c.execute("COMMIT")
return None
_append(c, sid, ids, added_by, now)
if row["cover_asset_id"] is None and ids:
c.execute("UPDATE stacks SET cover_asset_id=? WHERE id=?", (ids[0], sid))
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return get(org, sid)
def remove_assets(org: str, sid: str, asset_ids) -> dict | None:
"""Remove assets from a Stack — they return to the main gallery, never deleted.
Positions compact; a removed cover falls back to the new first member."""
ids = _clean_ids(asset_ids)
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
row = c.execute("SELECT cover_asset_id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
c.execute("COMMIT")
return None
for aid in ids:
c.execute("DELETE FROM stack_assets WHERE stack_id=? AND asset_id=?", (sid, aid))
# compact positions to keep them contiguous and stable
rest = c.execute("SELECT id FROM stack_assets WHERE stack_id=? ORDER BY position",
(sid,)).fetchall()
for i, r in enumerate(rest):
c.execute("UPDATE stack_assets SET position=? WHERE id=?", (i, r["id"]))
cover = row["cover_asset_id"]
if cover in ids:
nxt = c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position LIMIT 1",
(sid,)).fetchone()
c.execute("UPDATE stacks SET cover_asset_id=? WHERE id=?",
(nxt["asset_id"] if nxt else None, sid))
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return get(org, sid)
def reorder(org: str, sid: str, asset_ids) -> dict | None:
"""Persist a new order. The given ids must be exactly the Stack's current
membership (same set) a partial/foreign list is rejected so the order can
never silently drop or invent a member."""
ids = _clean_ids(asset_ids)
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
c.execute("COMMIT")
return None
cur = {r["asset_id"] for r in c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=?", (sid,)).fetchall()}
if set(ids) != cur:
raise ValueError("order must list exactly the Stack's assets")
for i, aid in enumerate(ids):
c.execute("UPDATE stack_assets SET position=? WHERE stack_id=? AND asset_id=?", (i, sid, aid))
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return get(org, sid)
def _dissolve(org: str, sid: str) -> list[str] | None:
"""Soft-delete the container and free its members (membership rows removed so
the assets are unstacked and one-per-asset stays true). Returns the freed asset
ids, or None if the Stack isn't the caller-org's live Stack. Backs both Unstack
and Delete-Stack."""
now = _now()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
(sid, org)).fetchone()
if row is None:
c.execute("COMMIT")
return None
members = [r["asset_id"] for r in
c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position", (sid,)).fetchall()]
c.execute("DELETE FROM stack_assets WHERE stack_id=?", (sid,))
c.execute("UPDATE stacks SET deleted_at=?, updated_at=? WHERE id=?", (now, now, sid))
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
return members
def unstack(org: str, sid: str) -> list[str] | None:
"""Delete the container, preserve every asset to the main gallery."""
return _dissolve(org, sid)
def delete(org: str, sid: str, mode: str = "only") -> dict | None:
"""Delete a Stack. ``only`` (default) returns its images to the gallery;
``images`` also asks the caller to soft-delete them. Files are never touched.
Returns ``{ok, mode, assetIds}`` (assetIds = the freed/soft-deletable assets),
or None across orgs / already gone."""
freed = _dissolve(org, sid)
if freed is None:
return None
return {"ok": True, "mode": ("images" if mode == "images" else "only"), "assetIds": freed}
def stack_of(org: str, asset_id: str) -> str | None:
c, _ = _conn(org)
row = c.execute("SELECT stack_id FROM stack_assets WHERE asset_id=?", (asset_id,)).fetchone()
return row["stack_id"] if row else None
# ── generation integration: route a render's outputs into a chosen Stack ─────────
def route_prefix(org: str, output_prefix: str, sid: str, *, added_by: str = "") -> None:
"""Remember "every output of the render at ``output_prefix`` goes into Stack
``sid``". Recorded at dispatch; consumed by :func:`absorb` when each output
lands (so ALL images of a multi-output job land in the same Stack). Stale
routing is pruned a render lands within minutes."""
if not output_prefix or not sid:
return
now = time.time()
c, lock = _conn(org)
with lock:
c.execute("BEGIN IMMEDIATE")
try:
c.execute("DELETE FROM stack_dest WHERE created_at < ?", (now - _DEST_TTL,))
c.execute("INSERT INTO stack_dest(prefix, stack_id, added_by, created_at) VALUES(?,?,?,?)"
" ON CONFLICT(prefix) DO UPDATE SET stack_id=excluded.stack_id,"
" added_by=excluded.added_by, created_at=excluded.created_at",
(output_prefix, sid, added_by, now))
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
_COUNTER = re.compile(r"_\d+_\.\w+$")
def absorb(org: str, rel: str, *, added_by: str = "") -> str | None:
"""A just-ingested asset ``rel`` → the Stack its render was routed to, if any.
Matches a landed ``<prefix>_<counter>_.png`` back to its ``output_prefix`` (and
the ingested-path case where the stored path IS the prefix). Idempotent via the
membership UNIQUE. Returns the Stack id it joined, or None."""
if not rel:
return None
c, _ = _conn(org)
cand = c.execute("SELECT stack_id FROM stack_dest WHERE prefix=?", (rel,)).fetchone()
if cand is None:
prefix = _COUNTER.sub("", rel)
if prefix != rel:
cand = c.execute("SELECT stack_id FROM stack_dest WHERE prefix=?", (prefix,)).fetchone()
if cand is None:
return None
sid = cand["stack_id"]
if add_assets(org, sid, [rel], added_by=added_by or "generation") is None:
return None
return sid
# ── per-org settings (auto-stack batch generations) ─────────────────────────────
def get_setting(org: str, key: str, default=None):
c, _ = _conn(org)
row = c.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if row is None:
return default
try:
return json.loads(row["value"])
except Exception:
return default
def set_setting(org: str, key: str, value) -> None:
c, lock = _conn(org)
with lock:
c.execute("INSERT INTO settings(key, value) VALUES(?,?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(value)))
# ── HTTP routes: /v1/stacks… (house style: /v1, never /api). Dependencies are
# INJECTED (org / owner / workspace resolvers + the library soft-deleter) so this
# module stays decoupled from studio_home — no import cycle, one stacks concern. ──
def add_stacks_routes(routes: web.RouteTableDef, server, *, org_of, owner_of, workspace_of,
soft_delete_assets) -> None:
async def _json(request):
try:
return await request.json()
except Exception:
return None
@routes.get("/v1/stacks")
async def list_route(request: web.Request):
org = org_of(request)
return web.json_response({"org": org, "stacks": list_stacks(org)})
@routes.post("/v1/stacks")
async def create_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
stack = create(org_of(request), body.get("name"), body.get("assetIds"),
owner=owner_of(request), workspace=workspace_of(request))
return web.json_response(stack)
# /v1/stacks/settings MUST precede /v1/stacks/{id} — else "settings" reads as an id.
@routes.get("/v1/stacks/settings")
async def get_settings_route(request: web.Request):
org = org_of(request)
return web.json_response({"org": org, "autoStack": bool(get_setting(org, "auto_stack_batches", False))})
@routes.patch("/v1/stacks/settings")
async def set_settings_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
org = org_of(request)
if "autoStack" in body:
set_setting(org, "auto_stack_batches", bool(body.get("autoStack")))
return web.json_response({"org": org, "autoStack": bool(get_setting(org, "auto_stack_batches", False))})
@routes.get("/v1/stacks/{id}")
async def get_route(request: web.Request):
stack = get(org_of(request), request.match_info["id"])
if stack is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response(stack)
@routes.patch("/v1/stacks/{id}")
async def patch_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
try:
stack = patch(org_of(request), request.match_info["id"], body)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
if stack is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response(stack)
@routes.delete("/v1/stacks/{id}")
async def delete_route(request: web.Request):
org = org_of(request)
mode = "images" if request.query.get("mode") == "images" else "only"
result = delete(org, request.match_info["id"], mode)
if result is None:
return web.json_response({"error": "not found"}, status=404)
if mode == "images" and result["assetIds"]:
soft_delete_assets(org, result["assetIds"])
return web.json_response(result)
@routes.post("/v1/stacks/{id}/assets")
async def add_assets_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
stack = add_assets(org_of(request), request.match_info["id"], body.get("assetIds"),
added_by=owner_of(request))
if stack is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response(stack)
@routes.delete("/v1/stacks/{id}/assets")
async def remove_assets_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
stack = remove_assets(org_of(request), request.match_info["id"], body.get("assetIds"))
if stack is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response(stack)
@routes.patch("/v1/stacks/{id}/order")
async def order_route(request: web.Request):
body = await _json(request)
if body is None:
return web.json_response({"error": "invalid JSON"}, status=400)
try:
stack = reorder(org_of(request), request.match_info["id"], body.get("assetIds"))
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
if stack is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response(stack)
@routes.post("/v1/stacks/{id}/unstack")
async def unstack_route(request: web.Request):
freed = unstack(org_of(request), request.match_info["id"])
if freed is None:
return web.json_response({"error": "not found"}, status=404)
return web.json_response({"ok": True, "assetIds": freed})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40 -4
View File
@@ -7,6 +7,7 @@ When Studio runs with --worker-mode, this module handles:
3. Reporting device capabilities (GPU model, VRAM, etc.)
"""
import asyncio
import hmac
import logging
import os
import time as _time
@@ -17,6 +18,7 @@ import aiohttp
from aiohttp import web
import execution
from studio.cli_args import args
logger = logging.getLogger(__name__)
@@ -31,10 +33,31 @@ WORKER_TOKEN = os.environ.get("STUDIO_WORKER_TOKEN", "")
def verify_worker_token(request) -> bool:
"""True if the request carries the coordinator shared secret (or none is set)."""
"""True iff the request carries the coordinator shared secret. FAILS CLOSED: in
worker mode the secret is MANDATORY (main.py refuses to boot without it, and the
KMS /v1 migration has emptied that secret before), so an empty/unset token is a
valid single-trust-domain dev signal ONLY when NOT in worker mode never a bypass
that reopens the hidden-run hole on a worker box. Constant-time comparison."""
if not WORKER_TOKEN:
return True
return request.headers.get("X-Worker-Token", "") == WORKER_TOKEN
return not args.worker_mode
return hmac.compare_digest(request.headers.get("X-Worker-Token", ""), WORKER_TOKEN)
def reject_untrusted_worker_submit(request, worker_mode: bool):
"""The ONE policy gate for worker-mode /prompt (decomplected: one function, one
place). In worker mode this process is a render BACKEND, not a front the only
accepted submit is a job claimed off the org's gpu-jobs queue and handed in over the
coordinator seam (POST /v1/worker/execute with X-Worker-Token). A direct /prompt POST
without a valid token is refused (403), so no render can reach a GPU without appearing
in the org's visible queue. Returns an aiohttp Response to send, or None to allow.
Not worker mode always allowed (returns None)."""
if worker_mode and not verify_worker_token(request):
return web.json_response({"error": {
"type": "worker_mode_forbidden",
"message": "worker-mode renders run only from the gpu-jobs queue "
"(POST /v1/worker/execute); direct /prompt is disabled.",
"details": "", "extra_info": {}}, "node_errors": {}}, status=403)
return None
def _worker_headers() -> dict:
@@ -172,6 +195,8 @@ def add_worker_routes(routes: web.RouteTableDef, prompt_server):
return web.json_response({"error": "No prompt provided"}, status=400)
prompt = json_data["prompt"]
if not isinstance(prompt, dict):
return web.json_response({"error": "prompt must be an object"}, status=400)
prompt_id = str(json_data.get("prompt_id", uuid.uuid4()))
partial_execution_targets = json_data.get("partial_execution_targets")
@@ -197,8 +222,19 @@ def add_worker_routes(routes: web.RouteTableDef, prompt_server):
if sensitive_val in extra_data:
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
number = float(json_data.get("number", 0))
try:
number = float(json_data.get("number", 0))
except (TypeError, ValueError):
return web.json_response({"error": "number must be numeric"}, status=400)
extra_data["create_time"] = int(_time.time() * 1000)
# `org` scopes outputs on THIS worker's LOCAL disk only — a worker-LOCAL label,
# never a gallery destination. The durable upload path re-derives the gallery org
# from the user's IAM token (not this body field), so a forged body `org` can at
# most mislabel a local temp dir, never write into another tenant's gallery. The
# prompt worker binds folder_paths.set_execution_org from extra_data["org_id"].
org = json_data.get("org") or extra_data.get("org_id")
if org:
extra_data["org_id"] = org
# Queue locally — the worker's prompt_worker thread will pick it up
prompt_server.prompt_queue.put(
+335
View File
@@ -0,0 +1,335 @@
"""Per-org work log — the ONE record of every render this org dispatched.
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
``rerun``) writes one row here at dispatch time, so the studio surface can show:
* the queue/history with REAL status queued · running · done · failed ·
cancelled including gpu-lane jobs the local /queue can't see (that was the
'nothing showed as queued' report) and dispatches that failed before queuing;
* the full context that produced each item prompt, reference images/uploads,
the workflow family (fix / compose / ), params, and the process trail;
* parent child lineage: each derived asset records the library asset(s) it was
derived from, so any item's versions (ancestors + later fixes) form a chain.
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` the same
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
items generated earlier have no recorded row (and derived items no recorded parent).
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
org's file, so one org can never read or mutate another's log.
"""
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
import folder_paths
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
_CAP = 1000
_TERMINAL = ("done", "failed", "cancelled")
def _dir(org: str) -> Path:
return Path(folder_paths.get_org_output_directory(org))
def _path(org: str) -> Path:
return _dir(org) / "worklog.json"
def load(org: str) -> list[dict]:
try:
rows = json.loads(_path(org).read_text())
return rows if isinstance(rows, list) else []
except Exception:
return []
def _save(org: str, rows: list[dict]) -> None:
d = _dir(org)
d.mkdir(parents=True, exist_ok=True)
p = _path(org)
tmp = p.with_name(p.name + ".tmp")
tmp.write_text(json.dumps(rows[-_CAP:]))
tmp.replace(p)
def has_output(org: str, path: str) -> bool:
"""Whether any row already owns this landed file — the ingest upsert key
(re-ingesting must repair, never duplicate). A dispatch row's output_prefix
lacks the SaveImage counter suffix (``fixes/x_ab12cd`` owns
``fixes/x_ab12cd_00001_.png``), so the match is prefix-aware, not equality
equality alone doubled every mirrored render with an ingest row."""
for r in load(org):
pre = r.get("output_prefix") or ""
if path and (path == pre or path == r.get("output") or
(pre and path.startswith(pre + "_"))):
return True
return False
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
parents: list, output_prefix: str, params: dict | None = None,
pid: str | None = None, lane: str | None = None, node: str | None = None,
status: str = "queued", error: str | None = None) -> dict:
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
live queue/history); a failed dispatch has no id, so a synthetic one is minted
and the row is marked failed with the reason so it STILL shows (never silent)."""
now = int(time.time())
row: dict = {
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
"ts": now,
"kind": kind,
"prompt": (prompt or "")[:800],
"refs": [str(x) for x in (refs or [])][:8],
"uploads": [str(x) for x in (uploads or [])][:8],
"parents": [str(x) for x in (parents or [])][:8],
"output_prefix": output_prefix or "",
"params": params or {},
"lane": lane or ("failed" if status == "failed" else "local"),
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
"status": status,
"updatedAt": now,
"events": [{"s": "queued", "ts": now}],
}
if error:
row["error"] = str(error)[:400]
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
rows = load(org)
rows.append(row)
_save(org, rows)
return row
def remove(org: str, ids) -> int:
"""Delete work-log rows by id (a queued/cancelled/failed/done entry the user wants
out of their queue). Returns how many were removed. The log is just a record of
dispatch requests, so dropping a row is safe it never touches an output image
(those live in the library and are archived/deleted there). Idempotent."""
want = {str(i) for i in (ids if isinstance(ids, (list, tuple, set)) else [ids]) if i}
if not want:
return 0
rows = load(org)
kept = [r for r in rows if str(r.get("id")) not in want]
removed = len(rows) - len(kept)
if removed:
_save(org, kept)
return removed
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
if not pid:
return False
rows = load(org)
hit = False
now = int(time.time())
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["status"] = status
r["updatedAt"] = now
r.setdefault("events", []).append(
{"s": status, "ts": now, **({"note": note} if note else {})})
hit = True
if hit:
_save(org, rows)
return hit
def add_event(org: str, output_prefix: str, s: str, note: str | None = None) -> bool:
"""Append one event to the row(s) carrying this landed output — an annotation,
not a lifecycle move. The render completed (its status is terminal); a later
judgment like the quality gate flagging it records an event without touching
that status. Keyed by output like ``has_output``. Returns True if a row was
annotated."""
if not output_prefix:
return False
rows = load(org)
now = int(time.time())
hit = False
for r in rows:
if r.get("output_prefix") == output_prefix or r.get("output") == output_prefix:
r.setdefault("events", []).append(
{"s": s, "ts": now, **({"note": note} if note else {})})
r["updatedAt"] = now
hit = True
if hit:
_save(org, rows)
return hit
def mark_started(org: str, pid: str, at: int) -> bool:
"""Stamp when a job first begins rendering (first seen at the head of its lane).
Idempotent: only the first observation sets started_at. Feeds duration = finished
started, the basis of the ETA median."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
r["started_at"] = int(at)
r["status"] = "running"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def mark_finished(org: str, pid: str, at: int) -> bool:
"""Stamp real completion (the output file's mtime) and close the row as done."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["finished_at"] = int(at)
r["status"] = "done"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def _duration(r: dict) -> float | None:
"""Measured render seconds for a completed row: finished started when both
exist (true render time), else finished queued (turnaround, an upper bound)."""
fin = r.get("finished_at")
if fin is None:
return None
start = r.get("started_at")
if start is None:
start = r.get("ts")
if start is None:
return None
return float(fin - start) if fin > start else None
def median_durations(rows: list[dict]) -> dict:
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
rows the ETA basis. Pure. Only kinds with a measured sample appear."""
buckets: dict[str, list[float]] = {}
for r in rows:
d = _duration(r)
if d and d > 0:
buckets.setdefault(r.get("kind", ""), []).append(d)
out: dict[str, int] = {}
for k, v in buckets.items():
v.sort()
n = len(v)
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
return out
def estimate_lanes(active: list[dict], medians: dict, now: int,
default_median: int = 180) -> tuple[dict, dict]:
"""Pure position + ETA math, per lane. ``active`` items are
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
the running item's remaining time (median elapsed). ETA to done = wait +
its own median. Returns (per-id info, per-lane count). No client math over
another tenant's data — the caller passes only THIS org's active jobs."""
lanes: dict[str, list[dict]] = {}
for j in active:
lanes.setdefault(j.get("lane") or "local", []).append(j)
info: dict[str, dict] = {}
counts: dict[str, int] = {}
for lane, jobs in lanes.items():
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
counts[lane] = len(jobs)
cum = 0.0 # seconds until the START of the current item
for idx, j in enumerate(jobs):
m = medians.get(j.get("kind", ""), default_median)
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
if idx == 0 and j.get("status") == "running":
started = j.get("started_at") or j.get("ts") or now
elapsed = max(0, now - started)
remaining = max(0.0, m - elapsed)
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
remaining_seconds=int(remaining), eta_seconds=int(remaining))
cum = remaining
else:
row.update(elapsed_seconds=0, wait_seconds=int(cum),
remaining_seconds=None, eta_seconds=int(cum + m))
cum += m
info[j["id"]] = row
return info, counts
def build_lineage(rows: list[dict], target: str) -> dict:
"""Pure: given work-log rows (each with ``parents`` = source library paths and
``output`` = the landed library path, resolved by the caller) and a target
library asset path, return its version chain:
{"ancestors": [older immediate parent], "node": <target>,
"descendants": [later derivations, chronological]}
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
up from the row that PRODUCED the target; descendants are rows that name the
target among their parents (and, transitively, their outputs). Cycles and
missing rows terminate the walk never loops."""
by_output: dict[str, dict] = {}
for r in rows:
out = r.get("output")
if out and out not in by_output:
by_output[out] = r
def entry(path: str, r: dict | None) -> dict:
return {
"path": path,
"prompt": (r or {}).get("prompt", ""),
"kind": (r or {}).get("kind", ""),
"ts": (r or {}).get("ts", 0),
}
# Ancestors: from the row that produced `target`, follow its first parent up.
ancestors: list[dict] = []
seen = {target}
cur = target
while True:
producer = by_output.get(cur)
if not producer:
break
parents = producer.get("parents") or []
if not parents:
break
parent = parents[0]
if parent in seen:
break
seen.add(parent)
ancestors.append(entry(parent, by_output.get(parent)))
cur = parent
ancestors.reverse() # oldest first
# Descendants: rows that derive FROM target, then rows deriving from those, …
descendants: list[dict] = []
frontier = [target]
seen_out = {target}
while frontier:
nxt: list[str] = []
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
kids.sort(key=lambda r: r.get("ts", 0))
for r in kids:
out = r.get("output")
key = out or r.get("id")
if key in seen_out:
continue
seen_out.add(key)
descendants.append(entry(out or "", r))
if out:
nxt.append(out)
frontier = nxt
return {"ancestors": ancestors, "node": target, "descendants": descendants}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.14.1"
version = "0.18.3"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
+9
View File
@@ -26,6 +26,15 @@ comfy-aimdo>=0.2.0
requests
PyJWT[crypto]>=2.7.0
# Office-document renderers (middleware/doc_render.py) — pure-Python, no GPU. WeasyPrint
# gives full-CSS PDFs when its native pango/cairo stack is present (see Dockerfile);
# where it is not (the light CI unit venv), the built-in PDF fallback takes over, so
# every format renders with just these wheels installed.
weasyprint>=63
python-docx>=1.1.0
openpyxl>=3.1.0
python-pptx>=1.0.0
#non essential dependencies:
kornia>=0.7.1
spandrel
+10 -2
View File
@@ -44,7 +44,10 @@ for name in CRIT:
PY
log "locked stack:"; sed 's/^/[packs] /' "$CGEN"
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped).
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped),
# then apply any local compat patches (scripts/patches/<pack>-*.patch). The
# forced checkout discards previously-applied patches so the pin stays
# authoritative and re-runs are idempotent.
log "reconciling packs from $MANIFEST"
while read -r name url sha _rest; do
case "$name" in ''|\#*) continue ;; esac
@@ -54,8 +57,13 @@ while read -r name url sha _rest; do
else
git clone -q --filter=blob:none "$url" "$dir"
fi
git -C "$dir" checkout -q "$sha"
git -C "$dir" checkout -qf "$sha"
log "pinned $name @ $(git -C "$dir" rev-parse --short HEAD)"
for p in "$REPO/scripts/patches/$name"-*.patch; do
[ -f "$p" ] || continue
git -C "$dir" apply "$p"
log "applied $(basename "$p")"
done
done < "$MANIFEST"
# 2. Install pack dependencies with the derived stack lock.
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""library_manifest.py — regenerate the ONE canonical asset-library index.
The Karma content pipeline has a single source of truth: a `library.json`
manifest that sits at the root of an org's studio-output subtree (any tenant; pass --root) and
indexes every asset Antje has rendered. The storefront (karma.style) and the
socials/blog queue both read THIS file there is no second index.
library root (== S3 prefix s3://hanzo-studio/orgs/karma/output/
== studio pod /app/orgs/karma/output/
== spark local /home/z/work/hanzo/studio/output/orgs/karma/output/)
designs/<slug>/<kind>_<role>.png catalog assets (ecom|product|lifestyle|hover)
marketing/<channel>/<name>.png queue assets (social|blog|campaign)
library.json <- this file, regenerated here
What this does, and nothing more:
* walk the tree, classify each image by path -> {design, kind, role}
* PRESERVE status/caption/tags from the existing library.json (keyed by path)
* brand-new files enter as status="draft"; deleted files drop out
* write library.json atomically (tmp + rename) so a reader never sees a
half-written file, and the S3 mirror sidecar carries it up on its next pass
It writes ONLY library.json. It never touches images and never regenerates
products.json (that file carries human-curated pricing/copy the site owns).
Runs one-shot, or with --watch as the studio pod's `build-manifest` sidecar.
Pure stdlib no PIL, no deps so it runs in the studio image or anywhere.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import sys
import tempfile
import time
from datetime import datetime, timezone
try:
import fcntl
except ImportError: # non-POSIX (never in the studio image); lock is a no-op
fcntl = None
LOCK_NAME = ".library.lock"
@contextlib.contextmanager
def library_lock(root: str):
"""Serialize the read-modify-write of library.json across processes — the
build-manifest sidecar and the studio app both rebuild-then-replace it, so an
unlocked interleave loses status/caption/tags updates. Same lock file name is
used by the app writer (studio_home._save_library)."""
if fcntl is None:
yield
return
lock_path = os.path.join(root, LOCK_NAME)
fh = open(lock_path, "w")
try:
fcntl.flock(fh, fcntl.LOCK_EX)
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(fh, fcntl.LOCK_UN)
fh.close()
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
VIDEO_EXTS = {".mp4", ".webm"}
MODEL3D_EXTS = {".glb"}
# Written marketing content (blog posts, campaign briefs, social ad copy) is
# first-class in the ONE index too — a .md under marketing/ is a queue asset
# whose body is the post/brief and whose caption/hashtags(=tags) are set with
# karma-queue.py. Restricted to marketing/ so catalog stays image-only.
MARKETING_TEXT_EXTS = {".md"}
MANIFEST_NAME = "library.json"
# The kinds a catalog asset may carry (designs/<slug>/<kind>_<role>.png). The
# storefront consumes ecom + product + lifestyle; hover is a card-hover alt.
CATALOG_KINDS = {"ecom", "product", "lifestyle", "hover"}
# Marketing channels double as the kind for marketing/<channel>/* assets.
MARKETING_KINDS = {"social", "blog", "campaign"}
def iso(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _sha_of(path: str) -> str | None:
"""Streamed content hash (a 40MB PNG must not balloon memory). None on error."""
import hashlib
try:
h = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
except OSError:
return None
def classify(relpath: str, slugs: set[str]) -> dict | None:
"""Map a path relative to the library root -> {design, kind, role}.
Returns None for anything that is not a manifestable asset.
"""
parts = relpath.split("/")
stem = os.path.splitext(parts[-1])[0]
if parts[0] == "marketing" and len(parts) >= 3:
channel = parts[1]
kind = channel if channel in MARKETING_KINDS else "campaign"
# design = leading slug token if it names a real design, else cross-cut.
head, _, rest = stem.partition("_")
if head in slugs and rest:
design, role = head, rest
else:
design, role = None, stem
return {"design": design, "kind": kind, "role": role}
if parts[0] == "designs" and len(parts) >= 3:
# 4k/print masters live alongside but are not catalog/queue items.
if "4k" in parts[2:-1]:
return None
design = parts[1]
kind, sep, role = stem.partition("_")
if not sep: # e.g. bare "front.png" -> treat stem as role, kind unknown
kind, role = "other", stem
if kind not in CATALOG_KINDS:
kind = kind if kind in ("other",) else "other"
return {"design": design, "kind": kind, "role": role}
return None
def scan(root: str) -> list[str]:
"""All manifestable image paths (relative to root), sorted for stable diffs."""
found: list[str] = []
for base, dirs, files in os.walk(root):
# Hidden segments are never assets — one rule covering AppleDouble
# forks (._foo.png) AND cache trees (.thumbs/ thumbnails indexed as
# library rows sent thumb paths into the fix dispatcher).
dirs[:] = [d for d in dirs if not d.startswith(".")]
for f in files:
if f.startswith("."):
continue
ext = os.path.splitext(f)[1].lower()
full = os.path.join(base, f)
rel = os.path.relpath(full, root)
is_image = ext in IMAGE_EXTS
is_video = ext in VIDEO_EXTS
is_model3d = ext in MODEL3D_EXTS
is_mktg_text = (ext in MARKETING_TEXT_EXTS
and rel.replace(os.sep, "/").startswith("marketing/"))
if not (is_image or is_video or is_model3d or is_mktg_text):
continue
try:
if os.path.getsize(full) == 0: # skip a render caught mid-write
continue
except OSError:
continue
found.append(rel)
return sorted(found)
def load_prior(path: str) -> dict[str, dict]:
"""Existing entries keyed by path, so status/caption/tags survive a rebuild."""
try:
with open(path, "r") as fh:
doc = json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
return {a["path"]: a for a in doc.get("assets", []) if "path" in a}
def build(root: str) -> dict:
manifest_path = os.path.join(root, MANIFEST_NAME)
prior = load_prior(manifest_path)
designs_dir = os.path.join(root, "designs")
slugs = {d for d in os.listdir(designs_dir)} if os.path.isdir(designs_dir) else set()
assets: list[dict] = []
for rel in scan(root):
norm = rel.replace(os.sep, "/")
if norm == MANIFEST_NAME:
continue
info = classify(norm, slugs)
prev = prior.get(norm, {})
if info is None:
# Taxonomy enriches, never gates: a file outside the naming scheme is
# still an asset. Dropping it here erased every ingest-indexed render on
# each sweep cycle. A video keeps kind="video" so it survives every sweep
# as the motion-judged, video-card kind.
_e = os.path.splitext(norm)[1].lower()
kind = prev.get("kind") or (
"model3d" if _e in MODEL3D_EXTS else "video" if _e in VIDEO_EXTS else "render")
info = {"design": prev.get("design"), "kind": kind, "role": prev.get("role")}
abspath = os.path.join(root, rel)
mtime = os.path.getmtime(abspath)
# Content hash powers the dedup invariant below. Reuse the prior sha when the
# file is byte-stable (same mtime), so a sweep hashes only NEW/changed files —
# O(new), not O(all), even on a large gallery. Only images are hashed (docs are
# not the byte-duplicate-clutter concern).
is_img = norm.lower().endswith((".png", ".jpg", ".jpeg", ".webp"))
sha = prev.get("sha") if (prev.get("mtime") == mtime and prev.get("sha")) \
else (_sha_of(abspath) if is_img else None)
entry = {
"path": norm,
"design": info["design"],
"kind": info["kind"],
"role": info["role"],
"status": prev.get("status", "draft"),
"tags": prev.get("tags", []),
"mtime": mtime,
"sha": sha,
"updatedAt": iso(mtime),
}
caption = prev.get("caption")
if caption:
entry["caption"] = caption
assets.append(entry)
# ── Structural invariant: AT MOST ONE active asset per content hash. A re-rendered
# byte-identical duplicate (a retried / double-dispatched render) is auto-marked
# deleted here — so the gallery can NEVER accumulate duplicate images, whatever bug
# fires upstream. Keeper = a curated asset if one exists, else the earliest by
# mtime; distinct renders (different seed → different bytes → different sha) are
# untouched. Idempotent (a prior-swept dup stays deleted) and recoverable like any
# soft-delete. This is the last line of defense that makes duplicate clutter
# impossible-by-construction, independent of dispatch/retry correctness. ──
from collections import defaultdict
_CURATED = {"approved", "published", "queued", "flagged"}
_by_sha: dict = defaultdict(list)
for a in assets:
if a.get("sha") and a["status"] != "deleted":
_by_sha[a["sha"]].append(a)
deduped = 0
for group in _by_sha.values():
if len(group) < 2:
continue
group.sort(key=lambda a: (a["status"] not in _CURATED, a.get("mtime", 0)))
for dup in group[1:]:
dup["status"] = "deleted"
dup["dedup"] = True
deduped += 1
by_status: dict[str, int] = {}
by_kind: dict[str, int] = {}
for a in assets:
by_status[a["status"]] = by_status.get(a["status"], 0) + 1
by_kind[a["kind"]] = by_kind.get(a["kind"], 0) + 1
return {
"_meta": {
"note": "Canonical Karma asset library. ONE index the storefront + "
"socials queue read. Regenerated by library_manifest.py; edit "
"status/caption/tags with karma-queue.py (they are preserved).",
"prefix": "orgs/karma/output",
"generatedAt": iso(time.time()),
"count": len(assets),
"byStatus": by_status,
"byKind": by_kind,
},
"assets": assets,
}
def write_atomic(root: str, doc: dict) -> str:
# A UNIQUE tmp per writer: a fixed ".library.json.tmp" is shared by every
# build-manifest sidecar, so two pods overlapping during a roll interleave into
# one inode -> a torn library.json -> load_prior()/reader gets JSONDecodeError
# -> {} -> every asset resets to status="draft" (the curation wipe). mkstemp in
# the SAME dir keeps the rename atomic and same-filesystem.
manifest_path = os.path.join(root, MANIFEST_NAME)
fd, tmp = tempfile.mkstemp(prefix=".library.json.", suffix=".tmp", dir=root)
try:
with os.fdopen(fd, "w") as fh:
json.dump(doc, fh, indent=2, ensure_ascii=False)
fh.write("\n")
os.replace(tmp, manifest_path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return manifest_path
def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None:
if not os.path.isdir(root):
msg = f"library_manifest: root does not exist yet: {root}"
if strict:
sys.exit(msg)
if not quiet:
print(msg, file=sys.stderr)
return None # watch mode: tolerate a tenant with no renders yet
# Hold the lock across build (which reads the prior manifest to preserve
# status/caption/tags) AND the write, so a concurrent app-side status change
# can't be read-then-clobbered.
with library_lock(root):
doc = build(root)
path = write_atomic(root, doc)
if not quiet:
m = doc["_meta"]
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
return doc
def main() -> None:
ap = argparse.ArgumentParser(description="Regenerate the Karma library.json manifest.")
ap.add_argument("--root", default=os.environ.get(
"KARMA_LIBRARY_ROOT", "/app/orgs/karma/output"),
help="Library root (contains designs/ and marketing/).")
ap.add_argument("--watch", action="store_true", help="Loop forever.")
ap.add_argument("--interval", type=int, default=30, help="--watch seconds.")
args = ap.parse_args()
if not args.watch:
run_once(args.root)
return
print(f"library_manifest: watching {args.root} every {args.interval}s", flush=True)
while True:
try:
run_once(args.root, quiet=True, strict=False)
except Exception as e: # a bad cycle must never kill the sidecar
print(f"library_manifest: cycle error: {e}", file=sys.stderr, flush=True)
time.sleep(args.interval)
if __name__ == "__main__":
main()
@@ -0,0 +1,262 @@
diff --git i/local_groundingdino/models/GroundingDINO/bertwarper.py w/local_groundingdino/models/GroundingDINO/bertwarper.py
index e209a39..2a87d42 100644
--- i/local_groundingdino/models/GroundingDINO/bertwarper.py
+++ w/local_groundingdino/models/GroundingDINO/bertwarper.py
@@ -7,22 +7,23 @@
import torch
from torch import nn
-from transformers.modeling_outputs import BaseModelOutputWithPoolingAndCrossAttentions
class BertModelWarper(nn.Module):
+ """Thin delegating wrapper around a HuggingFace ``BertModel``.
+
+ GroundingDINO feeds BERT a custom 3D ``[bs, seq, seq]`` self-attention mask
+ plus explicit ``position_ids``. Modern ``transformers`` handle both natively,
+ so we delegate to the real ``BertModel.forward`` instead of reimplementing
+ BERT internals (whose private call signatures drift between releases). The
+ fine-tuned checkpoint weights are remapped ``bert.*`` -> ``bert.bert_model.*``
+ at load time (see ``load_groundingdino_model`` in node.py).
+ """
+
def __init__(self, bert_model):
super().__init__()
- # self.bert = bert_modelc
-
+ self.bert_model = bert_model
self.config = bert_model.config
- self.embeddings = bert_model.embeddings
- self.encoder = bert_model.encoder
- self.pooler = bert_model.pooler
-
- self.get_extended_attention_mask = bert_model.get_extended_attention_mask
- self.invert_attention_mask = bert_model.invert_attention_mask
- self.get_head_mask = bert_model.get_head_mask
def forward(
self,
@@ -30,135 +31,26 @@ class BertModelWarper(nn.Module):
attention_mask=None,
token_type_ids=None,
position_ids=None,
- head_mask=None,
- inputs_embeds=None,
- encoder_hidden_states=None,
- encoder_attention_mask=None,
- past_key_values=None,
- use_cache=None,
- output_attentions=None,
output_hidden_states=None,
- return_dict=None,
+ **kwargs,
):
- r"""
- encoder_hidden_states (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`):
- Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if
- the model is configured as a decoder.
- encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):
- Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in
- the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``:
-
- - 1 for tokens that are **not masked**,
- - 0 for tokens that are **masked**.
- past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`):
- Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding.
-
- If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids`
- (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)`
- instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`.
- use_cache (:obj:`bool`, `optional`):
- If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up
- decoding (see :obj:`past_key_values`).
- """
- output_attentions = (
- output_attentions if output_attentions is not None else self.config.output_attentions
- )
- output_hidden_states = (
- output_hidden_states
- if output_hidden_states is not None
- else self.config.output_hidden_states
- )
- return_dict = return_dict if return_dict is not None else self.config.use_return_dict
-
- if self.config.is_decoder:
- use_cache = use_cache if use_cache is not None else self.config.use_cache
- else:
- use_cache = False
-
- if input_ids is not None and inputs_embeds is not None:
- raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
- elif input_ids is not None:
- input_shape = input_ids.size()
- batch_size, seq_length = input_shape
- elif inputs_embeds is not None:
- input_shape = inputs_embeds.size()[:-1]
- batch_size, seq_length = input_shape
- else:
- raise ValueError("You have to specify either input_ids or inputs_embeds")
-
- device = input_ids.device if input_ids is not None else inputs_embeds.device
-
- # past_key_values_length
- past_key_values_length = (
- past_key_values[0][0].shape[2] if past_key_values is not None else 0
- )
-
- if attention_mask is None:
- attention_mask = torch.ones(
- ((batch_size, seq_length + past_key_values_length)), device=device
+ if attention_mask is not None and attention_mask.dim() == 3:
+ # GroundingDINO supplies a custom [bs, seq, seq] self-attention mask
+ # (True = attend) to block cross-phrase attention. transformers>=5
+ # accepts an already-prepared 4D additive float mask
+ # [bs, 1, q_len, kv_len] and returns it as-is, so we convert here.
+ dtype = self.bert_model.dtype
+ keep = attention_mask[:, None, :, :].bool()
+ attention_mask = torch.zeros_like(keep, dtype=dtype).masked_fill(
+ ~keep, torch.finfo(dtype).min
)
- if token_type_ids is None:
- token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device)
-
- # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length]
- # ourselves in which case we just need to make it broadcastable to all heads.
- extended_attention_mask: torch.Tensor = self.get_extended_attention_mask(
- attention_mask, input_shape, device
- )
-
- # If a 2D or 3D attention mask is provided for the cross-attention
- # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length]
- if self.config.is_decoder and encoder_hidden_states is not None:
- encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states.size()
- encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length)
- if encoder_attention_mask is None:
- encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device)
- encoder_extended_attention_mask = self.invert_attention_mask(encoder_attention_mask)
- else:
- encoder_extended_attention_mask = None
- # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO':
- # import ipdb; ipdb.set_trace()
-
- # Prepare head mask if needed
- # 1.0 in head_mask indicate we keep the head
- # attention_probs has shape bsz x n_heads x N x N
- # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads]
- # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length]
- head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers)
-
- embedding_output = self.embeddings(
+ return self.bert_model(
input_ids=input_ids,
- position_ids=position_ids,
+ attention_mask=attention_mask,
token_type_ids=token_type_ids,
- inputs_embeds=inputs_embeds,
- past_key_values_length=past_key_values_length,
- )
-
- encoder_outputs = self.encoder(
- embedding_output,
- attention_mask=extended_attention_mask,
- head_mask=head_mask,
- encoder_hidden_states=encoder_hidden_states,
- encoder_attention_mask=encoder_extended_attention_mask,
- past_key_values=past_key_values,
- use_cache=use_cache,
- output_attentions=output_attentions,
+ position_ids=position_ids,
output_hidden_states=output_hidden_states,
- return_dict=return_dict,
- )
- sequence_output = encoder_outputs[0]
- pooled_output = self.pooler(sequence_output) if self.pooler is not None else None
-
- if not return_dict:
- return (sequence_output, pooled_output) + encoder_outputs[1:]
-
- return BaseModelOutputWithPoolingAndCrossAttentions(
- last_hidden_state=sequence_output,
- pooler_output=pooled_output,
- past_key_values=encoder_outputs.past_key_values,
- hidden_states=encoder_outputs.hidden_states,
- attentions=encoder_outputs.attentions,
- cross_attentions=encoder_outputs.cross_attentions,
+ return_dict=True,
)
diff --git i/node.py w/node.py
index cd88718..0cc48ed 100644
--- i/node.py
+++ w/node.py
@@ -12,7 +12,7 @@ import logging
from torch.hub import download_url_to_file
from urllib.parse import urlparse
import folder_paths
-import comfy.model_management
+import studio.model_management
from sam_hq.predictor import SamPredictorHQ
from sam_hq.build_sam_hq import sam_model_registry
from local_groundingdino.datasets import transforms as T
@@ -84,7 +84,7 @@ def load_sam_model(model_name):
if 'hq' not in model_type and 'mobile' not in model_type:
model_type = '_'.join(model_type.split('_')[:-1])
sam = sam_model_registry[model_type](checkpoint=sam_checkpoint_path)
- sam_device = comfy.model_management.get_torch_device()
+ sam_device = studio.model_management.get_torch_device()
sam.to(device=sam_device)
sam.eval()
sam.model_name = model_file_name
@@ -129,10 +129,17 @@ def load_groundingdino_model(model_name):
groundingdino_model_list[model_name]["model_url"],
groundingdino_model_dir_name,
),
+ weights_only=False,
)
- dino.load_state_dict(local_groundingdino_clean_state_dict(
- checkpoint['model']), strict=False)
- device = comfy.model_management.get_torch_device()
+ state_dict = local_groundingdino_clean_state_dict(checkpoint['model'])
+ # BertModelWarper now delegates to a nested `bert_model`; remap the
+ # fine-tuned BERT weights `bert.*` -> `bert.bert_model.*` so they load.
+ state_dict = {
+ (f'bert.bert_model.{k[len("bert."):]}' if k.startswith('bert.') else k): v
+ for k, v in state_dict.items()
+ }
+ dino.load_state_dict(state_dict, strict=False)
+ device = studio.model_management.get_torch_device()
dino.to(device=device)
dino.eval()
return dino
@@ -164,7 +171,7 @@ def groundingdino_predict(
caption = caption.strip()
if not caption.endswith("."):
caption = caption + "."
- device = comfy.model_management.get_torch_device()
+ device = studio.model_management.get_torch_device()
image = image.to(device)
with torch.no_grad():
outputs = model(image[None], captions=[caption])
@@ -243,7 +250,7 @@ def sam_segment(
predictor.set_image(image_np_rgb)
transformed_boxes = predictor.transform.apply_boxes_torch(
boxes, image_np.shape[:2])
- sam_device = comfy.model_management.get_torch_device()
+ sam_device = studio.model_management.get_torch_device()
masks, _, _ = predictor.predict_torch(
point_coords=None,
point_labels=None,
diff --git i/sam_hq/build_sam_hq.py w/sam_hq/build_sam_hq.py
index c8f41f2..153b819 100644
--- i/sam_hq/build_sam_hq.py
+++ w/sam_hq/build_sam_hq.py
@@ -64,7 +64,7 @@ def _load_sam_checkpoint(sam: Sam, checkpoint=None):
sam.eval()
if checkpoint is not None:
with open(checkpoint, "rb") as f:
- state_dict = torch.load(f)
+ state_dict = torch.load(f, weights_only=False)
info = sam.load_state_dict(state_dict, strict=False)
print(info)
for _, p in sam.named_parameters():
+169 -36
View File
@@ -64,6 +64,41 @@ def _remove_sensitive_from_queue(queue: list) -> list:
return [item[:5] for item in queue]
# ── Multi-tenant scoping for the inherited core endpoints (HIP-0506 security) ──
# /history and /queue are process-global in upstream ComfyUI. In a multi-tenant
# deployment that is cross-tenant disclosure (one org reads another's prompts,
# graphs, seeds) and a cross-tenant DoS (one org's "clear queue" wipes another's).
# Every item carries its owner in extra_data["org_id"] (bound at enqueue). These
# filter each list/dict to the caller's org. `org is None` means auth is OFF
# (single-tenant/local dev) → return everything unchanged (no behavior change).
def _item_org(item) -> str | None:
"""org_id from a queue-item tuple (index 3 = extra_data)."""
try:
return item[3].get("org_id") if len(item) > 3 and isinstance(item[3], dict) else None
except Exception:
return None
def _scope_queue_to_org(items: list, org: str | None) -> list:
if not org:
return items
return [it for it in items if _item_org(it) == org]
def _scope_history_to_org(hist: dict, org: str | None) -> dict:
if not org:
return hist
out = {}
for k, entry in (hist or {}).items():
try:
ed = entry.get("prompt", [None, None, None, {}])[3]
if isinstance(ed, dict) and ed.get("org_id") == org:
out[k] = entry
except Exception:
continue # malformed/legacy entry with no org → excluded (fail-closed)
return out
async def send_socket_catch_exception(function, message):
try:
await function(message)
@@ -229,7 +264,8 @@ class PromptServer():
self.client_session:Optional[aiohttp.ClientSession] = None
self.number = 0
middlewares = [cache_control, deprecation_warning]
from middleware.studio_home import home_redirect
middlewares = [cache_control, deprecation_warning, home_redirect]
self._iam_auth = None
if args.enable_iam_auth:
@@ -346,11 +382,14 @@ class PromptServer():
@routes.get("/ready")
async def readiness_check(request):
# Check that the prompt worker is alive and base directories exist
ready = (
os.path.isdir(folder_paths.get_input_directory())
and self.prompt_queue is not None
)
# The render worker must actually be ALIVE — not merely that the queue
# object exists (prompt_queue is set once at boot and never cleared, so
# it is constant-true and says nothing about the executor). A dead worker
# daemon thread accepts /prompt but runs nothing; readiness must fail so
# the pod leaves the Service and a roll never promotes a wedged pod.
wt = getattr(self, "prompt_worker_thread", None)
worker_alive = wt.is_alive() if wt is not None else (self.prompt_queue is not None)
ready = os.path.isdir(folder_paths.get_input_directory()) and worker_alive
if ready:
return web.json_response({"status": "ready"})
return web.json_response({"status": "not_ready"}, status=503)
@@ -399,11 +438,11 @@ class PromptServer():
},
)
response = web.FileResponse(os.path.join(self.web_root, "index.html"))
response.headers['Cache-Control'] = 'no-cache'
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Serve the editor SPA with the shared Studio shell injected (the ONE
# chrome — Studio/Editor toggle, wallet, GPUs, chat sidebar — that the
# Studio home also includes). No-cache, same as before.
from middleware.studio_home import editor_index
return editor_index(self.web_root)
@routes.get("/embeddings")
def get_embeddings(request):
@@ -541,7 +580,19 @@ class PromptServer():
# Force type=output regardless of what the client sent.
forced = dict(post)
forced["type"] = "output"
return image_upload(forced, request=request)
resp = image_upload(forced, request=request)
# Fix outputs: restore the render's global colour to its source, undoing
# the full-regen (denoise 1.0) white-balance/exposure drift and its
# fix-of-a-fix compounding. Pod-side + fail-safe, so it can never affect a
# render's success; a render that didn't drift matches to itself untouched.
try:
saved = json.loads(resp.body) if getattr(resp, "body", None) else {}
if saved.get("name"):
from middleware.studio_home import colormatch_fix_ingest
colormatch_fix_ingest(request, saved.get("subfolder", ""), saved["name"])
except Exception:
pass
return resp
@routes.post("/upload/mask")
async def upload_mask(request):
@@ -968,25 +1019,39 @@ class PromptServer():
else:
offset = -1
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
hist = self.prompt_queue.get_history(max_items=max_items, offset=offset)
hist = _scope_history_to_org(hist, self._caller_org(request))
return web.json_response(hist)
@routes.get("/history/{prompt_id}")
async def get_history_prompt_id(request):
prompt_id = request.match_info.get("prompt_id", None)
return web.json_response(self.prompt_queue.get_history(prompt_id=prompt_id))
hist = self.prompt_queue.get_history(prompt_id=prompt_id)
hist = _scope_history_to_org(hist, self._caller_org(request))
return web.json_response(hist)
@routes.get("/queue")
async def get_queue(request):
org = self._caller_org(request)
queue_info = {}
current_queue = self.prompt_queue.get_current_queue_volatile()
queue_info['queue_running'] = _remove_sensitive_from_queue(current_queue[0])
queue_info['queue_pending'] = _remove_sensitive_from_queue(current_queue[1])
queue_info['queue_running'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[0]), org)
queue_info['queue_pending'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[1]), org)
return web.json_response(queue_info)
@routes.post("/prompt")
async def post_prompt(request):
logging.info("got prompt")
# Execution-engine (worker) mode: this process IS a BYO-GPU render backend,
# not a front. A direct /prompt POST without the coordinator token is refused
# so no render reaches a GPU without appearing in the org's visible queue.
# One way onto a GPU: enqueue -> claim -> execute. (Policy: worker_client.)
from middleware.worker_client import reject_untrusted_worker_submit
_gate = reject_untrusted_worker_submit(request, args.worker_mode)
if _gate is not None:
return _gate
# Billing: check balance before accepting prompt
if args.enable_billing and args.billing_check_balance:
iam_user = request.get("iam_user")
@@ -1006,7 +1071,11 @@ class PromptServer():
json_data = self.trigger_on_prompt(json_data)
# --- Prompt routing: check if this should go to a GPU worker ---
if not args.worker_mode:
# Legacy in-cluster PUSH federation (prompt_router -> compute_config
# workers) is OFF by default: the gpu-jobs enqueue below (dispatch_if_worker)
# is the ONE submit path. Set STUDIO_LEGACY_PUSH_ROUTER=1 only to resurrect
# the old push seam. Keeps exactly one way onto a GPU in prod.
if not args.worker_mode and os.environ.get("STUDIO_LEGACY_PUSH_ROUTER") == "1":
org_id = self._get_org_id(request)
try:
route_result = await prompt_router.route_prompt(org_id, json_data)
@@ -1050,6 +1119,32 @@ class PromptServer():
self.node_replace_manager.apply_replacements(prompt)
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
# render backend — it must queue locally, never re-dispatch.
# Runs BEFORE local validation: the job executes on the org's GPU
# box, which validates against ITS node classes and model files. A
# GPU-less pod has an empty model list and would wrongly reject
# every checkpoint-referencing graph ("not in []").
if not args.worker_mode:
try:
from middleware.gpu_dispatch import dispatch_if_worker, has_models_for
except ImportError: # package-style checkout
from .middleware.gpu_dispatch import dispatch_if_worker, has_models_for
if dispatch_if_worker(request, org_id, prompt_id, prompt):
return web.json_response({"prompt_id": prompt_id, "number": number, "node_errors": {}})
# Dispatch didn't happen. If THIS box lacks the models THIS graph
# needs (a coordinator pod, even one with a stray SD1.5), local
# validation is GUARANTEED to fail with a cryptic "not in []" — the
# render belongs on the org's GPU worker, momentarily unreachable.
# Return a clear, retryable error instead of the doomed validation.
# A box that CAN load the graph falls through and renders it.
if not has_models_for(prompt):
return web.json_response({"error": {
"type": "gpu_worker_unavailable",
"message": "GPU render worker is momentarily unavailable — please retry.",
"details": "", "extra_info": {}}, "node_errors": {}}, status=503)
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
extra_data = {}
if "extra_data" in json_data:
@@ -1064,19 +1159,15 @@ class PromptServer():
if sensitive_val in extra_data:
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
extra_data["create_time"] = int(time.time() * 1000) # timestamp in milliseconds
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
extra_data["org_id"] = org_id
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
# render backend — it must queue locally, never re-dispatch.
dispatched = False
if not args.worker_mode:
try:
from middleware.gpu_dispatch import dispatch_if_worker
except ImportError: # package-style checkout
from .middleware.gpu_dispatch import dispatch_if_worker
dispatched = dispatch_if_worker(request, org_id, prompt_id, prompt)
if not dispatched:
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
# Carry the requester's verified IAM token to the worker so the
# finished render is recorded in the content lane as this user,
# into this user's org. `sensitive` is stripped from history (see
# SENSITIVE_EXTRA_DATA_KEYS) — the token is never persisted.
iam_token = request.get("iam_token")
if iam_token:
sensitive["iam_token"] = iam_token
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]}
return web.json_response(response)
else:
@@ -1094,13 +1185,20 @@ class PromptServer():
@routes.post("/queue")
async def post_queue(request):
json_data = await request.json()
org = self._caller_org(request) # None = auth off → upstream behavior
if "clear" in json_data:
if json_data["clear"]:
self.prompt_queue.wipe_queue()
if org is None:
self.prompt_queue.wipe_queue()
else:
# SCOPED clear: only THIS org's pending items, never others'.
self.prompt_queue.delete_queue_item(lambda a: _item_org(a) == org)
if "delete" in json_data:
to_delete = json_data['delete']
for id_to_delete in to_delete:
delete_func = lambda a: a[1] == id_to_delete
# Only delete an item the caller owns (org must match when auth on).
delete_func = (lambda a, i=id_to_delete: a[1] == i) if org is None \
else (lambda a, i=id_to_delete: a[1] == i and _item_org(a) == org)
self.prompt_queue.delete_queue_item(delete_func)
return web.Response(status=200)
@@ -1112,16 +1210,19 @@ class PromptServer():
except json.JSONDecodeError:
json_data = {}
org = self._caller_org(request) # None = auth off → upstream behavior
# Check if a specific prompt_id was provided for targeted interruption
prompt_id = json_data.get('prompt_id')
if prompt_id:
currently_running, _ = self.prompt_queue.get_current_queue()
# Check if the prompt_id matches any currently running prompt
# Check if the prompt_id matches any currently running prompt — and
# (multi-tenant) that it belongs to the caller's org, so one tenant
# cannot interrupt another tenant's render.
should_interrupt = False
for item in currently_running:
# item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute)
if item[1] == prompt_id:
if item[1] == prompt_id and (org is None or _item_org(item) == org):
logging.info(f"Interrupting prompt {prompt_id}")
should_interrupt = True
break
@@ -1129,11 +1230,18 @@ class PromptServer():
if should_interrupt:
nodes.interrupt_processing()
else:
logging.info(f"Prompt {prompt_id} is not currently running, skipping interrupt")
else:
# No prompt_id provided, do a global interrupt
logging.info(f"Prompt {prompt_id} is not currently running / not owned by caller, skipping interrupt")
elif org is None:
# No prompt_id + auth off → upstream global interrupt.
logging.info("Global interrupt (no prompt_id specified)")
nodes.interrupt_processing()
else:
# Multi-tenant: a global interrupt would kill another tenant's render.
# Only interrupt if the running job belongs to the caller's org.
currently_running, _ = self.prompt_queue.get_current_queue()
if any(_item_org(item) == org for item in currently_running):
logging.info(f"Interrupting current render for org {org}")
nodes.interrupt_processing()
return web.Response(status=200)
@@ -1310,6 +1418,16 @@ class PromptServer():
return iam_user.get("org_id", "default")
return os.environ.get("STUDIO_ORG_ID", "default")
def _caller_org(self, request) -> str | None:
"""The verified caller org to SCOPE cross-tenant reads by — or None when
multi-tenant auth is OFF (single-tenant/local dev) so /history + /queue
behave exactly as upstream. Only returns a value when there is a real IAM
user, so scoping never hides another tenant's data by accident."""
iam_user = request.get("iam_user")
if iam_user and iam_user.get("org_id"):
return iam_user.get("org_id")
return None
async def setup(self):
timeout = aiohttp.ClientTimeout(total=None) # no timeout
self.client_session = aiohttp.ClientSession(timeout=timeout)
@@ -1337,6 +1455,10 @@ class PromptServer():
add_copilot_routes(self.routes, self)
from middleware.session import add_session_routes
add_session_routes(self.routes, self)
from middleware.studio_home import add_studio_home_routes
add_studio_home_routes(self.routes, self)
from middleware.mcp import add_mcp_routes
add_mcp_routes(self.routes, self)
self.app.add_subapp('/internal', self.internal_routes.get_app())
# Prefix every route with /api for easier matching for delegation.
@@ -1390,6 +1512,17 @@ class PromptServer():
web.static('/docs', embedded_docs_path)
])
# Serve the unified Hanzo marketing shell (header + footer bundle) used by
# the logged-out login page. Built from web/shell into web/marketing and
# committed; assets are public (.js/.css) so they load pre-auth.
marketing_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "web", "marketing"
)
if os.path.isdir(marketing_path):
self.app.add_routes([
web.static('/marketing', marketing_path)
])
self.app.add_routes([
web.static('/', self.web_root),
])
+1 -1
View File
@@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.14.1"
__version__ = "0.18.2"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Studio watchdog: keep :8188 alive through the overnight karma render run.
# Emits a line ONLY on a restart event (so it's a signal, not a firehose).
# Detects: process gone, port unresponsive, or output-log silent >12min while a
# render is supposedly in flight (the 6h-hang failure mode from the QC log).
set -u
STUDIO=/home/z/work/hanzo/studio
LOG=$STUDIO/studio-local.log
launch() {
cd "$STUDIO"
# Free the port first: a stale/duplicate main.py holding :8188 makes the new
# one crash on bind (EADDRINUSE). Kill any existing listener, wait for release.
pkill -f 'main.py --listen' 2>/dev/null || true
for _ in $(seq 1 15); do
ss -ltn 2>/dev/null | grep -q ':8188 ' || break
sleep 1
done
HF_HOME=/home/z/.cache/huggingface \
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
STUDIO_PERSIST_QUEUE=1 \
nohup .venv/bin/python main.py --listen 0.0.0.0 --port 8188 --normalvram \
--disable-auto-launch --output-directory "$STUDIO/output" >> "$LOG" 2>&1 &
sleep 1
local pid
pid=$(pgrep -f 'main.py --listen' | head -1)
[ -n "$pid" ] && sudo -n sh -c "echo -700 > /proc/$pid/oom_score_adj" 2>/dev/null || true
echo "$pid"
}
while true; do
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
# give it one grace re-check (could be mid-render, momentarily busy)
sleep 8
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
pid=$(launch)
# wait for it to answer
for i in $(seq 1 45); do
c=$(curl -s -m 4 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
[ "$c" = "200" ] && break
sleep 4
done
echo "STUDIO_RESTARTED pid=$pid health=$c"
fi
fi
sleep 45
done
+500
View File
@@ -0,0 +1,500 @@
"""Office documents — the non-GPU document pipeline.
Covers the four concerns end to end:
* catalog every template well-formed, formats within archetype capability,
wire shape JSON-serializable (no build fn leaks).
* render schema -> IR -> real bytes for EVERY template in EVERY declared
format, asserted by magic numbers (%PDF, OOXML zip, markdown).
* AI-fill parse a mocked gateway completion -> coerced, typed fields; the
forwardable-bearer allowlist is fail-closed (JWT/pk- only).
* REST + tenancy generate/library/get/patch/delete/download over a real aiohttp app,
per-org isolation, honest 4xx, correct Content-Type/-Disposition.
Runs without a GPU, without torch, and without the WeasyPrint native stack (the built-in
PDF fallback produces a valid %PDF), so it is safe for the dependency-light CI unit gate.
"""
import importlib.util
import io
import json
import sys
import zipfile
from pathlib import Path
import pytest
# PDF (built-in fallback) and Markdown are pure-stdlib and always render. DOCX/XLSX/PPTX
# need their wheel; when it is absent (a bare env) we SKIP that format instead of failing —
# the canonical gate (requirements.txt installed) exercises every format for real.
_FMT_LIB = {"docx": "docx", "xlsx": "openpyxl", "pptx": "pptx"}
def _fmt_available(fmt: str) -> bool:
lib = _FMT_LIB.get(fmt)
return lib is None or importlib.util.find_spec(lib) is not None
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from aiohttp import web
import folder_paths
from middleware import documents as D
from middleware import doc_render as R
# ── helpers ────────────────────────────────────────────────────────────────────────
def _valid(fmt: str, b: bytes) -> bool:
if fmt == "pdf":
return b[:5] == b"%PDF-" and b"%%EOF" in b[-64:]
if fmt == "md":
try:
return len(b) > 10 and bool(b.decode("utf-8").strip()) # real, non-empty UTF-8 text
except UnicodeDecodeError:
return False
try:
return zipfile.ZipFile(io.BytesIO(b)).testzip() is None
except zipfile.BadZipFile:
return False
def _jwt(alg: str) -> str:
"""A JWT with the given header alg (payload/sig are inert — only the header matters
to the forwardable-bearer allowlist)."""
import base64
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
_OOXML_PART = {"docx": "word/document.xml", "xlsx": "xl/workbook.xml", "pptx": "ppt/presentation.xml"}
# ── catalog ─────────────────────────────────────────────────────────────────────────
def test_catalog_has_all_common_templates():
ids = {t["id"] for t in D.CATALOG}
expected = {
# Documents
"resume", "cover_letter", "business_letter", "memo", "report", "proposal", "sow",
"meeting_notes", "invoice", "receipt", "contract", "press_release", "newsletter",
"one_pager", "certificate",
# Spreadsheets
"budget", "expense_tracker", "timesheet", "project_plan",
# Presentations
"pitch_deck", "business_review", "project_update",
}
assert expected <= ids, f"missing templates: {expected - ids}"
assert len(D.CATALOG) == len(ids), "duplicate template ids"
def test_catalog_invariants():
for t in D.CATALOG:
assert t["category"] in D.CATEGORY_ORDER
assert t["formats"], f"{t['id']} has no formats"
# every template's declared formats are within its archetype's capability
kind, _ir = D.build_ir(t, t["sample"])
assert set(t["formats"]) <= set(R.KIND_FORMATS[kind]), \
f"{t['id']} ({kind}) declares formats outside {R.KIND_FORMATS[kind]}"
# the primary (first) format is the archetype's native format
assert t["formats"][0] == R.KIND_FORMATS[kind][0]
# fields typed and keyed
for f in t["fields"]:
assert f["key"] and f["label"] and f["type"]
def test_public_catalog_is_json_serializable_without_build_fn():
grouped = D.catalog_grouped()
blob = json.dumps({"categories": grouped}) # must not raise (build fn stripped)
assert '"build"' not in blob
names = [g["name"] for g in grouped]
assert names == [c for c in D.CATEGORY_ORDER] # grouped in the canonical order
total = sum(len(g["templates"]) for g in grouped)
assert total == len(D.CATALOG)
# ── render matrix: every template × every declared format ───────────────────────────
@pytest.mark.parametrize("tmpl", D.CATALOG, ids=[t["id"] for t in D.CATALOG])
def test_every_template_renders_every_format(tmpl):
for fmt in tmpl["formats"]:
if not _fmt_available(fmt):
continue # wheel not installed here; the canonical gate covers it
data, title = D.render_document(tmpl, tmpl["sample"], fmt)
assert isinstance(data, (bytes, bytearray)) and data, f"{tmpl['id']}/{fmt} produced no bytes"
assert _valid(fmt, data), f"{tmpl['id']}/{fmt} invalid magic bytes"
if fmt in _OOXML_PART:
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
assert _OOXML_PART[fmt] in names, f"{tmpl['id']}/{fmt} missing {_OOXML_PART[fmt]}"
assert title
def test_pdf_fallback_is_a_valid_pdf_without_weasyprint():
# The built-in writer must stand alone (CI/pods without pango). Assert directly.
ir = R.doc_ir("T", [R.title("Hello"), R.para("World " * 40),
R.table(["A", "B"], [["1", "2"], ["3", "4"]], foot=[["", "sum"]]),
R.bullets(["one", "two"]), R.signature("sig")])
pdf = R._builtin_pdf(ir)
assert pdf[:5] == b"%PDF-" and b"%%EOF" in pdf[-64:]
assert pdf.count(b" obj") == pdf.count(b"endobj") # balanced objects
assert b"/Root 1 0 R" in pdf and b"startxref" in pdf
def test_invoice_math_flows_into_the_document():
inv = D.get_template("invoice")
fields = dict(inv["sample"], tax_rate=10, items=[
{"description": "A", "qty": 2, "unit_price": 100}, # 200
{"description": "B", "qty": 1, "unit_price": 50}, # 50 -> subtotal 250, tax 25, total 275
])
md = D.render_document(inv, fields, "md")[0].decode()
assert "Subtotal" in md and "250.00" in md
assert "275.00" in md # total = subtotal + 10% tax
# ── AI-fill: parse + coercion + fail-closed bearer allowlist ────────────────────────
def test_parse_fill_from_tool_call_coerces_types():
inv = D.get_template("invoice")
completion = {"choices": [{"message": {"tool_calls": [{"function": {
"name": "fill_document",
"arguments": json.dumps({"fields": {
"number": "INV-9", "to_name": "Acme Corp", "tax_rate": "8.5",
"items": [{"description": "Consulting", "qty": "3", "unit_price": "$1,000"}],
"bogus_key": "dropped",
}}),
}}]}}]}
fields = D.parse_fill(completion, inv)
assert fields["number"] == "INV-9"
assert fields["tax_rate"] == 8.5 and isinstance(fields["tax_rate"], float)
assert fields["items"][0] == {"description": "Consulting", "qty": 3.0, "unit_price": 1000.0}
assert "bogus_key" not in fields # unknown keys dropped
assert "from_name" in fields # every declared field present
def test_parse_fill_from_embedded_json_content():
memo = D.get_template("memo")
completion = {"choices": [{"message": {"content":
'Here you go: {"fields": {"subject": "Policy", "body": "Text"}}'}}]}
fields = D.parse_fill(completion, memo)
assert fields["subject"] == "Policy" and fields["body"] == "Text"
def test_parse_fill_tolerates_garbage():
memo = D.get_template("memo")
assert D.parse_fill({}, memo) == {}
assert D.parse_fill({"choices": [{"message": {"content": "no json here"}}]}, memo) == {}
class _Req:
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
def test_forwardable_bearer_is_fail_closed(monkeypatch):
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
rs, hs = _jwt("RS256"), _jwt("HS256")
assert D._ai_token(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
assert D._ai_token(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
assert D._ai_token(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret keys refused
assert D._ai_token(_Req("Bearer hk-secret")) == ""
assert D._ai_token(_Req("Bearer rk-secret")) == ""
assert D._ai_token(_Req(None)) == "" # no token
assert D._ai_token(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
# session-cookie path (what the browser sends) is honored for a verifiable JWT
assert D._ai_token(_Req(None, {"access_token": rs})) == "Bearer " + rs
def test_ai_token_prefers_server_gateway_key(monkeypatch):
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
hs = _jwt("HS256")
# No server key + only an HS256 session token → no usable AI token (fail-closed → form).
assert D._ai_token(_Req("Bearer " + hs)) == ""
# A configured server gateway key (server-side only, never page source) is used and wins.
monkeypatch.setenv("STUDIO_DOC_TOKEN", "hk-server-key")
assert D._ai_token(_Req("Bearer " + hs)) == "Bearer hk-server-key"
# ── REST surface over a real aiohttp app ────────────────────────────────────────────
# Driven on a self-managed event loop so these are plain SYNC tests — no dependency on
# the pytest-asyncio / pytest-aiohttp async-mode configuration (which varies across
# environments). One loop per test; the app + client live and die with the fixture.
import asyncio
from aiohttp.test_utils import TestClient, TestServer
class _Resp:
"""A fully-materialized response (body read eagerly) with sync accessors."""
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self._body = body
def json(self):
return json.loads(self._body)
def read(self):
return self._body
def text(self):
return self._body.decode("utf-8", "replace")
class _SyncClient:
def __init__(self, tc, loop):
self._tc = tc
self._loop = loop
def _request(self, method, url, **kw):
async def go():
r = await self._tc.request(method, url, **kw)
body = await r.read()
return _Resp(r.status, dict(r.headers), body)
return self._loop.run_until_complete(go())
def get(self, url, **kw):
return self._request("GET", url, **kw)
def post(self, url, **kw):
return self._request("POST", url, **kw)
def patch(self, url, **kw):
return self._request("PATCH", url, **kw)
def delete(self, url, **kw):
return self._request("DELETE", url, **kw)
@pytest.fixture
def client(tmp_path, monkeypatch):
"""A live app with the documents routes and a tmp per-org tree. ``X-Test-Org`` header
selects the tenant (the injected org_of), so one client exercises multi-tenancy."""
def outd(org=None):
d = tmp_path / "orgs" / (org or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(folder_paths, "get_org_output_directory", outd)
def org_of(request):
return request.headers.get("X-Test-Org", "default")
def owner_of(request):
return request.headers.get("X-Test-User", "")
app = web.Application()
routes = web.RouteTableDef()
D.add_documents_routes(routes, object(), org_of=org_of, owner_of=owner_of)
app.add_routes(routes)
loop = asyncio.new_event_loop()
tc = TestClient(TestServer(app), loop=loop)
loop.run_until_complete(tc.start_server())
try:
yield _SyncClient(tc, loop)
finally:
loop.run_until_complete(tc.close())
loop.close()
def _gen(client, template_id, **body):
body["templateId"] = template_id
return client.post("/v1/documents/generate", json=body)
def test_catalog_endpoint(client):
r = client.get("/v1/documents")
assert r.status == 200
cats = {g["name"] for g in r.json()["categories"]}
assert {"Documents", "Spreadsheets", "Presentations"} <= cats
def test_generate_from_fields_then_download_pdf_and_docx(client):
"""The guarantee: a document renders end-to-end with NO LLM — form fields only."""
inv = D.get_template("invoice")
r = _gen(client, "invoice", fields=inv["sample"])
assert r.status == 200, r.text()
doc = r.json()
assert doc["id"] and doc["title"].startswith("Invoice")
assert set(doc["download"]) == set(inv["formats"])
fmts = [("pdf", "application/pdf")]
if _fmt_available("docx"):
fmts.append(("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
for fmt, want_ct in fmts:
dl = client.get(doc["download"][fmt])
assert dl.status == 200
assert dl.headers["Content-Type"] == want_ct
assert "attachment;" in dl.headers["Content-Disposition"]
assert dl.headers["Content-Disposition"].endswith(f'.{fmt}"')
assert _valid(fmt, dl.read())
def test_generate_each_archetype_native_format(client):
for tid, fmt in (("resume", "pdf"), ("budget", "xlsx"), ("pitch_deck", "pptx")):
if not _fmt_available(fmt):
continue
t = D.get_template(tid)
doc = _gen(client, tid, fields=t["sample"]).json()
dl = client.get(doc["download"][fmt])
assert dl.status == 200
assert dl.headers["Content-Type"] == R.content_type(fmt)
assert _valid(fmt, dl.read())
def test_generate_uses_ai_fill_when_description_given(client, monkeypatch):
# Mock the gateway call: description -> canned fields (no network).
async def fake_ai_fill(request, tmpl, description):
assert "Acme" in description
return D.coerce_fields(tmpl, {"to_name": "Acme Corp", "number": "INV-42",
"items": [{"description": "Work", "qty": 1, "unit_price": 5000}]})
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
doc = _gen(client, "invoice", description="Invoice for Acme Corp, $5,000, net-30").json()
assert doc["fields"]["to_name"] == "Acme Corp"
assert doc["title"] == "Invoice INV-42"
md = client.get(doc["download"]["md"]).read()
assert b"5,000.00" in md and b"Acme Corp" in md
def test_explicit_fields_override_ai(client, monkeypatch):
async def fake_ai_fill(request, tmpl, description):
return D.coerce_fields(tmpl, {"subject": "AI subject", "body": "AI body"})
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
doc = _gen(client, "memo", description="anything", fields={"subject": "Human subject"}).json()
assert doc["fields"]["subject"] == "Human subject" # explicit wins
assert doc["fields"]["body"] == "AI body" # AI fills the gap
def test_generate_fail_closed_without_token_or_fields(client, monkeypatch):
# description given, but no server key and no forwardable token -> AI unavailable, and
# no form fields to fall back to: honest 422 asking for fields, never a blank doc.
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
r = _gen(client, "invoice", description="Invoice for Acme")
assert r.status == 422
j = r.json()
assert j["needFields"] is True and j["template"]["id"] == "invoice"
def test_generate_rejects_unknown_template_and_empty_body(client):
assert _gen(client, "nope", fields={}).status == 404
assert client.post("/v1/documents/generate", json={"templateId": "memo"}).status == 400
def test_library_lists_generated_docs_newest_first(client):
_gen(client, "memo", fields={"subject": "One", "body": "b"})
_gen(client, "resume", fields=D.get_template("resume")["sample"])
docs = client.get("/v1/documents/library").json()["documents"]
assert len(docs) == 2
assert docs[0]["templateId"] == "resume" # newest first
assert {"id", "title", "templateId", "formats", "createdAt"} <= set(docs[0])
def test_get_patch_reflected_in_download(client):
doc_id = _gen(client, "memo", fields={"subject": "Before", "body": "b"}).json()["id"]
got = client.get(f"/v1/documents/{doc_id}").json()
assert got["fields"]["subject"] == "Before"
assert got["template"]["id"] == "memo"
assert client.patch(f"/v1/documents/{doc_id}", json={"fields": {"subject": "After"}}).status == 200
md = client.get(f"/v1/documents/{doc_id}/download?format=md").read()
assert b"After" in md and b"Before" not in md
def test_delete_removes_document(client):
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
assert client.delete(f"/v1/documents/{doc_id}").status == 200
assert client.get(f"/v1/documents/{doc_id}").status == 404
assert client.get("/v1/documents/library").json()["documents"] == []
def test_tenancy_isolation_between_orgs(client):
doc_id = client.post("/v1/documents/generate",
json={"templateId": "memo", "fields": {"subject": "secret"}},
headers={"X-Test-Org": "org-a"}).json()["id"]
# org-b cannot see or download org-a's document
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-b"}).status == 404
assert client.get(f"/v1/documents/{doc_id}/download?format=pdf",
headers={"X-Test-Org": "org-b"}).status == 404
assert client.get("/v1/documents/library", headers={"X-Test-Org": "org-b"}).json()["documents"] == []
# org-a still has it
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-a"}).status == 200
def test_download_rejects_bad_id_and_format(client):
assert client.get("/v1/documents/not-a-real-id/download").status == 404
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
assert client.get(f"/v1/documents/{doc_id}/download?format=xlsx").status == 400 # not in memo formats
# ── RED-review regressions (@e631d35f): XLSX formula injection, C0 control chars, crafted JWT alg ──
def test_xlsx_text_cell_formula_is_neutralized():
# the guard: a formula-trigger text cell becomes inert text; numbers are untouched.
assert R._xlsx_safe("=1+1") == "'=1+1"
assert R._xlsx_safe("+a") == "'+a" and R._xlsx_safe("-b") == "'-b" and R._xlsx_safe("@c") == "'@c"
assert R._xlsx_safe("plain") == "plain" and R._xlsx_safe(42) == 42 and R._xlsx_safe(3.5) == 3.5
if not _fmt_available("xlsx"):
pytest.skip("openpyxl not installed")
import openpyxl
tmpl = next(t for t in D.CATALOG if t["id"] == "budget")
fields = {k: (list(v) if isinstance(v, list) else v) for k, v in tmpl["sample"].items()}
rows = [dict(r) for r in fields.get("rows", [])] or [dict.fromkeys(("category", "planned", "actual"), "")]
first_key = next(iter(rows[0]), "category")
rows[0][first_key] = "=cmd|'/c calc.exe'!A1"
fields["rows"] = rows
data, _ = D.render_document(tmpl, fields, "xlsx")
ws = openpyxl.load_workbook(io.BytesIO(data)).worksheets[0]
cells = [c for row in ws.iter_rows() for c in row]
assert all(c.data_type != "f" for c in cells), "no cell may be a live formula"
hit = [c for c in cells if isinstance(c.value, str) and "calc.exe" in c.value]
assert hit and all(not c.value.startswith("=") for c in hit), "the =-leading value was neutralized to text"
def test_control_chars_stripped_and_ooxml_stays_valid():
# C0 controls (illegal in XML 1.0) are stripped at the coerce boundary; \t \n \r survive.
assert D._coerce("a\x00b\x0b\x1fc", "text") == "abc"
assert D._coerce("keep\ttab\nnl\rcr", "textarea") == "keep\ttab\nnl\rcr"
# end-to-end: a NUL/BEL in a field renders valid docx/pdf/md/xlsx — never a 500 / poison doc.
memo = next(t for t in D.CATALOG if t["id"] == "memo")
mf = dict(memo["sample"])
mf["body"] = "line1\x00\x07line2"
for fmt in [f for f in ("pdf", "docx", "md") if _fmt_available(f)]:
data, _ = D.render_document(memo, mf, fmt)
assert _valid(fmt, data), f"{fmt} must be valid after control-char strip"
if _fmt_available("xlsx"):
b = next(t for t in D.CATALOG if t["id"] == "budget")
bf = {k: (list(v) if isinstance(v, list) else v) for k, v in b["sample"].items()}
rows = [dict(r) for r in bf.get("rows", [])] or [{"category": "", "planned": 0, "actual": 0}]
rows[0][next(iter(rows[0]), "category")] = "x\x00y"
bf["rows"] = rows
assert _valid("xlsx", D.render_document(b, bf, "xlsx")[0])
def test_ai_token_delegates_to_canonical_gateway_auth(monkeypatch):
"""After the decomplect, documents._ai_token routes through the ONE gateway_auth
allowlist (no private copy they used to drift). Prove the fail-closed behavior via
the public _ai_token: a crafted non-string alg must not 500; HS256/secret refused;
pk-/asymmetric forwarded; a server key wins."""
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
def jwt_with_alg(alg_value): # a JWT header carrying an arbitrary (maybe non-string) alg
import base64
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
for bad in (None, [], 123, {"x": 1}, True):
assert D._ai_token(_Req("Bearer " + jwt_with_alg(bad))) == "" # crafted alg → no 500
assert D._ai_token(_Req("Bearer " + jwt_with_alg("HS256"))) == "" # HS256 session token refused
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret refused
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer pk-x" # publishable forwarded
assert D._ai_token(_Req("Bearer " + jwt_with_alg("RS256"))) == "Bearer " + jwt_with_alg("RS256")
monkeypatch.setenv("STUDIO_DOC_TOKEN", "sk-server") # server key wins
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer sk-server"
@@ -0,0 +1,50 @@
"""library.json write integrity — the curation-wipe class.
A fixed shared tmp let two build-manifest sidecars (one per pod during a roll)
interleave into one inode -> torn JSON -> readers get {} -> every asset resets to
draft. These pin: unique tmp per write, no fixed-tmp residue, concurrent writers
never produce a torn/invalid file, and the shared lock file coordinates."""
import concurrent.futures
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))
import library_manifest as lm
def _doc(n):
return {"_meta": {"count": n}, "assets": [{"path": f"a{i}.png", "status": "approved"} for i in range(n)]}
def test_write_atomic_uses_unique_tmp_no_fixed_residue(tmp_path):
root = str(tmp_path)
lm.write_atomic(root, _doc(3))
# the manifest is valid and complete
doc = json.loads((tmp_path / "library.json").read_text())
assert len(doc["assets"]) == 3
# the old fixed shared tmp must never be the write path
assert not (tmp_path / ".library.json.tmp").exists()
def test_concurrent_writes_never_tear(tmp_path):
root = str(tmp_path)
# many writers hammering the same manifest — under the fixed-tmp bug this tore;
# with unique tmp + atomic rename every reader sees a COMPLETE valid doc.
def w(n):
lm.write_atomic(root, _doc(n))
return json.loads((tmp_path / "library.json").read_text()) # read must always parse
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(w, [5, 10, 15, 20, 25, 30, 35, 40] * 3))
for doc in results:
assert "assets" in doc and isinstance(doc["assets"], list) # never {} from a torn read
final = json.loads((tmp_path / "library.json").read_text())
assert len(final["assets"]) in (5, 10, 15, 20, 25, 30, 35, 40) # one whole writer won, not a splice
def test_library_lock_serializes(tmp_path):
root = str(tmp_path)
with lm.library_lock(root):
assert (tmp_path / lm.LOCK_NAME).exists() # lock file materializes and is held
@@ -89,6 +89,15 @@ def test_count_outputs_handles_empty_and_none():
assert bm.count_outputs({"outputs": {"1": {"text": "no media here"}}}) == 0
def test_count_outputs_counts_video_extension_agnostic():
"""Metering is extension-agnostic: a SaveVideo history entry (its output reported
through `images` by ui.PreviewVideo) meters exactly like an image render the RFC's
shared-untouched claim."""
hist = {"outputs": {"12": {"images": [
{"filename": "video/x_00001_.mp4", "subfolder": "video", "type": "output"}]}}}
assert bm.count_outputs(hist) == 1
# ---------------------------------------------------------------------------
# record_render — the fire-and-forget metered event
# ---------------------------------------------------------------------------
@@ -0,0 +1,189 @@
"""Tests for recording a completed render as an Asset in the Hanzo content lane.
These are CONTRACT tests. They stand up a real aiohttp server that speaks the
exact contract hanzoai/cloud serves the generic framework surface
(POST /v1/framework/Asset) and drive the real publisher against it over real
HTTP. The server side of that contract (the Asset DocType, its draft default, the
lifecycle hook that owns every status edge, cross-org isolation) is proven
independently against the real engine and a real SQLite by the content lane's own
Go tests in hanzoai/cloud (clients/content/content_test.go).
What is asserted here is Studio's half: that a finished render produces exactly one
Asset per output artifact, in the SAME field shape clients/content itself writes
(so a studio-initiated render is indistinguishable from a lane-initiated one); that
`file` is the org-scoped output key (the bytes Studio already persisted this
module uploads nothing); that every call is authenticated AS THE REQUESTING USER;
that assets land as drafts (never auto-published to a storefront); and that a
render is never failed when the lane is unreachable.
"""
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import content_publish
pytestmark = pytest.mark.asyncio # module is all-async
TOKEN = "the-users-iam-access-token"
PROMPT_ID = "b6f1c0e2-0000-4000-8000-000000000001"
WORKFLOW = {"3": {"class_type": "KSampler", "inputs": {"steps": 30}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors"}}}
HISTORY = {"outputs": {"9": {"images": [
{"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
{"filename": "life1_00002_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
]}, "10": {"images": [
{"filename": "preview.png", "subfolder": "", "type": "temp"}, # a preview is never an artifact
]}}}
EXTRA = {"design": "valentina", "kind": "lifestyle", "role": "life1",
"prompt": "poolside, golden hour", "project": "fashion", "tags": "valentina,life1"}
class Content:
"""A stand-in for hanzoai/cloud's framework surface, module 'marketing'."""
def __init__(self, fail: bool = False):
self.assets: list[dict] = []
self.fail = fail
def app(self) -> web.Application:
app = web.Application()
app.router.add_post("/v1/framework/Asset", self._asset)
return app
async def _asset(self, request):
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
return web.json_response({"error": "valid principal required"}, status=403)
body = await request.json()
if self.fail:
return web.json_response({"error": "boom"}, status=500)
# The lane defaults status to draft; the client does not (and must not) set it.
doc = {**body, "name": "hash-id", "doctype": "Asset", "status": "draft"}
self.assets.append(doc)
return web.json_response(doc, status=201)
async def _publish(content, aiohttp_client, monkeypatch, **kw):
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
client: TestClient = await aiohttp_client(TestServer(content.app()))
monkeypatch.setattr(content_publish, "CLOUD_URL", str(client.make_url("")).rstrip("/"))
args = {"org_id": "karma", "prompt_id": PROMPT_ID, "workflow": WORKFLOW,
"history_result": HISTORY, "iam_token": TOKEN, "extra_data": EXTRA}
args.update(kw)
return await content_publish.publish_render(**args)
# --- pure helpers --------------------------------------------------------
async def test_artifacts_are_outputs_only_never_previews():
refs = content_publish.artifacts(HISTORY)
assert [r["filename"] for r in refs] == ["life1_00001_.png", "life1_00002_.png"]
async def test_object_key_is_the_org_scoped_output_key():
ref = {"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"}
assert content_publish.object_key("karma", ref) == \
"orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
# no subfolder collapses cleanly (no doubled slash)
assert content_publish.object_key("karma", {"filename": "x.png", "subfolder": "", "type": "output"}) == \
"orgs/karma/output/x.png"
async def test_kind_must_be_a_valid_select_value():
assert content_publish.kind_of({"kind": "lifestyle"}) == "lifestyle"
assert content_publish.kind_of({"kind": "nonsense"}) == "product" # off-list -> the default
assert content_publish.kind_of({}) == "product"
# --- the happy path ------------------------------------------------------
async def test_records_one_asset_per_artifact(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch) == 2
assert len(content.assets) == 2
assert [a["title"] for a in content.assets] == ["life1_00001_.png", "life1_00002_.png"]
async def test_asset_matches_the_content_lane_field_shape(aiohttp_client, monkeypatch):
content = Content()
await _publish(content, aiohttp_client, monkeypatch)
a = content.assets[0]
# The exact fields clients/content writes (studio_render.go draftAsset).
assert a["file"] == "orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
assert a["kind"] == "lifestyle"
assert a["design"] == "valentina"
assert a["role"] == "life1"
assert a["source_prompt_id"] == PROMPT_ID
assert a["render_params"] == WORKFLOW # the graph reproduces it
assert a["generator"] == "qwen-image-edit-2511.safetensors"
assert a["project"] == "fashion" and a["tags"] == "valentina,life1"
async def test_assets_land_as_draft_never_auto_published(aiohttp_client, monkeypatch):
"""A render must never push an image to a storefront. The client never sends a
status; the lane defaults it to draft; a human moves it through the lifecycle."""
content = Content()
await _publish(content, aiohttp_client, monkeypatch)
assert all("status" not in c for c in [content_publish.asset_doc(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW, ref=r, extra_data=EXTRA
) for r in content_publish.artifacts(HISTORY)])
assert all(a["status"] == "draft" for a in content.assets)
# --- auth ----------------------------------------------------------------
async def test_written_as_the_requesting_user(aiohttp_client, monkeypatch):
# The stub 403s any call without the user's exact bearer, so a full publish is
# itself proof every write carried it.
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch) == 2
async def test_without_a_user_token_nothing_is_written(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch, iam_token=None) == 0
assert content.assets == []
# --- a render is never failed --------------------------------------------
async def test_disabled_is_a_no_op(aiohttp_client, monkeypatch):
content = Content()
monkeypatch.delenv("STUDIO_CONTENT_PUBLISH", raising=False)
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1")
# enabled() reads the env directly, so call the real entrypoint with it unset.
assert await content_publish.publish_render(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
) == 0
assert content.assets == []
async def test_lane_error_is_survivable(aiohttp_client, monkeypatch):
content = Content(fail=True)
assert await _publish(content, aiohttp_client, monkeypatch) == 0 # nothing landed, no raise
async def test_unreachable_lane_never_raises(monkeypatch):
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1") # nothing listening
assert await content_publish.publish_render(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
) == 0
async def test_empty_render_writes_nothing(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch, history_result={"outputs": {}}) == 0
assert content.assets == []
# --- the token never leaks into history ----------------------------------
async def test_iam_token_is_a_sensitive_key_so_it_is_stripped_from_history():
# Force the CPU device path before the heavy import: model_management probes
# CUDA at import time and a GPU-less CI runner has no NVIDIA driver.
from studio.cli_args import args
args.cpu = True
import execution
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS
@@ -5,6 +5,7 @@ node types are passed in as a set, so this stays as light as the other
middleware tests. Async paths run via asyncio.run to avoid a plugin dependency.
"""
import asyncio
import base64
import json
from middleware import copilot
@@ -12,6 +13,39 @@ from middleware import copilot
VALID = {"KSampler", "SaveImage", "UpscaleModelLoader", "ImageUpscaleWithModel", "CLIPTextEncode"}
def _jwt(alg: str) -> str:
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
class _Req:
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
# ------------------------------------------------- _resolve_target token allowlist
def test_resolve_target_never_forwards_hs256_session_token(monkeypatch):
"""The regression the whole task turns on: the copilot MUST NOT forward the browser's
HS256 session token (gateway "unsupported signing method: HS256" 502). It forwards
only a gateway-verifiable caller token, and a server key wins."""
for env in copilot._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
hs, rs = _jwt("HS256"), _jwt("RS256")
# HS256 session token (header OR cookie) → NOT forwarded
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == ""
assert copilot._resolve_target(_Req(None, {"access_token": hs}))[2] == ""
# a JWKS-verifiable JWT → forwarded (bare token, no "Bearer " prefix — _post_chat adds it)
assert copilot._resolve_target(_Req("Bearer " + rs))[2] == rs
# a publishable pk- key → forwarded
assert copilot._resolve_target(_Req("Bearer pk-xyz"))[2] == "pk-xyz"
# a secret key → never forwarded
assert copilot._resolve_target(_Req("Bearer sk-secret"))[2] == ""
# a configured server gateway key wins, stripped to the bare token
monkeypatch.setenv("STUDIO_COPILOT_TOKEN", "hk-server")
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == "hk-server"
# --------------------------------------------------------------- validate_ops
def test_valid_ops_pass_through():
ops = [
@@ -0,0 +1,107 @@
"""The ONE fail-closed gateway-bearer allowlist (middleware/gateway_auth.py).
This is the credential seam that keeps Chat + copilot reaching api.hanzo.ai instead of
502-ing on the browser's HS256 session token. The invariants pinned here:
* a symmetric HS256 hanzo.id session token is NEVER forwarded (the gateway rejects it
"unsupported signing method: HS256");
* a secret sk-/hk-/rk- key is refused outright (never leaves the server, never in page
source);
* only a publishable pk- key or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA) is forwarded;
* a configured server-side gateway key wins over any caller token.
"""
import base64
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import gateway_auth as G
def _jwt(alg: str) -> str:
"""A JWT with the given header alg (payload/sig inert — only the header alg matters)."""
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
class _Req:
"""The 2 attributes gpu_dispatch._bearer reads: Authorization header + cookies."""
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
def test_forwardable_bearer_is_fail_closed():
rs, es, ps, ed, hs = _jwt("RS256"), _jwt("ES256"), _jwt("PS256"), _jwt("EdDSA"), _jwt("HS256")
# asymmetric / JWKS-verifiable JWTs — forwarded
assert G.forwardable_bearer(_Req("Bearer " + rs)) == "Bearer " + rs
assert G.forwardable_bearer(_Req("Bearer " + es)) == "Bearer " + es
assert G.forwardable_bearer(_Req("Bearer " + ps)) == "Bearer " + ps
assert G.forwardable_bearer(_Req("Bearer " + ed)) == "Bearer " + ed
# the HS256 hanzo.id session token — REFUSED (this is the 502 the fix closes)
assert G.forwardable_bearer(_Req("Bearer " + hs)) == ""
# publishable key — allowed
assert G.forwardable_bearer(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
# secret key families — refused outright, case-insensitively
for secret in ("sk-secret", "SK-Secret", "hk-secret", "rk-secret", "sk_secret", "hk_secret"):
assert G.forwardable_bearer(_Req("Bearer " + secret)) == ""
# no token / opaque non-JWT non-pk value
assert G.forwardable_bearer(_Req(None)) == ""
assert G.forwardable_bearer(_Req("Bearer notoken")) == ""
def test_forwardable_bearer_reads_session_cookie():
# The browser sends the session as a cookie, not an Authorization header. A verifiable
# JWT there is honored; an HS256 session cookie is still refused.
rs, hs = _jwt("RS256"), _jwt("HS256")
assert G.forwardable_bearer(_Req(None, {"access_token": rs})) == "Bearer " + rs
assert G.forwardable_bearer(_Req(None, {"hanzo_token": rs})) == "Bearer " + rs
assert G.forwardable_bearer(_Req(None, {"access_token": hs})) == ""
def test_server_token_precedence_and_normalization(monkeypatch):
envs = ("A_TOK", "B_TOK")
for e in envs:
monkeypatch.delenv(e, raising=False)
assert G.server_token(envs) == ""
monkeypatch.setenv("B_TOK", "hk-second")
assert G.server_token(envs) == "Bearer hk-second" # bare value gets a Bearer prefix
monkeypatch.setenv("A_TOK", "Bearer hk-first")
assert G.server_token(envs) == "Bearer hk-first" # first wins; already-prefixed kept as-is
def test_ai_bearer_server_key_wins_else_forwardable(monkeypatch):
envs = ("SRV_TOK",)
monkeypatch.delenv("SRV_TOK", raising=False)
hs, rs = _jwt("HS256"), _jwt("RS256")
# no server key + only an HS256 session → nothing usable (fail-closed → honest degrade)
assert G.ai_bearer(_Req("Bearer " + hs), server_env=envs) == ""
# no server key + a verifiable caller token → that token is forwarded
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer " + rs
# a configured server key wins over any caller token
monkeypatch.setenv("SRV_TOK", "hk-server-key")
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer hk-server-key"
def test_forwardable_bearer_survives_crafted_non_string_alg():
"""A JWT header with a non-string alg (null/list/number/dict) must yield "" — never
a self-inflicted 500 from alg[:2]/alg.startswith. Mirrors the documents.py RED fix."""
def jwt_bad_alg(alg_value):
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
for bad in (None, [], 123, {"x": 1}, True):
assert G.jwt_alg(jwt_bad_alg(bad)) == ""
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg(bad))) == ""
assert G.jwt_alg(jwt_bad_alg("RS256")) == "RS256" # real asymmetric alg still read
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg("RS256"))) == "Bearer " + jwt_bad_alg("RS256")
def test_alg_allowlist_is_the_exact_jws_set_not_a_prefix_match():
"""F3: a prefix match ("RS"/"Ed") admitted non-JWS names like RSA-OAEP / 'Edwina';
the allowlist is now the exact JWS asymmetric set."""
for good in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(good))) == "Bearer " + _jwt(good)
for bad in ("RSA-OAEP", "RSA1_5", "Edwina", "ES", "RS", "none", "HS256", "PSK"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(bad))) == ""
@@ -0,0 +1,315 @@
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import gpu_dispatch as g
def test_node_label_prefers_human_name():
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
assert g._node_label({}) == "gpu" # never blank
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "online", "gpu": True},
{"name": "idle", "status": "offline", "gpu": True},
{"name": "cpu-box", "status": "online", "gpu": False},
])
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
assert g._has_online_gpu("tok") is True
def test_no_online_gpu_when_all_offline(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "offline", "gpu": True},
])
assert g._online_gpu_nodes("tok") == []
assert g._has_online_gpu("tok") is False
# ── The cloud pod NEVER renders locally ──────────────────────────────────────────
class _Req:
"""Minimal stand-in for an aiohttp request that _bearer reads."""
def __init__(self, auth="Bearer u"):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = {}
class _Resp:
def read(self):
return b"{}"
def _capture_enqueue(monkeypatch):
"""Mock the gpu-jobs POST + input collection; return a dict the enqueue fills."""
sent: dict = {}
def fake_urlopen(req, timeout=0):
sent["url"] = req.full_url
sent["auth"] = req.headers.get("Authorization")
return _Resp()
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
monkeypatch.setattr(g, "_track_dispatched",
lambda org, pid, prompt, node="gpu": sent.update(node=node))
return sent
def test_modelless_pod_enqueues_even_when_org_sees_no_gpu(monkeypatch):
"""The karma case: the org-scoped fleet read shows no GPU (workers live under a
sibling org), but a MODEL-LESS pod must still enqueue to gpu-jobs and wait never
self-POST the GPU-less pod (the instant 'failed validation')."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: []) # org-blind
monkeypatch.setattr(g, "has_models_for", lambda prompt: False) # cloud coordinator
sent = _capture_enqueue(monkeypatch)
ok = g.dispatch_if_worker(_Req("Bearer karma"), "karma", "pid1", {})
assert ok is True # enqueued to the worker lane, NOT local
assert "gpu-jobs" in sent["url"]
assert sent["auth"] == "Bearer karma" # tenancy still flows from the user token
assert sent["node"] == "gpu" # claimer decided at pick-up
def test_modelless_pod_never_returns_local(monkeypatch):
"""Even if the enqueue POST fails, a model-less pod returns False so server.py
yields a retryable 503 it must NEVER fall through to local validation."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
def boom(req, timeout=0):
raise OSError("gpu-jobs unreachable")
monkeypatch.setattr(g.urllib.request, "urlopen", boom)
# False here means "not enqueued"; server.py then 503s on a model-less pod. The
# point: dispatch never claims a local render happened.
assert g.dispatch_if_worker(_Req("Bearer karma"), "karma", "pid", {}) is False
def test_box_with_models_renders_local_when_no_worker(monkeypatch):
"""A box that HAS its own models (local dev / worker node) still renders locally
when it sees no online worker unchanged behavior."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_models_for", lambda prompt: True)
touched = {"urlopen": False}
monkeypatch.setattr(g.urllib.request, "urlopen",
lambda *a, **k: touched.update(urlopen=True) or _Resp())
assert g.dispatch_if_worker(_Req(), "acme", "pid", {}) is False
assert touched["urlopen"] is False # never touched the gpu-jobs API
def test_online_worker_enqueues_with_its_label(monkeypatch):
monkeypatch.setattr(g, "_online_gpu_nodes",
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
monkeypatch.setattr(g, "has_models_for", lambda prompt: True) # an online worker wins
sent = _capture_enqueue(monkeypatch)
assert g.dispatch_if_worker(_Req(), "acme", "pid", {}) is True
assert sent["node"] == "spark"
def test_no_token_is_not_a_local_render_claim(monkeypatch):
assert g.dispatch_if_worker(_Req(auth=""), "acme", "pid", {}) is False
# ── The capacity gate is advisory + service-identity ─────────────────────────────
def test_service_token_prefers_worker_then_commerce(monkeypatch):
monkeypatch.setenv("STUDIO_WORKER_TOKEN", "w")
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "c")
assert g._service_token() == "Bearer w"
monkeypatch.delenv("STUDIO_WORKER_TOKEN")
assert g._service_token() == "Bearer c"
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN")
assert g._service_token() == ""
def test_advisory_gate_reads_fleet_with_service_identity(monkeypatch):
"""karma's user token sees no GPU; the SERVICE token sees the fleet's worker — the
gate reports online, so every org's UI reflects true availability."""
seen = []
def fleet(tok):
seen.append(tok)
return [{"name": "spark", "status": "online", "gpu": True}] if tok == "Bearer svc" else []
monkeypatch.setattr(g, "_fleet_machines", fleet)
monkeypatch.setenv("STUDIO_WORKER_TOKEN", "svc")
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
assert g.has_online_gpu_advisory("Bearer karma") is True
assert seen[0] == "Bearer svc" # service identity consulted FIRST
def test_advisory_gate_falls_back_to_user_token(monkeypatch):
monkeypatch.delenv("STUDIO_WORKER_TOKEN", raising=False)
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
monkeypatch.setattr(g, "_fleet_machines",
lambda tok: [{"name": "spark", "status": "online", "gpu": True}]
if tok == "Bearer user" else [])
assert g.has_online_gpu_advisory("Bearer user") is True
def test_advisory_gate_false_when_nothing_online(monkeypatch):
monkeypatch.delenv("STUDIO_WORKER_TOKEN", raising=False)
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [])
assert g.has_online_gpu_advisory("Bearer user") is False
# ── Per-GPU targeting: X-Target-GPU → taskQueue "gpu:<identity>" ──────────────────
class _ReqH(_Req):
"""_Req plus an arbitrary header map (so a test can set X-Target-GPU)."""
def __init__(self, auth="Bearer u", headers=None):
super().__init__(auth)
if headers:
self.headers.update(headers)
def _capture_body(monkeypatch):
"""Capture the enqueue POST body so a test can assert taskQueue + the URL namespace."""
import json as _json
sent: dict = {}
def fake_urlopen(req, timeout=0):
sent["url"] = req.full_url
sent["body"] = _json.loads(req.data.decode()) if req.data else {}
return _Resp()
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
monkeypatch.setattr(g, "_track_dispatched",
lambda org, pid, prompt, node="gpu": sent.update(node=node))
return sent
def test_untargeted_uses_shared_gpu_jobs_lane(monkeypatch):
"""No X-Target-GPU → the shared 'gpu-jobs' lane any worker drains; namespace unchanged."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
sent = _capture_body(monkeypatch)
assert g.dispatch_if_worker(_Req("Bearer acme"), "acme", "pid", {}) is True
assert sent["body"]["taskQueue"] == "gpu-jobs" # shared lane
assert "namespaces/gpu-jobs/activities" in sent["url"] # namespace is always gpu-jobs
assert sent["node"] == "gpu"
def test_x_target_gpu_routes_to_that_machine_lane(monkeypatch):
"""A targeted render — where the target IS one of the caller-org's own online GPUs —
enqueues on the machine's OWN lane 'gpu:<identity>' (namespace still gpu-jobs) and is
tracked to that node, so only spark claims it. An in-fleet target ALWAYS enqueues,
even on a box that could render the graph locally."""
monkeypatch.setattr(g, "_online_gpu_nodes",
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
monkeypatch.setattr(g, "has_models_for", lambda prompt: True) # would render local…
sent = _capture_body(monkeypatch)
ok = g.dispatch_if_worker(_ReqH("Bearer acme", {"X-Target-GPU": "spark"}), "acme", "pid", {})
assert ok is True # …but an in-fleet target forces the queue
assert sent["body"]["taskQueue"] == "gpu:spark" # the machine's own lane
assert "namespaces/gpu-jobs/activities" in sent["url"] # namespace unchanged
assert sent["node"] == "spark" # tracked to the targeted GPU
def test_out_of_fleet_target_falls_back_to_shared(monkeypatch):
"""A target that is NOT one of the caller-org's own online GPUs is ignored — the job
rides the shared lane, never a 'gpu:<bogus>' lane no worker claims (a 30-min hang),
and an injected label (XSS payload) never becomes a taskQueue / queue-row node."""
monkeypatch.setattr(g, "_online_gpu_nodes",
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
sent = _capture_body(monkeypatch)
ok = g.dispatch_if_worker(
_ReqH("Bearer acme", {"X-Target-GPU": "<img src=x onerror=alert(1)>"}), "acme", "pid", {})
assert ok is True
assert sent["body"]["taskQueue"] == "gpu-jobs" # bogus target → shared lane, not gpu:<payload>
assert sent["node"] == "spark" # node decided by the real fleet, not the header
def test_blank_target_header_falls_back_to_shared(monkeypatch):
"""An empty/whitespace X-Target-GPU is not a target — shared lane."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
sent = _capture_body(monkeypatch)
assert g.dispatch_if_worker(_ReqH("Bearer acme", {"X-Target-GPU": " "}), "acme", "pid", {}) is True
assert sent["body"]["taskQueue"] == "gpu-jobs"
# ── A BYO-GPU video job records its REAL prefix, not the "render" fallback ─────────
def test_track_savevideo(tmp_path, monkeypatch):
import json
monkeypatch.setattr(g.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
graph = {"7": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
"12": {"class_type": "SaveVideo", "inputs": {"filename_prefix": "video/beach_ab12cd"}}}
g._track_dispatched("acme", "pid-v", graph, node="spark")
jobs = json.loads((tmp_path / "render_jobs.json").read_text())
assert jobs[-1]["outPrefix"] == "video/beach_ab12cd" # the video prefix, not "render"
assert jobs[-1]["prefix"] == "beach_ab12cd"
assert jobs[-1]["refs"] == ["ref.png"] and jobs[-1]["node"] == "spark"
# --- enqueue retry across the cloud tasks-backend deploy gap ---
import urllib.error # noqa: E402
class _FakeResp:
def read(self):
return b"{}"
def test_enqueue_retries_through_503_deploy_gap(monkeypatch):
"""A 503 'no available server' (cloud Recreate deploy gap) is retried; the
enqueue succeeds once the pod is back no user-visible failure."""
monkeypatch.setattr(g.time, "sleep", lambda *_: None) # no real backoff wait
calls = {"n": 0}
def fake_urlopen(req, timeout=90):
calls["n"] += 1
if calls["n"] < 3: # first two attempts hit the empty-pool 503
raise urllib.error.HTTPError("u", 503, "no available server", {}, None)
return _FakeResp()
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
g._enqueue_with_retry(object()) # must NOT raise
assert calls["n"] == 3 # retried twice, succeeded on the 3rd
def test_enqueue_does_not_retry_4xx(monkeypatch):
"""A 4xx (auth/validation) is not a deploy gap — raise immediately, no retry."""
monkeypatch.setattr(g.time, "sleep", lambda *_: None)
calls = {"n": 0}
def fake_urlopen(req, timeout=90):
calls["n"] += 1
raise urllib.error.HTTPError("u", 403, "forbidden", {}, None)
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
try:
g._enqueue_with_retry(object())
raised = False
except urllib.error.HTTPError:
raised = True
assert raised and calls["n"] == 1 # one shot, no retry
def test_enqueue_does_not_retry_timeout(monkeypatch):
"""A timeout may have been processed server-side — never retry (no double render)."""
monkeypatch.setattr(g.time, "sleep", lambda *_: None)
calls = {"n": 0}
def fake_urlopen(req, timeout=90):
calls["n"] += 1
raise urllib.error.URLError(TimeoutError("timed out"))
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
try:
g._enqueue_with_retry(object())
raised = False
except urllib.error.URLError:
raised = True
assert raised and calls["n"] == 1
+156
View File
@@ -4,10 +4,14 @@ The JWT path is exercised end to end with a locally generated RSA keypair:
sign an access token like Hanzo IAM would, then verify it through the same
code the middleware uses. Covers signature, exp, iss, aud and the org claim.
"""
import asyncio
import time
from urllib.parse import parse_qs, urlencode, urlsplit
import jwt
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from cryptography.hazmat.primitives.asymmetric import rsa
from middleware import iam_auth_middleware as m
@@ -144,6 +148,14 @@ def test_protected_paths(path):
assert m._is_public_path(path) is False
def test_video_never_public():
"""A tenant video is only ever served by /v1/library/file?path=… (the URL path never
ends in a media suffix), so the suffix bypass cannot apply. ".mp4" must NEVER join the
static-suffix allowlist, and the org-scoped file route stays auth-gated pinned here."""
assert m._is_public_path("/a/b.mp4") is False
assert m._is_public_path("/v1/library/file") is False
# --- server-owned login entry point (/login → OIDC code flow) ---
def test_login_path_is_public():
@@ -196,3 +208,147 @@ def test_handle_login_returns_to_app_root_not_the_login_path():
# State is HMAC-signed with the client_id key (see _sign_state usage).
payload = m._verify_state(cookie, "hanzo-studio")
assert payload is not None and payload["t"] == "/"
# --- browser-vs-API gate: anon navigation → 302 /login, XHR/JSON → 401 ---
def _gate(headers):
"""Run the auth middleware for an unauthenticated GET /studio with the given
request headers; return the response. localhost_bypass is off so the token
gate actually runs (prod sits behind the ingress, never loopback)."""
mw = m.create_iam_auth_middleware("https://hanzo.id", localhost_bypass=False)
async def _ok(_request):
return web.Response(text="ok")
req = make_mocked_request("GET", "/studio", headers=headers)
return asyncio.run(mw(req, _ok))
def test_anon_browser_navigation_redirects_to_login():
"""A top-level browser navigation (Accept: text/html) to a protected page is
bounced to the server-owned /login entry point, carrying ?next so the OIDC
round trip returns to /studio never a raw 401."""
resp = _gate({"Accept": "text/html,application/xhtml+xml"})
assert resp.status == 302
loc = resp.headers["Location"]
assert loc.startswith("/login?")
assert parse_qs(urlsplit(loc).query)["next"] == ["/studio"]
def test_anon_plain_curl_redirects_to_login():
"""The confirmed repro: a bare client (Accept: */*, no fetch-metadata) is
treated as a navigation and redirected, not 401'd."""
resp = _gate({"Accept": "*/*"})
assert resp.status == 302
assert resp.headers["Location"].startswith("/login?next=")
def test_anon_no_accept_header_redirects_to_login():
"""No Accept header at all is still ambiguous, not an API call → redirect."""
resp = _gate({})
assert resp.status == 302
assert "/login" in resp.headers["Location"]
def test_anon_sec_fetch_navigate_redirects():
"""Modern browsers stamp Sec-Fetch-Mode: navigate on top-level loads."""
resp = _gate({"Accept": "*/*", "Sec-Fetch-Mode": "navigate"})
assert resp.status == 302
assert "/login" in resp.headers["Location"]
def test_anon_xhr_gets_401():
"""An XHR (X-Requested-With) must get a JSON 401 so the SPA can react, not a
redirect it would follow into HTML."""
resp = _gate({"Accept": "*/*", "X-Requested-With": "XMLHttpRequest"})
assert resp.status == 401
def test_anon_json_accept_gets_401():
"""An explicit JSON client (Accept: application/json) is an API caller."""
resp = _gate({"Accept": "application/json"})
assert resp.status == 401
def test_anon_fetch_cors_gets_401():
"""A programmatic fetch() carries Sec-Fetch-Mode: cors — 401, not redirect."""
resp = _gate({"Accept": "*/*", "Sec-Fetch-Mode": "cors"})
assert resp.status == 401
def test_is_api_request_predicate():
def api(headers):
return m._is_api_request(make_mocked_request("GET", "/studio", headers=headers))
assert api({"Accept": "*/*"}) is False # plain curl → nav
assert api({"Accept": "text/html"}) is False # browser → nav
assert api({}) is False # unknown → nav
assert api({"Sec-Fetch-Mode": "navigate"}) is False # top-level nav
assert api({"Sec-Fetch-Mode": "cors"}) is True # fetch/XHR
assert api({"Sec-Fetch-Mode": "same-origin"}) is True # fetch/XHR
assert api({"X-Requested-With": "XMLHttpRequest"}) is True # legacy AJAX
assert api({"Accept": "application/json"}) is True # JSON client
def test_safe_next_predicate():
assert m._safe_next("/studio") == "/studio"
assert m._safe_next("/studio?org=acme&run=1") == "/studio?org=acme&run=1"
assert m._safe_next(None) == "/"
assert m._safe_next("") == "/"
assert m._safe_next("//evil.example/x") == "/" # protocol-relative
assert m._safe_next("https://evil.example") == "/" # absolute
assert m._safe_next("/\\evil.example") == "/" # backslash-relative
assert m._safe_next("/login") == "/" # would loop
assert m._safe_next("/login?next=/x") == "/" # would loop
def test_handle_login_honors_next_param():
"""A redirect from the auth gate carries ?next=/studio; login must return the
user there after the OIDC round trip (the signed state proves the target)."""
mw = m.create_iam_auth_middleware("https://hanzo.id")
req = make_mocked_request(
"GET", "/login?" + urlencode({"next": "/studio"}),
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
)
resp = asyncio.run(mw.handle_login(req))
assert resp.status == 302
payload = m._verify_state(resp.cookies[m._STATE_COOKIE].value, "hanzo-studio")
assert payload["t"] == "/studio"
def test_handle_login_rejects_open_redirect_next():
"""An off-origin, protocol-relative or self-referential next is dropped to
'/', closing the open-redirect and login-loop vectors."""
mw = m.create_iam_auth_middleware("https://hanzo.id")
for bad in ("//evil.example/x", "https://evil.example", "/login"):
req = make_mocked_request(
"GET", "/login?" + urlencode({"next": bad}),
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
)
resp = asyncio.run(mw.handle_login(req))
payload = m._verify_state(resp.cookies[m._STATE_COOKIE].value, "hanzo-studio")
assert payload["t"] == "/", f"next={bad!r} should be rejected"
def test_anon_framed_navigation_gets_connect_card_not_redirect():
"""A FRAMED navigation (the console embeds Studio; Sec-Fetch-Dest: iframe)
must NOT be 302'd into the OIDC dance — the IdP's pages refuse framing and
the frame dies on a browser error page. It gets the same-origin CONNECT
card: 200 HTML with a top-level sign-in affordance + session poll, framable
only by our own consoles."""
resp = _gate({"Accept": "text/html", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe"})
assert resp.status == 200
assert "text/html" in resp.headers["Content-Type"]
assert "Connect Hanzo Studio" in resp.text
assert "/login?" in resp.text and "next=%2Fstudio" in resp.text
assert resp.headers["Cache-Control"] == "no-store"
assert "frame-ancestors" in resp.headers["Content-Security-Policy"]
def test_anon_top_level_navigation_still_redirects_with_dest_document():
"""The framed branch keys on Sec-Fetch-Dest — a top-level document load
(dest: document) keeps the classic 302 into /login."""
resp = _gate({"Accept": "text/html", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"})
assert resp.status == 302
assert resp.headers["Location"].startswith("/login?")
+204
View File
@@ -0,0 +1,204 @@
"""Tests for the /v1/mcp MCP-over-HTTP endpoint: initialize, tools/list, the
tools/call dispatch, DispatchError -> isError result, and unknown-method error.
Mirrors session_test.py: a real aiohttp app with a middleware that injects the
IAM user the auth layer would have set, so org resolution and the JSON-RPC
envelopes are the real thing. The render dispatch (studio_home.dispatch_fix /
dispatch_compose) is monkeypatched, so no GPU / self-POST is needed.
"""
import asyncio
import json
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import mcp
from middleware import studio_home
ORG_USER = {"sub": "hanzo/antje", "org_id": "hanzo", "orgs": ["hanzo"]}
def _app(iam_user=ORG_USER):
app = web.Application()
@web.middleware
async def inject(request, handler):
if iam_user is not None:
request["iam_user"] = iam_user
return await handler(request)
app.middlewares.append(inject)
routes = web.RouteTableDef()
mcp.add_mcp_routes(routes, None)
app.add_routes(routes)
return app
def _rpc(client, method, params=None, id_=1):
payload = {"jsonrpc": "2.0", "id": id_, "method": method}
if params is not None:
payload["params"] = params
return client.post("/v1/mcp", json=payload)
def _run(coro):
return asyncio.run(coro)
# --- initialize ----------------------------------------------------------
def test_initialize_reports_server_and_tools_capability():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "initialize")
assert r.status == 200
return await r.json()
body = _run(go())
assert body["jsonrpc"] == "2.0"
assert body["id"] == 1
assert body["result"]["serverInfo"]["name"] == "hanzo-studio"
assert body["result"]["protocolVersion"]
assert "tools" in body["result"]["capabilities"]
# --- tools/list ----------------------------------------------------------
def test_tools_list_returns_the_three_render_tools():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/list")
return await r.json()
body = _run(go())
tools = body["result"]["tools"]
assert {t["name"] for t in tools} == {"fix", "compose", "ask"}
for t in tools:
assert t["inputSchema"]["type"] == "object"
compose = next(t for t in tools if t["name"] == "compose")
assert compose["inputSchema"]["properties"]["paths"]["minItems"] == 2
assert compose["inputSchema"]["properties"]["paths"]["maxItems"] == 5
# --- tools/call ----------------------------------------------------------
def test_tools_call_fix_dispatches(monkeypatch):
seen = {}
async def fake_fix(request, server, org, path, instruction):
seen.update(org=org, path=path, instruction=instruction)
return {"ok": True, "prompt_id": "p1", "output_prefix": "fixes/x"}
monkeypatch.setattr(studio_home, "dispatch_fix", fake_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix",
"arguments": {"path": "a/b.png", "instruction": "make it red"},
})
assert r.status == 200
return await r.json()
body = _run(go())
assert seen == {"org": "hanzo", "path": "a/b.png", "instruction": "make it red"}
result = body["result"]
assert result.get("isError") is not True
payload = json.loads(result["content"][0]["text"])
assert payload["prompt_id"] == "p1"
assert payload["output_prefix"] == "fixes/x"
def test_tools_call_compose_dispatches(monkeypatch):
seen = {}
async def fake_compose(request, server, org, paths, instruction):
seen.update(org=org, paths=paths, instruction=instruction)
return {"ok": True, "prompt_id": "p2", "output_prefix": "composes/y", "refs": len(paths)}
monkeypatch.setattr(studio_home, "dispatch_compose", fake_compose)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "compose",
"arguments": {"paths": ["a.png", "b.png"], "instruction": "merge them"},
})
return await r.json()
body = _run(go())
assert seen["paths"] == ["a.png", "b.png"]
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["prompt_id"] == "p2"
assert payload["refs"] == 2
def test_tools_call_ask_echoes_question():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "ask", "arguments": {"question": "which asset?"},
})
return await r.json()
body = _run(go())
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["question"] == "which asset?"
assert body["result"].get("isError") is not True
def test_dispatch_error_becomes_iserror_result(monkeypatch):
async def bad_fix(request, server, org, path, instruction):
raise studio_home.DispatchError("unknown asset: nope.png")
monkeypatch.setattr(studio_home, "dispatch_fix", bad_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix", "arguments": {"path": "nope.png", "instruction": "x"},
})
assert r.status == 200 # never a 500
return await r.json()
body = _run(go())
assert "error" not in body # not a JSON-RPC error
assert body["result"]["isError"] is True
assert "unknown asset" in body["result"]["content"][0]["text"]
def test_unknown_tool_is_iserror_result():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {"name": "frobnicate", "arguments": {}})
return await r.json()
body = _run(go())
assert body["result"]["isError"] is True
# --- protocol edges ------------------------------------------------------
def test_unknown_method_is_jsonrpc_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/frobnicate")
assert r.status == 200
return await r.json()
body = _run(go())
assert "result" not in body
assert body["error"]["code"] == -32601
assert body["id"] == 1
def test_notification_gets_no_body():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
return r.status, await r.text()
status, text = _run(go())
assert status == 202
assert text == ""
def test_bad_json_is_parse_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", data="not json", headers={"Content-Type": "application/json"})
return r.status, await r.json()
status, body = _run(go())
assert status == 400
assert body["error"]["code"] == -32700
@@ -0,0 +1,54 @@
"""Quality gate — the verdict must survive REASONING judges.
zen-vl became a reasoning model: with a starved max_tokens the whole budget went
to reasoning_content, content came back null, the strict parse saw nothing and
the gate failed open on EVERY render (flagged count reached zero while corrupted
outputs sat in the grid). These pin the fixed parsing."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from middleware import quality_gate as qg
def _completion(content=None, reasoning=None):
msg = {"role": "assistant", "content": content}
if reasoning is not None:
msg["reasoning_content"] = reasoning
return {"choices": [{"message": msg}]}
def test_reply_reads_plain_content():
assert qg._reply_text(_completion(content="FAIL — mottled skin")) == "FAIL — mottled skin"
def test_reply_reads_parts_array():
assert qg._reply_text(_completion(content=[{"type": "text", "text": "PASS"}])) == "PASS"
def test_reply_falls_back_to_reasoning_content():
got = qg._reply_text(_completion(content=None, reasoning="the skin shows mosaic damage. FAIL"))
assert "FAIL" in got
def test_reply_empty_when_nothing():
assert qg._reply_text(_completion(content=None)) == ""
def test_verdict_from_reasoning_reply_flags():
reply = qg._reply_text(_completion(content=None, reasoning="Looking closely... verdict: FAIL"))
assert qg.parse_verdict(reply) == "fail"
def test_judge_payload_not_starved():
import inspect
src = inspect.getsource(qg.judge)
assert '"max_tokens": 8,' not in src
def test_verdict_last_token_wins_over_deliberation():
assert qg.parse_verdict("I must answer PASS or FAIL. The skin is mottled: FAIL") == "fail"
assert qg.parse_verdict("Could be a FAIL, but the artifacts are compression. PASS") == "pass"
assert qg.parse_verdict("no verdict words here") == "inconclusive"
@@ -0,0 +1,157 @@
"""Quality gate — the VIDEO judge (middleware/quality_gate.py).
The motion gate samples frames across a clip and asks the MOTION judge; a clear FAIL
flags the row exactly like the image gate. A video whose frames can't be sampled is
marked visibly UNGATED (gate='skipped' + an 'ungated' work-log event) NEVER a silent
pass. The image path still fails open, byte-for-byte unchanged.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import quality_gate as qg
from middleware import worklog as wl
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
def _write_mp4(path: Path, frames: int = 6) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
c = av.open(str(path), mode="w")
st = c.add_stream("libx264", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
@pytest.fixture
def orgdir(tmp_path, monkeypatch):
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
return tmp_path
# ── frame sampling ────────────────────────────────────────────────────────────────
def test_frames_samples_video(tmp_path):
mp4 = tmp_path / "clip.mp4"
_write_mp4(mp4)
frames = qg._frames(mp4)
assert frames is not None and 1 <= len(frames) <= 3
assert all(isinstance(b, str) and b for b in frames) # base64 JPEG strings
# a garbage container yields None (→ ungated, never a silent pass)
bad = tmp_path / "bad.mp4"
bad.write_bytes(b"\x00\x00\x00 ftyp" + b"garbage")
assert qg._frames(bad) is None
# ── the payload carries N frames + the MOTION prompt ──────────────────────────────
@pytest.mark.asyncio
async def test_video_judge_payload(monkeypatch):
seen = {}
class _Resp:
status = 200
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def json(self):
return {"choices": [{"message": {"content": "PASS"}}]}
class _Session:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def post(self, url, json=None, headers=None):
seen["payload"] = json
return _Resp()
monkeypatch.setattr(qg.aiohttp, "ClientSession", _Session)
reply = await qg.judge(["b64one", "b64two"], "Bearer u", qg.MOTION)
assert reply == "PASS"
content = seen["payload"]["messages"][0]["content"]
texts = [c for c in content if c["type"] == "text"]
images = [c for c in content if c["type"] == "image_url"]
assert len(texts) == 1 and texts[0]["text"] == qg.MOTION
assert len(images) == 2 # one part per frame
# ── a clear FAIL flags the row ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_video_fail_flags(orgdir, monkeypatch):
root = orgdir
_write_mp4(root / "video" / "c.mp4")
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "video/c.mp4", "status": "draft"}]}))
async def fake_judge(images, bearer, prompt):
assert prompt == qg.MOTION # the motion contract, not PROMPT
return "FAIL — hair morphs between frames"
monkeypatch.setattr(qg, "judge", fake_judge)
await qg.run("acme", root, "video/c.mp4", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0]["status"] == "flagged"
# ── an unsamplable video is UNGATED — badge + event, never silent ─────────────────
@pytest.mark.asyncio
async def test_unreadable_video_is_ungated_not_silent(orgdir, monkeypatch):
root = orgdir
(root / "video").mkdir(parents=True, exist_ok=True)
(root / "video" / "broken.mp4").write_bytes(b"\x00\x00\x00 ftyp" + b"not decodable")
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "video/broken.mp4", "status": "draft"}]}))
# a work-log row for this output so the 'ungated' event has somewhere to land
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
output_prefix="video/broken.mp4", pid="up-x", status="done")
async def never(*a, **k):
raise AssertionError("judge must not be called for an unsamplable video")
monkeypatch.setattr(qg, "judge", never)
await qg.run("acme", root, "video/broken.mp4", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0].get("gate") == "skipped" # the visible badge field
assert lib["assets"][0]["status"] == "draft" # status untouched
events = [e["s"] for r in wl.load("acme") for e in r.get("events", [])]
assert "ungated" in events # the work-log trail, not silent
# ── the image path still fails OPEN, unchanged ────────────────────────────────────
@pytest.mark.asyncio
async def test_image_still_fails_open(orgdir, monkeypatch):
root = orgdir
(root / "x.png").write_bytes(b"not a real png") # unreadable → _frames None
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "x.png", "status": "draft"}]}))
async def never(*a, **k):
raise AssertionError("judge must not run on an unreadable image")
monkeypatch.setattr(qg, "judge", never)
await qg.run("acme", root, "x.png", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0]["status"] == "draft" # fail open: not flagged
assert "gate" not in lib["assets"][0] # and NOT marked skipped (images fail open)
@@ -0,0 +1,366 @@
"""Unit tests for the fix pipeline (middleware/studio_home.py):
* the fix/compose graphs name only models that exist on the worker AND carry none
of the Flux-Kontext reference-method wiring that stippled Qwen edits;
* the honesty guard flips an implausibly-fast completion to FAILED;
* iteration hygiene falls the fix base back to the nearest clean ancestor.
Pure/filesystem only no torch, no GPU, no network.
"""
import base64
import hashlib
import json
import os
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
# What the render worker actually has on disk (spark: models/{diffusion_models,
# text_encoders,vae}). The graph must never name anything outside this.
AVAILABLE = {
"diffusion_models": {"qwen-image-edit-2511.safetensors", "flux1-fill-dev.safetensors",
"flux-2-klein-4b.safetensors", "wan2.2_ti2v_5B_fp16.safetensors"},
"text_encoders": {"qwen2.5-vl-7b.safetensors", "clip_l.safetensors",
"t5xxl_fp16.safetensors", "flux2-text-encoder.safetensors"},
"vae": {"qwen-image-vae.safetensors", "flux-ae.safetensors", "flux2-vae.safetensors"},
}
_LOADERS = {"UNETLoader": ("diffusion_models", "unet_name"),
"CheckpointLoaderSimple": ("checkpoints", "ckpt_name"),
"CLIPLoader": ("text_encoders", "clip_name"),
"VAELoader": ("vae", "vae_name")}
def _assert_models_exist(graph):
checked = 0
for node in graph.values():
spec = _LOADERS.get(node.get("class_type"))
if not spec:
continue
folder, key = spec
name = node["inputs"][key]
assert name in AVAILABLE[folder], f"{name!r} is not on the worker ({folder})"
checked += 1
return checked
# ── graph construction ───────────────────────────────────────────────────────────
def test_fix_graph_names_only_existing_models():
g = sh._fix_graph("ref.png", "brighten the skin tone", "fixes/x")
assert _assert_models_exist(g) == 3 # unet + clip + vae all verified
def test_fix_graph_is_the_canonical_qwen_edit():
"""ComfyUI's own Qwen-Image-Edit template: NO Flux-Kontext reference-method
override (the stipple cause), and the SAME FluxKontextImageScale'd reference feeds
BOTH the edit conditioning and the VAEEncode sampling latent matched size, so no
frame-within-frame border."""
g = sh._fix_graph("ref.png", "recolor the top", "fixes/x")
types = [n["class_type"] for n in g.values()]
assert "FluxKontextMultiReferenceLatentMethod" not in types # the stipple cause
assert types.count("TextEncodeQwenImageEditPlus") == 2 # positive + negative
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
# LOW-denoise edit (default 0.25) of the SOURCE latent — never 1.0 (that is Regenerate).
assert ks["inputs"]["denoise"] == sh.EDIT_DENOISE_DEFAULT == 0.25
latent = g[ks["inputs"]["latent_image"][0]]
assert latent["class_type"] == "VAEEncode"
scaled = latent["inputs"]["pixels"] # the sampling latent's source
assert g[scaled[0]]["class_type"] == "FluxKontextImageScale" # the scaled reference
assert g["6"]["inputs"]["image1"] == scaled # conditioning uses the SAME scaled ref
def test_compose_graph_clean_family_and_models():
g = sh._compose_graph(["a.png", "b.png"], "merge the two looks", "composes/x")
types = [n["class_type"] for n in g.values()]
assert "FluxKontextMultiReferenceLatentMethod" not in types
assert _assert_models_exist(g) == 3
# ── the honesty guard ─────────────────────────────────────────────────────────────
def test_implausibly_fast_only_flags_unsampled_renders():
now = 1_000_000
r30 = {"ts": now, "params": {"steps": 30}}
assert sh._implausibly_fast(r30, now + 1) is True # 1s for 30 steps: impossible
assert sh._implausibly_fast(r30, now + 4) is True # <5s: impossible
assert sh._implausibly_fast(r30, now + 350) is False # a real 30-step render
assert sh._implausibly_fast({"ts": now, "params": {"steps": 4}}, now + 1) is False # exempt
assert sh._implausibly_fast({"ts": now, "params": {}}, now + 1) is False # unknown steps
@pytest.fixture
def org_root(tmp_path, monkeypatch):
"""A per-org output dir that is BOTH the library root and the worklog dir, as in
the pod layout (orgs/<org>/output)."""
def out_dir(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", out_dir)
return out_dir
def _land(root: Path, rel: str, mtime: int):
p = root / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"\x89PNG\r\n")
os.utime(p, (mtime, mtime))
def test_finalize_landed_done_failed_and_stale(org_root):
org = "acme"
root = Path(org_root(org))
for pid, prefix in (("fast", "fixes/fast"), ("slow", "fixes/slow"), ("stale", "fixes/stale")):
sh.worklog.record(org, kind="fix", prompt="p", refs=[], uploads=[], parents=[],
output_prefix=prefix, params={"steps": 30}, pid=pid, status="queued")
ts = {r["id"]: r["ts"] for r in sh.worklog.load(org)}
_land(root, "fixes/fast_00001_.png", ts["fast"] + 1) # landed 1s after dispatch
_land(root, "fixes/slow_00001_.png", ts["slow"] + 350) # a real render
_land(root, "fixes/stale_00001_.png", ts["stale"] - 100) # predates dispatch: sibling
rows = sh.worklog.load(org)
sh._finalize_landed(org, root, rows, int(time.time()) + 500)
status = {r["id"]: r["status"] for r in sh.worklog.load(org)}
assert status["fast"] == "failed" # honesty guard: engine did not sample
assert status["slow"] == "done"
assert status["stale"] == "queued" # pre-existing sibling, never delivered as this job
failed = next(r for r in sh.worklog.load(org) if r["id"] == "fast")
assert any("did not sample" in (e.get("note") or "") for e in failed.get("events", []))
# ── iteration hygiene: clean-ancestor fallback ────────────────────────────────────
def test_clean_base_falls_back_to_nearest_clean_ancestor(org_root):
org = "acme"
root = Path(org_root(org))
# lineage: root.png (approved) → fix1 (flagged) → fix2 (flagged, the chosen base)
for rel in ("root.png", "fixes/root_00001_.png", "fixes/root_00001__00001_.png"):
(root / rel).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_bytes(b"\x89PNG\r\n")
(root / "library.json").write_text(json.dumps({"assets": [
{"path": "root.png", "status": "approved"},
{"path": "fixes/root_00001_.png", "status": "flagged"},
{"path": "fixes/root_00001__00001_.png", "status": "flagged"},
]}))
sh.worklog.record(org, kind="fix", prompt="a", refs=[], uploads=[], parents=["root.png"],
output_prefix="fixes/root", pid="p1", status="done")
sh.worklog.record(org, kind="fix", prompt="b", refs=[], uploads=[],
parents=["fixes/root_00001_.png"],
output_prefix="fixes/root_00001_", pid="p2", status="done")
# the flagged fix2 falls back to the approved root (skipping the flagged fix1)
base, substituted = sh._clean_base(org, root, "fixes/root_00001__00001_.png")
assert base == "root.png" and substituted is True
# a clean base is returned unchanged
assert sh._clean_base(org, root, "root.png") == ("root.png", False)
assert sh._asset_status(root, "fixes/root_00001_.png") == "flagged"
# ── Job identity: every dispatch owns a unique output namespace ──────────────────
def test_job_tag_is_short_hex_and_unique():
a, b = sh._job_tag(), sh._job_tag()
assert len(a) == 6 and int(a, 16) >= 0
assert a != b
# ── View/pose-change routing: repose asks use a free latent, anchored fixes keep
# the encoded-base latent — and the prompt tail never contradicts the ask ─────
def test_fix_graph_anchored_default_keeps_pose():
g = sh._fix_graph("x.png", "fix the coloring", "fixes/x")
assert g["10"]["class_type"] == "VAEEncode"
p = g["6"]["inputs"]["prompt"]
assert "pose, framing" in p # preservation directive
assert "apply only the requested change" in p.lower()
def test_fix_graph_never_uses_empty_latent():
"""Edit is ALWAYS a low-denoise img2img of the source — a pose/view change is now
REGENERATE, not Edit, so the fix graph NEVER falls back to EmptyLatentImage
(the old repose auto-switch, which drifted the whole image, is gone)."""
for instruction in ("fix the coloring", "same outfit but from the side only",
"make her standing and looking away"):
g = sh._fix_graph("x.png", instruction, "fixes/x")
assert not any("Empty" in n.get("class_type", "") for n in g.values()), instruction
ks = next(n for n in g.values() if n["class_type"] == "KSampler")["inputs"]
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
assert sh._assert_edit_graph(g) == ""
def test_repose_lexicon_matches_view_changes_only():
hits = ["show me the same model but from the side only", "turn around",
"back view please", "make her facing left", "different angle"]
misses = ["fix the coloring", "brighten the skin slightly", "remove the second strap"]
for t in hits:
assert sh._REPOSE.search(t), t
for t in misses:
assert not sh._REPOSE.search(t), t
# ── Flywheel retrieval: pointed-out mistakes condition the NEXT generation ──────
def test_mistakes_selects_corrections_not_praise():
ratings = {
"a.png": {"vote": -1, "notes": "sleeves way too wide"},
"b.png": {"stars": 1, "notes": "color washed out"},
"c.png": {"vote": 1, "notes": "love this lighting"}, # praise — excluded
"d.png": {"notes": "strap should be thinner"}, # bare note — a correction
"e.png": {"vote": -1}, # no text — nothing to inject
}
out = sh._mistakes(ratings, ["a.png", "b.png", "c.png", "d.png", "e.png", "missing.png"])
assert "sleeves way too wide" in out and "color washed out" in out
assert "strap should be thinner" in out
assert all("love this lighting" != m for m in out)
assert len(out) <= 4
def test_fix_graph_injects_lessons_into_both_encoders():
g = sh._fix_graph("x.png", "redo the sleeves", "fixes/x",
lessons=["sleeves way too wide", "color washed out"])
pos, neg = g["6"]["inputs"]["prompt"], g["7"]["inputs"]["prompt"]
assert "do not repeat" in pos and "sleeves way too wide" in pos
assert "sleeves way too wide" in neg and "color washed out" in neg
def test_compose_graph_injects_lessons():
g = sh._compose_graph(["x.png", "y.png"], "combine them", "composes/x",
lessons=["strap should be thinner"])
assert "strap should be thinner" in g["6"]["inputs"]["prompt"]
assert "strap should be thinner" in g["7"]["inputs"]["prompt"]
# ── Ingest upsert identity: a counter-suffixed landed file belongs to its
# dispatch row — mirroring must never add a duplicate ingest row ────────────
def test_has_output_matches_counter_suffixed_files(tmp_path, monkeypatch):
import middleware.worklog as wl
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", lambda org: str(tmp_path))
wl.record("acme", kind="fix", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="fixes/x_ab12cd")
assert wl.has_output("acme", "fixes/x_ab12cd_00001_.png") # the row owns its landed file
assert wl.has_output("acme", "fixes/x_ab12cd") # exact prefix still matches
assert not wl.has_output("acme", "fixes/other_00001_.png") # unrelated file → new row ok
# ── Job identity for re-run and template-render (the phantom-image class) ────────
def test_retag_points_every_saveimage_at_unique_prefix():
g = {"1": {"class_type": "KSampler", "inputs": {"seed": 1}},
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/original"}},
"10": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/original"}}}
sh._retag(g, "reruns/original_ab12cd")
assert g["9"]["inputs"]["filename_prefix"] == "reruns/original_ab12cd"
assert g["10"]["inputs"]["filename_prefix"] == "reruns/original_ab12cd"
# a re-run graph never keeps the source's SaveImage prefix (that was the collision)
assert all(n.get("inputs", {}).get("filename_prefix") != "fixes/original"
for n in g.values() if n.get("class_type") == "SaveImage")
# ── State isolation & job construction ────────────────────────────────────────────
# An Edit is built from the SELECTED source alone; two concurrent edits of DIFFERENT
# sources can never share a staged file, a LoadImage ref, a positive prompt, a
# sampling latent or a parent. Content-addressed staging (name == sha256 of the
# bytes) is the binding proof: the worker's LoadImage resolves the exact selected
# image, and different sources are cryptographically un-collidable on a shared
# worker input dir — the "unrelated random picture / wrong image" class of bug.
from middleware import gpu_dispatch as gd
def _pos_prompt(g):
"""The positive prompt actually wired into KSampler (not read by node id)."""
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
return g[ks["inputs"]["positive"][0]]["inputs"]["prompt"]
def _load_ref(g):
return next(n for n in g.values() if n["class_type"] == "LoadImage")["inputs"]["image"]
def _save_prefix(g):
return next(n for n in g.values() if n["class_type"] in sh._SAVERS)["inputs"]["filename_prefix"]
@pytest.fixture
def in_dir(tmp_path, monkeypatch):
"""Per-org input dir (orgs/<org>/input) where sources are staged for the worker —
patched on BOTH modules that touch it: studio_home stages, gpu_dispatch inlines."""
def _d(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", _d)
monkeypatch.setattr(gd.folder_paths, "get_org_input_directory", _d)
return _d
def test_stage_source_is_content_addressed(in_dir, tmp_path):
d = Path(in_dir("acme"))
a = tmp_path / "a.png"
a.write_bytes(b"AAAA-dress-bytes")
b = tmp_path / "b.png"
b.write_bytes(b"BBBB-clasp-bytes")
na, nb = sh._stage_source(d, a, "fix"), sh._stage_source(d, b, "fix")
# different bytes -> different name (cryptographic, not a salted-hash coincidence)
assert na != nb
# the name IS the sha256 of the bytes: a verifiable binding to the EXACT source
assert na == f"fix_src_{hashlib.sha256(a.read_bytes()).hexdigest()[:16]}.png"
assert (d / na).read_bytes() == a.read_bytes() # staged bytes are the source
assert sh._stage_source(d, a, "fix") == na # idempotent: same source, same name
a.write_bytes(b"AAAA-dress-bytez") # one byte changes the address
assert sh._stage_source(d, a, "fix") != na
def test_two_concurrent_edits_are_isolated(in_dir, tmp_path):
"""The reported scenario: Job A (design A, 'make the dress red') and Job B
(design B, 'add a silver clasp') constructed back-to-back share NOTHING."""
d = Path(in_dir("acme"))
srcA = tmp_path / "A.png"
srcA.write_bytes(b"\x89PNG-design-A" + b"\x00" * 64)
srcB = tmp_path / "B.png"
srcB.write_bytes(b"\x89PNG-design-B" + b"\x00" * 64)
refA, refB = sh._stage_source(d, srcA, "fix"), sh._stage_source(d, srcB, "fix")
gA = sh._fix_graph(refA, "make the dress red", "fixes/A_" + sh._job_tag())
gB = sh._fix_graph(refB, "add a silver clasp", "fixes/B_" + sh._job_tag())
# 1) distinct staged sources, each graph LoadImages its OWN source
assert refA != refB
assert _load_ref(gA) == refA and _load_ref(gB) == refB
# 2) prompts do not bleed across jobs
assert "make the dress red" in _pos_prompt(gA) and "clasp" not in _pos_prompt(gA)
assert "add a silver clasp" in _pos_prompt(gB) and "dress red" not in _pos_prompt(gB)
# 3) both are true low-denoise edits of their own source — no text-to-image fallback
assert sh._assert_edit_graph(gA) == "" and sh._assert_edit_graph(gB) == ""
for g in (gA, gB):
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
assert g[ks["inputs"]["latent_image"][0]]["class_type"] == "VAEEncode"
# 4) distinct output namespaces (no phantom-image cross-claim)
assert _save_prefix(gA) != _save_prefix(gB)
def test_collect_input_images_ships_exact_source_and_is_org_scoped(in_dir, tmp_path):
"""gpu_dispatch inlines the bytes the worker will LoadImage. It must ship the
SELECTED org's copy exactly — never a same-named file from another org."""
dA, dB = Path(in_dir("acme")), Path(in_dir("other"))
src = tmp_path / "s.png"
src.write_bytes(b"\x89PNG-real-source" + b"\x01" * 40)
ref = sh._stage_source(dA, src, "fix")
(dB / ref).write_bytes(b"\x89PNG-WRONG-org" + b"\x02" * 40) # decoy, same basename
shipped = gd._collect_input_images(sh._fix_graph(ref, "brighten", "fixes/x"), "acme")
got = {s["name"]: base64.b64decode(s["data"]) for s in shipped}
assert ref in got and got[ref] == src.read_bytes() # acme's exact bytes
assert b"WRONG-org" not in got[ref]
def test_assert_edit_graph_rejects_text_to_image_fallback():
"""The hard invariant: an Edit can NEVER be silently served by a text-to-image
(EmptyLatentImage) graph. If source resolution ever produced one, the assert
REJECTS it dispatch raises, never renders an unrelated picture."""
t2i = {
"1": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024}},
"2": {"class_type": "KSampler", "inputs": {"latent_image": ["1", 0], "denoise": 1.0}},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0], "filename_prefix": "x"}},
}
reason = sh._assert_edit_graph(t2i)
assert reason and "EmptyLatentImage" in reason # rejected with a clear cause
@@ -0,0 +1,129 @@
"""Studio 3D lane — Hunyuan3D-2.1 image-to-mesh (.glb) over the shared library/queue/
flywheel. Mirrors the video-lane tests: the proven graph builder, the dispatch route
(org from the token, unique job tag, one required reference), and .glb ingest/serve."""
import json
import os
import sys
from pathlib import Path
import pytest
from aiohttp import web
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from middleware import studio_home as sh
from middleware import worklog as wl
class _Req:
def __init__(self, *, body=None, query=None, org="acme"):
self._body = body
self.query = query or {}
self.headers = {}
self._user = {"org_id": org, "sub": f"u@{org}"} if org else None
self.cookies = {}
self.content_length = None
def get(self, key, default=None):
return self._user if key == "iam_user" else default
async def json(self):
return self._body if self._body is not None else {}
class _FakeQueue:
def get_tasks_remaining(self):
return 0
class _FakeServer:
port = 1
def __init__(self):
self.prompt_queue = _FakeQueue()
@pytest.fixture
def orgaware(tmp_path, monkeypatch):
def outd(o="default"):
d = tmp_path / "orgs" / o / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o="default"):
d = tmp_path / "orgs" / o / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", outd)
return outd, ind
def _handler(server, method, path, monkeypatch):
monkeypatch.setattr(sh.stacks_store, "add_stacks_routes", lambda *a, **k: None)
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
for r in routes:
if getattr(r, "method", None) == method and getattr(r, "path", None) == path:
return r.handler
raise AssertionError(f"no route {method} {path}")
# ── 1. the proven Hunyuan3D graph ──────────────────────────────────────────────────
def test_model3d_graph_is_the_proven_hunyuan3d():
g = sh._model3d_graph("ref.png", "model3d/x_ab12cd", seed=7)
cls = {k: v["class_type"] for k, v in g.items()}
# the ONE bundled checkpoint (not separate loaders — that produced noise)
assert g["1"]["class_type"] == "ImageOnlyCheckpointLoader"
assert g["1"]["inputs"]["ckpt_name"] == sh._HY3D_CKPT
# the flow-matching wrapper WITHOUT which the sampler yields fragments
assert "ModelSamplingAuraFlow" in cls.values()
assert g["5"]["inputs"]["shift"] == 1.0 and g["5"]["inputs"]["model"] == ["1", 0]
# image → clip-vision → conditioning; latent is EmptyLatentHunyuan3Dv2(4096)
assert g["2"]["inputs"]["image"] == "ref.png"
assert g["6"]["inputs"]["resolution"] == 4096
assert g["7"]["inputs"]["seed"] == 7 and g["7"]["inputs"]["cfg"] == 5.0
# ends at SaveGLB with the unique prefix
assert g["10"]["class_type"] == "SaveGLB"
assert g["10"]["inputs"]["filename_prefix"] == "model3d/x_ab12cd"
assert "VAEDecodeHunyuan3D" in cls.values() and "VoxelToMesh" in cls.values()
# ── 2. dispatch + route (org from token, unique tag, one required reference) ────────
@pytest.mark.asyncio
async def test_dispatch_model3d_route(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
root.joinpath("library.json").write_text('{"assets":[]}')
(root / "designs").mkdir(parents=True, exist_ok=True)
from PIL import Image
Image.new("RGB", (8, 8)).save(root / "designs" / "hero.png", "PNG")
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-3d"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
model3d = _handler(_FakeServer(), "POST", "/v1/library/model3d", monkeypatch)
# a library image → 3D; 'org' in the body is ignored (token org wins)
resp = await model3d(_Req(body={"path": "designs/hero.png", "org": "evil"}, org="acme"))
assert resp.status == 200
import re
out = json.loads(resp.text)
assert re.match(r"^model3d/.+_[0-9a-f]{6}$", out["output_prefix"]) # unique job tag
rows = wl.load("acme")
assert len(rows) == 1 and rows[0]["kind"] == "model3d"
assert wl.load("evil") == []
assert any(n.get("class_type") == "SaveGLB" for n in captured["graph"].values())
# NO reference → 400 (there is no text-to-3D)
r2 = await model3d(_Req(body={}, org="acme"))
assert r2.status == 400
# both a path and an upload → 400
r3 = await model3d(_Req(body={"path": "a.png", "upload": "u.png"}, org="acme"))
assert r3.status == 400
# ── 3. .glb ingest + serve ─────────────────────────────────────────────────────────
def test_glb_is_recognized_as_model3d():
assert ".glb" in sh._UPLOAD_EXT
assert sh._CTYPE[".glb"] == "model/gltf-binary"
assert ".glb" in sh._MODEL3D_EXT and ".glb" in sh._SAVE_EXT
@@ -0,0 +1,493 @@
"""Unit tests for the Studio video lane (middleware/studio_home.py):
* the Wan 2.2 TI2V-5B graph builder (t2v omits the reference node; clamps + snaps);
* dispatch + the /v1/library/video route (org from the token, never the body);
* landed-output + queue detection across .mp4/.webm (the honesty guard covers video);
* the ONE indexed ingest door accepts video, defaults kind, judges, colour-matches;
* the manifest sweeper keeps a video as kind="video";
* serving + the first-frame poster (404 on decode failure, never the raw mp4);
* embedded-metadata read parity (Versions / Re-run / provenance) and job re-tagging.
Filesystem + av only no torch, no GPU, no network. av/numpy are hard studio deps.
"""
import json
import os
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from aiohttp import web
from middleware import studio_home as sh
from middleware import worklog as wl
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
# ── media fixtures: real, tiny, in-test-encoded ───────────────────────────────────
def _write_mp4(path: Path, frames: int = 6, meta: dict | None = None) -> None:
"""A tiny real mp4 (ftyp at offset 4). ``meta`` embeds container metadata the way
SaveVideo does via movflags=use_metadata_tags, so av reads it back."""
path.parent.mkdir(parents=True, exist_ok=True)
opts = {"movflags": "use_metadata_tags"} if meta else {}
c = av.open(str(path), mode="w", options=opts)
for k, v in (meta or {}).items():
c.metadata[k] = v
st = c.add_stream("libx264", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
def _write_webm(path: Path, frames: int = 6) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
c = av.open(str(path), mode="w")
st = c.add_stream("libvpx-vp9", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
# ── request + server doubles ──────────────────────────────────────────────────────
class _Req:
"""A minimal stand-in for an aiohttp request the route handlers read: JSON body,
query, headers, cookies, and the iam_user the middleware would have attached."""
def __init__(self, *, body=None, raw=b"", query=None, headers=None,
org="acme", cookies=None, content_length=None):
self._body = body
self._raw = raw
self.query = query or {}
self.headers = headers or {}
self._user = {"org_id": org, "sub": f"u@{org}"} if org else None
self.cookies = cookies or {}
self.content_length = content_length if content_length is not None else (len(raw) or None)
def get(self, key, default=None):
return self._user if key == "iam_user" else default
async def json(self):
return self._body if self._body is not None else {}
async def read(self):
return self._raw
class _FakeQueue:
def get_current_queue_volatile(self):
return ([], [])
class _FakeServer:
port = 1
def __init__(self):
self.prompt_queue = _FakeQueue()
@pytest.fixture
def orgaware(tmp_path, monkeypatch):
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", outd)
return outd, ind
def _handler(server, method, path, monkeypatch):
"""Extract ONE registered route handler, without the stacks sub-router or a live app."""
monkeypatch.setattr(sh.stacks_store, "add_stacks_routes", lambda *a, **k: None)
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
for r in routes:
if getattr(r, "method", None) == method and getattr(r, "path", None) == path:
return r.handler
raise AssertionError(f"no route {method} {path}")
def _lib(root: Path, assets):
(root / "library.json").write_text(json.dumps({"assets": assets}))
# ── 1. the graph builder ──────────────────────────────────────────────────────────
def test_video_graph():
# text-to-video: NO LoadImage node, and NO start_image key on the latent
t2v = sh._video_graph("a woman walks on a sunlit beach", "video/x", length=73)
assert "7" not in t2v
assert "start_image" not in t2v["8"]["inputs"]
assert t2v["8"]["inputs"]["length"] == 73 # 4n+1, unchanged
# image-to-video: LoadImage → start_image; clamp/snap of the dims + length
g = sh._video_graph("beach scene", "video/y_ab12cd", ref_name="ref.png",
width=650, height=641, length=72)
assert g["7"]["class_type"] == "LoadImage" and g["7"]["inputs"]["image"] == "ref.png"
assert g["8"]["inputs"]["start_image"] == ["7", 0]
assert (g["8"]["inputs"]["width"], g["8"]["inputs"]["height"]) == (640, 640) # step of 32
assert g["8"]["inputs"]["length"] == 69 # 72 → nearest 4n+1 below
ks = g["9"]["inputs"]
assert ks["steps"] == 20 and ks["cfg"] == 5.0
assert ks["sampler_name"] == "uni_pc" and ks["scheduler"] == "simple"
assert g["4"]["class_type"] == "ModelSamplingSD3" and g["4"]["inputs"]["shift"] == 8
sv = g["12"]
assert sv["class_type"] == "SaveVideo"
assert sv["inputs"] == {"video": ["11", 0], "filename_prefix": "video/y_ab12cd",
"format": "mp4", "codec": "h264"}
assert g["6"]["inputs"]["text"] == sh._WAN_NEG
assert sh._WAN_NEG.startswith("色调艳丽") and "倒着走" in sh._WAN_NEG
# lessons condition BOTH encoders, exactly like _fix_graph
gl = sh._video_graph("beach", "video/z", lessons=["hair morphs between frames"])
assert "do not repeat" in gl["5"]["inputs"]["text"]
assert "hair morphs between frames" in gl["5"]["inputs"]["text"]
assert "hair morphs between frames" in gl["6"]["inputs"]["text"]
# ── 2. dispatch + the route (org from the token, not the body) ─────────────────────
@pytest.mark.asyncio
async def test_dispatch_route(orgaware, monkeypatch):
outd, _ = orgaware
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-v"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
video = _handler(_FakeServer(), "POST", "/v1/library/video", monkeypatch)
# an 'org' field in the body is IGNORED — the row lands under the TOKEN org
resp = await video(_Req(body={"prompt": "a woman walking on a beach at sunset",
"org": "evil"}, org="acme"))
assert resp.status == 200
out = json.loads(resp.text)
import re
assert re.match(r"^video/.+_[0-9a-f]{6}$", out["output_prefix"])
rows = wl.load("acme")
assert len(rows) == 1 and rows[0]["kind"] == "video"
assert rows[0]["lane"] in ("local", "gpu")
assert wl.load("evil") == [] # never under the body's org
# the graph that queued is the Wan graph (t2v: no LoadImage)
assert any(n.get("class_type") == "SaveVideo" for n in captured["graph"].values())
assert not any(n.get("class_type") == "LoadImage" for n in captured["graph"].values())
# prompt < 3 chars → 400
r2 = await video(_Req(body={"prompt": "hi"}, org="acme"))
assert r2.status == 400
# two references (a library path AND an upload) → 400
r3 = await video(_Req(body={"prompt": "a valid video prompt", "path": "a.png",
"upload": "u.png"}, org="acme"))
assert r3.status == 400
# ── 3. landed-output + the honesty guard, over video ──────────────────────────────
def test_landed_video(orgaware):
outd, _ = orgaware
root = Path(outd("acme"))
(root / "video").mkdir(parents=True, exist_ok=True)
(root / "video" / "x_ab12cd_00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
assert sh._landed_output(root, "video/x_ab12cd") == "video/x_ab12cd_00001_.mp4"
(root / "video" / "w_00001_.webm").write_bytes(b"\x1a\x45\xdf\xa3")
assert sh._landed_output(root, "video/w") == "video/w_00001_.webm"
# child-prefix must NOT be swallowed: "a" never claims "a_00001__00001_.mp4"
(root / "video" / "a_00001__00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
assert sh._landed_output(root, "video/a") is None
# png unchanged
(root / "fixes").mkdir(parents=True, exist_ok=True)
(root / "fixes" / "y_00001_.png").write_bytes(b"\x89PNG\r\n")
assert sh._landed_output(root, "fixes/y") == "fixes/y_00001_.png"
# _finalize_landed marks the video row done at the file mtime …
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/done", params={"steps": 20}, pid="dv", status="queued")
ts = next(r["ts"] for r in wl.load("acme") if r["id"] == "dv")
dp = root / "video" / "done_00001_.mp4"
dp.write_bytes(b"\x00\x00\x00 ftyp")
os.utime(dp, (ts + 200, ts + 200))
rows = wl.load("acme")
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
assert next(r for r in wl.load("acme") if r["id"] == "dv")["status"] == "done"
# … but a <5s landing for a 20-step render is impossible → failed
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/fast", params={"steps": 20}, pid="fv", status="queued")
tsf = next(r["ts"] for r in wl.load("acme") if r["id"] == "fv")
fp = root / "video" / "fast_00001_.mp4"
fp.write_bytes(b"\x00\x00\x00 ftyp")
os.utime(fp, (tsf + 2, tsf + 2))
rows = wl.load("acme")
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
failed = next(r for r in wl.load("acme") if r["id"] == "fv")
assert failed["status"] == "failed"
assert any("did not sample" in (e.get("note") or "") for e in failed.get("events", []))
# ── 4. the live BYO-GPU queue drops a video job when its mp4 lands ─────────────────
@pytest.mark.asyncio
async def test_render_queue_video(orgaware, monkeypatch):
"""The render-queue is AUTHORITATIVE: it reads the org's gpu-jobs activities from the
tasks engine and shows the REAL claiming GPU (activity.identity), dropping terminal
jobs. A STARTED studio.render is live; a COMPLETED one falls off."""
outd, _ = orgaware
root = Path(outd("acme"))
_lib(root, [])
render_queue = _handler(_FakeServer(), "GET", "/v1/render-queue", monkeypatch)
base = {
"execution": {"runId": "p", "workflowId": "p"},
"type": {"name": "studio.render"},
"taskQueue": "gpu:spark", "identity": "spark",
"input": {"prompt": {"12": {"class_type": "SaveVideo",
"inputs": {"filename_prefix": "video/x"}}}},
}
async def _started(_req):
return [{**base, "status": "STARTED"}]
monkeypatch.setattr(sh, "_list_gpu_jobs", _started)
resp = await render_queue(_Req(org="acme"))
jobs = json.loads(resp.text)["jobs"]
assert len(jobs) == 1 and jobs[0]["node"] == "spark" # real claimer shown
async def _completed(_req):
return [{**base, "status": "COMPLETED"}]
monkeypatch.setattr(sh, "_list_gpu_jobs", _completed)
resp = await render_queue(_Req(org="acme"))
assert json.loads(resp.text)["jobs"] == [] # engine says done → dropped
# ── 5. worklog params + per-kind ETA bucketing ────────────────────────────────────
def test_graph_params_video(orgaware):
outd, _ = orgaware
g = sh._video_graph("a beach", "video/x", seed=7, length=73)
p = sh._graph_params(g)
for k in ("steps", "cfg", "sampler", "scheduler", "seed", "width", "height", "length", "fps"):
assert k in p, k
assert p["fps"] == 24 and p["length"] == 73 and p["seed"] == 7
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
output_prefix="video/a", pid="v1", status="queued")
wl.mark_started("acme", "v1", 1000)
wl.mark_finished("acme", "v1", 1120)
wl.record("acme", kind="fix", prompt="", refs=[], uploads=[], parents=[],
output_prefix="fixes/a", pid="f1", status="queued")
wl.mark_started("acme", "f1", 1000)
wl.mark_finished("acme", "f1", 1030)
medians = wl.median_durations(wl.load("acme"))
assert medians.get("video") == 120 and medians.get("fix") == 30 # bucketed apart
# ── 6. the ONE indexed ingest door accepts video ──────────────────────────────────
@pytest.mark.asyncio
async def test_upload_video(orgaware, tmp_path, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
_lib(root, [])
scheduled = {}
cm = {}
monkeypatch.setattr(sh.quality_gate, "schedule",
lambda request, org, r, rel: scheduled.update(rel=rel, org=org))
monkeypatch.setattr(sh, "colormatch_fix_ingest",
lambda request, sub, name: cm.update(sub=sub, name=name))
monkeypatch.setattr(sh.stacks_store, "absorb", lambda *a, **k: None) # no stacks DB in this unit
upload = _handler(_FakeServer(), "POST", "/v1/library/upload", monkeypatch)
src = tmp_path / "clip.mp4"
_write_mp4(src)
mp4 = src.read_bytes()
resp = await upload(_Req(raw=mp4, query={"name": "clip.mp4"}, org="acme"))
body = json.loads(resp.text)
assert resp.status == 200 and body["path"] == "renders/clip.mp4"
assert (root / "renders" / "clip.mp4").is_file()
# kind defaults to "video" by suffix
lib = json.loads((root / "library.json").read_text())
row = next(a for a in lib["assets"] if a["path"] == "renders/clip.mp4")
assert row["kind"] == "video"
# the judge fired on the created row; the colour-restore hook saw (sub, name)
assert scheduled.get("rel") == "renders/clip.mp4"
assert cm == {"sub": "renders", "name": "clip.mp4"}
# webm (EBML) accepted too
wsrc = tmp_path / "clip.webm"
_write_webm(wsrc)
r_web = await upload(_Req(raw=wsrc.read_bytes(), query={"name": "clip.webm"}, org="acme"))
assert r_web.status == 200
# garbage named .mp4 → rejected; ftyp at OFFSET 0 (not 4) → rejected (regression pin)
r_bad = await upload(_Req(raw=b"totally not a video, just text bytes here", query={"name": "x.mp4"}, org="acme"))
assert r_bad.status == 400
r_off0 = await upload(_Req(raw=b"ftyp" + b"\x00" * 40, query={"name": "y.mp4"}, org="acme"))
assert r_off0.status == 400
# over the 256MB ceiling → 413 (checked via content_length, no giant allocation)
r_big = await upload(_Req(raw=mp4, query={"name": "big.mp4"}, org="acme",
content_length=sh._MAX_UPLOAD + 1))
assert r_big.status == 413
# ── 7. the manifest sweeper keeps a video as kind="video" ─────────────────────────
def test_manifest_video(tmp_path):
from scripts import library_manifest as lm
root = tmp_path
(root / "video").mkdir()
(root / "video" / "x.mp4").write_bytes(b"\x00\x00\x00 ftyp")
(root / "video" / "empty.mp4").write_bytes(b"") # zero-byte → skipped
assert "video/x.mp4" in lm.scan(str(root))
assert "video/empty.mp4" not in lm.scan(str(root))
doc = lm.build(str(root))
row = next(a for a in doc["assets"] if a["path"] == "video/x.mp4")
assert row["kind"] == "video" and row["status"] == "draft"
# a curated status + tags survive the next sweep (the erase-every-cycle bug stays dead)
lm.write_atomic(str(root), {"assets": [
{"path": "video/x.mp4", "kind": "video", "status": "approved", "tags": ["hero"]}]})
doc2 = lm.build(str(root))
row2 = next(a for a in doc2["assets"] if a["path"] == "video/x.mp4")
assert row2["kind"] == "video" and row2["status"] == "approved" and row2["tags"] == ["hero"]
# ── 8. serving + the first-frame poster ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_serve_and_poster(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
_write_mp4(root / "video" / "clip.mp4")
_lib(root, [{"path": "video/clip.mp4", "kind": "video", "status": "draft"}])
get_file = _handler(_FakeServer(), "GET", "/v1/library/file", monkeypatch)
# full file served as video/mp4
resp = await get_file(_Req(query={"path": "video/clip.mp4"}, org="acme"))
assert resp.content_type == "video/mp4"
# ?thumb=1 → a cached JPEG poster in .thumbs/<key>.jpg
tresp = await get_file(_Req(query={"path": "video/clip.mp4", "thumb": "1"}, org="acme"))
assert isinstance(tresp, web.FileResponse)
key = __import__("re").sub(r"[^a-zA-Z0-9]", "_", str((root / "video" / "clip.mp4").resolve()))[-120:]
assert (root / ".thumbs" / f"{key}.jpg").is_file()
# a poster that cannot decode → 404, NEVER the raw mp4 bytes (use an un-cached path)
_write_mp4(root / "video" / "clip2.mp4")
_lib(root, [{"path": "video/clip.mp4"}, {"path": "video/clip2.mp4"}])
def boom(*a, **k):
raise RuntimeError("cannot decode")
monkeypatch.setattr(av, "open", boom)
r404 = await get_file(_Req(query={"path": "video/clip2.mp4", "thumb": "1"}, org="acme"))
assert isinstance(r404, web.Response) and r404.status == 404
assert not isinstance(r404, web.FileResponse)
# ── 9. embedded-metadata read parity: provenance, Open-in-Editor, Re-run ──────────
@pytest.mark.asyncio
async def test_embedded_and_rerun(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
graph = sh._video_graph("a woman on a beach", "video/x_ab12cd", seed=42, length=73)
mp4 = root / "video" / "x_ab12cd_00001_.mp4"
_write_mp4(mp4, meta={"prompt": json.dumps(graph)})
_lib(root, [{"path": "video/x_ab12cd_00001_.mp4", "kind": "video", "status": "draft"}])
# _embedded reads the container metadata back
info = sh._embedded(mp4)
assert "prompt" in info and json.loads(info["prompt"])["12"]["class_type"] == "SaveVideo"
# _provenance yields the video params
prov = sh._provenance(mp4)
assert prov["workflow"] == "video/x_ab12cd" and prov["steps"] == 20
assert prov["cfg"] == 5.0 and prov["seed"] == 42 and prov["length"] == 73 and prov["fps"] == 24
# GET /v1/workflow serves format 'api' for the done video row. The dispatch ts must
# predate the file mtime (else finalize reads the mp4 as a pre-existing sibling).
ts = int(mp4.stat().st_mtime) - 100
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/x_ab12cd", params={"steps": 20}, pid="pv", status="queued")
rows = wl.load("acme")
for x in rows:
if x["id"] == "pv":
x["ts"] = ts
wl._save("acme", rows)
get_wf = _handler(_FakeServer(), "GET", "/v1/workflow", monkeypatch)
wfresp = await get_wf(_Req(query={"id": "pv"}, org="acme"))
wf = json.loads(wfresp.text)
assert wf["format"] == "api" and wf["graph"]["12"]["class_type"] == "SaveVideo"
# POST /v1/library/rerun dispatches kind='rerun' with a FRESH SaveVideo tag
captured = {}
async def fake_queue(request, server, g):
captured["graph"] = g
return "pid-rerun"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
rerun = _handler(_FakeServer(), "POST", "/v1/library/rerun", monkeypatch)
rr = await rerun(_Req(body={"path": "video/x_ab12cd_00001_.mp4"}, org="acme"))
assert rr.status == 200
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
import re
assert re.match(r"^video/x_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
assert sv["inputs"]["filename_prefix"] != "video/x_ab12cd" # a fresh namespace
assert any(r2["kind"] == "rerun" for r2 in wl.load("acme"))
# ── 10. template re-tag: video AND image savers get a fresh job identity ──────────
@pytest.mark.asyncio
async def test_template_retag(orgaware, monkeypatch):
import re
outd, _ = orgaware
tdir = Path(outd("acme")).parent / "templates"
tdir.mkdir(parents=True, exist_ok=True)
captured = {}
async def fake_queue(request, server, g):
captured["graph"] = g
return "pid-t"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
render = _handler(_FakeServer(), "POST", "/v1/templates/render", monkeypatch)
# a template whose graph saves via SaveVideo
vgraph = sh._video_graph("a beach", "video/tmpl", length=73)
(tdir / "clip.json").write_text(json.dumps({"name": "Clip", "slug": "clip", "graph": vgraph}))
resp = await render(_Req(body={"name": "clip"}, org="acme"))
assert resp.status == 200
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
assert re.match(r"^video/tmpl_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
# a SaveImage template is re-tagged too (the unique-job-tag rule holds for images)
igraph = {"9": {"class_type": "KSampler", "inputs": {"seed": 1, "steps": 30}},
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "templates/pic"}}}
(tdir / "pic.json").write_text(json.dumps({"name": "Pic", "slug": "pic", "graph": igraph}))
resp2 = await render(_Req(body={"name": "pic"}, org="acme"))
assert resp2.status == 200
si = next(n for n in captured["graph"].values() if n["class_type"] == "SaveImage")
assert re.match(r"^templates/pic_[0-9a-f]{6}$", si["inputs"]["filename_prefix"])
@@ -0,0 +1,164 @@
"""worker-mode /prompt gate + the /v1/worker/execute claim seam.
Covers:
- verify_worker_token / reject_untrusted_worker_submit the ONE policy gate that makes
a direct /prompt POST on a worker box refuse un-tokened submits (403), so no render
reaches a GPU without appearing in the org's gpu-jobs queue.
- POST /v1/worker/execute validates X-Worker-Token and RUNS the claimed graph on the
local prompt_queue, returning {prompt_id}, scoped to the job's org.
"""
import sys
from pathlib import Path
import pytest
from aiohttp import web
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
# CPU CI runners have no NVIDIA driver: force studio's CPU mode BEFORE importing
# worker_client (which pulls execution -> studio.model_management, whose import-time
# device probe would otherwise call torch.cuda and crash on a GPU-less box). Same
# switch the studio pod boots with (--cpu); harmless on a GPU box.
import studio.cli_args
studio.cli_args.args.cpu = True
from middleware import worker_client as wc
class _Req:
def __init__(self, headers=None):
self.headers = headers or {}
# ── The gate policy: worker-mode /prompt refuses un-tokened submits ───────────────
def test_empty_token_dev_bypass_ONLY_outside_worker_mode(monkeypatch):
"""FAIL CLOSED: an empty/unset STUDIO_WORKER_TOKEN is a valid single-trust-domain dev
signal ONLY when NOT in worker mode. On a worker box (worker_mode=True) an empty token
must NOT open /prompt or /v1/worker/execute that was the fail-open hole (KMS /v1
migration has emptied the secret before)."""
monkeypatch.setattr(wc, "WORKER_TOKEN", "")
monkeypatch.setattr(wc.args, "worker_mode", False)
assert wc.verify_worker_token(_Req()) is True # local dev, single trust domain
monkeypatch.setattr(wc.args, "worker_mode", True)
assert wc.verify_worker_token(_Req()) is False # worker box + empty token → refuse
def test_verify_worker_token_enforced(monkeypatch):
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
assert wc.verify_worker_token(_Req({"X-Worker-Token": "s3cret"})) is True
assert wc.verify_worker_token(_Req({"X-Worker-Token": "nope"})) is False
assert wc.verify_worker_token(_Req()) is False
# constant-time path (hmac.compare_digest) accepts only the exact secret
assert wc.verify_worker_token(_Req({"X-Worker-Token": "s3cret "})) is False
def test_gate_allows_when_not_worker_mode(monkeypatch):
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
assert wc.reject_untrusted_worker_submit(_Req(), worker_mode=False) is None
def test_gate_403_in_worker_mode_without_token(monkeypatch):
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
resp = wc.reject_untrusted_worker_submit(_Req(), worker_mode=True)
assert resp is not None and resp.status == 403 # direct /prompt refused
def test_gate_403_in_worker_mode_with_EMPTY_token(monkeypatch):
"""Fail closed even when the secret is empty (KMS-emptied): worker mode + empty token
still refuses a direct /prompt (403) runtime belt to main.py's refuse-to-start guard."""
monkeypatch.setattr(wc, "WORKER_TOKEN", "")
monkeypatch.setattr(wc.args, "worker_mode", True)
resp = wc.reject_untrusted_worker_submit(_Req(), worker_mode=True)
assert resp is not None and resp.status == 403
def test_gate_allows_worker_mode_with_token(monkeypatch):
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
ok = _Req({"X-Worker-Token": "s3cret"})
assert wc.reject_untrusted_worker_submit(ok, worker_mode=True) is None
# ── The claim seam: POST /v1/worker/execute validates token + runs locally ────────
class _Queue:
def __init__(self):
self.items = []
def put(self, item):
self.items.append(item)
class _NRM:
def apply_replacements(self, prompt):
pass
class _Server:
def __init__(self):
self.prompt_queue = _Queue()
self.node_replace_manager = _NRM()
@pytest.fixture
def worker_app(monkeypatch):
import execution
async def fake_validate(pid, prompt, targets=None):
return (True, None, list(prompt.keys()), {})
monkeypatch.setattr(execution, "validate_prompt", fake_validate)
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
server = _Server()
app = web.Application()
routes = web.RouteTableDef()
wc.add_worker_routes(routes, server)
app.add_routes(routes)
app["srv"] = server
return app
@pytest.mark.asyncio
async def test_worker_execute_rejects_without_token(worker_app, aiohttp_client):
client = await aiohttp_client(worker_app)
r = await client.post("/v1/worker/execute",
json={"prompt": {"9": {"class_type": "SaveImage", "inputs": {}}}})
assert r.status == 401
assert worker_app["srv"].prompt_queue.items == [] # nothing ran
@pytest.mark.asyncio
async def test_worker_execute_runs_claimed_job_with_token(worker_app, aiohttp_client):
client = await aiohttp_client(worker_app)
graph = {"9": {"class_type": "SaveImage", "inputs": {}}}
r = await client.post("/v1/worker/execute",
headers={"X-Worker-Token": "s3cret"},
json={"prompt": graph, "prompt_id": "pid-1", "org": "acme"})
assert r.status == 200
body = await r.json()
assert body["prompt_id"] == "pid-1"
items = worker_app["srv"].prompt_queue.items
assert len(items) == 1 # queued for local render
_number, pid, _prompt, extra, _outputs, _sensitive = items[0]
assert pid == "pid-1"
assert extra["org_id"] == "acme" # worker-LOCAL scope label (gallery org re-derived from IAM)
@pytest.mark.asyncio
async def test_worker_execute_rejects_non_dict_prompt(worker_app, aiohttp_client):
"""A non-object prompt (e.g. an injected string) is a clean 400, not a 500."""
client = await aiohttp_client(worker_app)
r = await client.post("/v1/worker/execute", headers={"X-Worker-Token": "s3cret"},
json={"prompt": "<img src=x onerror=alert(1)>"})
assert r.status == 400
assert worker_app["srv"].prompt_queue.items == [] # nothing ran
@pytest.mark.asyncio
async def test_worker_execute_rejects_non_numeric_number(worker_app, aiohttp_client):
"""A non-numeric `number` is a clean 400, not a float() 500."""
client = await aiohttp_client(worker_app)
r = await client.post("/v1/worker/execute", headers={"X-Worker-Token": "s3cret"},
json={"prompt": {"9": {"class_type": "SaveImage", "inputs": {}}},
"number": "not-a-number"})
assert r.status == 400
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for the per-org work log (middleware/worklog.py)."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import worklog
@pytest.fixture
def orgdirs(tmp_path, monkeypatch):
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
the tests exercise real cross-tenant isolation (one file per org)."""
def out_dir(org=None):
d = tmp_path / "orgs" / (org or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
return out_dir
def test_record_and_load_roundtrip(orgdirs):
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a",
pid="pid-1", lane="gpu")
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
rows = worklog.load("acme")
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a",
status="failed", error="no GPU worker online for this org")
assert row["status"] == "failed" and row["id"].startswith("failed-")
assert "no GPU worker" in row["error"]
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
def test_set_status_advances_but_respects_terminal(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/a", pid="p1")
assert worklog.set_status("acme", "p1", "running")
assert worklog.load("acme")[0]["status"] == "running"
assert worklog.set_status("acme", "p1", "cancelled")
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
assert not worklog.set_status("acme", "p1", "done")
assert worklog.load("acme")[0]["status"] == "cancelled"
def test_cross_tenant_isolation(orgdirs):
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a", pid="p1")
# a different org's log is a different file — zero leakage either direction
assert worklog.load("acme") and worklog.load("globex") == []
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
parents=["b.png"], output_prefix="fixes/b", pid="p2")
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
def test_cap_keeps_newest(orgdirs, monkeypatch):
monkeypatch.setattr(worklog, "_CAP", 5)
for i in range(12):
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
rows = worklog.load("acme")
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
def test_build_lineage_chain():
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
rows = [
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
]
lin = worklog.build_lineage(rows, "c.png")
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
assert lin["node"] == "c.png"
assert lin["descendants"] == []
# from the root, descendants include both branches, chronological
lin2 = worklog.build_lineage(rows, "a.png")
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
assert lin2["ancestors"] == []
def test_record_stores_node(orgdirs):
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="f", pid="p1", lane="gpu", node="spark")
assert r["node"] == "spark"
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
output_prefix="g", pid="p2", lane="local")
assert r2["node"] == "studio pod" # local jobs run on the pod
def test_mark_started_finished_and_median(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert worklog.mark_started("acme", "p1", 1000)
r = worklog.load("acme")[0]
assert r["started_at"] == 1000 and r["status"] == "running"
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
assert worklog.load("acme")[0]["started_at"] == 1000
assert worklog.mark_finished("acme", "p1", 1050)
r = worklog.load("acme")[0]
assert r["finished_at"] == 1050 and r["status"] == "done"
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
def test_median_durations_per_kind():
rows = [
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
{"kind": "fix", "ts": 400}, # unfinished → ignored
]
m = worklog.median_durations(rows)
assert m == {"fix": 20, "compose": 100}
# no started_at → duration falls back to finished queued(ts)
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
def test_estimate_lanes_positions_and_eta():
now = 1000
active = [
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
]
medians = {"fix": 120, "compose": 300}
est, counts = worklog.estimate_lanes(active, medians, now)
assert counts == {"gpu": 3, "local": 1}
# running: elapsed 60 of median 120 → 60 remaining
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
assert est["a"]["node"] == "spark"
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
# separate lane, single queued item, nothing running → starts ~now
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
def test_estimate_lanes_default_median_for_unknown_kind():
est, _ = worklog.estimate_lanes(
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
assert est["x"]["eta_seconds"] == 200
def test_build_lineage_terminates_on_cycle():
rows = [
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
]
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
assert lin["node"] == "x.png"
@@ -0,0 +1,139 @@
"""The composer's default create path — text-to-image — and the UX invariants the live
review demanded. Two concerns:
* backend _image_graph is a REAL text-to-image graph (EmptySD3LatentImage SaveImage,
the Z-Image-Turbo recipe), and dispatch_image validates before any dispatch.
* frontend the shipped page defaults the composer to an IMAGE model, routes textimage
(no "attach a photo first" dead end), ALWAYS shows a generating state on
Create, puts Download first-class on the tile AND the viewer, and stops
badging active work as ARCHIVED. Asserted statically against the served HTML
so the intuitiveness fixes can't silently regress.
"""
import asyncio
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
_HTML = (Path(__file__).resolve().parents[2] / "middleware" / "studio_home.html").read_text()
# ── backend: the text-to-image graph ────────────────────────────────────────────────
def test_image_graph_is_a_real_text_to_image_create():
g = sh._image_graph("a poster for a jazz night", "image/test")
classes = {n["class_type"] for n in g.values()}
# a true text-to-image CREATE starts from an empty latent (not an edit of a source)…
assert "EmptySD3LatentImage" in classes
# …and produces a visible saved image through the full load→sample→decode→save chain.
assert {"UNETLoader", "CLIPLoader", "VAELoader", "CLIPTextEncode", "KSampler",
"VAEDecode", "SaveImage"} <= classes
# the user's words land in the positive prompt (node 5), negative (node 6) stays empty.
assert "jazz" in g["5"]["inputs"]["text"]
assert g["6"]["inputs"]["text"] == ""
# the exact model set the shipped Z-Image-Turbo blueprint names.
assert g["1"]["inputs"]["unet_name"] == sh._ZIMAGE_UNET
assert g["2"]["inputs"]["clip_name"] == sh._ZIMAGE_CLIP and g["2"]["inputs"]["type"] == "lumina2"
assert g["3"]["inputs"]["vae_name"] == sh._ZIMAGE_VAE
# turbo recipe: 4 steps, cfg 1.0, res_multistep/simple, denoise 1.0 (a fresh render).
ks = next(n for n in g.values() if n["class_type"] == "KSampler")["inputs"]
assert ks["steps"] == 4 and ks["cfg"] == 1.0 and ks["denoise"] == 1.0
assert ks["sampler_name"] == "res_multistep" and ks["scheduler"] == "simple"
def test_image_graph_dims_clamp_and_snap():
g = sh._image_graph("x" * 5, "p", width=1000, height=770)
lat = g["7"]["inputs"]
assert lat["width"] % 16 == 0 and lat["height"] % 16 == 0 # snapped to the 16-px grid
g2 = sh._image_graph("x" * 5, "p", width=50, height=9000)
assert 256 <= g2["7"]["inputs"]["width"] <= 2048 # clamped low
assert 256 <= g2["7"]["inputs"]["height"] <= 2048 # clamped high
def test_dispatch_image_rejects_bad_prompt_before_dispatch():
# A too-short/empty prompt fails fast with a human reason — never a silent nothing,
# never a network call. (No server/GPU needed: the guard is the first statement.)
for bad in ("", "hi", " "):
with pytest.raises(sh.DispatchError):
asyncio.run(sh.dispatch_image(None, None, "org", bad))
# ── frontend: the served page's intuitiveness invariants ─────────────────────────────
def test_composer_defaults_to_an_image_model():
# default is Zen Image (an IMAGE model), NOT a chat model, so the first ↑ produces
# something visible. Decoupled from the chat model (its own storage key).
assert "hz_create_model||'zen-image'" in _HTML
assert "hz_create_model" in _HTML and "localStorage.hz_model=v" not in _HTML
# the Image group leads the picker; the code model is hidden from the creative composer.
assert _HTML.index("{label:'Image'") < _HTML.index("{label:'Assistant'")
assert "zen5-coder" not in _HTML
def test_text_only_prompt_routes_to_text_to_image():
# the DEFAULT create (words, no photo) hits the new /v1/library/image route — the old
# "attach a photo first" dead end is gone.
assert "url:'/v1/library/image'" in _HTML
assert "Describe what to create, then attach a photo with +" not in _HTML
def test_create_always_shows_a_state_and_fails_friendly():
assert "function pulseGenerating(" in _HTML and "pulseGenerating(plan)" in _HTML
assert "function createFailed(" in _HTML and "tap ↑ to retry" in _HTML
def test_download_is_first_class_on_tile_and_viewer():
assert "function downloadImage(" in _HTML and "function dlMenu(" in _HTML
assert "dlMenu(event,'" in _HTML # a per-tile download control
assert 'id="zrdl"' in _HTML # a download control in the image viewer
# PNG/JPG/WebP format choice is exposed at download.
assert "downloadImage(path,'jpg')" in _HTML or "downloadImage(path,v)" in _HTML
def test_active_items_are_not_badged_archived():
# the old unconditional status badge (which read every active item as its status /
# "archived") is gone; a badge now renders ONLY for deleted/flagged items.
assert "'archived':a.status}" not in _HTML
assert "a.status==='deleted'||a.status==='flagged'" in _HTML
def test_bars_are_opaque_and_dev_control_is_gated():
assert "background:var(--bg);z-index:30" in _HTML # opaque fixed header
assert "#livebar::before" in _HTML # full-bleed opaque backing behind the sticky status bar
assert "rgba(10,10,11,.82)" not in _HTML # the old translucent header is gone
assert "body:not(.hzdev) .pico.dev" in _HTML # </> hidden unless Developers mode
assert 'class="pico dev"' in _HTML
def test_mobile_collapses_the_filter_pills():
assert ".chips{flex-wrap:nowrap;overflow-x:auto" in _HTML
# ── RED create-lane regressions (@c00384cf): F1 dedup identity, F2 dim coercion ──
def test_dedup_key_is_stable_identity_not_the_random_prefix():
# F1: the key must be the STABLE render identity (org|kind|prompt|refs|uploads) —
# NOT output_prefix, which each lane stamps with a random _job_tag() so two
# identical submits used to miss and double-bill.
k = sh._dedup_key("acme", "image", "a jazz poster", [], [])
assert k == sh._dedup_key("acme", "image", "a jazz poster", [], []), "identical creates share a key"
assert k != sh._dedup_key("acme", "image", "a DIFFERENT poster", [], []), "different prompt → different key"
assert k != sh._dedup_key("other", "image", "a jazz poster", [], []), "different org → different key"
assert k != sh._dedup_key("acme", "video", "a jazz poster", [], []), "different lane → different key"
assert k != sh._dedup_key("acme", "image", "a jazz poster", ["ref.png"], []), "different refs → different key"
def test_image_and_video_graphs_reject_non_numeric_dims():
# F2: a non-numeric width/height/length raises DispatchError (→ clean 400), never a bare ValueError (→ 500).
for bad in ("abc", "10px", [], {}):
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", width=bad)
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", height=bad)
with pytest.raises(sh.DispatchError):
sh._video_graph("a valid prompt", "video/x", width=bad)
# numeric strings still coerce and render
g = sh._image_graph("a valid prompt", "image/x", width="512", height="512")
assert g["7"]["inputs"]["width"] == 512 and g["7"]["inputs"]["height"] == 512
@@ -0,0 +1,80 @@
"""Guard tests for the browser-injected publishable key (middleware/studio_home.py).
The publishable key is the ONE credential Studio prints into page source, so these
pin the invariants that keep a server secret out of the client:
* ``_publishable`` is an ALLOWLIST -- only ``pk-`` (the gateway's single
client-safe prefix, iamauth.go) survives; every server-secret family
(``hk-``/``sk-``/``fw_``/``hz_``) and JWT is dropped (fail CLOSED).
* ``_hz_config_script`` cannot be broken out of its ``<script>`` tag, even by a
hostile value that also satisfies the allowlist.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
_PREFIX, _SUFFIX = "<script>window.HZ=", ";</script>"
_LINE_SEP, _PARA_SEP = chr(0x2028), chr(0x2029) # raw JS statement terminators (U+2028/9)
def _parse_hz(script: str) -> dict:
assert script.startswith(_PREFIX) and script.endswith(_SUFFIX)
payload = script[len(_PREFIX):-len(_SUFFIX)]
return json.loads(payload.replace("<\\/", "</")) # reverse the </ neutralization
@pytest.mark.parametrize("secret", [
"hk-live-abc", "sk-live-abc", "fw_live_abc", "hz_live_abc", # gateway server-secret families
"eyJhbGciOiJIUzI1NiJ9.body.sig", # a JWT
"secret-abc", "PK-upper", "pk_underscore", "pkno-dash", # not the pk- publishable prefix
" ", "", # blank
])
def test_publishable_drops_everything_but_pk(secret):
assert sh._publishable(secret) == ""
@pytest.mark.parametrize("pk,want", [
("pk-abc123", "pk-abc123"),
(" pk-abc123 ", "pk-abc123"), # trimmed
])
def test_publishable_passes_pk(pk, want):
assert sh._publishable(pk) == want
def test_hz_config_injects_publishable_env(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-live-xyz")
assert _parse_hz(sh._hz_config_script())["pk"] == "pk-live-xyz"
def test_hz_config_drops_secret_env(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("STUDIO_PUBLISHABLE_KEY", "hz_server_secret") # a real server key
assert _parse_hz(sh._hz_config_script())["pk"] == ""
def test_hz_config_empty_when_unset(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
assert _parse_hz(sh._hz_config_script())["pk"] == ""
def test_hz_config_script_cannot_break_out(monkeypatch):
# A hostile value that still satisfies the pk- allowlist must not be able to
# close the <script> tag or smuggle a raw JS line/paragraph separator (U+2028/9).
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-</script><img>" + _LINE_SEP + _PARA_SEP)
script = sh._hz_config_script()
body = script[len(_PREFIX):-len(_SUFFIX)]
assert "</" not in body # no tag can be opened/closed
assert _LINE_SEP not in body and _PARA_SEP not in body # separators escaped, not raw
assert _parse_hz(script)["pk"].startswith("pk-") # value survives intact, just neutralized
@@ -0,0 +1,76 @@
"""The Chat SSE parser lives in middleware/shell.js as two pure functions
(``sseLines`` splits streamed chunks into whole lines across buffer boundaries;
``sseDelta`` maps one line to its content delta). This test extracts them from the
SHIPPED shell and exercises them under node -- chunk-boundary splits, CRLF, [DONE],
keep-alives and malformed lines -- so the one parser the browser runs is the one
under test. Skipped where node is unavailable, keeping ``uv run pytest`` green.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
SHELL = Path(__file__).resolve().parents[2] / "middleware" / "shell.js"
def _fn(src: str, name: str) -> str:
"""Return the source of the top-level `function <name>(...) {...}` by brace match."""
i = src.index("function " + name + "(")
depth = 0
start = src.index("{", i)
for j in range(start, len(src)):
if src[j] == "{":
depth += 1
elif src[j] == "}":
depth -= 1
if depth == 0:
return src[i:j + 1]
raise AssertionError("unbalanced braces extracting " + name)
_CHECKS = r"""
function eq(a, b, m) {
if (JSON.stringify(a) !== JSON.stringify(b)) {
console.error("FAIL " + m + ": " + JSON.stringify(a) + " !== " + JSON.stringify(b));
process.exit(1);
}
}
// sseDelta: line -> content delta (or null)
eq(sseDelta('data: {"choices":[{"delta":{"content":"hi"}}]}'), "hi", "basic");
eq(sseDelta('data: {"choices":[{"delta":{"content":"x"}}]}\r'), "x", "trailing-CR-tolerated");
eq(sseDelta('data: [DONE]'), null, "done-sentinel");
eq(sseDelta(': keep-alive'), null, "comment-line");
eq(sseDelta('event: ping'), null, "non-data-line");
eq(sseDelta('data: {bad json'), null, "malformed-json");
eq(sseDelta('data: {"choices":[{"delta":{}}]}'), null, "no-content-field");
eq(sseDelta(''), null, "empty-line");
// sseLines: a line split across two feeds emerges once, whole (chunk boundary)
var feed = sseLines();
eq(feed('data: {"choices":[{"delta":{"content":"He'), [], "partial-held");
var out = feed('llo"}}]}\ndata: [DONE]\n');
eq(out.length, 2, "two-lines-after-boundary");
eq(sseDelta(out[0]), "Hello", "reassembled-delta");
eq(sseDelta(out[1]), null, "trailing-done");
// sseLines: multiple CRLF-terminated lines in one chunk; the CR rides through for
// sseDelta to strip, and a dangling partial (no newline) is withheld.
var f2 = sseLines();
var o2 = f2("data: a\r\ndata: b\r\ndata: c");
eq(o2, ["data: a\r", "data: b\r"], "crlf-split-holds-partial");
eq(sseDelta(o2[0]), null, "plain-a-not-json");
console.log("OK");
"""
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_sse_parser_under_node():
src = SHELL.read_text()
driver = _fn(src, "sseLines") + "\n" + _fn(src, "sseDelta") + "\n" + _CHECKS
r = subprocess.run([shutil.which("node"), "-e", driver],
capture_output=True, text=True, timeout=30)
assert r.returncode == 0, "node failed:\nSTDERR:\n" + r.stderr + "\nSTDOUT:\n" + r.stdout
assert "OK" in r.stdout
@@ -0,0 +1,157 @@
"""Unit tests for the render quality gate (middleware/quality_gate.py)."""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import quality_gate as qg
from middleware import studio_home as sh
from middleware import worklog
def test_parse_verdict_strict():
assert qg.parse_verdict("PASS") == "pass"
assert qg.parse_verdict("pass") == "pass"
assert qg.parse_verdict("FAIL") == "fail"
assert qg.parse_verdict("fail.") == "fail"
assert qg.parse_verdict("FAIL — mottled, speckled skin") == "fail"
assert qg.parse_verdict("The skin is clean. PASS") == "pass"
# garbage / ambiguous / empty → inconclusive (the gate fails open on these)
assert qg.parse_verdict("") == "inconclusive"
assert qg.parse_verdict(None) == "inconclusive"
assert qg.parse_verdict("MAYBE") == "inconclusive"
assert qg.parse_verdict("PASS FAIL") == "fail" # both tokens → the LAST is the verdict (reasoning judges deliberate before concluding)
assert qg.parse_verdict("failure to load") == "inconclusive" # not a whole-token FAIL
def test_enabled_off_switch(monkeypatch):
for off in ("off", "OFF", "0", "false", "no"):
monkeypatch.setenv("STUDIO_GATE", off)
assert qg.enabled() is False
for on in ("", "on", "1", "true", "yes"):
monkeypatch.setenv("STUDIO_GATE", on)
assert qg.enabled() is True
monkeypatch.delenv("STUDIO_GATE", raising=False)
assert qg.enabled() is True # default ON
def test_config_defaults_and_overrides(monkeypatch):
monkeypatch.delenv("STUDIO_GATE_MODEL", raising=False)
monkeypatch.delenv("STUDIO_GATE_URL", raising=False)
assert qg._model() == "zen-vl" # the cloud's VL model
assert qg._url().endswith("/v1/chat/completions")
monkeypatch.setenv("STUDIO_GATE_URL", "http://127.0.0.1:1234/v1/chat/completions")
assert qg._url() == "http://127.0.0.1:1234/v1/chat/completions" # local-engine override
monkeypatch.setenv("STUDIO_GATE_MODEL", "zen-vl-local")
assert qg._model() == "zen-vl-local"
def _const(v):
async def j(images, bearer, prompt):
return v
return j
def _seed(tmp_path, status="draft", org="acme"):
from PIL import Image
root = tmp_path / "orgs" / org / "output"
(root / "renders").mkdir(parents=True)
Image.new("RGB", (8, 8), (200, 150, 120)).save(root / "renders" / "r.png", "PNG")
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "renders/r.png", "status": status}]}))
return root
def _status(root, rel="renders/r.png"):
lib = json.loads((root / "library.json").read_text())
return next(a["status"] for a in lib["assets"] if a["path"] == rel)
@pytest.mark.asyncio
async def test_run_flags_on_fail_and_annotates_worklog(tmp_path, monkeypatch):
root = _seed(tmp_path)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
worklog.record("acme", kind="render", prompt="", refs=[], uploads=[], parents=[],
output_prefix="renders/r.png", node="spark", status="done")
seen = {}
async def fake_judge(images, bearer, prompt):
seen["b64"], seen["bearer"], seen["prompt"] = images, bearer, prompt
return "FAIL - mottled, speckled skin all over the arms and shoulders here"
monkeypatch.setattr(qg, "judge", fake_judge)
await qg.run("acme", root, "renders/r.png", "Bearer t")
assert seen["b64"] and seen["bearer"] == "Bearer t" # downscaled + same bearer
assert _status(root) == "flagged" # asset hidden from default grid
row = next(r for r in worklog.load("acme") if r.get("output_prefix") == "renders/r.png")
ev = [e for e in row.get("events", []) if e.get("s") == "flagged"]
assert len(ev) == 1 and ev[0]["note"].startswith("quality gate: FAIL")
assert len(ev[0]["note"]) <= len("quality gate: ") + 80 # first 80 chars of the reply
assert row["status"] == "done" # worklog lifecycle untouched — annotation only
@pytest.mark.asyncio
async def test_run_fails_open_on_pass_inconclusive_and_error(tmp_path, monkeypatch):
root = _seed(tmp_path)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
for verdict in ("PASS", "who knows", None): # pass · inconclusive · judge error (None)
monkeypatch.setattr(qg, "judge", _const(verdict))
await qg.run("acme", root, "renders/r.png", "Bearer t")
assert _status(root) == "draft"
async def boom(images, bearer, prompt):
raise RuntimeError("429 rate limited") # 402/429/timeout/unreachable class
monkeypatch.setattr(qg, "judge", boom)
await qg.run("acme", root, "renders/r.png", "Bearer t") # must not raise
assert _status(root) == "draft" # good work is never hidden by accident
@pytest.mark.asyncio
async def test_run_never_clobbers_curated_status(tmp_path, monkeypatch):
root = _seed(tmp_path, status="approved") # a human already approved it
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(qg, "judge", _const("FAIL"))
await qg.run("acme", root, "renders/r.png", "Bearer t")
assert _status(root) == "approved" # a late FAIL must not yank curated work
@pytest.mark.asyncio
async def test_run_missing_or_unreadable_is_a_no_op(tmp_path, monkeypatch):
root = _seed(tmp_path)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
called = {"n": 0}
async def counting_judge(images, bearer, prompt):
called["n"] += 1
return "FAIL"
monkeypatch.setattr(qg, "judge", counting_judge)
await qg.run("acme", root, "renders/gone.png", "Bearer t") # no such file
assert called["n"] == 0 and _status(root) == "draft" # never judged, never flagged
@pytest.mark.asyncio
async def test_run_is_tenant_scoped(tmp_path, monkeypatch):
a_root = _seed(tmp_path, org="acme")
b_root = _seed(tmp_path, org="globex")
roots = {"acme": a_root, "globex": b_root}
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory",
lambda o=None: str(roots.get(o, a_root)))
monkeypatch.setattr(qg, "judge", _const("FAIL"))
await qg.run("acme", a_root, "renders/r.png", "Bearer t")
assert _status(a_root) == "flagged"
assert _status(b_root) == "draft" # the other tenant is untouched
def test_schedule_off_switch_never_fires(monkeypatch):
"""STUDIO_GATE=off short-circuits before touching the loop — nothing scheduled."""
monkeypatch.setenv("STUDIO_GATE", "off")
fired = {"n": 0}
monkeypatch.setattr(qg.asyncio, "ensure_future", lambda coro: fired.__setitem__("n", fired["n"] + 1))
qg.schedule(object(), "acme", Path("/tmp"), "renders/r.png")
assert fired["n"] == 0
+358
View File
@@ -0,0 +1,358 @@
"""Stacks — the Procreate-style gallery folders.
Covers the per-org SQLite store (middleware/stacks_store.py) and its wiring into
the Studio home routes: every REST route, transactional membership, tenancy
isolation, one-stack-per-asset, order persistence, and delete/unstack semantics
(remove is never delete; delete defaults to preserving; delete-images soft-deletes
in the library), plus the generation-integration routing and auto-stack setting.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import stacks_store as st
from middleware import studio_home as sh
@pytest.fixture
def org(tmp_path, monkeypatch):
"""A tmp per-org tree + a fresh connection cache — the SAME folder_paths seam
production tenancy uses, so a Stack DB lands at ``orgs/<org>/stacks.db``."""
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
for c in st._conns.values():
try:
c.close()
except Exception:
pass
st._conns.clear()
st._locks.clear()
yield "acme"
# ── store: the file lives where tenancy expects it ──────────────────────────────
def test_db_path_is_org_rooted(org, tmp_path):
st.create(org, "x", [])
assert (tmp_path / "orgs" / "acme" / "stacks.db").is_file()
# ── create: drag A onto B = a Stack with BOTH, nothing copied/merged/deleted ─────
def test_create_from_two_assets_holds_both_cover_first(org):
s = st.create(org, "Fashion", ["a.png", "b.png"])
assert s["name"] == "Fashion" and s["count"] == 2
assert [m["assetId"] for m in s["assets"]] == ["a.png", "b.png"]
assert s["coverAssetId"] == "a.png" # default cover = first image
# reloading from the DB yields the same membership (nothing copied/merged)
got = st.get(org, s["id"])
assert [m["assetId"] for m in got["assets"]] == ["a.png", "b.png"]
def test_create_defaults_untitled_and_dedupes(org):
s = st.create(org, " ", ["a.png", "a.png", "b.png"])
assert s["name"] == "Untitled Stack"
assert [m["assetId"] for m in s["assets"]] == ["a.png", "b.png"]
def test_single_image_stack_allowed(org):
s = st.create(org, "solo", ["only.png"])
assert s["count"] == 1 and st.get(org, s["id"])["count"] == 1
def test_empty_stack_allowed_for_generation_destination(org):
s = st.create(org, "New", [])
assert s["count"] == 0 and s["coverAssetId"] is None
# ── one stack per asset: adding to a second Stack MOVES it ───────────────────────
def test_one_stack_per_asset_move_on_readd(org):
a = st.create(org, "A", ["x.png", "y.png"])
b = st.create(org, "B", ["z.png"])
st.add_assets(org, b["id"], ["x.png"]) # steal x from A
assert [m["assetId"] for m in st.get(org, a["id"])["assets"]] == ["y.png"]
assert [m["assetId"] for m in st.get(org, b["id"])["assets"]] == ["z.png", "x.png"]
assert st.stack_of(org, "x.png") == b["id"]
def test_moving_the_cover_out_repoints_source_cover(org):
a = st.create(org, "A", ["cover.png", "second.png"])
assert a["coverAssetId"] == "cover.png"
b = st.create(org, "B", [])
st.add_assets(org, b["id"], ["cover.png"]) # cover leaves A
assert st.get(org, a["id"])["coverAssetId"] == "second.png"
# ── add / remove: remove returns to gallery, never deletes ───────────────────────
def test_remove_returns_asset_and_repoints_cover(org):
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
st.remove_assets(org, s["id"], ["a.png"]) # a.png was the cover
got = st.get(org, s["id"])
assert [m["assetId"] for m in got["assets"]] == ["b.png", "c.png"]
assert [m["position"] for m in got["assets"]] == [0, 1] # positions compact
assert got["coverAssetId"] == "b.png"
assert st.stack_of(org, "a.png") is None # free again, not deleted
# ── reorder: order persists and is validated against membership ──────────────────
def test_reorder_persists_and_survives_reload(org):
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
st.reorder(org, s["id"], ["c.png", "a.png", "b.png"])
assert [m["assetId"] for m in st.get(org, s["id"])["assets"]] == ["c.png", "a.png", "b.png"]
def test_reorder_rejects_wrong_set(org):
s = st.create(org, "S", ["a.png", "b.png"])
with pytest.raises(ValueError):
st.reorder(org, s["id"], ["a.png"]) # missing a member
with pytest.raises(ValueError):
st.reorder(org, s["id"], ["a.png", "b.png", "c.png"]) # foreign member
# ── rename / describe / cover / archive ─────────────────────────────────────────
def test_patch_name_description_cover(org):
s = st.create(org, "old", ["a.png", "b.png"])
p = st.patch(org, s["id"], {"name": "new", "description": "desc", "cover": "b.png"})
assert p["name"] == "new" and p["description"] == "desc" and p["coverAssetId"] == "b.png"
def test_cover_must_be_a_member(org):
s = st.create(org, "S", ["a.png"])
with pytest.raises(ValueError):
st.patch(org, s["id"], {"cover": "not-in-stack.png"})
def test_archive_hides_from_default_list(org):
s = st.create(org, "S", ["a.png"])
st.patch(org, s["id"], {"archived": True})
assert st.get(org, s["id"])["archivedAt"] is not None
assert s["id"] not in [x["id"] for x in st.list_stacks(org, include_archived=False)]
assert s["id"] in [x["id"] for x in st.list_stacks(org, include_archived=True)]
# ── unstack: container gone, EVERY asset preserved ──────────────────────────────
def test_unstack_preserves_all_assets(org):
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
freed = st.unstack(org, s["id"])
assert freed == ["a.png", "b.png", "c.png"]
assert st.get(org, s["id"]) is None # container dissolved
assert all(st.stack_of(org, a) is None for a in freed)
# ── delete: defaults to preserving images; images-mode soft-deletes them ─────────
def test_delete_only_returns_images(org):
s = st.create(org, "S", ["a.png", "b.png"])
out = st.delete(org, s["id"], "only")
assert out["mode"] == "only" and out["assetIds"] == ["a.png", "b.png"]
assert st.get(org, s["id"]) is None
assert st.stack_of(org, "a.png") is None
def test_delete_images_reports_assets_for_soft_delete(org):
s = st.create(org, "S", ["a.png", "b.png"])
out = st.delete(org, s["id"], "images")
assert out["mode"] == "images" and set(out["assetIds"]) == {"a.png", "b.png"}
# ── generation integration: route a prefix, absorb the landed outputs ───────────
def test_route_and_absorb_assigns_all_outputs(org):
s = st.create(org, "Batch", [])
st.route_prefix(org, "renders/foo", s["id"])
# SaveImage counter form + a second output of the same job both join
assert st.absorb(org, "renders/foo_00001_.png") == s["id"]
assert st.absorb(org, "renders/foo_00002_.png") == s["id"]
got = st.get(org, s["id"])
assert [m["assetId"] for m in got["assets"]] == ["renders/foo_00001_.png", "renders/foo_00002_.png"]
assert got["coverAssetId"] == "renders/foo_00001_.png"
def test_absorb_ingested_path_and_miss(org):
s = st.create(org, "S", [])
st.route_prefix(org, "composes/bar", s["id"])
assert st.absorb(org, "composes/bar") == s["id"] # ingested-path (no counter)
assert st.absorb(org, "renders/unrelated_00001_.png") is None
# ── per-org settings (auto-stack batch generations) ─────────────────────────────
def test_settings_roundtrip(org):
assert st.get_setting(org, "auto_stack_batches", False) is False
st.set_setting(org, "auto_stack_batches", True)
assert st.get_setting(org, "auto_stack_batches", False) is True
def test_dest_stack_auto_creates_for_batch_when_enabled(org):
# explicit id wins
assert sh._dest_stack(org, "sid-123", prompt="x", multi=True, owner="", workspace=None) == "sid-123"
# off → None even for a batch
assert sh._dest_stack(org, None, prompt="a red hat", multi=True, owner="", workspace=None) is None
st.set_setting(org, "auto_stack_batches", True)
# on + single output → still None (auto-stack is for batches)
assert sh._dest_stack(org, None, prompt="x", multi=False, owner="", workspace=None) is None
# on + batch → a new '<short prompt> — <date>' Stack
sid = sh._dest_stack(org, None, prompt="a red hat on a model", multi=True, owner="u1", workspace="proj")
got = st.get(org, sid)
assert got is not None and got["name"].startswith("a red hat on a") and got["ownerId"] == "u1"
assert got["workspaceId"] == "proj"
def test_index_upload_absorbs_landed_output_into_routed_stack(org, tmp_path):
"""The real ingest point: a render routed to a Stack, then its output landing
via _index_upload, joins that Stack the ONE membership-assignment hook (and it
still returns `created` for the quality gate)."""
root = Path(sh.folder_paths.get_org_output_directory(org))
(root / "library.json").write_text(json.dumps({"assets": []}))
s = st.create(org, "Gen", [])
st.route_prefix(org, "renders/gen", s["id"])
created = sh._index_upload(org, root, "renders/gen_00001_.png",
design=None, kind="render", role=None, node="upload")
assert created is True # gate contract preserved
assert [m["assetId"] for m in st.get(org, s["id"])["assets"]] == ["renders/gen_00001_.png"]
def test_soft_delete_assets_marks_library_deleted(org, tmp_path):
root = Path(sh.folder_paths.get_org_output_directory(org))
(root / "library.json").write_text(json.dumps({"assets": [
{"path": "a.png", "status": "draft"}, {"path": "b.png", "status": "approved"}]}))
sh._soft_delete_assets(org, ["a.png"])
lib = json.loads((root / "library.json").read_text())
by = {a["path"]: a["status"] for a in lib["assets"]}
assert by == {"a.png": "deleted", "b.png": "approved"} # only the named one, files never unlinked
assert (root / "library.json").is_file()
# ══ route surface ═══════════════════════════════════════════════════════════════
class _FakeServer:
port = 1
async def _client(monkeypatch, sub="u1"):
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
@web.middleware
async def fake_auth(request, handler):
o = request.headers.get("X-Org")
if o:
request["iam_user"] = {"org_id": o, "orgs": [o], "sub": sub}
return await handler(request)
app = web.Application(middlewares=[fake_auth])
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, _FakeServer())
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
client = TestClient(ts)
await client.start_server()
return client
async def _mk(client, org, name, ids):
r = await client.post("/v1/stacks", json={"name": name, "assetIds": ids}, headers={"X-Org": org})
assert r.status == 200
return await r.json()
@pytest.mark.asyncio
async def test_routes_full_lifecycle(org, monkeypatch):
client = await _client(monkeypatch)
# create
s = await _mk(client, "acme", "Fashion", ["a.png", "b.png"])
assert s["count"] == 2 and s["ownerId"] == "u1"
sid = s["id"]
# list
lst = await (await client.get("/v1/stacks", headers={"X-Org": "acme"})).json()
assert lst["org"] == "acme" and [x["id"] for x in lst["stacks"]] == [sid]
# get
assert (await (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).json())["name"] == "Fashion"
# add
r = await client.post(f"/v1/stacks/{sid}/assets", json={"assetIds": ["c.png"]}, headers={"X-Org": "acme"})
assert [m["assetId"] for m in (await r.json())["assets"]] == ["a.png", "b.png", "c.png"]
# reorder (persists)
r = await client.patch(f"/v1/stacks/{sid}/order", json={"assetIds": ["c.png", "b.png", "a.png"]}, headers={"X-Org": "acme"})
assert [m["assetId"] for m in (await r.json())["assets"]] == ["c.png", "b.png", "a.png"]
reloaded = await (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).json()
assert [m["assetId"] for m in reloaded["assets"]] == ["c.png", "b.png", "a.png"]
# rename + cover
r = await client.patch(f"/v1/stacks/{sid}", json={"name": "Looks", "cover": "b.png"}, headers={"X-Org": "acme"})
body = await r.json()
assert body["name"] == "Looks" and body["coverAssetId"] == "b.png"
# remove (returns to gallery)
r = await client.delete(f"/v1/stacks/{sid}/assets", json={"assetIds": ["c.png"]}, headers={"X-Org": "acme"})
assert [m["assetId"] for m in (await r.json())["assets"]] == ["b.png", "a.png"]
await client.close()
@pytest.mark.asyncio
async def test_routes_unstack_and_delete_default_preserves(org, monkeypatch):
client = await _client(monkeypatch)
s = await _mk(client, "acme", "S", ["a.png", "b.png"])
r = await client.post(f"/v1/stacks/{s['id']}/unstack", headers={"X-Org": "acme"})
assert (await r.json())["assetIds"] == ["a.png", "b.png"]
assert (await client.get(f"/v1/stacks/{s['id']}", headers={"X-Org": "acme"})).status == 404
# delete default (mode=only) preserves images
s2 = await _mk(client, "acme", "S2", ["x.png"])
r = await client.delete(f"/v1/stacks/{s2['id']}", headers={"X-Org": "acme"})
j = await r.json()
assert j["mode"] == "only" and j["assetIds"] == ["x.png"]
await client.close()
@pytest.mark.asyncio
async def test_routes_delete_images_soft_deletes_library(org, monkeypatch):
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
client = await _client(monkeypatch)
s = await _mk(client, "acme", "S", ["a.png"])
r = await client.delete(f"/v1/stacks/{s['id']}?mode=images", headers={"X-Org": "acme"})
assert (await r.json())["mode"] == "images"
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0]["status"] == "deleted" # soft-deleted, file kept
await client.close()
@pytest.mark.asyncio
async def test_routes_tenancy_cross_org_404(org, monkeypatch):
client = await _client(monkeypatch)
s = await _mk(client, "acme", "Secret", ["a.png"])
sid = s["id"]
# globex sees an empty list and cannot read/patch/delete acme's Stack
assert (await (await client.get("/v1/stacks", headers={"X-Org": "globex"})).json())["stacks"] == []
assert (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "globex"})).status == 404
assert (await client.patch(f"/v1/stacks/{sid}", json={"name": "x"}, headers={"X-Org": "globex"})).status == 404
assert (await client.post(f"/v1/stacks/{sid}/assets", json={"assetIds": ["z.png"]}, headers={"X-Org": "globex"})).status == 404
assert (await client.delete(f"/v1/stacks/{sid}", headers={"X-Org": "globex"})).status == 404
assert (await client.post(f"/v1/stacks/{sid}/unstack", headers={"X-Org": "globex"})).status == 404
# acme still intact
assert (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).status == 200
await client.close()
@pytest.mark.asyncio
async def test_routes_validation_and_settings(org, monkeypatch):
client = await _client(monkeypatch)
# bad cover → 400
s = await _mk(client, "acme", "S", ["a.png"])
assert (await client.patch(f"/v1/stacks/{s['id']}", json={"cover": "ghost.png"}, headers={"X-Org": "acme"})).status == 400
# bad reorder → 400
assert (await client.patch(f"/v1/stacks/{s['id']}/order", json={"assetIds": ["a.png", "b.png"]}, headers={"X-Org": "acme"})).status == 400
# settings roundtrip
assert (await (await client.get("/v1/stacks/settings", headers={"X-Org": "acme"})).json())["autoStack"] is False
r = await client.patch("/v1/stacks/settings", json={"autoStack": True}, headers={"X-Org": "acme"})
assert (await r.json())["autoStack"] is True
# "settings" is NOT read as a stack id
assert (await client.get("/v1/stacks/settings", headers={"X-Org": "acme"})).status == 200
await client.close()
@@ -0,0 +1,934 @@
"""Unit tests for the Studio Home surface (middleware/studio_home.py)."""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
def test_reseed_randomizes_every_sampler_seed():
graph = {
"a": {"class_type": "KSampler", "inputs": {"seed": 7, "steps": 30}},
"b": {"class_type": "KSamplerAdvanced", "inputs": {"noise_seed": 7}},
"c": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x"}},
}
out = sh._reseed(json.loads(json.dumps(graph)))
assert out["a"]["inputs"]["steps"] == 30
# 2**31 space: a collision with the old seed twice over is not a thing.
assert (out["a"]["inputs"]["seed"], out["b"]["inputs"]["noise_seed"]) != (7, 7)
def test_fix_graph_keeps_recipe_invariants():
g = sh._fix_graph("ref.png", "make the straps thicker", "fixes/test")
ks = g["13"]["inputs"]
assert ks["cfg"] == 4.0 and ks["steps"] == 30 # proven floor, not the weak 3.0
assert g["12"]["class_type"] == "CFGNorm"
# The canonical Qwen edit: NO Flux-Kontext reference-method override (that
# `index_timestep_zero` graft onto Qwen conditioning was the noise-stipple cause);
# the SAME scaled reference feeds conditioning AND the VAEEncode latent. It is now a
# LOW-denoise edit (default 0.25) — never 1.0 (that is Regenerate).
assert all(n["class_type"] != "FluxKontextMultiReferenceLatentMethod" for n in g.values())
assert ks["denoise"] == sh.EDIT_DENOISE_DEFAULT == 0.25
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
assert g["6"]["inputs"]["image1"] == ["2", 0] # the scaled reference conditions the edit
assert g["10"]["inputs"]["pixels"] == ["2", 0] # the SAME scaled reference sizes the latent
assert g["2"]["class_type"] == "FluxKontextImageScale"
assert "straps thicker" in g["6"]["inputs"]["prompt"]
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
# ── Generated-image Edit path: the spec invariants (edit ≠ regenerate) ───────────
def test_edit_uses_source_latent_never_empty():
"""Edit encodes the SOURCE (LoadImage → VAEEncode) as the KSampler latent — never
an EmptyLatentImage (that would be a text-to-image regenerate)."""
g = sh._fix_graph("fix_src.png", "brighten very slightly", "fixes/e")
ks = g["13"]["inputs"]
assert g["1"]["class_type"] == "LoadImage"
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
assert not any("Empty" in n.get("class_type", "") for n in g.values())
assert sh._assert_edit_graph(g) == ""
def test_edit_denoise_defaults_near_025_and_maps_strength():
assert sh._edit_denoise(None) == 0.25
assert sh._edit_denoise(25) == 0.25 # percent
assert sh._edit_denoise(0.4) == 0.4 # fraction
assert sh._edit_denoise(10) == 0.1
assert sh._edit_denoise(100) == 0.95 # 100% clamps below a true 1.0 regen
assert sh._edit_denoise("bad") == 0.25
assert sh._fix_graph("s.png", "x", "p", denoise=0.4)["13"]["inputs"]["denoise"] == 0.4
def test_regenerate_empty_latent_is_rejected_as_an_edit():
"""A graph with EmptyLatentImage / denoise 1.0 must be refused by the edit guard —
an Edit can NEVER silently become a Regenerate."""
regen = {
"10": {"class_type": "EmptySD3LatentImage", "inputs": {}},
"13": {"class_type": "KSampler", "inputs": {"latent_image": ["10", 0], "denoise": 1.0}},
"15": {"class_type": "SaveImage", "inputs": {}},
}
assert "EmptyLatentImage" in sh._assert_edit_graph(regen)
# denoise 1.0 over a real source latent is still refused (that band is Regenerate).
hot = sh._fix_graph("s.png", "x", "p")
hot["13"]["inputs"]["denoise"] = 1.0
assert "denoise" in sh._assert_edit_graph(hot)
def test_edit_seed_reuse_is_deterministic_and_varying_still_edits_source():
a = sh._fix_graph("s.png", "x", "p", seed=123)
b = sh._fix_graph("s.png", "x", "p", seed=123)
assert a["13"]["inputs"]["seed"] == b["13"]["inputs"]["seed"] == 123 # locked seed → deterministic
c = sh._fix_graph("s.png", "x", "p", seed=999)
assert c["13"]["inputs"]["seed"] == 999
# a varied seed STILL edits the source: latent is the VAEEncode, not an empty one.
assert c[c["13"]["inputs"]["latent_image"][0]]["class_type"] == "VAEEncode"
def test_edit_prompt_preserves_and_does_not_expand():
"""The edit instruction is wrapped with a PRESERVATION directive, not creative
scene/style expansion."""
g = sh._fix_graph("s.png", "brighten very slightly", "p")
pos = g["6"]["inputs"]["prompt"].lower()
assert "brighten very slightly" in pos
assert "apply only the requested change" in pos and "leave everything else unchanged" in pos
def test_masked_edit_inpaints_and_composites_outside_pixels():
"""Edit selected area: SetLatentNoiseMask restricts sampling, ImageCompositeMasked
keeps pixels OUTSIDE the mask (the composite's destination is the original)."""
g = sh._inpaint_graph("src.png", "mask.png", "recolor the top", "fixes/m")
cts = {n["class_type"] for n in g.values()}
assert {"SetLatentNoiseMask", "ImageCompositeMasked", "VAEEncode", "LoadImage"} <= cts
ks = g["13"]["inputs"]
assert g[ks["latent_image"][0]]["class_type"] == "SetLatentNoiseMask"
comp = next(n for n in g.values() if n["class_type"] == "ImageCompositeMasked")
assert comp["inputs"]["destination"] == ["2", 0] # composite OVER the original scaled source
save = next(n for n in g.values() if n["class_type"] in sh._SAVERS)
assert save["inputs"]["images"][0] == "18" # the composited image is saved
assert sh._assert_edit_graph(g) == ""
def test_safe_asset_blocks_traversal(tmp_path):
root = tmp_path / "out"
(root / "designs").mkdir(parents=True)
(root / "designs" / "a.png").write_bytes(b"x")
(tmp_path / "secret.txt").write_text("no")
assert sh._safe_asset(root, "designs/a.png") is not None
assert sh._safe_asset(root, "../secret.txt") is None
assert sh._safe_asset(root, "designs/missing.png") is None
def test_status_vocab_matches_library_lifecycle():
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted", "flagged")
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
in_dir = tmp_path / "orgs" / "acme" / "input"
in_dir.mkdir(parents=True)
(in_dir / "ref.png").write_bytes(b"\x89PNG")
(in_dir / "shot.jpg").write_bytes(b"jpg")
(tmp_path / "secret.txt").write_text("no")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
out = sh._resolve_uploads("acme", [
"ref.png", # kept
"sub/dir/shot.jpg", # basename kept (upload names are flat)
"ref.png", # de-duped
"../secret.txt", # traversal + non-image -> dropped
"missing.png", # absent -> dropped
"notes.txt", # non-image -> dropped
42, # non-string -> dropped
])
assert out == ["ref.png", "shot.jpg"]
@pytest.mark.asyncio
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
"""A base library asset + one uploaded image is a valid 2-reference compose:
the upload is named directly (already in the input dir), the library asset is
staged, and both reach the graph no lower bound violation."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG-base")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-1"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
assert out["ok"] and out["refs"] == 2
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
if n["class_type"] == "LoadImage"]
assert "up_0001_.png" in load_names # upload referenced by its own name
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
class _FakeQueue:
def __init__(self, running=None, pending=None):
self.running = running or []
self.pending = pending or []
self.deleted = []
def get_current_queue_volatile(self):
return (self.running, self.pending)
def delete_queue_item(self, fn):
before = len(self.pending)
self.pending = [it for it in self.pending if not fn(it)]
self.deleted.append(before - len(self.pending))
class _FakeServer:
port = 1
def __init__(self, **kw):
self.prompt_queue = _FakeQueue(**kw)
def _orgaware(tmp_path, monkeypatch):
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
return outd, ind
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1", lane="gpu")
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
srv = _FakeServer()
assert sh._owns_job(srv, "acme", "p1")
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
mine = (1, "p1", {}, {"org_id": "acme"}, [])
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
srv = _FakeServer(pending=[mine, theirs])
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert sh._cancel_job(srv, "acme", "p1") == "local"
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
@pytest.mark.asyncio
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
outd, ind = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "p2"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
paths=[], uploads=["drop_9.png"])
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
rows = {r["id"]: r for r in sh.worklog.load("acme")}
assert rows["p1"]["status"] == "cancelled" # original swapped out
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
@pytest.mark.asyncio
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
with pytest.raises(sh.NotOwned):
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
@pytest.mark.asyncio
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.set_status("acme", "p1", "done")
with pytest.raises(sh.DispatchError):
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
def test_prompt_error_message_extracts_real_reason():
# model-less pod: /prompt returns a validation error — the user must SEE it.
body = {"error": {"type": "prompt_outputs_failed_validation",
"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
msg = sh._prompt_error_message(body)
assert "Prompt outputs failed validation" in msg and "not in []" in msg
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
@pytest.mark.asyncio
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-fix"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
assert out["ok"] and out["prompt_id"] == "pid-fix"
g = captured["graph"]
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
@pytest.mark.asyncio
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
the library first the '+ add a photo' single-ref path."""
org = "acme"
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-up"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
with pytest.raises(sh.DispatchError):
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
@pytest.mark.asyncio
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
carrying the REAL reason never a raw 502 the dialog swallows into 'Failed:'."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
class Srv:
port = None
srv = Srv()
routes = web.RouteTableDef()
@routes.post("/prompt")
async def prompt(request):
return web.json_response({"error": {"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name '' not in []"}}, status=400)
async def edit(request):
graph = (await request.json())["graph"]
try:
pid = await sh._queue_prompt(request, srv, graph)
return web.json_response({"prompt_id": pid})
except sh.DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
routes.post("/edit")(edit)
app = web.Application()
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
srv.port = ts.port
client = TestClient(ts)
await client.start_server()
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
body = await r.json()
assert r.status == 400 and "error" in body
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
await client.close()
@pytest.mark.asyncio
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
with pytest.raises(sh.DispatchError):
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
@pytest.mark.asyncio
async def test_home_redirect_only_hits_root_html(monkeypatch):
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
monkeypatch.setenv("STUDIO_HOME_DEFAULT", "1")
async def handler(_):
return web.Response(text="spa")
# root + html accept -> redirect to /studio
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
with pytest.raises(web.HTTPFound) as e:
await sh.home_redirect(req, handler)
assert e.value.location == "/studio"
# advanced escape hatch and non-root paths pass through untouched
for path, accept in [("/?advanced=1", "text/html"), ("/assets/x.js", "*/*"), ("/", "application/json")]:
req = make_mocked_request("GET", path, headers={"Accept": accept})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
# env off (local dev / worker pods): root is untouched
monkeypatch.delenv("STUDIO_HOME_DEFAULT")
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
# ── Shared shell: editor index injection + templates + workflow + wallet ─────────
async def _client(server, org_middleware=True):
"""A real aiohttp app with the studio-home routes and a fake-auth middleware that
stamps request['iam_user'] from an X-Org header so route tests exercise the
SAME server-side org scoping (_org_of) production uses."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
@web.middleware
async def fake_auth(request, handler):
o = request.headers.get("X-Org")
if o:
request["iam_user"] = {"org_id": o, "orgs": [o]}
return await handler(request)
app = web.Application(middlewares=[fake_auth] if org_middleware else [])
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
client = TestClient(ts)
await client.start_server()
return client
def test_editor_index_injects_shell_once(tmp_path):
root = tmp_path / "web"
root.mkdir()
(root / "index.html").write_text("<html><head></head><body><div id=app></div></body></html>")
resp = sh.editor_index(str(root))
assert resp.headers["Cache-Control"] == "no-cache"
assert '<link rel="stylesheet" href="/studio/shell.css"></head>' in resp.text
assert '<script src="/studio/shell.js" defer></script></body>' in resp.text
# idempotent: an index that already carries the shell is served unchanged
(root / "index.html").write_text(resp.text)
again = sh.editor_index(str(root))
assert again.text.count("/studio/shell.js") == 1
def test_template_api_prompt_shapes():
api = {"9": {"class_type": "KSampler", "inputs": {}}, "15": {"class_type": "SaveImage", "inputs": {}}}
assert sh._template_api_prompt({"output": api, "workflow": {"nodes": []}}) == api # editor save
assert sh._template_api_prompt(api) == api # bare API prompt
assert sh._template_api_prompt({"nodes": [{"id": 1}], "last_node_id": 1}) is None # UI-only, not runnable
assert sh._template_api_prompt({}) is None and sh._template_api_prompt("x") is None
assert sh._template_slug("Karma — Ghost Mannequin!") == "karma-ghost-mannequin"
@pytest.mark.asyncio
async def test_templates_crud_and_tenant_isolation(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
graph = {"output": {"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/x"}}}}
r = await client.post("/v1/templates", json={"name": "Ghost Mannequin", "graph": graph}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["slug"] == "ghost-mannequin"
# acme sees it (renderable — the graph carries an API prompt), fetches it by slug
r = await client.get("/v1/templates", headers={"X-Org": "acme"})
body = await r.json()
assert body["org"] == "acme" and len(body["templates"]) == 1
assert body["templates"][0]["name"] == "Ghost Mannequin" and body["templates"][0]["renderable"] is True
r = await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "acme"})
assert (await r.json())["graph"] == graph
# tenant isolation: globex's list is empty and it can't read acme's template
assert (await (await client.get("/v1/templates", headers={"X-Org": "globex"})).json())["templates"] == []
assert (await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "globex"})).status == 404
# validation
assert (await client.post("/v1/templates", json={"name": "", "graph": graph}, headers={"X-Org": "acme"})).status == 400
assert (await client.post("/v1/templates", json={"name": "x", "graph": {}}, headers={"X-Org": "acme"})).status == 400
await client.close()
@pytest.mark.asyncio
async def test_template_render_dispatches_and_rejects_ui_only(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_queue(request, server, graph):
return "pid-tmpl"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
client = await _client(_FakeServer())
runnable = {"output": {"9": {"class_type": "KSampler", "inputs": {"seed": 1}},
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/run"}}}}
await client.post("/v1/templates", json={"name": "Run Me", "graph": runnable}, headers={"X-Org": "acme"})
r = await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["prompt_id"] == "pid-tmpl"
assert sh.worklog.load("acme")[-1]["kind"] == "template" # went through the ONE funnel
# UI-only graph can't run headless -> 422 with a reason, not a silent dispatch
await client.post("/v1/templates", json={"name": "UI Only", "graph": {"nodes": [], "last_node_id": 0}}, headers={"X-Org": "acme"})
assert (await client.post("/v1/templates/render", json={"name": "ui-only"}, headers={"X-Org": "acme"})).status == 422
# cross-tenant render: globex has no such template -> 404
assert (await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_workflow_reads_embedded_graph_tenant_scoped(tmp_path, monkeypatch):
from PIL import Image
from PIL.PngImagePlugin import PngInfo
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
(root / "fixes").mkdir(parents=True, exist_ok=True)
info = PngInfo()
ui = {"nodes": [{"id": 1, "type": "SaveImage"}], "last_node_id": 1}
info.add_text("workflow", json.dumps(ui))
info.add_text("prompt", json.dumps({"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/x"}}}))
Image.new("RGB", (4, 4)).save(root / "fixes" / "x_00001_.png", "PNG", pnginfo=info)
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.record("acme", kind="fix", prompt="pending", refs=[], uploads=[], parents=[],
output_prefix="fixes/nope", pid="p2")
client = await _client(_FakeServer())
r = await client.get("/v1/workflow?id=p1", headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["format"] == "ui" and body["graph"] == ui # UI graph preferred
# not landed yet -> 404 (no graph to open)
assert (await client.get("/v1/workflow?id=p2", headers={"X-Org": "acme"})).status == 404
# cross-tenant id is never confirmed -> 404
assert (await client.get("/v1/workflow?id=p1", headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_wallet_proxies_and_scopes_org(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_balance(tok):
assert tok == "Bearer tkn"
return {"availableCents": 2500, "currency": "usd", "pendingCents": 0}
monkeypatch.setattr(sh, "_finance_balance", fake_balance)
client = await _client(_FakeServer())
r = await client.get("/v1/wallet", headers={"X-Org": "acme", "Authorization": "Bearer tkn"})
body = await r.json()
assert body == {"org": "acme", "balance": 25.0, "currency": "usd"}
# signed-out (no bearer): display-only null balance, never a fabricated number
r = await client.get("/v1/wallet", headers={"X-Org": "acme"})
assert await r.json() == {"org": "acme", "balance": None, "currency": None}
await client.close()
@pytest.mark.asyncio
async def test_library_upload_roundtrip_dedup_and_tenant_isolation(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
# library.json so _library_root resolves for the GET round-trip
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"acme-bytes"
# multipart upload as acme -> renders/r1.png
form = aiohttp.FormData()
form.add_field("image", png, filename="r1.png", content_type="image/png")
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["ok"] and body["path"] == "renders/r1.png" and not body.get("existed")
# round-trip through the org-scoped file server
g = await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})
assert g.status == 200 and (await g.read()) == png
# dedup: identical name+size -> existed, no rewrite
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="r1.png", content_type="image/png")
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()) == {"ok": True, "existed": True, "path": "renders/r1.png"}
# tenant isolation: org B never sees org A's file; its own upload is a separate tree
assert (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "globex"})).status == 404
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNG\r\n\x1a\nglobex",
headers={"X-Org": "globex", "Content-Type": "application/octet-stream"})
assert (await rg.json())["path"] == "renders/r1.png"
# org A's bytes are untouched by org B's same-named upload
assert (await (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})).read()) == png
await client.close()
@pytest.mark.asyncio
async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
hdr = {"X-Org": "acme", "Content-Type": "application/octet-stream"}
# subpath traversal -> 400
assert (await client.post("/v1/library/upload?name=x.png&subpath=../secret", data=b"\x89PNGx", headers=hdr)).status == 400
# non-image name -> 400
assert (await client.post("/v1/library/upload?name=notes.txt", data=b"hi", headers=hdr)).status == 400
# a traversal-y NAME is neutralized to its basename, lands safely under renders/
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNG\r\n\x1a\nevil", headers=hdr)
assert (await r.json())["path"] == "renders/evil.png"
assert (Path(sh.folder_paths.get_org_output_directory("acme")) / "renders" / "evil.png").is_file()
# size cap -> 413
monkeypatch.setattr(sh, "_MAX_UPLOAD", 8)
assert (await client.post("/v1/library/upload?name=big.png", data=b"0123456789", headers=hdr)).status == 413
await client.close()
@pytest.mark.asyncio
async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"render-bytes"
form = aiohttp.FormData()
form.add_field("image", png, filename="shot.png", content_type="image/png")
r = await client.post("/v1/library/upload?node=spark&design=karma&kind=render",
data=form, headers={"X-Org": "acme"})
assert (await r.json())["path"] == "renders/shot.png"
# instantly findable in Assets (library.json entry, draft)
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
a = next(x for x in lib["assets"] if x["path"] == "renders/shot.png")
assert a["status"] == "draft" and a["kind"] == "render" and a["design"] == "karma" and a["tags"] == []
# instantly findable in Queue & History with its source node + resolved output
wl = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
it = next(x for x in wl["items"] if x["output_prefix"] == "renders/shot.png")
assert it["kind"] == "render" and it["node"] == "spark" and it["status"] == "done"
assert it["output"] == "renders/shot.png" and it["lane"] == "gpu"
# tenant isolation: org B sees neither the asset nor the row
lib2 = await (await client.get("/v1/library", headers={"X-Org": "globex"})).json()
assert all(x["path"] != "renders/shot.png" for x in lib2.get("assets", []))
assert (await (await client.get("/v1/worklog", headers={"X-Org": "globex"})).json())["items"] == []
# dedup: re-upload identical bytes adds no second asset or row
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="shot.png", content_type="image/png")
r2 = await client.post("/v1/library/upload?node=spark", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()).get("existed") is True
lib3 = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
assert sum(1 for x in lib3["assets"] if x["path"] == "renders/shot.png") == 1
wl3 = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
assert sum(1 for x in wl3["items"] if x["output_prefix"] == "renders/shot.png") == 1
await client.close()
@pytest.mark.asyncio
async def test_upload_schedules_quality_gate_only_for_new_renders(tmp_path, monkeypatch):
"""The gate fires at the ingest choke point exactly when a NEW asset row is
created a fresh render, or a restored-store file with no row yet and never
again for an identical re-post (dedup). Wiring only; the judgment runs
out-of-band and is unit-tested against quality_gate.run."""
import aiohttp
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
calls = []
monkeypatch.setattr(sh.quality_gate, "schedule",
lambda request, org, r, rel: calls.append((org, rel)))
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"gate-bytes"
form = aiohttp.FormData()
form.add_field("image", png, filename="g.png", content_type="image/png")
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
assert (await r.json())["path"] == "renders/g.png"
assert calls == [("acme", "renders/g.png")] # fresh render → judged once
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="g.png", content_type="image/png")
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()).get("existed") is True
assert calls == [("acme", "renders/g.png")] # identical re-post → NOT re-judged
# a file already on disk but with no library row (restored store) → a new row → judged
png2 = b"\x89PNG\r\n\x1a\n" + b"restored"
(root / "renders" / "old.png").write_bytes(png2)
form3 = aiohttp.FormData()
form3.add_field("image", png2, filename="old.png", content_type="image/png")
r3 = await client.post("/v1/library/upload", data=form3, headers={"X-Org": "acme"})
assert (await r3.json()).get("existed") is True # bytes already there (dedup write)
assert calls == [("acme", "renders/g.png"), ("acme", "renders/old.png")]
await client.close()
@pytest.mark.asyncio
async def test_status_route_accepts_flagged_and_restores(tmp_path, monkeypatch):
"""The one lifecycle route gains ``flagged`` (so the gate's status is a first-
class one) and drives the UI's Restore back to draft. /v1/library still returns
every status the UI hides flagged; the backend never filters it away."""
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "a.png").write_bytes(b"\x89PNG")
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
client = await _client(_FakeServer())
r = await client.post("/v1/library/status", json={"path": "a.png", "status": "flagged"}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["status"] == "flagged"
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
assert next(x for x in lib["assets"] if x["path"] == "a.png")["status"] == "flagged"
r2 = await client.post("/v1/library/status", json={"path": "a.png", "status": "draft"}, headers={"X-Org": "acme"})
assert r2.status == 200 and (await r2.json())["status"] == "draft"
assert (await client.post("/v1/library/status", json={"path": "a.png", "status": "bogus"}, headers={"X-Org": "acme"})).status == 400
await client.close()
@pytest.mark.asyncio
async def test_archive_records_negative_training_signal(tmp_path, monkeypatch):
"""Curation IS training signal: archiving (status=deleted) or flagging an output
records it as a bad response vote -1 in ratings.json AND an event in feedback.jsonl
carrying the deleted/flagged status and restoring clears that AUTO-negative. The
downvote is note-less, so it trains the reward dataset WITHOUT polluting prompt
lessons (_mistakes needs note text)."""
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "a.png").write_bytes(b"\x89PNG")
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
client = await _client(_FakeServer())
r = await client.post("/v1/library/status", json={"path": "a.png", "status": "deleted"}, headers={"X-Org": "acme"})
assert r.status == 200
ratings = json.loads((root / "ratings.json").read_text())
assert ratings["a.png"]["vote"] == -1 and ratings["a.png"]["archived"] is True
assert not ratings["a.png"].get("notes") # note-less: trains, never injects a lesson
fb = [json.loads(x) for x in (root / "feedback.jsonl").read_text().splitlines() if x.strip()]
ev = [e for e in fb if e.get("output") == "a.png"][-1]
assert ev["vote"] == -1 and ev["status"] == "deleted" # deleted status rides along → negative-only
assert sh._mistakes(ratings, {"a.png"}) == [] # a bare downvote never becomes a prompt lesson
r2 = await client.post("/v1/library/status", json={"path": "a.png", "status": "draft"}, headers={"X-Org": "acme"})
assert r2.status == 200
ratings2 = json.loads((root / "ratings.json").read_text())
assert ratings2["a.png"]["vote"] == 0 and ratings2["a.png"]["archived"] is False # restore clears the auto-negative
await client.close()
@pytest.mark.asyncio
async def test_archive_respects_a_user_judgement(tmp_path, monkeypatch):
"""A downvote + note the user typed is user-owned: archiving never overwrites it and
restoring never clears it (only the AUTO archive-negative is reversible). Their note
stays an injected lesson."""
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "b.png").write_bytes(b"\x89PNG")
(root / "library.json").write_text(json.dumps({"assets": [{"path": "b.png", "status": "draft"}]}))
client = await _client(_FakeServer())
await client.post("/v1/library/rate", json={"key": "b.png", "path": "b.png", "vote": -1, "notes": "left strap missing"}, headers={"X-Org": "acme"})
await client.post("/v1/library/status", json={"path": "b.png", "status": "deleted"}, headers={"X-Org": "acme"})
await client.post("/v1/library/status", json={"path": "b.png", "status": "draft"}, headers={"X-Org": "acme"})
ratings = json.loads((root / "ratings.json").read_text())
assert ratings["b.png"]["vote"] == -1 # survives archive+restore
assert ratings["b.png"]["notes"] == "left strap missing"
assert sh._mistakes(ratings, {"b.png"}) == ["left strap missing"] # still a real lesson
await client.close()
def test_studio_home_hides_flagged_and_offers_review():
"""UI contract: flagged is hidden from the normal grid exactly like deleted, and
reachable for review + restore. /v1/library returns all statuses, so the grid is
the filter this locks the change I made to it."""
html = sh._HTML.read_text()
assert "a.status==='deleted'||a.status==='flagged'" in html # hidden from normal views
assert "FILTER='flagged'" in html # the Flagged review filter
assert "showDel?'Recover':'Restore'" in html # restore affordance
@pytest.mark.asyncio
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
# an ingested render: empty prompt, findable only by its filename / ref
sh.worklog.record("acme", kind="render", prompt="", refs=["srcphoto.png"], uploads=[],
parents=[], output_prefix="renders/model_shot.png", pid="r1", status="done")
sh.worklog.record("acme", kind="fix", prompt="brighten the sky", refs=[], uploads=[],
parents=[], output_prefix="fixes/x", pid="r2")
client = await _client(_FakeServer())
async def ids(q):
r = await client.get("/v1/worklog?q=" + q, headers={"X-Org": "acme"})
return [it["id"] for it in (await r.json())["items"]]
assert await ids("model_shot") == ["r1"] # by output filename, empty prompt
assert await ids("srcphoto") == ["r1"] # by reference
assert await ids("brighten") == ["r2"] # prompt search still works
assert await ids("nomatch") == []
await client.close()
# ── Queue/History surface: favorites, stacks, ratings-with-notes, flow templates ──
@pytest.mark.asyncio
async def test_favorites_toggle_and_tenant_isolation(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
# empty to start
assert (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json()) == {"org": "acme", "favorites": {}}
# favorite a run (key = run id) — echoes back, stamps a ts
r = await client.post("/v1/favorites", json={"key": "run-1", "favorite": True}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json()) == {"ok": True, "key": "run-1", "favorite": True}
favs = (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]
assert "run-1" in favs and isinstance(favs["run-1"]["ts"], int)
# favorite defaults to True when omitted
await client.post("/v1/favorites", json={"key": "run-2"}, headers={"X-Org": "acme"})
assert set((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == {"run-1", "run-2"}
# un-favorite removes it (the heart toggles both ways)
await client.post("/v1/favorites", json={"key": "run-1", "favorite": False}, headers={"X-Org": "acme"})
assert list((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == ["run-2"]
# validation: empty key -> 400
assert (await client.post("/v1/favorites", json={"key": ""}, headers={"X-Org": "acme"})).status == 400
# tenant isolation: globex sees none of acme's favorites
assert (await (await client.get("/v1/favorites", headers={"X-Org": "globex"})).json())["favorites"] == {}
await client.close()
# Stacks are now the per-org SQLite store (Procreate folders) — the full REST
# surface, tenancy, one-stack-per-asset, order persistence and delete semantics
# live in test_stacks.py, which supersedes the earlier JSON whole-set store.
@pytest.mark.asyncio
async def test_rate_stars_notes_path_and_tenant_isolation(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
# rate resolves via _library_root -> the org needs a library.json
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
client = await _client(_FakeServer())
# 0-5 stars + notes + the run's output path, keyed by run id (the flywheel row)
r = await client.post("/v1/library/rate", json={
"key": "run-1", "stars": 4, "notes": "hands look off", "path": "fixes/x_00001_.png"},
headers={"X-Org": "acme"})
assert r.status == 200
entry = (await r.json())["entry"]
assert entry["stars"] == 4 and entry["notes"] == "hands look off" and entry["path"] == "fixes/x_00001_.png"
# visible on reopen; a note-only update keeps the stars (partial update)
await client.post("/v1/library/rate", json={"key": "run-1", "notes": "better now"}, headers={"X-Org": "acme"})
ratings = await (await client.get("/v1/library/ratings", headers={"X-Org": "acme"})).json()
assert ratings["run-1"]["stars"] == 4 and ratings["run-1"]["notes"] == "better now"
# a 0-star rating is valid (0-5 inclusive)
assert (await client.post("/v1/library/rate", json={"key": "run-1", "stars": 0}, headers={"X-Org": "acme"})).status == 200
# validation: out of range -> 400; missing key -> 400
assert (await client.post("/v1/library/rate", json={"key": "r", "stars": 6}, headers={"X-Org": "acme"})).status == 400
assert (await client.post("/v1/library/rate", json={"key": "", "stars": 3}, headers={"X-Org": "acme"})).status == 400
# tenant isolation: globex never sees acme's rating
assert (await (await client.get("/v1/library/ratings", headers={"X-Org": "globex"})).json()) == {}
await client.close()
@pytest.mark.asyncio
async def test_template_accepts_flow_payload_not_just_graph(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
# a flow template (no node graph) — what "favorite -> turn into a template" saves
flow = {"kind": "fix", "prompt": "make the straps thicker", "refs": ["a.png"], "parents": ["a.png"]}
r = await client.post("/v1/templates", json={"name": "Thicken Straps", "flow": flow}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["slug"] == "thicken-straps"
# lists (not renderable — no runnable graph) and round-trips the captured flow
lst = (await (await client.get("/v1/templates", headers={"X-Org": "acme"})).json())["templates"]
assert lst[0]["name"] == "Thicken Straps" and lst[0]["renderable"] is False
got = await (await client.get("/v1/templates/thicken-straps", headers={"X-Org": "acme"})).json()
assert got["flow"] == flow and "graph" not in got
# neither graph nor flow -> 400 (a template must capture something)
assert (await client.post("/v1/templates", json={"name": "empty"}, headers={"X-Org": "acme"})).status == 400
await client.close()
@pytest.mark.asyncio
async def test_critique_persists_thread_and_records_lesson(tmp_path, monkeypatch):
"""A critique message: 200, replies (canned fallback when the vision call is
unavailable), persists a user+assistant thread on the image, and writes the
user's words as the note _mistakes reads — so critiquing trains the next render."""
from middleware import quality_gate as qg
monkeypatch.setattr(qg, "_downscale", lambda p: None) # skip the network vision call
outd, _ = _orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
(root / "fixes").mkdir(parents=True, exist_ok=True)
from PIL import Image
Image.new("RGB", (8, 8)).save(root / "fixes" / "x_00001_.png", "PNG")
client = await _client(_FakeServer())
rel = "fixes/x_00001_.png"
r = await client.post("/v1/library/critique", json={"path": rel, "message": "skin is too orange"}, headers={"X-Org": "acme"})
assert r.status == 200
body = await r.json()
assert body["reply"] and len(body["thread"]) == 2
assert body["thread"][0]["role"] == "user" and body["thread"][1]["role"] == "assistant"
# the note the flywheel reads carries the critique; reopening returns the thread
ratings = await (await client.get("/v1/library/ratings", headers={"X-Org": "acme"})).json()
assert "skin is too orange" in ratings[rel]["notes"]
got = await (await client.get("/v1/library/critique?path=" + rel, headers={"X-Org": "acme"})).json()
assert len(got["thread"]) == 2
# guards: unknown asset 404, missing fields 400, tenant isolation
assert (await client.post("/v1/library/critique", json={"path": "nope.png", "message": "x"}, headers={"X-Org": "acme"})).status == 404
assert (await client.post("/v1/library/critique", json={"path": rel, "message": ""}, headers={"X-Org": "acme"})).status == 400
other = await (await client.get("/v1/library/critique?path=" + rel, headers={"X-Org": "globex"})).json()
assert other["thread"] == []
await client.close()
@pytest.mark.asyncio
async def test_upload_name_sanitized_against_html_injection(tmp_path, monkeypatch):
"""A hostile upload filename (HTML/JS metacharacters) is collapsed to a safe
charset at ingest the stored path can never carry a stored-XSS payload."""
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
r = await client.post('/v1/library/upload?name=<img src=x onerror=alert(1)>.png', data=png, headers={"X-Org": "acme"})
assert r.status == 200
path = (await r.json())["path"]
assert "<" not in path and ">" not in path and '"' not in path and "'" not in path
assert path.endswith(".png")
await client.close()
@pytest.mark.asyncio
async def test_thumbnail_serves_image_content_type(tmp_path, monkeypatch):
"""The grid thumbnail MUST carry an image Content-Type. aiohttp's FileResponse
guesses from the extension via mimetypes, which does not know .webp, so it served
application/octet-stream which the browser refuses to render (blank grid,
'assets won't load'). Pin it."""
from PIL import Image
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
(root / "renders").mkdir(parents=True, exist_ok=True)
Image.new("RGB", (64, 96)).save(root / "renders" / "shot.png", "PNG")
client = await _client(_FakeServer())
r = await client.get("/v1/library/file?thumb=1&path=renders/shot.png", headers={"X-Org": "acme"})
assert r.status == 200
assert r.headers.get("Content-Type") == "image/webp" # NOT application/octet-stream
await client.close()
@@ -0,0 +1,53 @@
"""Stored-XSS regression for the queue / worklog rows (middleware/studio_home.html).
A malicious job field j.node ( X-Target-GPU), j.prefix/title ( SaveImage prefix),
desc ( prompt text), j.id ( client prompt_id) must render INERT. Two layers:
(1) executable: the file's OWN esc() neutralizes an <img onerror> payload (run under
node; skipped only if node is unavailable);
(2) static: the row builders route every field through esc() and drive row actions via
esc'd data-* attributes + one delegated listener — never a raw-concatenated onclick.
"""
import re
from pathlib import Path
HTML = (Path(__file__).resolve().parents[2] / "middleware" / "studio_home.html").read_text()
PAYLOAD = "<img src=x onerror=alert(1)>"
def test_file_esc_map_neutralizes_payload():
"""Apply the FILE's OWN esc() character map (extracted from studio_home.html) to the
payload and assert it renders INERT every '<'/'>' neutralized, no live tag survives.
Pure-Python (no node dep) so it runs in CI; weakening the map (e.g. dropping '<')
fails here."""
m = re.search(r"const esc=.*?replace\(/\[([^\]]+)\]/g,\s*c=>\((\{[^}]+\})", HTML)
assert m, "esc() helper/map not found"
charclass, mapsrc = m.group(1), m.group(2)
emap = dict(re.findall(r"'(.)':'([^']+)'", mapsrc))
assert {"<", ">", "&", '"'} <= set(charclass) and {"<", ">"} <= emap.keys()
out = "".join(emap.get(ch, ch) for ch in PAYLOAD)
assert "<img" not in out and "<" not in out # every '<' neutralized
assert emap["<"] in out # escaped entity present, inert text
def test_gjRow_escapes_fields_and_delegates_cancel():
assert "${esc(j.prefix||'render')}" in HTML # SaveImage prefix escaped
assert "esc((j.node&&j.node!=='queued')?j.node:'your GPU')" in HTML # X-Target-GPU/identity escaped
assert 'data-cancel="${esc(j.id)}"' in HTML # id via esc'd data attr
def test_qitem_escapes_title_desc_and_delegates_cancel():
assert "${esc(title)}" in HTML
assert "${esc(desc)||'(no description)'}" in HTML
assert 'data-cancel="${esc(id)}"' in HTML
def test_no_raw_id_onclick_sinks_remain():
for sink in ("cancelJob('${j.id}')", "cancelJob('${id}')", "cancelJob('${it.id}')",
"openAmend('${it.id}')", "openContext('${it.id}')", "delWork('${it.id}')"):
assert sink not in HTML, f"raw client-id onclick sink still present: {sink}"
def test_one_delegated_listener_wires_data_actions():
assert "t.closest('[data-cancel]')" in HTML
assert "cancelJob(el.getAttribute('data-cancel'))" in HTML
assert "openAmend(el.getAttribute('data-amend'))" in HTML
@@ -0,0 +1,744 @@
{
"id": "hanzo-full-generative-pipeline",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "HanzoChat",
"pos": [
40,
40
],
"size": [
420,
340
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": null
},
{
"name": "system",
"type": "STRING",
"widget": {
"name": "system"
},
"link": null
},
{
"name": "temperature",
"type": "FLOAT",
"widget": {
"name": "temperature"
},
"link": null
},
{
"name": "max_tokens",
"type": "INT",
"widget": {
"name": "max_tokens"
},
"link": null
},
{
"name": "stream",
"type": "BOOLEAN",
"widget": {
"name": "stream"
},
"link": null
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": [
1,
2,
3,
4
]
}
],
"properties": {
"Node name for S&R": "HanzoChat"
},
"widgets_values": [
"default",
"/no_think Write one vivid comma-separated image prompt for a majestic bronze dragon perched on a cliff at golden hour. Output only the prompt.",
"You are a concise text-to-image prompt engineer.",
0.4,
200,
true
]
},
{
"id": 2,
"type": "HanzoImageGen",
"pos": [
500,
40
],
"size": [
340,
220
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": 1
},
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "n",
"type": "INT",
"widget": {
"name": "n"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
5,
6
]
}
],
"properties": {
"Node name for S&R": "HanzoImageGen"
},
"widgets_values": [
"",
1024,
1024,
"default",
1
]
},
{
"id": 3,
"type": "HanzoImageTo3D",
"pos": [
880,
40
],
"size": [
320,
200
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 5
},
{
"name": "format",
"type": "COMBO",
"widget": {
"name": "format"
},
"link": null
},
{
"name": "texture",
"type": "BOOLEAN",
"widget": {
"name": "texture"
},
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
}
],
"outputs": [
{
"name": "mesh_path",
"type": "STRING",
"links": []
}
],
"properties": {
"Node name for S&R": "HanzoImageTo3D"
},
"widgets_values": [
"glb",
true,
0,
30
]
},
{
"id": 4,
"type": "HanzoTextToVideo",
"pos": [
500,
320
],
"size": [
340,
260
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": 2
},
{
"name": "num_frames",
"type": "INT",
"widget": {
"name": "num_frames"
},
"link": null
},
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "HanzoTextToVideo"
},
"widgets_values": [
"",
81,
832,
480,
30,
"default"
]
},
{
"id": 5,
"type": "HanzoTTS",
"pos": [
500,
620
],
"size": [
340,
160
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 3
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "response_format",
"type": "COMBO",
"widget": {
"name": "response_format"
},
"link": null
}
],
"outputs": [
{
"name": "audio",
"type": "AUDIO",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "HanzoTTS"
},
"widgets_values": [
"default",
"wav"
]
},
{
"id": 6,
"type": "HanzoMusic",
"pos": [
40,
620
],
"size": [
420,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "response_format",
"type": "COMBO",
"widget": {
"name": "response_format"
},
"link": null
}
],
"outputs": [
{
"name": "audio",
"type": "AUDIO",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "HanzoMusic"
},
"widgets_values": [
"epic cinematic orchestral, driving percussion, brass swells, heroic",
"default",
"wav"
]
},
{
"id": 7,
"type": "SaveImage",
"pos": [
1240,
40
],
"size": [
380,
380
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 6
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"hanzo_full_pipeline_still"
]
},
{
"id": 8,
"type": "SaveImage",
"pos": [
880,
320
],
"size": [
340,
300
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 7
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"hanzo_full_pipeline_video_frames"
]
},
{
"id": 9,
"type": "SaveAudio",
"pos": [
880,
620
],
"size": [
320,
140
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "audio",
"type": "AUDIO",
"link": 8
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveAudio"
},
"widgets_values": [
"audio/hanzo_full_pipeline_narration"
]
},
{
"id": 10,
"type": "SaveAudio",
"pos": [
40,
840
],
"size": [
420,
140
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "audio",
"type": "AUDIO",
"link": 9
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveAudio"
},
"widgets_values": [
"audio/hanzo_full_pipeline_music"
]
},
{
"id": 11,
"type": "HanzoSaveText",
"pos": [
1240,
460
],
"size": [
340,
160
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 4
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "HanzoSaveText"
},
"widgets_values": [
"hanzo_full_pipeline_prompt"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"STRING"
],
[
2,
1,
0,
4,
0,
"STRING"
],
[
3,
1,
0,
5,
0,
"STRING"
],
[
4,
1,
0,
11,
0,
"STRING"
],
[
5,
2,
0,
3,
0,
"IMAGE"
],
[
6,
2,
0,
7,
0,
"IMAGE"
],
[
7,
4,
0,
8,
0,
"IMAGE"
],
[
8,
5,
0,
9,
0,
"AUDIO"
],
[
9,
6,
0,
10,
0,
"AUDIO"
]
],
"groups": [
{
"title": "Prompt",
"bounding": [
20,
0,
460,
380
],
"color": "#3f789e"
},
{
"title": "Image + 3D",
"bounding": [
480,
0,
1140,
300
],
"color": "#a1309b"
},
{
"title": "Video",
"bounding": [
480,
300,
740,
320
],
"color": "#b58b2a"
},
{
"title": "Audio (speech + music)",
"bounding": [
20,
600,
1180,
400
],
"color": "#2a7d4f"
}
],
"config": {},
"extra": {},
"version": 0.4
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
# Studio web — the Hanzo Studio product frontend
Our unique studio frontend: a specialized **chat app** on the shared Hanzo stack.
You say what to create; the cloud `/v1/chat` orchestrator drives studio's render
tools and the outputs land in the library. This is the default surface at
`studio.hanzo.ai/studio`.
- **Stack**: Vite + React + TS. `@hanzo/*` packages are npm-published, so a clean
`npm install` — no workspace linking.
- **Chat**: `POST api.hanzo.ai/v1/chat {capability, messages}``{reply, actions, ops}`
— the cloud `clients/chat` orchestrator, called **directly** (no studio proxy). The
`hanzo_token` cookie is `.hanzo.ai`-scoped, so `credentials:'include'` carries it
cross-origin and the handler bills the caller org. (Gateway CORS must allow the
`studio.hanzo.ai` origin with credentials.)
- **Engine**: the studio pod serves `/v1/session`, `/v1/library`, `/v1/render-queue`,
`/v1/gpu-status` same-origin (same cookie) — no token handling in the browser.
```bash
npm install
npm run build # -> dist/ (served at /studio; base = /studio/)
npm run dev # local, set VITE_STUDIO_API=https://studio.hanzo.ai
```
The ComfyUI-derived node editor ("Advanced mode") is a **separate** repo —
`hanzoai/studio-ui` (formerly `studio-frontend`) — not this app.
## Status / next
- Increment 1 (done): chat wired to `/v1/chat` create + live library/queue/gpu strip.
- Increment 2: swap the transcript/composer for `@hanzo/chat` `<Chat>` on `@hanzo/gui`
(GuiProvider + SessionProvider, copying console2's `Provider.tsx` spine).
- Wiring: Dockerfile multi-stage to build `dist/` and serve it at `/studio` in
place of `middleware/studio_home.html`. (No proxy — chat hits api.hanzo.ai direct.)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Hanzo Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24 -22
View File
@@ -7,6 +7,9 @@
<meta name="color-scheme" content="dark">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- Unified Hanzo shell chrome (header + pre-footer + footer). Self-contained
bundle built from web/shell (@hanzogui/shell), served at /marketing. -->
<link rel="stylesheet" href="/marketing/shell.css">
<style>
/* Hanzo Studio sign-in — matches console.hanzo.ai's design system:
* monochrome true-black surfaces, hairline borders, 9px radii, a white
@@ -36,15 +39,22 @@
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
line-height: 1.5;
}
/* Growing hero that vertically centers the sign-in card between the
unified header and pre-footer/footer. */
.hero {
flex: 1 0 auto;
display: flex;
align-items: center;
justify-content: center;
padding: 64px 24px;
}
.card {
width: 100%;
max-width: 380px;
@@ -96,19 +106,15 @@
.footnote { font-size: 12px; color: var(--muted); text-align: center; }
.footnote a { color: var(--text-2); text-decoration: none; transition: color 0.15s; }
.footnote a:hover { color: var(--text); }
.product {
margin-top: 16px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
.product a { color: var(--text-2); text-decoration: none; transition: color 0.15s; }
.product a:hover { color: var(--text); }
.product .sep { opacity: 0.5; padding: 0 8px; }
</style>
</head>
<body>
<!-- Unified Hanzo marketing header (surface: studio.hanzo.ai). Rendered by
/marketing/shell.js; the sign-in card below is the progressive-enhancement
fallback and the primary auth action. -->
<div id="hanzo-header"></div>
<main class="hero">
<div class="card">
<div class="head">
<svg class="mark" viewBox="0 0 67 67" role="img" aria-label="Hanzo" xmlns="http://www.w3.org/2000/svg">
@@ -136,15 +142,11 @@
<a href="https://console.hanzo.ai">Create an account</a>.
</p>
</div>
</main>
<p class="product">
<a href="https://docs.hanzo.ai/docs/studio">Docs</a>
<span class="sep">·</span>
<a href="https://github.com/hanzoai/studio">GitHub</a>
<span class="sep">·</span>
<a href="https://console.hanzo.ai">Console</a>
<span class="sep">·</span>
<a href="https://discord.gg/hanzoai">Discord</a>
</p>
<!-- Unified Hanzo pre-footer CTA + ecosystem footer (rendered by shell.js). -->
<div id="hanzo-footer"></div>
<script type="module" src="/marketing/shell.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
:root{--hanzo-black: #0a0a0b;--hanzo-black-rgb: 10, 10, 11;--hanzo-white: #ffffff;--hanzo-white-rgb: 255, 255, 255;--hanzo-mono-50: #fafafa;--hanzo-mono-100: #f5f5f5;--hanzo-mono-200: #e5e5e5;--hanzo-mono-300: #d4d4d4;--hanzo-mono-400: #a3a3a3;--hanzo-mono-500: #737373;--hanzo-mono-600: #525252;--hanzo-mono-700: #404040;--hanzo-mono-800: #262626;--hanzo-mono-900: #171717;--hanzo-mono-950: #0a0a0a;--brand: var(--hanzo-black);--brand-light: var(--hanzo-mono-800);--brand-dark: #000000;--brand-hover: var(--hanzo-mono-900);--brand-secondary: var(--hanzo-mono-600);--bg-primary: #0a0a0a;--bg-secondary: #141414;--bg-tertiary: #1a1a1a;--bg-card: rgba(23, 23, 23, .5);--bg-light: #ffffff;--bg-light-secondary: #fafafa;--bg-light-tertiary: #f5f5f5;--border: #262626;--border-light: #e5e5e5;--border-focus: var(--hanzo-black);--text-primary: #fafafa;--text-secondary: #a3a3a3;--text-muted: #737373;--text-disabled: #525252;--text-light-primary: #0a0a0b;--text-light-secondary: #525252;--text-light-muted: #737373;--neutral-0: #ffffff;--neutral-50: #fafafa;--neutral-100: #f5f5f5;--neutral-200: #e5e5e5;--neutral-300: #d4d4d4;--neutral-400: #a3a3a3;--neutral-500: #737373;--neutral-600: #525252;--neutral-700: #404040;--neutral-800: #262626;--neutral-900: #171717;--neutral-950: #0a0a0a;--neutral-1000: #000000;--success: #10b981;--success-light: #34d399;--success-dark: #059669;--warning: #f59e0b;--warning-light: #fcd34d;--warning-dark: #d97706;--error: #ef4444;--error-light: #f87171;--error-dark: #dc2626;--info: #3b82f6;--info-light: #60a5fa;--info-dark: #2563eb;--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-5: 1.25rem;--space-6: 1.5rem;--space-8: 2rem;--space-10: 2.5rem;--space-12: 3rem;--space-16: 4rem;--space-20: 5rem;--space-24: 6rem;--radius-sm: .125rem;--radius: .25rem;--radius-md: .375rem;--radius-lg: .5rem;--radius-xl: .75rem;--radius-2xl: 1rem;--radius-full: 9999px;--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / .05);--shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--transition-fast: .15s cubic-bezier(.4, 0, .2, 1);--transition: .2s cubic-bezier(.4, 0, .2, 1);--transition-slow: .3s cubic-bezier(.4, 0, .2, 1);--font-sans: "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono: "Geist Mono", Monaco, monospace}[data-theme=dark],.dark{color-scheme:dark}[data-theme=light],.light{--bg-primary: var(--bg-light);--bg-secondary: var(--bg-light-secondary);--bg-tertiary: var(--bg-light-tertiary);--bg-card: rgba(255, 255, 255, .8);--border: var(--border-light);--text-primary: var(--text-light-primary);--text-secondary: var(--text-light-secondary);--text-muted: var(--text-light-muted);color-scheme:light}
+54
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+1888
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@hanzo/studio-chat",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Studio as a specialized chat app — @hanzo/ai + @hanzo/gui talking to the cloud /v1/chat orchestrator, served at studio.hanzo.ai/studio.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.7"
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* Builds the self-contained unified-Hanzo-shell bundle for the studio.hanzo.ai
* logged-out page. Output (../marketing/shell.{js,css}) is committed and served
* by the Python app at /marketing. Rebuild after bumping @hanzogui/shell:
* pnpm install && pnpm build
*/
import { build } from 'esbuild'
await build({
entryPoints: ['src/mount.jsx'],
bundle: true,
minify: true,
format: 'esm',
jsx: 'automatic',
target: 'es2020',
// Production React: prunes dev-only warnings and removes bare `process` refs
// (there is no `process` in the browser).
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
outfile: '../marketing/shell.js',
logLevel: 'info',
})
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@hanzo/studio-marketing-shell",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Builds the self-contained unified-Hanzo-shell bundle (header + pre-footer + footer) served on the studio.hanzo.ai logged-out page. Output is committed to ../marketing and served by the Python app at /marketing.",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@hanzo/brand": "^1.4.1",
"@hanzogui/shell": "^7.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@hanzo/iam": "^0.13.1",
"esbuild": "^0.24.2"
}
}
Generated Vendored
+437
View File
@@ -0,0 +1,437 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@hanzo/brand':
specifier: ^1.4.1
version: 1.4.1(react@18.3.1)
'@hanzogui/shell':
specifier: ^7.4.2
version: 7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@hanzo/iam':
specifier: ^0.13.1
version: 0.13.8(react@18.3.1)
esbuild:
specifier: ^0.24.2
version: 0.24.2
packages:
'@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.2':
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.2':
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.2':
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.2':
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.2':
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.2':
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.2':
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.2':
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.2':
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.2':
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.2':
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.2':
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.24.2':
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.2':
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.2':
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.2':
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.2':
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@hanzo/brand@1.4.1':
resolution: {integrity: sha512-tApXct8gE/HOYa61h6TWQfDp07l1pmB+vqmxlJtU+MhocbwEkW4VPJrQdPuwF/5GPZYQauNT2uaRpnZdCK8ikw==}
hasBin: true
peerDependencies:
react: '>=18'
peerDependenciesMeta:
react:
optional: true
'@hanzo/iam@0.13.8':
resolution: {integrity: sha512-6D4lZSSuTBWV/bof5u5l6VEE0VZhNnpF99svirmHTRko5MsZ5sEd3+7xZ28KH0UL0oquvHwRJ6tkvn+E+f5YuQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=17'
peerDependenciesMeta:
react:
optional: true
'@hanzo/logo@1.0.13':
resolution: {integrity: sha512-94VA6S7oVY5UZ816295Ez12p8a76ZGEct14JQwE6r5UCutD3K9TBgx5V01gjwxwVL9tWnXn/yut4PHRAY/9RNQ==}
peerDependencies:
react: '>=16.8.0'
peerDependenciesMeta:
react:
optional: true
'@hanzogui/shell@7.4.2':
resolution: {integrity: sha512-Haig4FLJUAgNbHvYxgsX/Y4D+VZW9fzvYoYi6Z19ZKkYCXnFG6S0wQmwZhqFW5KCmlnwUV7jUSJw9vOdvqj7nw==}
peerDependencies:
'@hanzo/iam': ^0.13.1
react: '*'
react-dom: '*'
base64url@3.0.1:
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
engines: {node: '>=6.0.0'}
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
hasBin: true
jose@6.2.4:
resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
libphonenumber-js@1.13.9:
resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
oauth@0.10.2:
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
passport-oauth2@1.8.0:
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
engines: {node: '>= 0.4.0'}
passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
snapshots:
'@esbuild/aix-ppc64@0.24.2':
optional: true
'@esbuild/android-arm64@0.24.2':
optional: true
'@esbuild/android-arm@0.24.2':
optional: true
'@esbuild/android-x64@0.24.2':
optional: true
'@esbuild/darwin-arm64@0.24.2':
optional: true
'@esbuild/darwin-x64@0.24.2':
optional: true
'@esbuild/freebsd-arm64@0.24.2':
optional: true
'@esbuild/freebsd-x64@0.24.2':
optional: true
'@esbuild/linux-arm64@0.24.2':
optional: true
'@esbuild/linux-arm@0.24.2':
optional: true
'@esbuild/linux-ia32@0.24.2':
optional: true
'@esbuild/linux-loong64@0.24.2':
optional: true
'@esbuild/linux-mips64el@0.24.2':
optional: true
'@esbuild/linux-ppc64@0.24.2':
optional: true
'@esbuild/linux-riscv64@0.24.2':
optional: true
'@esbuild/linux-s390x@0.24.2':
optional: true
'@esbuild/linux-x64@0.24.2':
optional: true
'@esbuild/netbsd-arm64@0.24.2':
optional: true
'@esbuild/netbsd-x64@0.24.2':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
'@esbuild/openbsd-x64@0.24.2':
optional: true
'@esbuild/sunos-x64@0.24.2':
optional: true
'@esbuild/win32-arm64@0.24.2':
optional: true
'@esbuild/win32-ia32@0.24.2':
optional: true
'@esbuild/win32-x64@0.24.2':
optional: true
'@hanzo/brand@1.4.1(react@18.3.1)':
dependencies:
'@hanzo/logo': 1.0.13(react@18.3.1)
optionalDependencies:
react: 18.3.1
'@hanzo/iam@0.13.8(react@18.3.1)':
dependencies:
jose: 6.2.4
libphonenumber-js: 1.13.9
passport-oauth2: 1.8.0
optionalDependencies:
react: 18.3.1
'@hanzo/logo@1.0.13(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@hanzogui/shell@7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@hanzo/iam': 0.13.8(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
base64url@3.0.1: {}
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
'@esbuild/android-arm': 0.24.2
'@esbuild/android-arm64': 0.24.2
'@esbuild/android-x64': 0.24.2
'@esbuild/darwin-arm64': 0.24.2
'@esbuild/darwin-x64': 0.24.2
'@esbuild/freebsd-arm64': 0.24.2
'@esbuild/freebsd-x64': 0.24.2
'@esbuild/linux-arm': 0.24.2
'@esbuild/linux-arm64': 0.24.2
'@esbuild/linux-ia32': 0.24.2
'@esbuild/linux-loong64': 0.24.2
'@esbuild/linux-mips64el': 0.24.2
'@esbuild/linux-ppc64': 0.24.2
'@esbuild/linux-riscv64': 0.24.2
'@esbuild/linux-s390x': 0.24.2
'@esbuild/linux-x64': 0.24.2
'@esbuild/netbsd-arm64': 0.24.2
'@esbuild/netbsd-x64': 0.24.2
'@esbuild/openbsd-arm64': 0.24.2
'@esbuild/openbsd-x64': 0.24.2
'@esbuild/sunos-x64': 0.24.2
'@esbuild/win32-arm64': 0.24.2
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
jose@6.2.4: {}
js-tokens@4.0.0: {}
libphonenumber-js@1.13.9: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
oauth@0.10.2: {}
passport-oauth2@1.8.0:
dependencies:
base64url: 3.0.1
oauth: 0.10.2
passport-strategy: 1.0.0
uid2: 0.0.4
utils-merge: 1.0.1
passport-strategy@1.0.0: {}
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
uid2@0.0.4: {}
utils-merge@1.0.1: {}
+55
View File
@@ -0,0 +1,55 @@
/*
* Mounts the unified Hanzo shell onto the studio.hanzo.ai logged-out page.
*
* The header, pre-footer CTA and footer are the ONE canonical chrome shipped in
* `@hanzogui/shell` consumed as data from the "studio.hanzo.ai" surface, never
* reimplemented here. `@hanzo/brand` supplies the CSS custom properties the shell
* themes from. esbuild bundles React + the three marketing components into one
* self-contained ESM asset (`../marketing/shell.{js,css}`) served at /marketing.
*
* Only the three logged-out components are imported, so the signed-in graph
* (HanzoAppHeader useTenantAuth @hanzo/iam) is tree-shaken away.
*/
import { createRoot } from 'react-dom/client'
import { HanzoHeader, HanzoPreFooterCTA, HanzoFooter } from '@hanzogui/shell'
import '@hanzo/brand/styles/variables.css'
const SURFACE = 'studio.hanzo.ai'
// The header's account slot: a same-origin "Sign in" link. `/login` is the OIDC
// entry point the server's handle_login decides framing-aware connect-card vs
// IdP 302 so we never build the IAM URL client-side.
const account = (
<a
href="/login"
style={{
display: 'inline-flex',
alignItems: 'center',
height: 34,
padding: '0 16px',
borderRadius: 9,
background: 'var(--hanzo-white, #ffffff)',
color: '#000',
fontSize: 14,
fontWeight: 600,
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
Sign in
</a>
)
function mount(id, node) {
const el = document.getElementById(id)
if (el) createRoot(el).render(node)
}
mount('hanzo-header', <HanzoHeader surface={SURFACE} account={account} />)
mount(
'hanzo-footer',
<>
<HanzoPreFooterCTA surface={SURFACE} />
<HanzoFooter currentProductId="studio" />
</>,
)
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react'
import { chat, getSession, gpuOnline, type ChatMessage, type Session } from './api'
import { Rail } from './rail'
import { useLayout, startDrag, clamp, DEFAULT, RAIL_MIN, RAIL_MAX } from './layout'
// Studio-chat: the specialized chat surface. You say what to create; the cloud
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the runs
// land in the queue/history rail on the right — Up next while they render, History
// once done. The main area is the conversation / current generation; the rail is the
// whole YouTube-watch-page-style queue + history + stacks + ratings surface.
const EXAMPLES = [
'make the Valentina back crotchless',
'put design 13 on model 01',
'brighten the studio lighting',
'compose design 4 and design 9 into one look',
]
export function App() {
const [session, setSession] = useState<Session>({})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [conversationId, setConversationId] = useState<string>()
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [gpu, setGpu] = useState(false)
const [acct, setAcct] = useState(false)
const scroller = useRef<HTMLDivElement>(null)
const composer = useRef<HTMLTextAreaElement>(null)
// Layout is keyed by org+user so each person's dividers/collapse/filter persist.
const who = session.authenticated !== undefined ? `${session.org || 'default'}:${session.user || 'anon'}` : ''
const { layout, patch, toggle, reset, beginDrag, endDrag } = useLayout(who)
useEffect(() => { getSession().then(setSession) }, [])
useEffect(() => {
let alive = true
const tick = () => gpuOnline().then(on => { if (alive) setGpu(on) })
tick(); const t = setInterval(tick, 15000)
return () => { alive = false; clearInterval(t) }
}, [])
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
const ready = !!input.trim() && !busy
async function send() {
const text = input.trim()
if (!text || busy) return
const next = [...messages, { role: 'user' as const, content: text }]
setMessages(next); setInput(''); setBusy(true)
try {
const r = await chat(next, conversationId)
setConversationId(r.conversationId)
const note = r.actions.length
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `${a.name} dispatched — see Up next →`).join('\n')}`
: ''
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
} catch (e) {
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
} finally {
setBusy(false)
}
}
function insertRef(text: string) {
setInput(v => (v.trim() ? `${v.trim()} ${text}` : text) + ' ')
composer.current?.focus()
}
function useExample(ex: string) {
setInput(ex); composer.current?.focus()
}
function onVSplit(e: React.PointerEvent) {
beginDrag()
const startW = layout.railW
startDrag(e, 'x', d => patch({ railW: clamp(startW - d, RAIL_MIN, RAIL_MAX) }), endDrag)
}
const initials = (session.user || session.org || session.email || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'
return (
<div className="app">
<header className="top">
<div className="brand">Hanzo <b>Studio</b></div>
<div className="spacer" />
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
<div className="acct-wrap">
<button className="avatar" aria-label="account" aria-haspopup="menu"
title={session.email || session.user || 'account'} onClick={() => setAcct(a => !a)}>
{initials}
</button>
{acct && (
<div className="rc-menu acct-menu" role="menu" onMouseLeave={() => setAcct(false)}>
<div className="acct-id">
<div className="acct-name">{session.user || (session.authenticated ? 'signed in' : 'guest')}</div>
{session.email && <div className="acct-sub">{session.email}</div>}
{session.org && <div className="acct-sub">org · {session.org}</div>}
</div>
<a role="menuitem" href="https://console.hanzo.ai" target="_blank" rel="noreferrer">Console</a>
{session.authenticated
? <a role="menuitem" href="/logout">Sign out</a>
: <a role="menuitem" href={session.iam_url || '/login'}>Sign in with Hanzo</a>}
</div>
)}
</div>
</header>
<main className="body">
<section className="chat">
<div className="scroll" ref={scroller}>
{messages.length === 0 && (
<div className="hero">
<h1>What should we create?</h1>
<p>Describe an edit or a new shot. Runs appear in <b>Up next</b> on the right as they render,
and finished results land in <b>History</b> rate them to teach the next pass.</p>
<div className="examples">
{EXAMPLES.map(ex => <button key={ex} className="ex-chip" onClick={() => useExample(ex)}>{ex}</button>)}
</div>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
))}
{busy && <div className="msg assistant"><div className="bubble dim">working</div></div>}
</div>
<div className="composer">
<textarea
ref={composer} value={input} placeholder="Describe what to create or change…"
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
/>
<button className={`send${ready ? ' ready' : ''}`} onClick={send} disabled={busy}>Send</button>
</div>
</section>
<div className="vsplit" onPointerDown={onVSplit} onDoubleClick={() => patch({ railW: DEFAULT.railW })}
title="drag to resize · double-click to reset" role="separator" aria-orientation="vertical" />
<aside className="rail" style={{ width: layout.railW }}>
<Rail layout={layout} patch={patch} toggle={toggle} reset={reset}
beginDrag={beginDrag} endDrag={endDrag} onUseRef={insertRef} />
</aside>
</main>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
// The studio-chat data layer. Two backends, no proxy layer between them:
// - the agent orchestrator: POST api.hanzo.ai/v1/agent (github.com/hanzoai/agent,
// mounted in the cloud binary) — called DIRECTLY, no studio pass-through. The
// hanzo_token cookie is scoped to .hanzo.ai so credentials:'include' carries it
// cross-origin, and the round replays it into the per-org-billed completion.
// (Gateway CORS must allow the studio.hanzo.ai origin with credentials.)
// - the studio engine API: /v1/session, /v1/worklog, /v1/queue/*, /v1/favorites,
// /v1/stacks, … — same-origin on studio.hanzo.ai (same cookie).
// No token handling in the browser: the cookie is the credential for both.
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
// Default: same-origin through the studio's /v1/agent bridge — the host-only
// session cookie authorizes it and the middleware forwards the bearer, so no
// parent-domain cookie and no gateway CORS dependency. VITE_AGENT_API points
// straight at the gateway for direct mode.
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? '').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) {
// Surface the server's own message — a 402 says "add credits at
// pay.hanzo.ai", which beats a bare status code.
let msg = `${url} -> ${r.status}`
try {
const b = await r.json()
const m = b?.error?.message ?? b?.error ?? b?.message
if (typeof m === 'string' && m) msg = m
} catch { /* keep the status line */ }
throw new Error(msg)
}
return r.json() as Promise<T>
}
export type Role = 'user' | 'assistant'
export interface ChatMessage { role: Role; content: string }
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
export interface Op { name: string; args?: Record<string, unknown> }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[]; conversationId: string }
// One turn of a preset: the model edits/combines library assets; `actions` are the
// renders it dispatched (each result carries a prompt_id). Pass the prior reply's
// conversationId to continue a thread — the orchestrator persists per-org history.
export function chat(messages: ChatMessage[], conversationId?: string, preset = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${AGENT_API}/v1/agent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset, messages, conversationId }),
})
}
export interface Session {
user?: string; email?: string; org?: string; orgs?: string[]
authenticated?: boolean; iam_url?: string
}
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
// A run in a flow: one dispatched generation (fix/compose/rerun/render/template) with
// its instruction, references, parents (the assets it derived from), landed output and
// real status. The queue/history backbone — GET /v1/worklog, newest first.
export type RunStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'
export interface Run {
id: string; ts: number; kind: string; prompt: string
refs: string[]; uploads: string[]; parents: string[]
output_prefix: string; output: string | null
lane: string; node: string; status: RunStatus
started_at?: number; finished_at?: number; error?: string
params?: Record<string, unknown>
}
export const getWorklog = (q?: string) =>
j<{ org: string; items: Run[] }>(`${BASE}/v1/worklog${q ? `?q=${encodeURIComponent(q)}` : ''}`)
.catch(() => ({ org: '', items: [] as Run[] }))
// Live position + ETA overlay for the ACTIVE runs, per lane, from the org's own
// measured medians. Joined onto the worklog rows by id.
export interface QueueItem {
id: string; kind: string; prompt: string; status: string; node: string
lane?: string; position?: number; of?: number
eta_seconds?: number; wait_seconds?: number; elapsed_seconds?: number; remaining_seconds?: number | null
}
export interface QueueStatus { items: Record<string, QueueItem>; medians: Record<string, number>; lanes: Record<string, number>; now: number }
export const getQueueStatus = () =>
j<QueueStatus>(`${BASE}/v1/queue/status`).catch(() => ({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) }))
// The version chain of one output — ancestors (what it derived from) + descendants
// (later iterations). The iteration history of a design flow.
export interface LineageStep { path: string; prompt: string; kind: string; ts: number }
export interface Lineage { ancestors: LineageStep[]; node: string; descendants: LineageStep[] }
export const getLineage = (path: string) =>
j<Lineage>(`${BASE}/v1/worklog/lineage?path=${encodeURIComponent(path)}`)
.catch(() => ({ ancestors: [], node: path, descendants: [] }))
// Delete a queued run (tenant-scoped cancel; a foreign id 404s).
export const cancelRun = (id: string) =>
j<{ ok: boolean; canceled: string }>(`${BASE}/v1/queue/cancel`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_id: id }),
})
// Amend a still-queued run — the ONE way to change it (atomic cancel + re-dispatch
// with the same instruction plus added references). Adding references IS an amend.
export const amendRun = (id: string, instruction: string, uploads: string[], paths: string[] = []) =>
j<{ ok: boolean; prompt_id: string; amended_from: string; refs: number }>(`${BASE}/v1/queue/amend`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_id: id, instruction, uploads, paths }),
})
// Stage a reference image in the org input dir (the engine's upload route); returns
// the flat name amend/compose reference by.
export const uploadRef = (file: File) => {
const fd = new FormData()
fd.append('image', file, file.name)
return j<{ name: string; subfolder: string; type: string }>(`${BASE}/upload/image`, { method: 'POST', body: fd })
}
// Ratings feed the AI flywheel: 0-5 stars + a note, keyed by run id, carrying the
// output path. Persisted per-org; visible on reopen.
export interface Rating { stars?: number; notes?: string; path?: string; updatedAt?: number }
export const getRatings = () => j<Record<string, Rating>>(`${BASE}/v1/library/ratings`).catch(() => ({}))
export const rate = (key: string, patch: { stars?: number; notes?: string; path?: string }) =>
j<{ ok: boolean; key: string; entry: Rating }>(`${BASE}/v1/library/rate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, ...patch }),
})
// Soft-delete a finished result — the library status lifecycle ('deleted'), the same
// mechanism the assets view uses. Keyed by the run's output path.
export const softDelete = (path: string) =>
j<{ ok: boolean; path: string; status: string }>(`${BASE}/v1/library/status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, status: 'deleted' }),
})
// The org's soft-deleted asset paths — so History hides results deleted here or in the
// assets view, and the delete persists across reloads.
export const getDeletedPaths = () =>
j<{ assets: { path: string; status?: string }[] }>(`${BASE}/v1/library`)
.then(r => (r.assets || []).filter(a => a.status === 'deleted').map(a => a.path))
.catch(() => [] as string[])
// Favorites: the heart toggle, a per-org set keyed by run id. Separate concern from
// ratings — a bookmark, not quality feedback.
export const getFavorites = () =>
j<{ org: string; favorites: Record<string, { ts: number }> }>(`${BASE}/v1/favorites`)
.then(r => r.favorites).catch(() => ({} as Record<string, { ts: number }>))
export const setFavorite = (key: string, favorite: boolean) =>
j<{ ok: boolean; key: string; favorite: boolean }>(`${BASE}/v1/favorites`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, favorite }),
})
// Stacks: iOS-folder groupings of runs. The client owns the arrangement; the whole
// normalized set is POSTed back and echoed.
export interface Stack { id: string; name: string; items: string[]; ts: number }
export const getStacks = () =>
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`).then(r => r.stacks).catch(() => [] as Stack[])
export const saveStacks = (stacks: Stack[]) =>
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stacks }),
}).then(r => r.stacks)
// Favorite → template: capture a reusable FLOW (kind + instruction + refs + parents).
export interface Flow { kind: string; prompt: string; refs: string[]; parents: string[] }
export const saveTemplate = (name: string, flow: Flow) =>
j<{ ok: boolean; name: string; slug: string }>(`${BASE}/v1/templates`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, flow }),
})
// Thumbnails: a landed library output, a staged input reference, and the full-res file.
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
export const inputThumbURL = (name: string) => `${BASE}/v1/library/file?input=1&thumb=1&path=${encodeURIComponent(name)}`
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react'
import {
type Run, type QueueItem, type Rating,
thumbURL, inputThumbURL, fileURL,
} from './api'
// ── Small shared bits ────────────────────────────────────────────────────────────
export function relTime(ts: number, now: number): string {
const s = Math.max(0, now - ts)
if (s < 45) return 'just now'
if (s < 3600) return `${Math.round(s / 60)}m ago`
if (s < 86400) return `${Math.round(s / 3600)}h ago`
return `${Math.round(s / 86400)}d ago`
}
export function fmtDur(sec: number): string {
if (sec < 60) return `${Math.round(sec)}s`
if (sec < 3600) return `${Math.round(sec / 60)}m`
return `${(sec / 3600).toFixed(1)}h`
}
// A friendly identifier for a run, from its output/prefix (the finding: cards must
// carry names like the "design 13" the prompts teach). Strips the _NNNNN_ counter.
export function idLabel(run: Run): string {
const raw = (run.output || run.output_prefix || run.kind || '').split('/').pop() || run.kind
return raw.replace(/_\d+_?\.\w+$/, '').replace(/_\d+_$/, '').replace(/\.\w+$/, '') || run.kind
}
export function runThumb(run: Run): string | null {
if (run.output) return thumbURL(run.output)
if (run.parents[0]) return thumbURL(run.parents[0])
if (run.refs[0]) return inputThumbURL(run.refs[0])
return null
}
const isActive = (run: Run) => run.status === 'queued' || run.status === 'running'
export function Stars({ value, onChange }: { value: number; onChange?: (n: number) => void }) {
const stars = [1, 2, 3, 4, 5]
return (
<span className={`stars${onChange ? ' interactive' : ''}`} role={onChange ? 'radiogroup' : undefined} aria-label="rating">
{stars.map(n => (
<button
key={n} type="button" className={`star${n <= value ? ' on' : ''}`}
disabled={!onChange} aria-label={`${n} star${n > 1 ? 's' : ''}`}
onClick={onChange ? () => onChange(n === value ? 0 : n) : undefined}
></button>
))}
</span>
)
}
// ── One run card ─────────────────────────────────────────────────────────────────
export interface CardProps {
run: Run
qitem?: QueueItem
rating?: Rating
favorite: boolean
now: number
version?: { pos: number; len: number }
onRemove: (run: Run) => void
onAmend: (run: Run, files: File[]) => Promise<void>
onRate: (run: Run, patch: { stars?: number; notes?: string; path?: string }) => void
onFavorite: (run: Run) => void
onAddToStack: (run: Run) => void
onOpenVersions: (run: Run) => void
onUseRef: (run: Run) => void
dnd: {
onDragStart: (e: React.DragEvent) => void
onDragEnter: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
intent: boolean
}
}
export function RunCard(p: CardProps) {
const { run, qitem, rating, favorite, now, version } = p
const [menu, setMenu] = useState(false)
const [panel, setPanel] = useState<'none' | 'rate' | 'refs'>('none')
const [stars, setStars] = useState(rating?.stars ?? 0)
const [notes, setNotes] = useState(rating?.notes ?? '')
const [files, setFiles] = useState<File[]>([])
const [busy, setBusy] = useState(false)
const [fileOver, setFileOver] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const active = isActive(run)
const src = runThumb(run)
const fresh = run.status === 'done' && now - (run.finished_at || run.ts) < 120
// Corner badge: ETA while queued, elapsed while running, stars-or-node when done.
let badge = ''
if (run.status === 'running') badge = qitem?.elapsed_seconds != null ? fmtDur(qitem.elapsed_seconds) : 'running'
else if (run.status === 'queued') badge = qitem?.eta_seconds != null ? `~${fmtDur(qitem.eta_seconds)}` : 'queued'
else if (run.status === 'failed') badge = 'failed'
else if (run.status === 'cancelled') badge = 'cancelled'
else badge = rating?.stars ? `${rating.stars}` : run.node
const pos = qitem?.position && qitem?.of ? `${qitem.position}/${qitem.of}` : ''
const meta = [run.node, relTime(run.ts, now), run.status + (pos ? ` · ${pos}` : '')].filter(Boolean).join(' · ')
const collectFiles = (list: FileList | null) =>
setFiles(f => [...f, ...[...(list || [])].filter(x => x.type.startsWith('image/'))])
function onDrop(e: React.DragEvent) {
const dropped = [...(e.dataTransfer?.files || [])].filter(x => x.type.startsWith('image/'))
if (dropped.length && active) { // files onto a queued card → add references
e.preventDefault(); e.stopPropagation(); setFileOver(false)
setPanel('refs'); setFiles(f => [...f, ...dropped]); return
}
setFileOver(false); p.dnd.onDrop(e) // otherwise an internal run drag → stacking
}
function onDragOver(e: React.DragEvent) {
if (active && e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); setFileOver(true); return }
p.dnd.onDragOver(e) // internal run drag → let the stack drop fire
}
async function applyRefs() {
if (!files.length || busy) return
setBusy(true)
try { await p.onAmend(run, files); setFiles([]); setPanel('none') } finally { setBusy(false) }
}
function saveRate() {
p.onRate(run, { stars, notes, path: run.output || undefined })
setPanel('none')
}
// The × (and the menu's Delete) share ONE handler: a queued run cancels straight
// away; a finished result is a destructive soft-delete, so it asks first.
function requestDelete() {
if (active) p.onRemove(run)
else setConfirmDel(true)
}
return (
<article
className={`rc rc-${run.status}${p.dnd.intent ? ' stack-intent' : ''}${fileOver ? ' file-over' : ''}`}
draggable onDragStart={p.dnd.onDragStart}
onDragEnter={p.dnd.onDragEnter} onDragLeave={e => { setFileOver(false); p.dnd.onDragLeave(e) }}
onDragOver={onDragOver} onDrop={onDrop}
>
<a className="rc-thumb" draggable={false} href={run.output ? fileURL(run.output) : undefined}
target="_blank" rel="noreferrer" onClick={e => { if (!run.output) e.preventDefault() }}>
{src ? <img loading="lazy" draggable={false} src={src} alt="" /> : <span className="rc-ph">{run.kind || '—'}</span>}
<span className={`rc-badge s-${run.status}`}>{badge}</span>
{fresh && <span className="rc-new">New</span>}
</a>
<button className="rc-x" aria-label={active ? 'cancel' : 'delete'}
title={active ? 'Cancel' : 'Delete'} onClick={requestDelete}>×</button>
{confirmDel && (
<div className="rc-confirm" role="alertdialog" aria-label="confirm delete">
<span>Delete result?</span>
<button className="danger" onClick={() => { setConfirmDel(false); p.onRemove(run) }}>Delete</button>
<button onClick={() => setConfirmDel(false)}>Keep</button>
</div>
)}
<div className="rc-body">
<div className="rc-title" title={run.prompt || run.kind}>{run.prompt || `(${run.kind})`}</div>
<div className="rc-meta">{meta}</div>
<div className="rc-tags">
<span className="rc-id">{idLabel(run)}</span>
{version && version.len > 1 && (
<button className="rc-vers" title="versions of this design" onClick={() => p.onOpenVersions(run)}>
{Array.from({ length: Math.min(version.len, 6) }).map((_, i) =>
<span key={i} className={`pip${i === version.pos - 1 ? ' on' : ''}`} />)}
<span className="rc-vn">v{version.pos}</span>
</button>
)}
{favorite && <span className="rc-fav" title="favorite"></span>}
{rating?.stars ? <span className="rc-rated"><Stars value={rating.stars} /></span> : null}
</div>
</div>
<button className="rc-more" aria-label="more actions" aria-haspopup="menu"
onClick={() => setMenu(m => !m)}></button>
{menu && (
<div className="rc-menu" role="menu" onMouseLeave={() => setMenu(false)}>
<button role="menuitem" onClick={() => { setMenu(false); requestDelete() }}>Delete</button>
{active && <button role="menuitem" onClick={() => { setMenu(false); setPanel('refs') }}>Add references</button>}
<button role="menuitem" onClick={() => { setMenu(false); setStars(rating?.stars ?? 0); setNotes(rating?.notes ?? ''); setPanel('rate') }}>Rate &amp; note</button>
<button role="menuitem" onClick={() => { setMenu(false); p.onFavorite(run) }}>{favorite ? 'Unfavorite' : 'Favorite'}</button>
<button role="menuitem" onClick={() => { setMenu(false); p.onAddToStack(run) }}>Add to stack</button>
<button role="menuitem" onClick={() => { setMenu(false); p.onUseRef(run) }}>Use as reference</button>
</div>
)}
{panel === 'rate' && (
<div className="rc-panel">
<Stars value={stars} onChange={setStars} />
<textarea className="rc-notes" value={notes} onChange={e => setNotes(e.target.value)}
placeholder="What should the AI do differently next time?" />
<div className="rc-panel-row">
<button className="pri" onClick={saveRate}>Save</button>
<button onClick={() => setPanel('none')}>Cancel</button>
</div>
</div>
)}
{panel === 'refs' && (
<div className={`rc-panel rc-drop${fileOver ? ' over' : ''}`}
onDragOver={e => { e.preventDefault(); setFileOver(true) }}
onDragLeave={() => setFileOver(false)}
onDrop={e => { e.preventDefault(); e.stopPropagation(); setFileOver(false); collectFiles(e.dataTransfer.files) }}>
<div className="rc-drop-hint">Drop images here, or</div>
<label className="rc-pick">Choose files
<input type="file" multiple accept="image/*" onChange={e => collectFiles(e.target.files)} />
</label>
{files.length > 0 && <div className="rc-files">{files.map((f, i) => <span key={i} className="chip">{f.name}</span>)}</div>}
<div className="rc-panel-row">
<button className="pri" disabled={!files.length || busy} onClick={applyRefs}>
{busy ? 'Requeuing…' : `Add ${files.length || ''} & requeue`}
</button>
<button onClick={() => { setFiles([]); setPanel('none') }}>Cancel</button>
</div>
</div>
)}
</article>
)
}
+82
View File
@@ -0,0 +1,82 @@
// Layout state for the queue/history rail: divider positions, per-section collapse,
// and the active filter chip — ONE mechanism, persisted in localStorage keyed by
// org+user (from /v1/session) and restored on load. Dividers are native pointer
// drags; nothing here depends on a DnD library.
import { useCallback, useEffect, useRef, useState } from 'react'
import type React from 'react'
export type Filter = 'all' | 'queued' | 'done' | 'favorites' | 'stacks'
export interface Layout {
railW: number // px — the main | rail divider (horizontal resize)
upFrac: number // 0..1 — the Up next | History divider (vertical resize)
collapsed: { next: boolean; up: boolean; hist: boolean }
filter: Filter
}
export const DEFAULT: Layout = {
railW: 360, upFrac: 0.5,
collapsed: { next: false, up: false, hist: false }, filter: 'all',
}
export const RAIL_MIN = 300
export const RAIL_MAX = 640
const KEY = (who: string) => `studio.layout:${who}`
export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
export function useLayout(who: string) {
const [layout, setLayout] = useState<Layout>(DEFAULT)
const loaded = useRef(false)
const dragging = useRef(false)
// Restore once we know who the user is (org:user); merge over defaults so a stored
// layout from an older shape never loses a newly-added field.
useEffect(() => {
if (!who || loaded.current) return
loaded.current = true
try {
const raw = localStorage.getItem(KEY(who))
if (raw) {
const s = JSON.parse(raw)
setLayout({ ...DEFAULT, ...s, collapsed: { ...DEFAULT.collapsed, ...(s.collapsed || {}) } })
}
} catch { /* corrupt entry — fall back to defaults */ }
}, [who])
// Persist on change — but not on every drag frame; the drag commits once on release.
useEffect(() => {
if (!who || !loaded.current || dragging.current) return
try { localStorage.setItem(KEY(who), JSON.stringify(layout)) } catch { /* quota — ignore */ }
}, [who, layout])
const patch = useCallback((p: Partial<Layout>) => setLayout(l => ({ ...l, ...p })), [])
const toggle = useCallback((k: keyof Layout['collapsed']) =>
setLayout(l => ({ ...l, collapsed: { ...l.collapsed, [k]: !l.collapsed[k] } })), [])
const reset = useCallback(() => { setLayout(DEFAULT); dragging.current = false }, [])
const beginDrag = useCallback(() => { dragging.current = true }, [])
const endDrag = useCallback(() => { dragging.current = false; setLayout(l => ({ ...l })) }, [])
return { layout, patch, toggle, reset, beginDrag, endDrag }
}
// Native divider drag. Tracks pointer delta from the grab point on the given axis and
// hands it to `onMove` (the caller clamps + applies); `onEnd` commits/persists.
export function startDrag(
e: React.PointerEvent, axis: 'x' | 'y',
onMove: (delta: number) => void, onEnd: () => void,
) {
e.preventDefault()
const origin = axis === 'x' ? e.clientX : e.clientY
const move = (ev: PointerEvent) => onMove((axis === 'x' ? ev.clientX : ev.clientY) - origin)
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
document.body.style.userSelect = ''
document.body.style.cursor = ''
onEnd()
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
document.body.style.userSelect = 'none'
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+442
View File
@@ -0,0 +1,442 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
getWorklog, getQueueStatus, getRatings, getFavorites, getStacks, saveStacks,
cancelRun, amendRun, uploadRef, rate, setFavorite, saveTemplate, getLineage,
softDelete, getDeletedPaths, thumbURL, fileURL,
type Run, type QueueStatus, type QueueItem, type Rating, type Stack, type Lineage,
} from './api'
import { RunCard, runThumb, idLabel, relTime, fmtDur } from './card'
import { type Filter, type Layout, startDrag, clamp } from './layout'
// The queue/history rail — the whole YouTube-watch-page surface adapted to studio's
// generative flows. App owns the shell + composer + layout persistence; this owns all
// queue/history/favorites/stacks data, polling and mutations, and calls back to insert
// a reference into the composer.
// Version chain (pos/len) for each run's output, derived from the loaded rows — the
// same parent→child edges the server's lineage walk uses, counted for the "vN" pips.
function chains(rows: Run[]): Map<string, { pos: number; len: number }> {
const producer = new Map<string, Run>()
for (const r of rows) if (r.output && !producer.has(r.output)) producer.set(r.output, r)
const childrenOf = new Map<string, Run[]>()
for (const r of rows) for (const par of r.parents || []) {
const arr = childrenOf.get(par)
if (arr) arr.push(r); else childrenOf.set(par, [r])
}
const out = new Map<string, { pos: number; len: number }>()
for (const r of rows) {
if (!r.output) continue
let anc = 0, cur: string | undefined = r.output
const seen = new Set([r.output])
for (;;) {
const par: string | undefined = producer.get(cur!)?.parents?.[0]
if (!par || seen.has(par)) break
seen.add(par); anc++; cur = par
}
let desc = 0
const q = [r.output], seenD = new Set([r.output])
while (q.length) {
for (const kid of childrenOf.get(q.shift()!) || []) {
const key = kid.output || kid.id
if (seenD.has(key)) continue
seenD.add(key); desc++
if (kid.output) q.push(kid.output)
}
}
out.set(r.id, { pos: anc + 1, len: anc + 1 + desc })
}
return out
}
export interface RailProps {
layout: Layout
patch: (p: Partial<Layout>) => void
toggle: (k: 'next' | 'up' | 'hist') => void
reset: () => void
beginDrag: () => void
endDrag: () => void
onUseRef: (text: string) => void
}
export function Rail(props: RailProps) {
const { layout, patch, toggle, reset, beginDrag, endDrag } = props
const filter = layout.filter
const [rows, setRows] = useState<Run[]>([])
const [q, setQ] = useState<QueueStatus>({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) })
const [now, setNow] = useState(Math.floor(Date.now() / 1000))
const [ratings, setRatings] = useState<Record<string, Rating>>({})
const [favorites, setFavorites] = useState<Record<string, { ts: number }>>({})
const [stacks, setStacks] = useState<Stack[]>([])
const [deletedPaths, setDeletedPaths] = useState<Set<string>>(new Set())
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [query, setQuery] = useState('')
const [openStack, setOpenStack] = useState<string | null>(null)
const [stackPick, setStackPick] = useState<Run | null>(null)
const [versionsFor, setVersionsFor] = useState<Run | null>(null)
const [lineage, setLineage] = useState<Lineage | null>(null)
const [offer, setOffer] = useState<Run | null>(null)
const [railMenu, setRailMenu] = useState(false)
const [err, setErr] = useState('')
const pollLive = useCallback(async () => {
const [w, qs] = await Promise.all([getWorklog(query), getQueueStatus()])
setRows(w.items); setQ(qs); setNow(qs.now)
}, [query])
const loadRatings = useCallback(() => getRatings().then(setRatings), [])
const loadFavorites = useCallback(() => getFavorites().then(setFavorites), [])
const loadStacks = useCallback(() => getStacks().then(setStacks), [])
const loadDeleted = useCallback(() => getDeletedPaths().then(ps => setDeletedPaths(new Set(ps))), [])
useEffect(() => { pollLive(); const t = setInterval(pollLive, 4000); return () => clearInterval(t) }, [pollLive])
useEffect(() => { loadRatings(); loadFavorites(); loadStacks(); loadDeleted() }, [loadRatings, loadFavorites, loadStacks, loadDeleted])
useEffect(() => { if (!err) return; const t = setTimeout(() => setErr(''), 4000); return () => clearTimeout(t) }, [err])
// Active = the runs the server still counts as in-flight (present in queue/status),
// plus just-dispatched rows not yet in the next poll — so a stalled row doesn't
// linger in Up next forever (matches the queue's own 30-min active window).
const active = rows.filter(r => (r.status === 'queued' || r.status === 'running') && (q.items[r.id] || now - r.ts <= 1800))
.sort((a, b) => ((q.items[a.id]?.position ?? 999) - (q.items[b.id]?.position ?? 999)) || (a.ts - b.ts))
const history = rows.filter(r => (r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
&& !hidden.has(r.id) && !(r.output && deletedPaths.has(r.output)))
const favRuns = rows.filter(r => favorites[r.id])
const nextRun = active[0]
const chainMap = chains(rows)
// ── Mutations (each refreshes the affected store) ────────────────────────────────
// A dispatch/mutation can fail because the run or asset changed since this list was
// fetched (deduped, finished, cancelled). The trailing refresh already re-fetches;
// swap the raw error for a plain "pick it again" — one self-heal for every mutation.
const STALE = /unknown asset|not found|no such job|already finished/i
const heal = (e: Error): boolean => {
if (!STALE.test(e.message)) return false
setErr('That run changed — refreshed, pick it again.')
return true
}
// The ONE remove handler behind both the card's × and its menu Delete: a queued run
// cancels; a finished result soft-deletes its output (the library lifecycle) and is
// hidden from History optimistically (reconciled from the server's deleted set).
async function onRemove(run: Run) {
if (run.status === 'queued' || run.status === 'running') {
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
pollLive(); return
}
setHidden(h => new Set(h).add(run.id))
const out = run.output
if (out) {
try { await softDelete(out); setDeletedPaths(d => new Set(d).add(out)) }
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
}
pollLive()
}
async function onAmend(run: Run, files: File[]) {
const names: string[] = []
for (const f of files) { try { const u = await uploadRef(f); if (u?.name) names.push(u.name) } catch (e) { setErr((e as Error).message) } }
if (!names.length) return
const instr = (run.prompt || '').trim()
try { await amendRun(run.id, instr.length >= 3 ? instr : `${run.kind || 'edit'} update`, names) }
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
pollLive()
}
async function onRate(run: Run, p: { stars?: number; notes?: string; path?: string }) {
try { await rate(run.id, p) } catch (e) { setErr((e as Error).message) }
loadRatings()
}
async function onFavorite(run: Run) {
const nowFav = !favorites[run.id]
try { await setFavorite(run.id, nowFav) } catch (e) { setErr((e as Error).message) }
await loadFavorites()
if (nowFav) setOffer(run) // offer to turn the flow into a template
}
async function onOpenVersions(run: Run) {
setVersionsFor(run); setLineage(null)
setLineage(await getLineage(run.output || ''))
}
async function persistStacks(next: Stack[]) {
const clean = next.filter(s => s.items.length >= 2) // a folder of one dissolves
setStacks(clean)
try { setStacks(await saveStacks(clean)) } catch (e) { setErr((e as Error).message) }
}
function stackRuns(from: string, target: string) {
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== from) }))
const host = next.find(s => s.items.includes(target))
if (host) host.items = [...host.items, from]
else next.push({ id: `st_${Date.now().toString(36)}`, name: 'Stack', items: [target, from], ts: Math.floor(Date.now() / 1000) })
persistStacks(next)
}
function addToStack(run: Run, stackId: string) {
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) }))
const host = next.find(s => s.id === stackId)
if (host) host.items = [...host.items, run.id]
persistStacks(next); setStackPick(null)
}
function unstack(run: Run) {
persistStacks(stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) })))
}
function renameStack(id: string, name: string) {
setStacks(s => s.map(x => x.id === id ? { ...x, name } : x))
}
function commitRename() { persistStacks(stacks) }
// ── Drag-hold stacking (native HTML5 drag + a 600ms hover timer) ─────────────────
const dragId = useRef<string | null>(null)
const holdTimer = useRef<number | undefined>(undefined)
const [intentId, setIntentId] = useState<string | null>(null)
function dndFor(run: Run) {
return {
onDragStart: (e: React.DragEvent) => { dragId.current = run.id; e.dataTransfer.setData('text/plain', run.id); e.dataTransfer.effectAllowed = 'move' },
onDragOver: (e: React.DragEvent) => { if (dragId.current && dragId.current !== run.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } },
onDragEnter: (e: React.DragEvent) => {
if (!dragId.current || dragId.current === run.id) return
e.preventDefault()
window.clearTimeout(holdTimer.current)
holdTimer.current = window.setTimeout(() => setIntentId(run.id), 600) // hold to form a stack
},
onDragLeave: () => { window.clearTimeout(holdTimer.current); setIntentId(cur => cur === run.id ? null : cur) },
onDrop: () => {
window.clearTimeout(holdTimer.current)
const from = dragId.current, held = intentId === run.id
setIntentId(null); dragId.current = null
if (from && from !== run.id && held) stackRuns(from, run.id)
},
intent: intentId === run.id,
}
}
const card = (run: Run) => (
<RunCard
key={run.id} run={run} qitem={q.items[run.id]} rating={ratings[run.id]}
favorite={!!favorites[run.id]} now={now} version={chainMap.get(run.id)}
onRemove={onRemove} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
onAddToStack={r => setStackPick(r)} onOpenVersions={onOpenVersions}
onUseRef={r => props.onUseRef(idLabel(r))} dnd={dndFor(run)}
/>
)
// ── Section layout (content-driven flex + the vertical resize divider) ───────────
const sectionsRef = useRef<HTMLDivElement>(null)
const bothOpen = filter === 'all' && !layout.collapsed.up && !layout.collapsed.hist
const upStyle: React.CSSProperties = filter === 'queued' ? { flex: '1 1 auto' }
: bothOpen ? { flex: `0 0 ${Math.round(layout.upFrac * 100)}%` }
: layout.collapsed.up ? { flex: '0 0 auto' } : { flex: '1 1 auto' }
const histStyle: React.CSSProperties = layout.collapsed.hist ? { flex: '0 0 auto' } : { flex: '1 1 0' }
function onHSplit(e: React.PointerEvent) {
beginDrag()
const h = sectionsRef.current?.getBoundingClientRect().height || 1
const start = layout.upFrac
startDrag(e, 'y', d => patch({ upFrac: clamp(start + d / h, 0.15, 0.85) }), endDrag)
}
const chips: { k: Filter; label: string; n?: number }[] = [
{ k: 'all', label: 'All' },
{ k: 'queued', label: 'Queued', n: active.length },
{ k: 'done', label: 'Done' },
{ k: 'favorites', label: 'Favorites', n: favRuns.length },
{ k: 'stacks', label: 'Stacks', n: stacks.length },
]
return (
<>
{(filter === 'all' || filter === 'queued') && nextRun && (
<div className={`nextpin${layout.collapsed.next ? ' col' : ''}`}>
<button className="pin-h" onClick={() => toggle('next')} aria-expanded={!layout.collapsed.next}>
<span className="pin-lab">Next</span>
<span className="pin-count">{active.length} in queue</span>
<span className="chev">{layout.collapsed.next ? '▸' : '▾'}</span>
</button>
{!layout.collapsed.next && <NextBody run={nextRun} q={q.items[nextRun.id]} now={now} />}
</div>
)}
<div className="chips">
<div className="chips-row">
{chips.map(c => (
<button key={c.k} className={`chip-f${filter === c.k ? ' on' : ''}`} onClick={() => patch({ filter: c.k })}>
{c.label}{c.n ? <span className="chip-n">{c.n}</span> : null}
</button>
))}
</div>
<div className="rail-more-wrap">
<button className="rail-more" aria-label="rail options" onClick={() => setRailMenu(m => !m)}></button>
{railMenu && (
<div className="rc-menu" role="menu" onMouseLeave={() => setRailMenu(false)}>
<button role="menuitem" onClick={() => { setRailMenu(false); reset() }}>Reset layout</button>
<button role="menuitem" onClick={() => { setRailMenu(false); pollLive(); loadRatings(); loadFavorites(); loadStacks() }}>Refresh</button>
</div>
)}
</div>
</div>
<div className="rail-main">
{filter === 'stacks' ? (
<div className="stacks-view">
{stacks.length === 0 && <div className="empty">No stacks yet. Drag one card onto another and hold (~½s) to make a stack.</div>}
{stacks.map(s => {
const members = s.items.map(id => rows.find(r => r.id === id)).filter((r): r is Run => !!r)
const open = openStack === s.id
return (
<div key={s.id} className={`stack${open ? ' open' : ''}`}>
<button className="stack-tile" onClick={() => setOpenStack(open ? null : s.id)}>
<span className="stack-cards">
{members.slice(0, 3).map((m, i) => {
const src = runThumb(m)
return <span key={i} className="sc" style={src ? { backgroundImage: `url(${src})` } : undefined} />
})}
</span>
<span className="stack-meta">
<span className="stack-name">{s.name}</span>
<span className="stack-count">{members.length}</span>
</span>
</button>
{open && (
<div className="stack-open">
<input className="stack-rename" value={s.name} aria-label="stack name"
onChange={e => renameStack(s.id, e.target.value)} onBlur={commitRename} />
{members.map(m => (
<div key={m.id} className="stack-member">
{card(m)}
<button className="unstack" title="remove from stack" onClick={() => unstack(m)}>×</button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
) : filter === 'favorites' ? (
<section className="sec">
<div className="sec-body">
{favRuns.length ? favRuns.map(card) : <div className="empty">No favorites yet. Tap on any card.</div>}
</div>
</section>
) : (
<div className="sections" ref={sectionsRef}>
{(filter === 'all' || filter === 'queued') && (
<section className={`sec${layout.collapsed.up ? ' col' : ''}`} style={upStyle}>
<SecHeader label="Up next" n={active.length} collapsed={layout.collapsed.up} onToggle={() => toggle('up')} />
{!layout.collapsed.up && (
<div className="sec-body">{active.length ? active.map(card) : <div className="empty">Nothing queued. Describe a change to start a run.</div>}</div>
)}
</section>
)}
{bothOpen && <div className="hsplit" onPointerDown={onHSplit} onDoubleClick={() => patch({ upFrac: 0.5 })} title="drag to resize · double-click to reset" />}
{(filter === 'all' || filter === 'done') && (
<section className={`sec${layout.collapsed.hist ? ' col' : ''}`} style={histStyle}>
<SecHeader label="History" n={history.length} collapsed={layout.collapsed.hist} onToggle={() => toggle('hist')}
search={<input className="sec-search" placeholder="Search history…" value={query} onChange={e => setQuery(e.target.value)} />} />
{!layout.collapsed.hist && (
<div className="sec-body">{history.length ? history.map(card) : <div className="empty">No past generations{query ? ' match your search' : ' yet'}.</div>}</div>
)}
</section>
)}
</div>
)}
</div>
{stackPick && (
<div className="ovl" onClick={() => setStackPick(null)}>
<div className="sheet" onClick={e => e.stopPropagation()}>
<div className="sheet-h">Add {idLabel(stackPick)} to a stack</div>
{stacks.length === 0
? <p className="muted">No stacks yet drag one card onto another and hold to make the first one.</p>
: stacks.map(s => <button key={s.id} className="sheet-item" onClick={() => addToStack(stackPick, s.id)}>{s.name} · {s.items.length}</button>)}
<div className="sheet-row"><button onClick={() => setStackPick(null)}>Cancel</button></div>
</div>
</div>
)}
{versionsFor && (
<div className="ovl" onClick={() => setVersionsFor(null)}>
<div className="sheet vers" onClick={e => e.stopPropagation()}>
<div className="sheet-h">Versions of {idLabel(versionsFor)}</div>
{!lineage ? <div className="empty">Loading</div> : (
<div className="vers-strip">
{[...lineage.ancestors,
{ path: lineage.node, prompt: versionsFor.prompt, kind: versionsFor.kind, ts: versionsFor.ts },
...lineage.descendants].map((step, i) => (
<a key={i} className={`vstep${step.path === lineage.node ? ' cur' : ''}`}
href={step.path ? fileURL(step.path) : undefined} target="_blank" rel="noreferrer"
onClick={e => { if (!step.path) e.preventDefault() }} title={step.prompt || step.kind}>
{step.path ? <img src={thumbURL(step.path)} alt="" /> : <span className="rc-ph">?</span>}
<span className="vlabel">v{i + 1}</span>
</a>
))}
</div>
)}
<div className="sheet-row"><button onClick={() => setVersionsFor(null)}>Close</button></div>
</div>
</div>
)}
{offer && <TemplateOffer run={offer} onClose={() => setOffer(null)} onError={setErr} />}
{err && <div className="rail-toast" role="alert">{err}</div>}
</>
)
}
function NextBody({ run, q, now }: { run: Run; q?: QueueItem; now: number }) {
const src = runThumb(run)
const badge = run.status === 'running'
? (q?.elapsed_seconds != null ? `running · ${fmtDur(q.elapsed_seconds)}` : 'running')
: (q?.eta_seconds != null ? `starts in ~${fmtDur(q.eta_seconds)}` : 'queued')
return (
<div className="pin-body">
<div className="pin-thumb">{src ? <img src={src} alt="" /> : <span className="rc-ph">{run.kind}</span>}</div>
<div className="pin-txt">
<div className="pin-prompt" title={run.prompt}>{run.prompt || `(${run.kind})`}</div>
<div className="pin-meta">{badge} · {run.node} · {relTime(run.ts, now)}</div>
</div>
</div>
)
}
function SecHeader({ label, n, collapsed, onToggle, search }:
{ label: string; n: number; collapsed: boolean; onToggle: () => void; search?: React.ReactNode }) {
return (
<div className="sec-h">
<button className="sec-toggle" onClick={onToggle} aria-expanded={!collapsed}>
<span className="chev">{collapsed ? '▸' : '▾'}</span>
<span className="sec-lab">{label}</span>
<span className="sec-n">{n}</span>
</button>
{search}
</div>
)
}
function TemplateOffer({ run, onClose, onError }: { run: Run; onClose: () => void; onError: (m: string) => void }) {
const [stage, setStage] = useState<'offer' | 'name'>('offer')
const [name, setName] = useState(idLabel(run))
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
async function save() {
setBusy(true)
try {
await saveTemplate(name.trim() || idLabel(run), { kind: run.kind, prompt: run.prompt, refs: run.refs, parents: run.parents })
setDone(true); setTimeout(onClose, 900)
} catch (e) { onError((e as Error).message); onClose() } finally { setBusy(false) }
}
return (
<div className="ovl" onClick={onClose}>
<div className="sheet" onClick={e => e.stopPropagation()}>
{done ? <div className="sheet-h">Saved to templates </div>
: stage === 'offer' ? (
<>
<div className="sheet-h">Turn this into a template?</div>
<p className="muted">Reuse this flow {run.kind}, its instruction and {run.refs.length} reference{run.refs.length === 1 ? '' : 's'} as a starting point next time.</p>
<div className="sheet-row"><button className="pri" onClick={() => setStage('name')}>Name it</button><button onClick={onClose}>Not now</button></div>
</>
) : (
<>
<div className="sheet-h">Name this template</div>
<input className="sheet-in" value={name} autoFocus onChange={e => setName(e.target.value)} placeholder="Template name" />
<p className="muted">Captures: {run.kind} · {run.prompt || '(no instruction)'} · {run.refs.length} ref{run.refs.length === 1 ? '' : 's'}</p>
<div className="sheet-row"><button className="pri" disabled={busy || !name.trim()} onClick={save}>{busy ? 'Saving…' : 'Save template'}</button><button onClick={onClose}>Cancel</button></div>
</>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More