Node-граф
Типізований 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)
Як це читається механіками рушія:
brief— одинtext_prompt, підключений уsystemусіх LLM-нод. Одне джерело правди про креатив замість шести копій промпта.dimensionsописує що є, а не що змінити:fieldsтут — рядки з редактора полів, деchannel— закритий набір (video|voiceover|on-screen text), який провайдер зобов’язаний дотримати. Саме тому на це поле можна безпечно маршрутизувати.gateпаркує запуск. Оператор бачить три виміри й обирає, скажімо, два.- Два обрані об’єкти прилітають у скалярний
contextнодиvary→ вона виконується двічі, по разу на вимір, кожен раз повертаючиcount=2варіанти. Їїitems— list-вихід, тож результати конкатенуються: на виході чотири варіації, а не два списки по дві. - Три
filter_itemsрозрізають ці чотири заchannel. Вимір, якого ніхто не обрав, дає порожній список — і вся гілка під ним виконується нуль разів, безкоштовно. - Далі кожен
llm_textзнову розмножується — по разу на варіацію свого носія — і тягне за собою свою генерацію:frame→nano_banana→first_frame_urlдляclip,line→speech. Гілкаcaptionзакінчується текстом: екранний напис нічого не генерує. compose_videoмукситься проти незмінного базового треку: варіація озвучки бере старий кліп, а варіація відео лишає звук, який прийшов із самим кліпом (уAudioнічого не під’єднано). Так у A/B-тесті змінюється рівно один вимір.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():
- є
ctx.checkpoint["task_id"]→ не створювати задачу, а дополлити наявну; - інакше
createTaskі зразуsave_checkpoint({"task_id": ...}); poll()до термінального стану, форвардячи прогрес і перевіряючи скасування;report_cost(creditsConsumed);- завантажити результат у 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 ключуються іменами вхідних портів (окремого
простору налаштувань ноди немає):
{
"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ноди, яку він живив.