feat(setups): add published setup execution API

This commit is contained in:
malkuthro
2026-06-07 22:30:36 +02:00
parent e19d16a329
commit 591b8964f5
7 changed files with 826 additions and 3 deletions
+7
View File
@@ -15,6 +15,13 @@ The format is inspired by Keep a Changelog and SemVer.
a bundled sample fixture, and read-only external catalog endpoints:
`GET /koolook/api/setups` for summaries and
`GET /koolook/api/setups/{id}` for detail contracts.
- **Published setup execution API.** Added the first external run path for
callable published setups: `POST /koolook/api/setups/{id}/run` clones the
stored API prompt, accepts only declared setup/app inputs, injects approved
values, submits the prompt to ComfyUI `/prompt`, and returns a stable Koolook
run id plus ComfyUI prompt id. `GET /koolook/api/runs/{runId}` translates
ComfyUI history/queue data into queued/running/succeeded/failed state and
exposes published output-contract items for external frontends.
- **Sidebar publish flow for callable setups.** Saved sidebar workflows now
expose an explicit **Publish setup...** context-menu action that captures
setup metadata, source workflow reference, input/output contract JSON, and
+94
View File
@@ -124,6 +124,16 @@ External callers should use:
- `PublishedSetupRegistry.getSetup(id)`
- `PublishedSetupRegistry.publishSetup(visualGraph, metadata, inputContract, outputContract, source)`
Execution lives behind [`../../koolook_setup_runner.py`](../../koolook_setup_runner.py).
External routes and future app adapters should use:
- `PublishedSetupRunner.runSetup(id, inputs)`
- `PublishedSetupRunner.getRun(runId)`
The runner hides prompt cloning, input injection, ComfyUI `/prompt`
submission, run-id mapping, history/queue polling, and output flattening from
route callers.
Invalid setup objects are omitted from list/detail results. Diagnostics are
kept on `registry.diagnostics` and logged by the HTTP adapter. File-level
storage diagnostics, such as unreadable JSON, are reported through the same
@@ -192,6 +202,90 @@ Success returns `{ "ok": true, "setup": { ... } }` with the generated
`apiPrompt` stored alongside the original `visualGraph`. Validation failures
return HTTP `400` with `{ "ok": false, "errors": [...] }`.
## Run API
`POST /koolook/api/setups/{id}/run`
Queues a callable published setup on the managed ComfyUI server. The body must
be a JSON object with an `inputs` object:
```json
{
"inputs": {
"prompt": "A cinematic close-up"
}
}
```
Only fields declared by `inputContract.inputs` or `setupSurface.app.inputs`
are accepted. `setupSurface.app.switch` is also accepted when present. The
runner deep-clones the stored `apiPrompt`, injects approved values into their
declared targets, and submits `{ "prompt": <cloned prompt> }` to ComfyUI
`/prompt`; the stored setup record is not mutated by a run request.
Success returns:
```json
{
"ok": true,
"run": {
"runId": "run-000001",
"promptId": "f0c2...",
"status": "queued"
}
}
```
Failure responses use the standard route error shape:
```json
{
"ok": false,
"errors": ["input 'seed' is not declared by this setup"]
}
```
Missing setup ids return `404`; invalid inputs and non-callable draft setups
return `400`; ComfyUI queue failures return `502`.
`GET /koolook/api/runs/{runId}`
Looks up a stable Koolook run id and translates ComfyUI history/queue data into
external state:
```json
{
"ok": true,
"run": {
"runId": "run-000001",
"setupId": "ltx-director-demo",
"promptId": "f0c2...",
"status": "succeeded",
"outputs": [
{
"key": "video",
"label": "Video",
"type": "video",
"items": [
{
"nodeId": "20",
"kind": "videos",
"filename": "demo.mp4",
"subfolder": "koolook",
"type": "output"
}
]
}
]
}
}
```
Status is one of `queued`, `running`, `succeeded`, or `failed`. History entries
produce terminal state and output items; otherwise the runner checks ComfyUI
queue data to distinguish `running` from still `queued`. Unknown run ids return
`404`; ComfyUI status lookup failures return `502`.
## Comfy-Native Setup Surface
Issue #219 amends the #209 direction: curators should define a setup's app
+64 -1
View File
@@ -36,8 +36,20 @@ from aiohttp import web
try:
from .koolook_setups import PublishedSetupRegistry, default_registry
from .koolook_setup_runner import (
AiohttpComfyClient,
InMemorySetupRunStore,
PublishedSetupRunner,
SetupRunError,
)
except ImportError: # pragma: no cover - standalone test/import context
from koolook_setups import PublishedSetupRegistry, default_registry
from koolook_setup_runner import (
AiohttpComfyClient,
InMemorySetupRunStore,
PublishedSetupRunner,
SetupRunError,
)
ENV_VAR = "KFORGELABS_PRESETS"
DEFAULT_SUBDIR = "koolook-presets"
@@ -384,7 +396,7 @@ def _resolve_target(lib_base: Path, subdir: str, name: str) -> tuple[Path, Path]
return target_dir, file_path
def register_routes(routes, setup_registry_factory=None) -> None:
def register_routes(routes, setup_registry_factory=None, setup_runner_factory=None) -> None:
"""Attach the preset endpoints to the given aiohttp ``RouteTableDef``.
Called once from ``__init__.py`` at custom-node load time. Splitting
@@ -393,6 +405,16 @@ def register_routes(routes, setup_registry_factory=None) -> None:
"""
if setup_registry_factory is None:
setup_registry_factory = default_registry
run_store = InMemorySetupRunStore()
def _default_setup_runner(request, registry: PublishedSetupRegistry) -> PublishedSetupRunner:
base_url = f"{request.scheme}://{request.host}"
return PublishedSetupRunner(registry, AiohttpComfyClient(base_url), run_store)
def _setup_runner(request, registry: PublishedSetupRegistry):
if setup_runner_factory is not None:
return setup_runner_factory()
return _default_setup_runner(request, registry)
def _log_setup_diagnostics(registry: PublishedSetupRegistry) -> None:
for diagnostic in registry.diagnostics:
@@ -445,6 +467,47 @@ def register_routes(routes, setup_registry_factory=None) -> None:
)
return web.json_response({"ok": True, "setup": result.setup})
@routes.post("/koolook/api/setups/{setup_id}/run")
async def run_published_setup(request):
try:
payload = await request.json()
except Exception as exc:
return web.json_response(
{"ok": False, "errors": [f"Body must be JSON: {exc}"]},
status=400,
)
if not isinstance(payload, dict):
return web.json_response(
{"ok": False, "errors": ["Body must be a JSON object."]},
status=400,
)
registry: PublishedSetupRegistry = setup_registry_factory()
runner = _setup_runner(request, registry)
try:
result = await runner.runSetup(
request.match_info["setup_id"],
payload.get("inputs", {}),
)
except SetupRunError as exc:
return web.json_response(
{"ok": False, "errors": exc.errors},
status=exc.status_code,
)
return web.json_response({"ok": True, "run": result})
@routes.get("/koolook/api/runs/{run_id}")
async def get_published_setup_run(request):
registry: PublishedSetupRegistry = setup_registry_factory()
runner = _setup_runner(request, registry)
try:
result = await runner.getRun(request.match_info["run_id"])
except SetupRunError as exc:
return web.json_response(
{"ok": False, "errors": exc.errors},
status=exc.status_code,
)
return web.json_response({"ok": True, "run": result})
@routes.get("/koolook/presets/info")
async def info(_request):
base, source = _configured_dir()
+317
View File
@@ -0,0 +1,317 @@
"""Execution boundary for published Koolook workflow setups."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
try:
from .koolook_setups import PublishedSetupRegistry
except ImportError: # pragma: no cover - standalone test/import context
from koolook_setups import PublishedSetupRegistry
class SetupRunError(Exception):
"""API-facing setup runner error with stable HTTP status semantics."""
def __init__(self, status_code: int, errors: list[str]) -> None:
super().__init__("; ".join(errors))
self.status_code = status_code
self.errors = errors
@dataclass(frozen=True)
class RunRecord:
run_id: str
setup_id: str
prompt_id: str
status: str
class InMemorySetupRunStore:
"""Small run-id mapping store for a running ComfyUI process."""
def __init__(self) -> None:
self._next_id = 1
self._records: dict[str, RunRecord] = {}
def create(self, *, setup_id: str, prompt_id: str) -> RunRecord:
run_id = f"run-{self._next_id:06d}"
self._next_id += 1
record = RunRecord(
run_id=run_id,
setup_id=setup_id,
prompt_id=prompt_id,
status="queued",
)
self._records[run_id] = record
return record
def get(self, run_id: str) -> RunRecord | None:
return self._records.get(run_id)
class AiohttpComfyClient:
"""HTTP adapter for the managed ComfyUI server."""
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
async def queue_prompt(self, prompt: dict) -> dict:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(f"{self._base_url}/prompt", json={"prompt": prompt}) as response:
try:
payload = await response.json()
except Exception:
payload = {"error": await response.text()}
if response.status >= 400:
detail = payload.get("error") if isinstance(payload, dict) else payload
raise SetupRunError(
502,
[f"ComfyUI queue request failed with HTTP {response.status}: {detail}"],
)
return payload if isinstance(payload, dict) else {}
async def get_history(self, prompt_id: str) -> dict:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"{self._base_url}/history/{prompt_id}") as response:
try:
payload = await response.json()
except Exception:
payload = {"error": await response.text()}
if response.status >= 400:
detail = payload.get("error") if isinstance(payload, dict) else payload
raise SetupRunError(
502,
[f"ComfyUI history request failed with HTTP {response.status}: {detail}"],
)
return payload if isinstance(payload, dict) else {}
async def get_queue(self) -> dict:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"{self._base_url}/queue") as response:
try:
payload = await response.json()
except Exception:
payload = {"error": await response.text()}
if response.status >= 400:
detail = payload.get("error") if isinstance(payload, dict) else payload
raise SetupRunError(
502,
[f"ComfyUI queue status request failed with HTTP {response.status}: {detail}"],
)
return payload if isinstance(payload, dict) else {}
class PublishedSetupRunner:
"""Runs callable published setups without exposing ComfyUI internals."""
def __init__(
self,
registry: PublishedSetupRegistry,
comfy_client: Any,
run_store: InMemorySetupRunStore | None = None,
) -> None:
self._registry = registry
self._comfy_client = comfy_client
self._run_store = run_store or InMemorySetupRunStore()
async def runSetup(self, setup_id: str, inputs: dict[str, Any]) -> dict[str, Any]:
setup = self._registry.getSetup(setup_id)
if setup is None:
raise SetupRunError(404, [f"Published setup '{setup_id}' not found."])
if not isinstance(inputs, dict):
raise SetupRunError(400, ["inputs must be a JSON object"])
api_prompt = setup.get("apiPrompt")
if not isinstance(api_prompt, dict) or setup.get("validation", {}).get("status") != "valid":
raise SetupRunError(400, [f"Published setup '{setup_id}' is not callable."])
prompt = deepcopy(api_prompt)
input_fields = _declared_input_fields(setup)
errors = _validate_run_inputs(input_fields, inputs)
if errors:
raise SetupRunError(400, errors)
for key, value in inputs.items():
field = input_fields[key]
target = field["target"]
prompt[str(target["node"])]["inputs"][str(target["input"])] = value
try:
queued = await self._comfy_client.queue_prompt(prompt)
except SetupRunError:
raise
except Exception as exc:
raise SetupRunError(502, [f"ComfyUI queue request failed: {exc}"]) from exc
prompt_id = queued.get("prompt_id") if isinstance(queued, dict) else None
if not isinstance(prompt_id, str) or not prompt_id:
raise SetupRunError(502, ["ComfyUI queue response did not include prompt_id"])
record = self._run_store.create(setup_id=setup_id, prompt_id=prompt_id)
return {"runId": record.run_id, "promptId": record.prompt_id, "status": record.status}
async def getRun(self, run_id: str) -> dict[str, Any]:
record = self._run_store.get(run_id)
if record is None:
raise SetupRunError(404, [f"Koolook run '{run_id}' not found."])
setup = self._registry.getSetup(record.setup_id)
if setup is None:
raise SetupRunError(404, [f"Published setup '{record.setup_id}' not found."])
try:
history = await self._comfy_client.get_history(record.prompt_id)
except SetupRunError:
raise
except Exception as exc:
raise SetupRunError(502, [f"ComfyUI history request failed: {exc}"]) from exc
history_entry = history.get(record.prompt_id) if isinstance(history, dict) else None
if isinstance(history_entry, dict):
status = _status_from_history(history_entry)
return {
"runId": record.run_id,
"setupId": record.setup_id,
"promptId": record.prompt_id,
"status": status,
"outputs": _summarize_outputs(setup, history_entry.get("outputs", {})),
}
try:
queue = await self._comfy_client.get_queue()
except SetupRunError:
raise
except Exception as exc:
raise SetupRunError(502, [f"ComfyUI queue status request failed: {exc}"]) from exc
return {
"runId": record.run_id,
"setupId": record.setup_id,
"promptId": record.prompt_id,
"status": _status_from_queue(record.prompt_id, queue),
"outputs": [],
}
def _declared_input_fields(setup: dict[str, Any]) -> dict[str, dict[str, Any]]:
fields: dict[str, dict[str, Any]] = {}
for field in setup.get("inputContract", {}).get("inputs", []):
if not isinstance(field, dict):
continue
key = field.get("key")
target = field.get("target")
if isinstance(key, str) and key and isinstance(target, dict):
fields[key] = field
app = setup.get("setupSurface", {}).get("app", {})
if isinstance(app, dict):
for field in app.get("inputs", []):
if not isinstance(field, dict):
continue
key = field.get("key")
target = field.get("target")
if isinstance(key, str) and key and isinstance(target, dict):
fields.setdefault(key, field)
switch = app.get("switch")
if isinstance(switch, dict):
key = switch.get("key")
target = switch.get("target")
if isinstance(key, str) and key and isinstance(target, dict):
fields.setdefault(key, switch)
return fields
def _validate_run_inputs(input_fields: dict[str, dict[str, Any]], inputs: dict[str, Any]) -> list[str]:
errors: list[str] = []
for key in inputs:
if key not in input_fields:
errors.append(f"input '{key}' is not declared by this setup")
for key, field in input_fields.items():
if field.get("required") is True and key not in inputs:
errors.append(f"required input '{key}' is missing")
return errors
def _status_from_history(history_entry: dict[str, Any]) -> str:
status = history_entry.get("status")
if not isinstance(status, dict):
return "succeeded"
status_text = str(status.get("status_str", "")).lower()
if status.get("completed") is True and status_text not in {"error", "failed", "failure"}:
return "succeeded"
return "failed"
def _status_from_queue(prompt_id: str, queue: Any) -> str:
if not isinstance(queue, dict):
return "queued"
if _queue_contains_prompt(queue.get("queue_running", []), prompt_id):
return "running"
if _queue_contains_prompt(queue.get("queue_pending", []), prompt_id):
return "queued"
return "queued"
def _queue_contains_prompt(entries: Any, prompt_id: str) -> bool:
if not isinstance(entries, list):
return False
for entry in entries:
if isinstance(entry, dict) and entry.get("prompt_id") == prompt_id:
return True
if isinstance(entry, list) and prompt_id in entry:
return True
return False
def _summarize_outputs(setup: dict[str, Any], raw_outputs: Any) -> list[dict[str, Any]]:
output_items = _flatten_history_outputs(raw_outputs)
summaries = []
for output in setup.get("outputContract", {}).get("outputs", []):
if not isinstance(output, dict):
continue
summaries.append(
{
"key": output.get("key", ""),
"label": output.get("label", output.get("key", "")),
"type": output.get("type", ""),
"items": [
item
for item in output_items
if _history_output_matches_contract(item, str(output.get("type", "")))
],
}
)
return summaries
def _flatten_history_outputs(raw_outputs: Any) -> list[dict[str, Any]]:
if not isinstance(raw_outputs, dict):
return []
items: list[dict[str, Any]] = []
for node_id, node_outputs in raw_outputs.items():
if not isinstance(node_outputs, dict):
continue
for kind, values in node_outputs.items():
if not isinstance(values, list):
continue
for value in values:
if isinstance(value, dict):
item = {"nodeId": str(node_id), "kind": str(kind)}
item.update(value)
items.append(item)
return items
def _history_output_matches_contract(item: dict[str, Any], contract_type: str) -> bool:
kind = str(item.get("kind", "")).lower()
if contract_type == "video":
return kind in {"video", "videos", "animated"}
if contract_type == "image":
return kind in {"image", "images"}
return True
+1
View File
@@ -165,6 +165,7 @@ RUNTIME_PATHS: tuple[str, ...] = (
"k_video_load.py",
"koolook_install_guard.py",
"koolook_routes.py",
"koolook_setup_runner.py",
"koolook_setups.py",
"koolook_versioning.py",
"forks",
+171 -2
View File
@@ -8,6 +8,7 @@ from aiohttp import web
from aiohttp.test_utils import make_mocked_request
import koolook_routes
from koolook_setup_runner import PublishedSetupRunner
from koolook_setups import PublishedSetupRegistry, StaticSetupStorage
from tests.server.test_published_setup_registry import _valid_setup
@@ -171,9 +172,177 @@ def test_publish_route_rejects_missing_metadata_with_clear_error() -> None:
asyncio.run(exercise())
def _app_with_registry(registry: PublishedSetupRegistry) -> web.Application:
def test_run_route_queues_callable_setup_and_returns_stable_run_id() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
comfy = _FakeComfyClient()
app = _app_with_registry(
registry,
setup_runner_factory=lambda: PublishedSetupRunner(registry, comfy),
)
response = await _handle_json(
app,
"POST",
"/koolook/api/setups/ltx-director-demo/run",
{"inputs": {"prompt": "external prompt"}},
)
assert response.status == 200
assert _json_body(response) == {
"ok": True,
"run": {
"runId": "run-000001",
"promptId": "comfy-prompt-1",
"status": "queued",
},
}
assert comfy.submitted_prompts == [
{"12": {"class_type": "Text Multiline", "inputs": {"text": "external prompt"}}}
]
asyncio.run(exercise())
def test_run_status_route_reports_succeeded_outputs_from_comfy_history() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
comfy = _FakeComfyClient(
history={
"comfy-prompt-1": {
"status": {"completed": True, "status_str": "success"},
"outputs": {
"20": {
"videos": [
{
"filename": "demo.mp4",
"subfolder": "koolook",
"type": "output",
}
]
}
},
}
}
)
runner = PublishedSetupRunner(registry, comfy)
app = _app_with_registry(registry, setup_runner_factory=lambda: runner)
run_response = await _handle_json(
app,
"POST",
"/koolook/api/setups/ltx-director-demo/run",
{"inputs": {"prompt": "external prompt"}},
)
run_id = _json_body(run_response)["run"]["runId"]
status_response = await _handle(app, "GET", f"/koolook/api/runs/{run_id}")
assert status_response.status == 200
assert _json_body(status_response) == {
"ok": True,
"run": {
"runId": "run-000001",
"setupId": "ltx-director-demo",
"promptId": "comfy-prompt-1",
"status": "succeeded",
"outputs": [
{
"key": "video",
"label": "Video",
"type": "video",
"items": [
{
"nodeId": "20",
"kind": "videos",
"filename": "demo.mp4",
"subfolder": "koolook",
"type": "output",
}
],
}
],
},
}
asyncio.run(exercise())
def test_run_route_returns_clear_errors_for_invalid_inputs() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
app = _app_with_registry(
registry,
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
)
response = await _handle_json(
app,
"POST",
"/koolook/api/setups/ltx-director-demo/run",
{"inputs": {"prompt": "ok", "seed": 123}},
)
assert response.status == 400
assert _json_body(response) == {
"ok": False,
"errors": ["input 'seed' is not declared by this setup"],
}
asyncio.run(exercise())
def test_run_route_returns_clear_error_for_missing_setup() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
app = _app_with_registry(
registry,
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
)
response = await _handle_json(
app,
"POST",
"/koolook/api/setups/missing/run",
{"inputs": {"prompt": "ok"}},
)
assert response.status == 404
assert _json_body(response) == {
"ok": False,
"errors": ["Published setup 'missing' not found."],
}
asyncio.run(exercise())
class _FakeComfyClient:
def __init__(self, *, history=None, queue=None) -> None:
self.submitted_prompts: list[dict] = []
self.history = history or {}
self.queue = queue or {"queue_running": [], "queue_pending": []}
async def queue_prompt(self, prompt: dict) -> dict:
self.submitted_prompts.append(prompt)
return {"prompt_id": "comfy-prompt-1"}
async def get_history(self, prompt_id: str) -> dict:
return {prompt_id: self.history[prompt_id]} if prompt_id in self.history else {}
async def get_queue(self) -> dict:
return self.queue
def _app_with_registry(
registry: PublishedSetupRegistry,
*,
setup_runner_factory=None,
) -> web.Application:
routes = web.RouteTableDef()
koolook_routes.register_routes(routes, setup_registry_factory=lambda: registry)
koolook_routes.register_routes(
routes,
setup_registry_factory=lambda: registry,
setup_runner_factory=setup_runner_factory,
)
app = web.Application()
app.add_routes(routes)
return app
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import asyncio
from copy import deepcopy
import pytest
from koolook_setup_runner import PublishedSetupRunner, SetupRunError
from koolook_setups import PublishedSetupRegistry, StaticSetupStorage
from tests.server.test_published_setup_registry import _valid_setup
class FakeComfyClient:
def __init__(self, prompt_response=None, *, history=None, queue=None, queue_error=None) -> None:
self.prompt_response = prompt_response or {"prompt_id": "comfy-prompt-1"}
self.submitted_prompts: list[dict] = []
self.history = history or {}
self.queue = queue or {"queue_running": [], "queue_pending": []}
self.queue_error = queue_error
async def queue_prompt(self, prompt: dict) -> dict:
if self.queue_error is not None:
raise self.queue_error
self.submitted_prompts.append(prompt)
return self.prompt_response
async def get_history(self, prompt_id: str) -> dict:
return {prompt_id: self.history[prompt_id]} if prompt_id in self.history else {}
async def get_queue(self) -> dict:
return self.queue
def test_run_setup_injects_declared_inputs_into_cloned_prompt() -> None:
async def exercise() -> None:
setup = _valid_setup()
original_prompt = deepcopy(setup["apiPrompt"])
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
comfy = FakeComfyClient()
runner = PublishedSetupRunner(registry, comfy)
result = await runner.runSetup("ltx-director-demo", {"prompt": "external prompt"})
assert result == {
"runId": "run-000001",
"promptId": "comfy-prompt-1",
"status": "queued",
}
assert comfy.submitted_prompts == [
{"12": {"class_type": "Text Multiline", "inputs": {"text": "external prompt"}}}
]
assert setup["apiPrompt"] == original_prompt
asyncio.run(exercise())
def test_run_setup_rejects_inputs_not_declared_by_contract() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
runner = PublishedSetupRunner(registry, FakeComfyClient())
with pytest.raises(SetupRunError) as exc_info:
await runner.runSetup("ltx-director-demo", {"prompt": "ok", "seed": 123})
assert exc_info.value.status_code == 400
assert exc_info.value.errors == ["input 'seed' is not declared by this setup"]
asyncio.run(exercise())
def test_run_setup_rejects_non_callable_setup() -> None:
async def exercise() -> None:
setup = _valid_setup()
setup["apiPrompt"] = None
setup["validation"] = {"status": "draft", "diagnostics": ["API prompt conversion pending."]}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
runner = PublishedSetupRunner(registry, FakeComfyClient())
with pytest.raises(SetupRunError) as exc_info:
await runner.runSetup("ltx-director-demo", {"prompt": "external prompt"})
assert exc_info.value.status_code == 400
assert exc_info.value.errors == ["Published setup 'ltx-director-demo' is not callable."]
asyncio.run(exercise())
def test_run_setup_translates_comfy_queue_failures() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
runner = PublishedSetupRunner(registry, FakeComfyClient(queue_error=RuntimeError("server down")))
with pytest.raises(SetupRunError) as exc_info:
await runner.runSetup("ltx-director-demo", {"prompt": "external prompt"})
assert exc_info.value.status_code == 502
assert exc_info.value.errors == ["ComfyUI queue request failed: server down"]
asyncio.run(exercise())
def test_get_run_reports_running_state_from_comfy_queue() -> None:
async def exercise() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
runner = PublishedSetupRunner(
registry,
FakeComfyClient(queue={"queue_running": [[7, "comfy-prompt-1", {}]], "queue_pending": []}),
)
queued = await runner.runSetup("ltx-director-demo", {"prompt": "external prompt"})
status = await runner.getRun(queued["runId"])
assert status == {
"runId": "run-000001",
"setupId": "ltx-director-demo",
"promptId": "comfy-prompt-1",
"status": "running",
"outputs": [],
}
asyncio.run(exercise())
def test_run_setup_accepts_inputs_declared_by_app_surface_contract() -> None:
async def exercise() -> None:
setup = _valid_setup()
setup["inputContract"] = {"inputs": []}
setup["outputContract"] = {"outputs": []}
setup["visualGraph"]["groups"] = [
{"title": "Koolook Input", "bounding": [0, 0, 300, 200]},
{"title": "Koolook Output", "bounding": [400, 0, 300, 200]},
]
setup["visualGraph"]["nodes"][0]["pos"] = [20, 20]
setup["setupSurface"] = {
"sourceInputs": [
{
"group": "Koolook Input",
"nodes": [{"id": "12", "type": "Text Multiline", "title": "Prompt"}],
}
],
"outputs": [
{
"group": "Koolook Output",
"nodes": [{"id": "20", "type": "Preview Image", "title": "Preview"}],
}
],
"controls": [],
"app": {
"inputs": [
{
"key": "prompt",
"label": "Prompt",
"visible": True,
"target": {"node": "12", "input": "text"},
"default": "demo prompt",
}
],
"outputs": [],
"results": [],
},
}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
comfy = FakeComfyClient()
runner = PublishedSetupRunner(registry, comfy)
await runner.runSetup("ltx-director-demo", {"prompt": "app contract prompt"})
assert comfy.submitted_prompts == [
{"12": {"class_type": "Text Multiline", "inputs": {"text": "app contract prompt"}}}
]
asyncio.run(exercise())