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.
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>
- 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>
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>
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>
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>
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>
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>
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).
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.
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).
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.
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.
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.
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.
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>
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>
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>
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.
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.
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>
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.
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.
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.
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.
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.
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).
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.
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'.
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).
`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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.