Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

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)

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

  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 ключуються іменами вхідних портів (окремого простору налаштувань ноди немає):

{
  "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 ноди, яку він живив.

Was this page helpful?