---
title: Node-граф
description: Типізований DAG-executor і canvas-редактор — нода не знає про граф, executor рахує хвилями.
---

Спрощений аналог ComfyUI: типізований DAG-executor плюс canvas-редактор. Користувач збирає граф
із нод (текст, LLM, генерація зображень/відео/озвучки, монтаж), зберігає, запускає — воркер рахує
граф хвилями, прогрес летить у браузер по WebSocket.

Ключова ідея: **нода не знає про граф**. Вона оголошує типізовані входи/виходи і `run()`; реєстр
і executor роблять решту, а фронтенд малює ноду з даних `GET /nodes`. Додати ноду = написати
клас (приклад — `packages/kumquat-graphs/README.md`).

Основні файли:

- `packages/kumquat-graphs/src/kumquat_graphs/engine/` — рушій: `ports.py`, `registry.py`,
  `graph.py` (валідація + топосорт), `executor.py`, `node.py`, `services.py`, `storage.py`,
  `factory.py` (які графи потребують платних провайдерів і preflight балансу).
  - `engine/nodes/` — самі ноди: `text.py`, `flow.py`, `llm.py`, `media.py`, `compose.py`;
  - `engine/nodes/kie/` — каталог моделей і фабрика генеративних нод.
- `kumquat_core/integrations/` — клієнти: `kie.py` (kie.ai), `gemini.py` / `openrouter.py` (LLM),
  `s3.py`. Прогрес і скасування приходять колбеками, тому клієнт не знає про рушій.
- `kumquat_api/generation.py` — збірка `NodeServices` на запуск (kie + S3 + LLM).
- `kumquat_api/routes/graphs.py` — графи, каталог нод і шаблони;
  `graph_runs.py` — запуски та WebSocket; `graph_history.py` — історія;
  `graph_common.py` — спільні перевірки.
- `kumquat_api/worker/tasks.py` — Celery-задача `tasks.graphs.execute`; `worker/cache.py` — кеш
  виходів (`MongoNodeCache`) і чекпойнти (`MongoCheckpointStore`).
- `frontend/src/modules/canvas/` — редактор на `@xyflow/react`.


## Порти й ноди

`PortType`: `string`, `integer`, `float`, `boolean`, `object`, `image`, `video`, `audio`.
Сумісність ребра — **строга рівність типів**, неявних приведень немає.

`InputPort(name, type, label, required, default, options, placeholder, ui_hint, is_list)`.
`options` робить із входу `<select>`, `is_list=True` — порт бере цілий список (див. «Списки й
фан-аут»), а `ui_hint` просить у канваса спеціальний віджет замість текстового поля:

| `ui_hint` | Віджет |
| --- | --- |
| `fields` | Рядки «назва + тип» — схема відповіді `llm_list` |
| `template` | Те саме поле, але `{field}` підсвічені як те, що прийде з `context` |
| `field_name` | Поле для назви поля, з підказкою імен, які оголошено вище |
`OutputPort` — те саме без віджетів, плюс `is_list`.

**`repeatable=True`** — не порт, а шаблон: нода отримує стільки пронумерованих портів
(`videos_1`, `videos_2`, …), скільки слотів їй задано власним параметром `<name>_count` (типово
два, стеля 12). Кожен слот — окремий handle на канвасі, тож порядок склеювання це ребро, яке
можна перетягнути, а не рядок у віджеті, який доводиться перебивати. Ноду це не стосується:
`collapse_inputs()` віддає слоти в `run()` одним списком під іменем шаблону, а порожній слот у
нього не потрапляє. Розгортання й згортання живуть у `ports.py`, а канвас дзеркалить те саме
правило в `inputSlots()`.

Нода — клас із `node_type`, `title`, `category`, `description`, `inputs`, `outputs` і async
`run()`. Плюс прапорці: `version` (входить у fingerprint кешу), `cacheable`, `generates`
(нода кличе модель — палітра сортує такі після звичайних) і `requires` — які поля `NodeServices`
їй потрібні (`kie`, `llm`, `media`). `run()` отримує розв'язані входи як kwargs і мусить
повернути dict рівно з оголошеними ключами виходів; значення мусять бути JSON-серіалізовними —
вони їдуть у Mongo, кеш і WebSocket.

`requires` живе на самій ноді, а не в списку, який запуск звіряє окремо: інакше нову ноду легко
забути в тому списку, і вона доїжджає до `run()` із порожнім `ctx.services`. `required_services()`
у `factory.py` просто збирає об'єднання по нодах графа.

Реєстрація — декоратор `@register_node` у глобальний `registry`; падає на порожній/зайнятий
`node_type` і на дублі імен портів. `registry.specs()` → `GET /nodes` → палітра фронтенду.
Ноди підключає `load_builtin_nodes()`, який викликають **обидва** процеси (роут і воркер) —
реєстри мусять збігатися.


## Каталог нод

| `node_type` | Категорія | Що робить |
| --- | --- | --- |
| `text_prompt` | text | Текст, який ви друкуєте: бриф, промпт, будь-що спільне для кількох нод |
| `concat_text` | text | Склеює тексти зі слотів через іменований сепаратор (blank line, space…) |
| `llm_text` | text | Один шматок тексту з LLM; `{field}` у промпті заповнюються з `context` |
| `llm_list` | text | N об'єктів за один виклик із полями, які ви назвали — **джерело фан-ауту** |
| `filter_items` | flow | Лишає елементи, у яких поле дорівнює значенню; порожній результат = гілка не виконується |
| `select_input` | flow | Зупиняє запуск і просить оператора вибрати з варіантів; нічого не пише сам |
| `image` · `video` · `audio` | media | URL, який ви вставили, **або** прохід того, що прийшло згори (з показом) |
| `compose_video` | video | ffmpeg-мукс: кліп + аудіо → один mp4, обрізаний до довжини хука; без під'єднаного аудіо кліп лишає своє |
| `concat_video` | video | Програє відео зі слотів одне за одним як один файл, не чіпаючи жодного |
| `analyze_video` | video | Дивиться кліп цілком і повертає об'єкт із полями, які ви назвали — контекст для промптів нижче |
| `z_image` | image | `z-image` через kie; найдешевша (~0.8 кредита) |
| `nano_banana` | image | `google/nano-banana` (~4 кредити) |
| `seedance_2_fast` | video | `bytedance/seedance-2-fast`; анімує `first_frame_url` |
| `kling_3_0` | video | `kling-3.0/video`; анімує `first_frame_url`, свої звукові ефекти |
| `kling_3_0_omni_transformation` | video | `kling-3.0-omni/transformation`; редагує наявний кліп (3–15.5 с) за промптом, рух і камера лишаються |
| `text_to_speech` | audio | `google/gemini-3-1-flash-tts`: один рядок одним голосом |

`image`/`video`/`audio` навмисно суміщають «джерело» і «прев'ю»: вставлений URL стає таким самим
артефактом `{"url": ...}`, який віддають генеративні ноди, тож нижче по графу різниці немає.

**Imagen 4 свідомо відсутній.** І `google/imagen4`, і `google/imagen4-fast` віддають миттєвий
`500 Internal Error` (0 кредитів, ~1 мс) на будь-який payload, включно з документованим прикладом
kie. Це не блок родини: `google/nano-banana` на тому ж ключі працює.


## Списки й фан-аут

Це те, що робить із графа «дай N варіантів» замість однієї гілки.

- **Список у скалярний порт** (`is_list=False`) розмножує ноду: один виклик на елемент, усі
  паралельно. Скалярні виходи такої ноди стають списками, list-виходи **конкатенуються**
  (шість варіацій кожного з двох піків — це дванадцять варіацій, а не два списки по шість).
- **Список у list-порт** передається цілим — це спосіб працювати з колекцією. З одним винятком,
  і він вирішальний: якщо цей список — результат розмноженої гілки (скалярний вихід ноди, яка
  сама виконалась по разу на елемент), то list-порт теж розмножується, і кожен виклик отримує
  свій елемент. Інакше `Join`, під'єднаний до варіацій, склеїв би їх усі в один файл замість
  того, щоб дозбирати кожну.
- **Списковий вихід розмноженої ноди — це колекція, а не елементи.** `vary.items` віддає плаский
  список усіх варіацій, тож `filter_items` бачить його цілим. Різниця між двома випадками — чи
  вихідний порт `is_list`, і саме вона робить правило однозначним: обидва приходять як список,
  відрізняє їх лише походження.
- **Скаляр у list-порт** загортається в список з одного елемента, тож `T -> list[T]` не потребує
  коду в ноді.
- Кілька розмножених входів **зіпуються за індексом**; список із одного елемента бродкаститься на
  всі виклики. Саме це дозволяє `compose(video_i, audio_i)` звичайними ребрами, без сабграфа.
  Різні довжини (крім 1) — помилка, а не мовчазне обрізання.
- **Порожній список = нуль викликів.** Нода все одно завершується (`item_count=0`) і віддає
  порожні списки, тож уся гілка під нею коштує нічого. На цьому тримаються `filter_items`.
- Ліміти: `MAX_NODE_FANOUT = 32` на ноду і `MAX_RUN_INVOCATIONS = 512` на запуск. Обидва — про
  гроші, а не пам'ять: LLM, який відповів 400 варіантами, має завалити запуск, а не рахунок.

**Кілька ребер в один вхід** приймає лише list-порт: там ребра — це внески в один список, і
конкатенація в порядку під'єднання — єдине, чим вони можуть бути. Усе інше бере одне значення;
щоб склеїти кілька текстів, є нода `concat_text`, де порядок і сепаратор видно на канвасі, а не
заховані в поведінці ребра.

Разом ці два правила дають те, заради чого вони існують: `concat_video`, у слоти якого заходять
варіації **і** незмінне тіло ролика, робить по одному готовому файлу на варіацію — кліп цієї
варіації плюс спільне тіло. Слот — звичайний скалярний порт, тож розмножена гілка в ньому
розмножує й саму ноду, а тіло з одним значенням бродкаститься на всі виклики.

Порядок розв'язання входу: **ребро > `params` > `default` порту**. Немає нічого й порт
обов'язковий → `NodeExecutionError`.


## Пауза на людину

`select_input` не має вибору серед своїх входів — він його *питає*. Якщо на цю ноду ще немає
відповіді, вона кидає `RunPaused`, запуск паркується в `awaiting_input`, а в документі лишається
`pending_input = {node_id, request}` з питанням і показаними варіантами.

`POST /runs/{id}/resume` записує відповідь (індекси обраних варіантів) у мапу `answers` і ставить
запуск у чергу знову. Граф виконується **з нуля**, але все вище відповідальної ноди читається з
кешу, тож двічі за те саме ніхто не платить. Мапа (а не одне поле) — щоб граф із кількома
воротами тримав попередні відповіді на кожному повторі. Сама нода `cacheable = False`: відповідь
не є її входом, тож fingerprint не описав би результат.


## Приклад: граф «Hook Variations»

Збережений граф у `node_graphs` — повний конвеєр варіацій хука для **RockIn**. Наразі націлений
на креатив-talking-head 9:16 (`ed959071…`, «Zodiac Signs and Crystals to Avoid»): ведуча в машині
з чорним турмаліном у руці, а далі — незмінне тіло ролика з інфографікою знаків зодіаку. Він же
найкраща ілюстрація механік вище.

```
brief (text_prompt) ──system──> усі 6 LLM-нод
                              │
                           dimensions (llm_list, count=3)
                              │ items: [{dimension, current_value, channel}]
                              ▼
                           gate (select_input, multi)     ← людина обирає, що варіювати
                              │ selected: список об'єктів
                              ▼  (у скалярний порт context ⇒ фан-аут)
                           vary (llm_list, count=2)
                              │ items: {dimension, channel, value}, пласкі
        ┌─────────────────────┼─────────────────────┐
   visual_only            voice_only           overlay_only     (filter_items за channel)
        │                     │                     │
   frame + motion           line                 caption        (llm_text, фан-аут по елементах)
        │                     │
   nano_banana → clip      speech (TTS)
        │                     │
   visual_cut             voice_cut                             (compose_video)
   (clip + базове аудіо)  (базовий кліп + нова озвучка)
        │                     │
   visual_full            voice_full  ← body (незмінне тіло)    (concat_video)
```

Як це читається механіками рушія:

1. `brief` — один `text_prompt`, підключений у `system` усіх LLM-нод. Одне джерело правди про
   креатив замість шести копій промпта.
2. `dimensions` описує **що є**, а не що змінити: `fields` тут — рядки з редактора полів, де
   `channel` — закритий набір (`video|voiceover|on-screen text`), який провайдер зобов'язаний
   дотримати. Саме тому на це поле можна безпечно маршрутизувати.
3. `gate` паркує запуск. Оператор бачить три виміри й обирає, скажімо, два.
4. Два обрані об'єкти прилітають у **скалярний** `context` ноди `vary` → вона виконується двічі,
   по разу на вимір, кожен раз повертаючи `count=2` варіанти. Її `items` — list-вихід, тож
   результати конкатенуються: на виході чотири варіації, а не два списки по дві.
5. Три `filter_items` розрізають ці чотири за `channel`. Вимір, якого ніхто не обрав, дає
   порожній список — і вся гілка під ним виконується нуль разів, безкоштовно.
6. Далі кожен `llm_text` знову розмножується — по разу на варіацію свого носія — і тягне за собою
   свою генерацію: `frame` → `nano_banana` → `first_frame_url` для `clip`, `line` → `speech`.
   Гілка `caption` закінчується текстом: екранний напис нічого не генерує.
7. `compose_video` мукситься проти **незмінного** базового треку: варіація озвучки бере старий
   кліп, а варіація відео лишає звук, який прийшов із самим кліпом (у `Audio` нічого не
   під'єднано). Так у A/B-тесті змінюється рівно один вимір.
8. `concat_video` доклеює тіло ролика до кожної варіації — те саме `body` на обидві гілки, бо
   скалярний вхід із одним значенням бродкаститься на всі елементи фан-ауту. На виході готовий
   до заливки mp4, а не самий хук.

Кеш і чекпойнти тут поелементні: змінили одну варіацію — переплачується тільки її гілка, решта
приходить із кешу (подія `node_cached` з `item_index`).

Відео в графі рахується в `480p` — на етапі тестування хука важливо, чи спрацювала ідея, а не
роздільність; це ж і дефолт порту `resolution`.


## Текстові ноди (LLM)

Провайдер налаштовується (`LLM__PROVIDER`): Gemini напряму або OpenRouter; `LLM__MODEL` іде з
провайдером у парі, бо id моделі різні (`gemini-3.1-flash` проти `google/gemini-3.1-flash`).
Клієнт синхронний (`google-genai` SDK), тому ноди кличуть його через `asyncio.to_thread` — інакше
одна генерація тексту блокувала б увесь executor.

Кожна нода **володіє своїм промптом**: поле `prompt` — шаблон, `{field}` заповнюються з об'єкта
`context` (списки й словники розгортаються в читабельний текст). **Увесь `context` дописується під
промпт незалежно від того, чи згадані його поля** — названі просто повторюються рядком нижче. Раніше
згадка одного поля відкидала решту, тож плейсхолдер, доданий для зручності, тихо звужував те, що
бачила модель. Немає контексту — промпт береться дослівно, тож literal-фігурні дужки не ламаються.
Невідоме поле — помилка з назвою поля. Порожні поля в дописаний блок не потрапляють.
Це єдиний порт із `ui_hint="template"`, тож канвас підсвічує в ньому `{field}` — видно, що саме
приїде з контексту, ще до запуску.

`llm_list` додатково перетворює `fields` на JSON-схему відповіді. Форма одна — та, яку пише
редактор полів на канвасі: `[{"name": …, "type": "text|number|boolean|enum", "values": [...]}]`,
і зберігається вона саме так — списком рядків, а не рядком JSON. Той самий рядок JSON
`parse_fields()` теж читає: ним збережені старіші графи, і саме він приїжджає ребром у порт.
`enum` — закритий набір, який провайдер зобов'язаний дотримати; це те, що робить поле придатним
для маршрутизації через `filter_items`. Вільний текст замість рядків відхиляється, а не
вгадується: схему, якої ніхто не обирав, провайдеру краще не відправляти.

Оголошені імена йдуть графом далі. `Node.field_source` каже, звідки нода бере ключі своїх
`object`-виходів (`"fields"` — з власного редактора, `"input:<порт>"` — з того, що прийшло), а
канвас іде цим уперед і показує кожній ноді, які `{імена}` їй доступні: невідомий плейсхолдер і
невідома назва поля у `pick_field`/`filter_items` підсвічуються одразу, а не на запуску. Де вище
нічого полів не оголошує, канвас мовчить — червона позначка, з якою нічого не зробиш, гірша за її
відсутність.

Кількість іде і у схему (`minItems == maxItems == count`), і окремим реченням у промпт: схема
обмежує відповідь, речення її пояснює. Стеля — 32 елементи.


## Аналіз відео (VLM)

`analyze_video` дивиться кліп цілком — рух, монтаж і звук, а не окремі кадри — і повертає об'єкт
із полями, які ви назвали тим самим редактором `fields`, що й `llm_list`. Це те, чого текстові
ноди не вміють: вони читають запис про кліп, а кліп, за яким запису немає (щойно експортований
монтаж, боді за URL), описувати нічим. Вихід — `OBJECT`, тобто та сама форма, яку бере порт
`context` генеративних текстових нод: аналіз доходить до промптів так само, як Pack чи Creative.

Ключ і модель — ті самі `LLM__*`, але провайдер має бути саме Gemini: файл їде через
**Gemini Files API**, чого OpenRouter не проксює. На `LLM__PROVIDER=openrouter` нода відмовляє
і називає налаштування, а решта графа працює як раніше. Нода тягне файл із медіа-сховища на диск
і віддає байти — провайдер не має доступу до нашого бакета, — а аплоуд видаляється одразу після
відповіді, успішної чи ні.

Саме на цій ноді тримається шаблон «Hook from a brief and a body» — вісім нод в один рядок: тіло
ролика → аналіз → бриф оператора → промпт хука → перший кадр → кліп → склейка з тілом. Ані
фан-ауту, ані воріт: хук уже вирішений брифом, лишається зняти його.


## Шаблони

`kumquat-cli seed-template-graphs` кладе на канвас три графи, з яких команда починає. Шаблон — це
граф **без власника** і з `template: true`. Обидві половини важливі: правила запису й так відхиляють
чужий граф («duplicate it to change it»), тож граф без власника лишається read-only для всіх без
окремого правила, а прапорець тримає ці три поза особистими списками — у списку графів вони за
власною вкладкою (`GET /graphs?templates=only`).

Кожен шаблон **клонується з живого графа** — визначення плюс його найсвіжіший успішний запуск, — тож
відкривається на реальних результатах, а не на порожніх нодах. Джерела не змінюються: шаблон це
копія, не конвертація. Повторний запуск команди освіжає шаблони на місці, лишаючи їм той самий id.


## Генеративні ноди (kie.ai)

Провайдер — **kie.ai Market API**: єдина пара `POST /api/v1/jobs/createTask` →
`GET /api/v1/jobs/recordInfo?taskId=` обслуговує всі моделі, тож `integrations/kie.py` — єдине
місце, що знає формат провайдера. Стани задачі: `waiting | queuing | generating | success | fail`.

**Одна нода = одна модель.** Дискавері — задача палітри (пошук + групування за `category`), не
дропдауна в ноді: у кожної моделі своя схема входів. Каталог (`nodes/kie/catalog.py`) — список
`ModelSpec`; `build_node()` робить із кожного клас зі спільним `run()`. **Додати модель = дописати
`ModelSpec`**: ні нового класу, ні змін у рушії, ні на фронті. `node_type` навмисно
провайдер-незалежний (`z_image`, не `kie_z_image`) — це ключ у збережених графах; перейменувати
його = осиротити всі графи, що на нього посилаються.

`ModelPort` описує, як порт потрапляє в `input` провайдера: скаляри — як є, медіа-порти віддають
`.url` зі свого артефакту. Якщо формат моделі взагалі не «значення на порт», спек має
`build_input` — так TTS показує на канвасі один рядок тексту, а провайдеру віддає каст і сценарій
справжніми масивами.

Хід одного `run()`:

1. є `ctx.checkpoint["task_id"]` → **не** створювати задачу, а дополлити наявну;
2. інакше `createTask` і **зразу** `save_checkpoint({"task_id": ...})`;
3. `poll()` до термінального стану, форвардячи прогрес і перевіряючи скасування;
4. `report_cost(creditsConsumed)`;
5. завантажити результат у S3, віддати артефакт `{url, key, sha256, model, prompt}`.

Помилки поділені так, як їх мусить трактувати викликач: `KieFatalError` (4xx, `state=fail`,
відхилений промпт) — ретрай лише заплатить ще раз за ту саму відмову; `KieTransientError`
(429/5xx, мережа, застій понад дедлайн) — ретрай із backoff.

`recordInfo` **не повертає жодного відсотка** — лише грубий `state`. Тому прогрес оцінюється:
підлога за станом плюс асимптотичне наближення за часом (стеля 0.95, τ = 60 с). Він лише зростає
й ніколи не досягає 1.0 — єдиний чесний сигнал завершення це термінальний стан. Стан їде в
`message`, тож у вузлі видно слово, а не тільки число.

Кредити **дробові** (зображення ~0.8), тож усі поля — `float`. Тривалість ноди рушій міряє сам:
`costTime` від kie показує 14 мс на картинку, тобто це не wall-clock.

Виконання лишається **інлайн-полінгом у Celery**: `execute_graph` має власні `time_limit=3600 /
soft_time_limit=3300`, решта черг тримають свої жорсткі ліміти.


## Сховище результатів

Провайдерські URL протухають, тож нода завантажує результат і кладе його в S3 під ключем від
**sha256 самих байтів**: `{ASSETS__GENERATED_PREFIX}/{sha256}/{node_type}.{ext}`. Бакет і CDN ті
самі, що в gdrive-sync — розділені лише префіксом (`videos/` проти `generations/`). Однаковий
контент перезаписує той самий об'єкт, тому ретрай ноди ідемпотентний. Роздача — CDN над бакетом
(`ASSETS__BASE_URL`), стабільними публічними URL без presigning. В артефакті лежить і `key`, щоб
URL можна було перерахувати, якщо CDN зміниться.

`compose_video` і `concat_video` користуються тим самим контрактом з іншого боку: `fetch()` кладе
входи на диск, ffmpeg робить mp4, `store_file()` повертає його назад. ffmpeg уже є в образі
воркера (пайплайн аналізу Drive), команди будуються чистими функціями, тож тестуються без
бінарника.

`concat_video` бере по кліпу на пронумерований слот (`repeatable`, див. «Порти й ноди») — так
само, як `concat_text` бере свої частини. Перед склеюванням нода вирівнює всі кліпи до
**найбільшого кадру й найвищої частоти** з набору (менший вписується з полями, а не
розтягується), а сегменту без звуку генерує тишу його довжини — інакше `concat` відмовиться
зшивати сегменти з різною кількістю потоків.

Окремої моделі ассета (галерея, provenance, дедуп між запусками) **немає** — тільки сховище.

Витрати: `creditsConsumed` пишеться на ноду і `$inc`-ом у суму запуску. **Кеш-хіт додає 0.** Перед
платним запуском воркер перевіряє баланс проти `KIE__MIN_CREDIT_BALANCE` і валить запуск ще до
`running`. Граф без генеративних нод не потребує ні ключа, ні бакета — незібрана можливість
лишається `None`, і про неї скаржиться саме та нода, якій вона знадобилась.


## Виконання

Граф приходить із фронтенду як JSON; `params` ключуються **іменами вхідних портів** (окремого
простору налаштувань ноди немає):

```json
{
  "nodes": [{"id": "n1", "type": "text_prompt", "params": {"text": "cat"}}],
  "edges": [{"source": "n1", "source_output": "text",
             "target": "n2", "target_input": "prompt"}]
}
```

`validate_graph()` перевіряє унікальність `id`, відомість `type`, існування портів, рівність типів
і кількість ребер на вхід; усі помилки збираються в один `GraphValidationError`.
`topological_order()` — Kahn із детермінованим порядком, цикл → та сама помилка.

Виконання **хвилями**: кожна ітерація бере всі ноди, чиї джерела вже готові, і рахує їх разом;
елементи однієї розмноженої ноди теж ідуть паралельно. Спільний семафор тримає одночасність на
`DEFAULT_CONCURRENCY = 4`. Хвиля завжди дочікується до кінця, навіть якщо сусід уже впав: кинути
задачу в польоті означало б осиротити платний виклик, який більше нікому полити й чекпойнтити.

Кеш content-addressed: fingerprint = sha256 від `{type, version, inputs}`, де `inputs` — **реальні
значення**, з якими викликалась нода, а не ланцюг угору. Через це змінений один елемент
розмноженої ноди інвалідовує лише свій виклик, а не сусідів. `version` у формулі — це
`Node.version`: змінив код або зміст портів, підняв версію, і інвалідовується сама нода плюс усе
нижче.

`NodeCache` — `Protocol`; у пакеті `InMemoryCache`, у проді **`MongoNodeCache`** (колекція
`node_results`, `_id` = fingerprint) — **без TTL**: виходи ноди це квитанція за платний виклик
провайдера, втратити їх означає заплатити вдруге.

`CheckpointStore` (`MongoCheckpointStore` пише в `nodes.<id>.checkpoint`, а для розмноженої ноди —
в `nodes.<id>.items.<i>.checkpoint`) — проміжний стан ноди. Потрібен через `task_acks_late` +
`task_reject_on_worker_lost`: убитий воркер програє задачу повторно, і без чекпойнта нода створила
б другу платну задачу. Завершені ноди прикриті кешем — чекпойнт рятує саму задачу в польоті.

Події через колбек `on_event`: `run_started`, `node_started`, `node_progress`, `node_finished`,
`node_cached`, `node_failed`, `run_finished`, `run_failed`, `run_cancelled`, `run_awaiting_input`.
Події розмноженої ноди несуть `item_index`/`item_count`; після них іде один підсумковий
`node_finished` без індексу — зі зібраними списками. `credits_consumed` у події — це витрачене
**зараз**, тож `node_cached` завжди несе 0.

`NodeContext` дає ноді: `node_id`/`run_id`/`owner_id`, `services` (клієнти провайдерів і сховище,
збирає воркер на запуск), `checkpoint`, `answer`, `report_progress()`, `report_cost()`,
`save_checkpoint()`, `raise_if_cancelled()`.


## Скасування

`POST /runs/{run_id}/cancel` ставить `cancel_requested`; якщо запуск ще `queued` — додатково
`revoke` Celery-задачі за збереженим `task_id`. Воркер помічає флаг між хвилями й на кожному
полінгу провайдера (`ctx.raise_if_cancelled()` → `RunCancelled` → подія `run_cancelled`, статус
`cancelled`). Читання флага мемоїзоване на ~2 с, щоб полінг не бомбардував Mongo.

Чесне обмеження: **ми перестаємо чекати, а не зупиняємо провайдера** — кредити, вже витрачені на
задачу в польоті, не повертаються.


## Дані

**`node_graphs`** — збережений граф: `name`, `description`, `owner_id`, `definition` (сирий JSON
графа), `ui_state` (позиції нод для React Flow), таймстемпи. Індекс `(owner_id, updated_at desc)`.

**`graph_runs`** — один запуск: `graph_id`, `owner_id`, `definition` (**знімок** графа на момент
запуску, тож пізніші правки не переписують історію), `status`
(`queued|running|awaiting_input|succeeded|failed|cancelled`), `nodes` — стан по нодах,
`credits_consumed` (сума), `cancel_requested`, `answers` (відповіді оператора по `node_id`),
`pending_input`, `task_id` (Celery), `error`, таймстемпи. Індекси `(graph_id, created_at desc)` і
`(owner_id, created_at desc)`.

Стан ноди — `{status, progress, outputs, error, message, credits_consumed, duration_seconds,
checkpoint}` зі статусом `pending|running|awaiting_input|succeeded|cached|failed|cancelled`, плюс
`items` — та сама структура на кожен виклик розмноженої ноди (ключ — індекс рядком, бо в Mongo
немає цілочисельних ключів). Поля самої ноди при цьому лишаються підсумком.

**Виходи зберігаються один раз** — у підсумку ноди, а не ще й на кожному елементі: це ті самі
значення двічі в одному документі, а найширші фан-аути якраз і є найближчими до ліміту в 16 МБ.
Ціна: якщо запуск упав посеред фан-ауту, виходи вцілілих елементів лишаються тільки в кеші
(`node_results`) — заплачено за них не буде вдруге, але в документі запуску їх не видно. Кожна
подія — **один** запис у Mongo, підсумок ноди включно: елемент шле прогрес на кожному полінгу
провайдера, тож другий round trip на подію був би основним трафіком широкого фан-ауту.

**`node_results`** — постійний кеш виходів, `_id` = fingerprint, плюс `node_type`, `version`,
`credits_consumed`, `created_at`/`last_used_at`. Читається по `_id`; індекс на `last_used_at`
існує тільки для майбутнього прибирання.

Виходи нод лежать **інлайн** у документі запуску (і в кеші) — самі байти в S3.

Схема графа живе в `engine/` і валідується на межі API — тому в моделях `definition` і `ui_state`
це `dict[str, Any]`, і пакет лишається незалежним від рушія.


## API

Усе під `/api/v1`, Bearer Clerk JWT, конверт `Response[T]`.

| Метод | Шлях | Що робить |
| --- | --- | --- |
| GET | `/nodes` | Палітра: `list[NodeSpec]` із реєстру |
| POST | `/graphs` | Створити граф (валідує `definition`, якщо непорожній) |
| GET | `/graphs` | Свої графи, `limit`/`offset` |
| GET · PATCH · DELETE | `/graphs/{graph_id}` | Читання / часткове оновлення / видалення |
| POST | `/graphs/{graph_id}/runs` | Створити `GraphRun` (`queued`) і поставити в Celery |
| GET | `/graphs/{graph_id}/runs` | Історія запусків |
| GET | `/runs/{run_id}` | Один запуск зі станом нод |
| POST | `/runs/{run_id}/cancel` | Попросити зупинку; `409`, якщо запуск уже завершений |
| POST | `/runs/{run_id}/resume` | Відповісти запаркованій ноді й продовжити; `409`, якщо не чекає |
| WS | `/ws/runs/{run_id}?token=<jwt>` | Стрім прогресу |

Кожен хендлер перевіряє `owner_id`: чужий граф/запуск → `403`, неіснуючий → `404`. Невалідний
`definition`, порожній `PATCH` і запуск графа без нод → `422`. Постановка в чергу винесена в
залежність `get_run_enqueuer()` — так пакет не імпортує `kumquat_api` (той самий підхід, що в
задачах).

**WebSocket.** Токен у query, бо браузерний WebSocket не дає заголовків. Коди закриття: `4401`
немає/невалідний токен, `4404` запуск не знайдено, `4403` чужий запуск. Порядок навмисний: спершу
`SUBSCRIBE` на `graph_run:<run_id>`, потім кадр `{"type": "snapshot", "run": ...}` з Mongo — тож
події між читанням і підпискою не втрачаються. Термінальний або запаркований запуск закривається
одразу після знімка. Pub/sub нічого не буферизує, але стан кожної ноди персиститься в Mongo
паралельно з публікацією, тож реконект відновлює картинку зі знімка.


## Обмеження

- **Обробка помилок** — падіння однієї ноди валить увесь запуск; немає skipped-нод і продовження
  решти гілок.
- **Кілька результатів від моделі** — якщо провайдер вернув кілька `resultUrls`, беремо перший
  (решта — в лог): порт моделі скалярний, для батчу потрібен list-вихід у самій `ModelSpec`.
- **Подвійна оплата звужена, але не виключена** — падіння між `createTask` і `save_checkpoint`
  осиротить одну платну задачу.
- **Модель ассета** — байти в S3 є, а галереї, provenance й дедупу між запусками немає.
- **Заміна моделі** — нода = модель, тож поміняти модель означає перезібрати ноду й ребра.
- **Редактор** — немає undo/redo, copy/paste, автосейву, сабграфів, повторного запуску окремої
  ноди.
- **Шаблони** — немає способу віддати креативній команді граф як форму з кількома полями.
- **Черга** — одна `graphs` без пріоритетів, без глибини черги в UI й ліміту на користувача;
  довгий інлайн-полінг тримає слот воркера на весь час генерації.
- **Осиротілі графи** — у `node_graphs` лежать старі документи з типами, яких у реєстрі вже немає
  (`generate_image`, `image_to_video`, `preview_image`, `imagen4`); вони не валідуються й не
  запускаються. Видалення типу ноди вимагає міграції збережених графів — як `constant_int`, чиє
  значення переїхало в `params` ноди, яку він живив.
