4992 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.
v0.18.3
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 v0.17.24 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