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.
This commit is contained in:
hanzo-dev
2026-07-12 22:05:54 -07:00
committed by Hanzo AI
parent 203375e5db
commit 50c338ecfd
6 changed files with 413 additions and 1 deletions
+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)
+21
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))
+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
+6
View File
@@ -446,6 +446,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return web.json_response({"error": "Invalid or expired token"}, status=401)
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.
+7
View File
@@ -1066,6 +1066,13 @@ class PromptServer():
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
# 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
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
# render backend — it must queue locally, never re-dispatch.
dispatched = False
@@ -0,0 +1,185 @@
"""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():
import execution
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS