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.
This commit is contained in:
Hanzo AI
2026-07-16 21:50:15 -07:00
parent d00a0580d8
commit 963272ba5c
3 changed files with 82 additions and 5 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
+42 -2
View File
@@ -38,6 +38,7 @@ import os
import random
import re
import time
import uuid
from pathlib import Path
import aiohttp
@@ -691,6 +692,39 @@ def _safe_subpath(raw: str) -> str | None:
return sub if _SUBPATH_RE.fullmatch(sub) else None
def _row_output(root: Path | None, output_prefix: str | None) -> str | None:
"""The library-relative output for a work-log row: the file a SaveImage prefix
produced, or — for an INGESTED render whose output_prefix is already the stored
file's path — that path itself. Lets a mirrored render show its thumbnail and open
its graph, without a SaveImage-style counter suffix."""
landed = _landed_output(root, output_prefix)
if landed is None and root is not None and output_prefix and _safe_asset(root, output_prefix) is not None:
landed = output_prefix
return landed
def _index_upload(org: str, root: Path, rel: str, *, design, kind, role, node) -> None:
"""Event-driven index at the ingest point so a just-stored render is INSTANTLY
findable — in Assets (a library.json entry) and in Queue & History (a done
work-log row carrying its source node) — without waiting for the manifest sweeper.
Atomic per-org writes like every other metadata file; upserts by path so a
re-render of the same name never duplicates the asset."""
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
lib = _load_library(root)
assets = lib.setdefault("assets", [])
ex = next((a for a in assets if a.get("path") == rel), None)
if ex is None:
assets.append({"path": rel, "design": design, "kind": kind, "role": role,
"status": "draft", "tags": [], "updatedAt": now_iso})
else:
ex.update(design=design, kind=kind, role=role, updatedAt=now_iso)
_save_library(root, lib)
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
output_prefix=rel, node=node, status="done",
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
pid="up-" + uuid.uuid4().hex[:12])
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
@routes.get("/studio")
@@ -723,7 +757,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
if row is None:
return web.json_response({"error": "not found"}, status=404)
root = _library_root(org)
landed = _landed_output(root, row.get("output_prefix"))
landed = _row_output(root, row.get("output_prefix"))
p = _safe_asset(root, landed) if (root and landed) else None
if p is None or p.suffix.lower() != ".png":
return web.json_response({"error": "workflow not available yet"}, status=404)
@@ -936,6 +970,12 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(dest)
# index at ingest → instantly findable in Assets + Queue & History (with node)
_index_upload(org, out_root, rel,
design=request.query.get("design") or None,
kind=request.query.get("kind") or "render",
role=request.query.get("role") or None,
node=request.query.get("node") or "upload")
return web.json_response({"ok": True, "path": rel})
@routes.get("/v1/gpu-status")
@@ -1152,7 +1192,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
out = []
for r in reversed(rows):
row = dict(r)
landed = _landed_output(root, r.get("output_prefix"))
landed = _row_output(root, r.get("output_prefix"))
row["output"] = landed
if landed and r.get("status") not in ("failed", "cancelled"):
row["status"] = "done"
@@ -530,3 +530,39 @@ async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path,
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()