---
title: Автоматизація задач у канбані
description: Контракт між Celery-воркером і канбаном — CAS у хендлері, відкат при збої enqueue, живі оновлення дошки.
---

> Доповнює [Статуси задач та блокування полів](/guide/task-statuses-and-naming-lock-overview),
> який описує сам флоу статусів. Тут — тільки те, що робить перехід у **Ready for Production**
> особливим, і які наслідки це має для бекенду й дошки.
> ⚠️ Автоматизація стосується лише задач з `Work Stream = Performance`; для інших work stream
> хендлер лише ставить `automation.state = ok` і виходить.

---

## 1. У чому суть

`Ready for Production` — єдиний статус, вхід у який запускає **бота**. Перехід сюди комітиться
одразу, а Celery-хендлер працює вже після нього: задача стоїть у Ready for Production з
`automation.state = pending`, і саме ця пара — а не сам статус — означає «прогон іде».

Окремого статусу `automation` більше немає. Він був транзитним — задача висіла в ньому рівно доки
працював хендлер, і жодного стану, який хтось міг би прочитати як осмислений, не позначав.

```mermaid
sequenceDiagram
    participant U as Користувач
    participant API as PATCH /tasks/{id}/status
    participant W as Celery (tasks.io.task_transition)
    participant DB as Mongo

    U->>API: drop картки в Ready for Production
    API->>DB: status=ready_for_production, automation.state=pending
    API->>W: enqueue
    API-->>U: 200
    Note over DB: задача заморожена: PATCH/DELETE → 409
    W->>DB: планує назви креативів, потім створює Drive-папку
    alt успіх
        W->>DB: generated_name/planned_creative_names/drive_url + automation.state=ok
        Note over DB: статус не чіпаємо — задача вже там, де треба
        W->>W: Slack-нотифікації (поза try)
    else помилка
        W->>DB: planned_creative_names=[] — назви знову вільні
        W->>DB: automation.state=error + текст
        W->>DB: status=backlog (CAS)
    end
```

Код: `kumquat_api/routes/tasks.py` (`transition_task_status`),
`kumquat_api/worker/tasks.py` (`run_task_transition`),
`packages/kumquat-tasks/.../workflow/handlers.py` (`run_automation`).

Наслідок, з якого випливає все решта: **відповідь на PATCH уже застаріла в момент, коли
клієнт її отримав.** Дошка не має права вважати, що картка лишиться там, куди її кинули.

---

## 2. Правила для бекенду

### 2.1 Відкат у Backlog — compare-and-set

Користувач може перетягнути картку з Ready for Production кудись далі, поки хендлер працює.
Хендлер тримає в пам'яті знімок задачі, прочитаний до цього, тож безумовний `$set` мовчки
затирав би рішення користувача — це втрата даних, а не косметика.

Тому `_revert()` пише статус через `TasksRepository.update_if_status(...)` проти
`AUTOMATION_STATUS`. Якщо CAS не пройшов — `logger.warning` і вихід, статус не чіпаємо.

Успішний прогон статус **не пише взагалі**: задача вже в тому статусі, у який її мав би
перевести старий `_advance()`. Це не спрощення, а посилення — картку, яку користувач устиг
перетягнути далі, успіх більше не має чим смикнути назад.

### 2.2 Артефакти пишуться безумовно

Drive-папка вже створена фізично, а план назв — це те, за чим дизайнер називатиме файли. Якщо не
записати `generated_name` / `planned_creative_names` / `drive_url` / `drive_folder_id`, вони
осиротіють. Тому запис розділено:

| Що | Як пишеться |
|---|---|
| `generated_name`, `planned_creative_names`, `drive_url`, `drive_folder_id`, `automation.*` | безумовно (`update`) |
| `status` (тільки відкат у `backlog`) | CAS проти `AUTOMATION_STATUS` (`update_if_status`) |

Тобто в `_revert()`: текст помилки й очищення плану записуються завжди, повернення в `backlog` —
тільки CAS.

### 2.3 Креативи створює Done, а не Ready for Production

Прогон Ready for Production **карток не робить** — він робить те, що потрібне самому виробництву:
фіксує теги, планує назви (`plan_creative_names` → `Task.planned_creative_names`) і створює папку.
Картки з цього плану мінтить перехід у **Done**.

Наслідок для відкату: у провального прогону більше нема чого прибирати. Раніше `_fail()` мусив
purge-ити щойно вставлені креативи, а `_clear_orphan_cards()` — розрізняти «сміття недороблного
прогону» і «чиюсь роботу». Тепер картка на задачі означає рівно одне: задача колись доходила до
Done. Тому `_refuse_materialized_cards()` відмовляє завжди, незалежно від `generated_name`, а
`_is_redelivery()` читається лише з `generated_name` + `automation.state`.

### 2.3.1 Перехід у Done вимагає файлів, які задача запланувала

Перехід у **Done** відхиляється (409), поки хоча б одна назва з
`Task.planned_creative_names` не має файлу в Drive-папці задачі. Перевірка синхронна — у
`TasksService.transition_status`, поряд з `assert_ready_to_enter`, до будь-якого запису: задача не
рухається, рядка історії немає, хендлер не ставиться в чергу. Повідомлення перелічує саме ті назви,
яких бракує.

Чому тут, а не в `on_done`: Done термінальний і мінтить картки з плану. Хендлер, який поскаржився б
постфактум, скаржиться вже на пачку карток, кожна з яких стоїть за файлом, якого ніхто не
завантажив, — і `gdrive-sync` ніколи не знайде для них пари.

Правило порівняння живе в `kumquat_tasks/deliverables.py`, читання папки — в
`kumquat_api/production_folder.py` (пакет задач не залежить ні від воркера, ні від Drive, тому
контракт — локальний `Protocol`). Два джерела, саме в такому порядку:

1. **`gdrive__files` по `folder_id = task.drive_folder_id`** — одне індексоване читання, але
   сканер ходить раз на 5 хвилин, тож щойно завантажений файл там ще не видно.
2. **Сам Drive** — тільки про ті назви, яких не вистачило першому джерелу. На штатному шляху це
   нуль назв і нуль запитів у мережу.

Недоступний Drive задачу не пропускає: лишається вердикт із першого джерела, а в тексті помилки
з'являється уточнення, що до Drive не достукались. Інакше будь-який збій Google тихо вимикав би
перевірку — і про це стало б відомо лише з карток, які вона пропустила.

Для задачі з `media_type = VID` назву закриває **тільки відеофайл**. Фото з тим самим стемом — це
обкладинка того відео (розд. 2.3.2), тож зарахувати його означало б закрити задачу, відео для якої
ніхто не зняв. Для решти media-типів підходить будь-який файл із такою назвою.

Мовчки пропускаються задачі, яким нема з чим звірятися: не-Performance воркстріми (автоматизація
для них не працює), порожній план (задача з часів до цього поля або тип, що карток не мінтить) і
задача без `drive_folder_id`.

### 2.3.2 Фото в папці VID-задачі — це обкладинка

Сканер `gdrive-sync` кладе файл або в `gdrive__files` (креатив, який треба обробити), або в
`gdrive__posters` (обкладинка відео). Раніше це вирішувала лише назва файлу: зображення з токеном
`Vid` — обкладинка. Тепер додалося друге правило — **зображення в папці задачі з
`media_type = VID`**, хай як воно назване.

Обидва правила діють разом (`Scanner._is_poster`), бо відповідають за різні файли. Назва
класифікує файл сам по собі — а це якраз те, що приносить стрічка змін Drive: довільний набір
файлів, ніколи не вміст папки. Задача класифікує решту: вона попросила відео, отже зображення в її
папці — кадр до нього, як би дизайнер його не назвав. Папку задачі сканер знаходить через
`task__tasks.drive_folder_id` (`gdrive_sync/task_folders.py`), з кешем на час одного прогону;
відповідний індекс — у `kumquat_tasks/indexes.py`.

**Зміна `media_type` не породжує події в Drive**, тому зображення, синхронізовані до того, як
задачу позначили VID, самі нікуди не переїдуть. Для них є `gdrive-sync reroute-posters`
(`maintenance.reroute_task_posters`) — ідемпотентна, за замовчуванням dry-run. Повна `resync` дає
той самий результат, але переставляє курсор і перечитує всі диски.

Команда чистить і `creatives.media` тих карток, які показували саме це зображення (матч по
`media.drive_file_id`, тож блок, зібраний з відео, не чіпається). Без цього картка лишалася б із
фото назавжди: `project_all_media` обходить назви, які **є** в `gdrive__files`, а ця назва звідти
щойно пішла, тож до картки більше ніхто не повернувся б. Блок занулюється, а не перенаправляється
на відео: відео може бути ще не синхронізоване, і `media: null` — це рівно те, що проєкція вже
означає під «файлу для цієї назви немає». Наступний цикл підставить відео, щойно воно з'явиться.

**Межа правила: постер стикується з відео за назвою.** `SyncWorker._sidecar` шукає
`gdrive__posters` по `name`, ідентичній назві відео. Тип медіа задачі вирішує, що фото — **не**
окремий креатив (і воно зникає зі стрічки файлів задачі), але чиїм саме постером воно є, каже
назва. Фото, назване довільно, лишається постером без відео — сканер рахує такі в
`posters_orphaned`, а відео показує власний перший кадр. Саме цю дисципліну назв і вимагає гейт із
розд. 2.3.1: назва з плану — те, під чим має лежати відео, і те, під чим має лежати його обкладинка.

### 2.4 `on_done` мінтить картки — і не має права кидати виняток

`on_done()` робить три речі: створює картки з плану (`_mint_creatives`), ставить `completed_at` і
пінгує UAM, якщо стоїть Manual Launch.

Імена беруться **з плану**, не перебудовуються (`_named_by_plan`). Це не оптимізація: назва
похідної картки (LOC/WHT/ADPT/RSZ) будується з `number` і `name` **оригінала**, а оригінал належить
іншій задачі, яку ніхто не блокував. Якби її встигли перейменувати між Ready for Production і Done,
перебудована назва розійшлася б із тією, під якою дизайнер уже назвав файли — і `gdrive-sync`,
який стикує файл із карткою рівно за рядком назви, більше не знайшов би пари. План довший або
коротший за набір карток — це вже справжня розбіжність, і вона відхиляється, бо вгадувати, який із
кінців правильний, означає або осиротити файли, або створити картку, якої ніхто не називав.

Виняток назовні не випускається **ніколи**. Відкочувати нема чого (вставка — остання дія), відсилати
задачу нікуди, а `acks_late` перевидав би джобу нескінченно. Тому помилка пишеться в
`automation.state = error` з текстом і йде DM власнику, а задача лишається в Done — робота ж
прийнята, `completed_at` ставиться в будь-якому разі.

Повторний прохід (перевидана джоба, другий вхід у Done) картки не дублює: `_mint_creatives`
виходить одразу, якщо на задачі вже є картки. Назви унікальні, тож другий мінт усе одно не пройшов
би — але й звітувати про нього як про помилку неправильно, бо робити не було чого.

### 2.6 Drive: папка перейменовується, файли їдуть за нею

`ensure_task_folder(product, task_name, folder_id)` замінив `create_task_folder`. З `folder_id` він
робить `files.update` — читає поточні `name`/`parents` і пише тільки те, що справді змінилось, тож
прогон без змін не шле жодного запиту. Без `folder_id` — стара поведінка find-or-create.

Чому перейменування, а не нова папка: посилання Drive адресується за id, тож перейменування зберігає
кожен раніше розшарений лінк і не рухає жодного байта. `addParents` при зміні Product іде в парі з
`removeParents` — самого `addParents` мало, папка опинилась би у двох місцях одночасно.

`_rename_files()` перейменовує вміст під новий план. Пари підбирає `plan_renames()` — чиста функція
в `drive.py`, покрита `tests/tasks/test_drive_renames.py`:

- позиція **плюс** збіг «координат» картки (`{var}-{task#}` префікс). Самої позиції мало: LOC-задача
  з підміненим Origin Creative дає ту саму кількість назв для іншого оригінала, і перейменування за
  позицією підписало б роботу одного оригінала іменем іншого;
- різна довжина планів не парується взагалі — змінився сам набір карток;
- legacy-назва (`{ім'я оригінала}_{суфікс}`) координат не має і не парується ніколи.

Усе, що не спарувалось, іде в `WorkflowNotifier.files_left_behind` — файл лишається зі старою назвою,
а людина бачить, які саме. Сам rename — best effort на файл: відмова одного не валить прогон, який
усе інше вже закомітив.

Наслідок для `_revert()`: **план більше не чиститься при провалі**. Він не вихід цього прогону — це
останній *опублікований* план, тобто єдиний запис про те, як зараз називаються файли в папці.
Почистити його означало б позбавити наступний прогон можливості їх перейменувати. Нічого це не
резервує: `planned_name_claims` ігнорує задачі в Backlog, куди відкат її і кладе.

### 2.7 Куди веде Backlog, і чому не з Done

`ALLOWED_TRANSITIONS` пускає в Backlog з Ready for Production, In Progress, Tech Check і Creative
Check. Дві перевірки додані тому, що помилку в тегах помічають саме там, а до цієї зміни задача з
перевірки могла піти лише вперед у Done або назад у In Progress — і жоден із цих шляхів тегів не
відкриває.

**Done і Archived лишаються фінальними.** Це не технічне обмеження: те саме скидання спрацювало б.
Це властивість завершеного пака — `mark_pack_launched` віддає його в кабінет, звіт про запуск
приходить проти опублікованих назв, і проти них же написані рядки аналітики. Пак потрапляє у фід
**саме тому**, що він у Done/Archived (`Task.offered_to_integrations`), тож «запускається» і «не
відкочується» — один факт. Через це правило живе у графі переходів, а не окремою перевіркою на
`launched_at`: окрема перевірка була б недосяжним кодом, який стверджує, що ловить випадок, якого
граф не допускає.

`TasksService._discard_creatives` при цьому лишається, але вже не як частина відкату Done. Він
тримає інваріант «задача в Backlog не має карток», і сьогодні має один реальний привід спрацювати:
задачі, чиї картки вставив ще старий прогон Ready for Production. Без нього кожен їхній повторний
запуск упирався б у `_refuse_materialized_cards`.

### 2.8 Не кожен вхід у Ready for Production запускає прогон

`ALLOWED_TRANSITIONS` пускає з In Progress назад у Ready for Production — це спосіб зняти задачу з
роботи, не скидаючи її. Але вхід у цей статус більше не означає «запустити автоматизацію»: рішення
читається з **ребра**, а не з цільового статусу (`HANDLER_FREE_EDGES` у `workflow/transitions.py`,
`runs_handler(current, target)`).

Чому саме так. Задача, яка робить цей крок, уже несе назву, план назв і Drive-папку від того
прогону, що привів її сюди, а виробництво вже назвало за цим планом свої файли. Повторний прогон
опублікував би той самий план (від Ready for Production і далі всі naming-поля закриті —
`field_policy`, тож нових вхідних даних у нього нема), перейменував би під нього ті самі файли й на
час прогону заморозив би задачу. Тому це ребро не ставить `automation.state = pending`, не пише
`automation.started_at` і не кладе job у чергу.

Наслідок для шару роутів: `TasksService.transition_status` повертає `TransitionOutcome(task,
handler_due)` замість самої задачі. Обидва кінці переходу бачить лише сервіс — роут знає цільовий
статус, але не той, у якому задача насправді була, — тож відповідь їде разом із задачею, а не
перевираховується з `payload.target_status`.

Перезапуск автоматизації лишається за Backlog: це єдиний статус, де теги відкриваються.

### 2.5 Виконавець їде на картки на Done, а не на перевірках

`on_tech_check` / `on_creative_check` більше не чіпають креативи — до Done їх не існує.
`_mint_creatives` проставляє `assignee_id` із задачі в момент створення картки; LOC/WHT/ADPT/RSZ
виключені, бо їхня картка вже несе виконавця, скопійованого з оригінала.

За `on_creative_check` лишилась одна річ: для NEWDES/VARDES без виконавця роль Assignee бере на себе
Task Owner. Це запис **на задачу**, а не на картки, — саме його `_mint_creatives` потім і читає.

Побічний виграш: `CreativesRepository.set_assignee_for_task` — один `update_many` без подельтової
дельти на кожен креатив — не має більше жодного виклику й видалений. Разом із ним зникла й
однойменна прогалина в [аудит-лозі](/backend/audit-log).

Що при цьому не зникло — унікальність назв. Її давав унікальний індекс на `creatives.name`, який
спрацьовував на вставці; тепер вставка на цикл пізніше, тож перевірку відновлено на місці:
`_reject_planned_collisions()` відхиляє план, назви якого вже тримає наявна картка або план іншої
задачі поза Backlog (`TasksRepository.planned_name_claims`).

### 2.3 Поки прогон іде, задачу не можна редагувати

Хендлер генерує назву, картки креативів і Drive-папку зі **знімка** задачі, прочитаного на
початку прогону. Правка, що прилетіла після цього, у прогон не потрапить — і навпаки,
`_inherit_from_origin()` запише успадковані теги поверх щойно введених. Тому доки прогон іде,
задача заморожена:

| Запит | Поведінка |
|---|---|
| `PATCH /tasks/{id}` | 409 `TaskLockedError` |
| `DELETE /tasks/{id}` | 409 — каскад приховав би картки, які прогон ось-ось створить |
| `PATCH /tasks/{id}/status` | дозволено — це єдиний вихід із зависання |

«Прогон іде» — це `status = ready_for_production` **і** `automation.state = pending`
(`Task.automation_running`). Одного статусу мало: задача живе в Ready for Production і після
прогону, скільки завгодно довго, і замок на весь статус заморозив би її назавжди.

Цей замок — не те саме, що політика полів (`field_policy.py`, див.
[Воркфлоу задач](/backend/workflow)). Та закриває **частину** полів залежно від статусу і живе в
сервісі; цей — **усі** поля на час прогону, і живе всередині запису. Політика його навмисно не
дублює: вона не питає про `automation_running`, бо між читанням і записом воркер устигає зрушити.

Перевірка вбудована в сам запис (`TasksRepository.update_unless_automation_running(...)`), а не
зроблена окремим читанням перед ним: обидві половини стану змінює воркер, тож між читанням і
записом він устигає зрушити. Хендлер пише через звичайний `update()` і замка не бачить.

`automation.state = pending` ставиться і на вході в `tech_check` / `creative_check` / `done` —
у них теж є асинхронні хендлери. Але замикає тільки пара з `ready_for_production`: решта
хендлерів не генерує зі знімка, тож морозити під ними форму немає за що.

Фронтенд читає те саме правило з `isAutomationLocked()` і робить форму read-only, лишаючи
живим тільки контрол статусу — щоб користувач не друкував у поле, яке все одно не збережеться. Це
єдина половина замка, що лишилась на клієнті після переїзду політики полів на сервер, і причина
конкретна: строк життя `pending` — це показання годинника, а застрягла задача не породжує події, на
яку можна перемалюватись, тож зняти замок може тільки власний тік браузера.

Замок ставить одну вимогу й до сторінки задачі: **переїзд статусу не повинен тягнути за собою
`PATCH /tasks/{id}`**. Раніше тягнув — автозбереження порівнювало чернетку з задачею цілком,
разом із `automation.*`, `generated_name` і Drive-полями, які пише воркер. Кожен його запис
робив форму «зміненою», і наступний же кадр зі стріму вона відпрацьовувала записом застарілої
чернетки — у 409, поки прогон іде, і поверх щойно згенерованого, коли він закінчився. Тепер
питання «чи є що зберігати» ставиться лише по полях, які форма справді відправляє
(`TASK_WRITABLE_KEYS`), а серверну половину відповіді сторінка вливає в чернетку
(`taskServerFields()`) — так замок вмикається разом із переїздом, а не кадром пізніше.

#### Як замок знімається — і чому кожен запис мусить зрушити `updated_at`

Заморозку знімає лише **результат** прогону, і про нього сторінка дізнається одним каналом —
кадром зі стріму. Кадр підписаний `updated_at`, і сторінка відкидає той, що не новіший за версію,
яку вона вже малює: власний save повертається зі стріму так само, як чужа правка (§3.6).

Звідси інваріант, який легко порушити й важко побачити: **будь-який запис у поля задачі мусить
зрушити `updated_at`**. Хендлери воркфлоу цього не робили — `_automation_set()` писав
`automation.state`, `generated_name` і Drive-поля, а мітку лишав там, де її поставив перехід у
Ready for Production. Кадр «прогон завершився» приходив з міткою переходу, який його **почав**,
кожна відкрита сторінка читала його як власне відлуння й відкидала — і форма лишалась замороженою
на прогоні, що вже закінчився, до перезавантаження або до кінця 20-хвилинного вікна. Симптом
виглядав як баг політики полів («статус змінився, а поля не редагуються»), хоча жодне правило
політики до нього не дотичне.

Тому мітку ставить `TasksRepository` (`_stamp()`), а не кожен хендлер окремо: наступний запис не
може забути. Дві деталі, обидві навмисні:

- `_stamp()` **мутує** переданий dict, а не копіює його. `@audited` читає цей dict **після**
  повернення із запису, тож мітка в копії не потрапила б у рядок аудиту — і replay перестав би
  відтворювати документ, який описує (`tests/audit/test_replay_integration.py` це й ловить).
- Хто ставить мітку сам — переїзд статусу, soft-delete — її зберігає, бо там вона означає момент
  рішення, а не момент запису. `touch_last_comment()` не пише жодного поля задачі й через
  `_stamp()` навмисно не йде (§3.6).

Стрім — єдиний канал, тож поки прогон у польоті (і тільки тоді, це секунди) сторінка задачі ще й
перечитує задачу сама, з інтервалом `AUTOMATION_POLL_MS`. Це не той poll, що прибрано в §3.1: там
дошка перечитувала найдорожчий ендпоінт по таймеру, тут — одна задача, заради єдиного стану, який
може лишити форму без виходу. Перечитати мало: форма судить про заморозку по **своїй** копії
серверної половини (`draft.automationState`), тож тік мусить ще й влити її через
`taskServerFields()` — інакше банер зникає і poll спиняється (вони дивляться на задачу), а поля
лишаються read-only на `pending`, у який вірить уже тільки чернетка.

### 2.4 Прогон не лишає по собі напівстворених карток

Вставка карток — єдина незворотна дія, тому вона йде **останньою**: спочатку валідація, потім
пошук джерел, потім Drive-папка (мережевий виклик, який падає найчастіше), і аж тоді
`create_many`. Якщо після вставки щось падає, `_fail()` жорстко видаляє саме ті картки, які
створив цей прогон, і вже потім повертає задачу в Backlog.

Нотифікації винесені **за** `try`. Раніше вони були всередині, і збій Slack відкочував уже
закомічений прогон у Backlog — картки лишались, а кожен наступний запуск бився об охоронця з
`Creative cards already exist for this task` і виходу з цього не було.

Охоронець на початку прогону тепер розрізняє два випадки:

| Стан | Рішення |
|---|---|
| Картки є, `generated_name` порожній | уламки прогону, що не дожив до запису імені (або ределівері Celery через `acks_late`) — видалити й продовжити |
| Картки є, `generated_name` заповнений | задача вже відпрацювала автоматизацію; відмова з текстом «видаліть картки перед повторним запуском» |

Видаляють їх через `DELETE /creatives/{id}` — дозволено, поки задача в `backlog`, і відмовлено
(409), якщо з картки вже зроблено іншу задачу. Статус задачі питає `CreativesService` через
Protocol `TaskGate`: `kumquat-tasks` залежить від `kumquat-creatives`, тож імпорт назад був би
циклом, який uv не пропустить. Видалення жорстке з тієї ж причини, що й у `purge_for_task`.

Drive-папку переставлено перед вставкою ще й тому, що `create_task_folder()` шукає-або-створює
за назвою: повторний прогон перевикористає ту саму папку, а не наробить дублів.

### 2.5 Збій enqueue відкочує перехід

Перехід комітиться в Mongo **до** постановки в чергу. Якщо брокер недоступний, задача
інакше зависла б у `ready_for_production`/`pending` назавжди — жоден воркер її вже не підбере,
а замок з 2.3 тримався б вічно.

`TasksService.abort_transition()` читає попередній статус з аудит-рядка, який щойно записав
цей самий перехід, і повертає задачу назад (теж CAS — паралельне ручне переміщення важливіше).
Аудит append-only, тому відкат — це **окремий рядок**, а не видалення. Роут віддає 503.

### 2.6 Чого досі немає

Якщо воркер убито по `task_time_limit` (600 с, `worker/celery_app.py`), хендлер не встигає
записати нічого — задача лишається в `ready_for_production`/`pending` без шляху назад. Автоматичного
watchdog немає. Дошка це показує (розд. 3.3), а ручний вихід один: перетягнути картку назад у
Backlog і запустити ще раз. Це працює й тоді, коли воркер помер уже після вставки карток —
розд. 2.4 прибере їх на наступному прогоні.

---

## 3. Правила для дошки

`useTasksKanban` (`frontend/src/modules/tasks/hooks/use-tasks-kanban.ts`) — свідомо рукописний
стор, а не SWR: вісім колонок з незалежною keyset-пагінацією не лягають на хук, який не
можна викликати в циклі. Через це всі гарантії, які SWR дав би безкоштовно, тут явні.

### 3.0 Три ендпоінти, кожен зі своєю роллю

| Ендпоінт | Коли | Що віддає |
|---|---|---|
| `GET /tasks/board/columns` | відкриття дошки, кожна подія change stream, після drop | перша сторінка **всіх** колонок + `next_cursor` і `total` кожної |
| `GET /tasks/board` | «Load more», глибокий refresh розгорнутої колонки | одна keyset-сторінка однієї колонки |
| `GET /tasks/schema` | один раз за сесію | `filter_schema` / `sort_schema` / `status_schema` |

Схеми не залежать ні від сторінки, ні від курсора, ні від користувача, тому їх немає в
`meta` фіда — інакше кожне оновлення дошки перечитувало б їх заново (`useTaskSchemas`
бере їх SWR-ом з `revalidateIfStale: false`).

### 3.0.1 Колонки виводяться з enum, а не перелічені руками

Дошка бере колонки з двох різних місць: рендерить `options` поля `status`
(`record-table.tsx` → `groupField.options`), а фетчить `COLUMN_LABELS` у `use-tasks-kanban.ts`.
Поки це були два рукописні масиви, вони могли розійтися — і колонка або малювалась порожньою, бо
її ніхто не читав, або читалась, але не малювалась.

Тепер обидва читають один `STATUS_OPTIONS` (`task-mapping.ts`), зібраний з `Object.values(TaskStatus)`
через `STATUS_LABELS`. Сама мапа лейблів закрита `satisfies Record<TaskStatus, string>`: доданий в
enum статус без лейбла і лейбл без статусу — обидва помилка компіляції, а не мовчазна дірка. Саме
на цьому впіймався забутий `automation` під час його прибирання.

`optionIcons` типами не закривається (ключі — лейбли, тобто просто рядки), тож цю половину тримає
`task-fields.test.ts`: іконка на кожну опцію плюс `Deleted`, і жодної іконки на статус, якого вже
немає.

### 3.1 Дошку штовхає change stream, а не poll

Дошка не опитує сервер. Кожна репліка API дивиться change stream по `task__tasks`
(`kumquat_api/live.py`) і роздає події в ті SSE-стріми, які обслуговує сама —
`GET /api/v1/tasks/board/events`, подія `task_changed`. Клієнт — `use-board-events.ts`
на `EventSource`.

Change stream **сам є broadcast-шиною**: кожна репліка бачить кожен запис, тому між Mongo
і клієнтами немає Redis, і жодне місце запису не мусить пам'ятати про publish. Через це
хендлери воркера, CLI та ручна правка в Mongo доїжджають до дошки так само, як рух через API.

Кадр — це **інвалідація, а не картка**: `{ task_id, status, updated_at }`, де `status` —
лише підказка для дебагу, а `updated_at` — єдине, чим підписник відрізняє зміну, яку він уже
прочитав (власний запис), від чужої. Обидва поля можуть бути `null`: проєкція бачить їх на
insert, і на update — тільки якщо саме цей запис їх торкнувся. У кожної дошки свій фільтр і
пошук, тому «що ця зміна означає для цього клієнта» відповідає власний refetch клієнта.
Стрім через це не стає другим контрактом серіалізації поруч зі схемою картки.

| Правило | Чому |
|---|---|
| Дебаунс 500 мс перед refetch | один рух — це серія записів (перехід, далі те, що пише автоматизація) |
| Черга на клієнта — глибиною 1, переповнення відкидається | кадр у черзі вже означає «перечитай», другий не додає нічого; і watcher ніколи не чекає на повільного клієнта |
| Схована таба не перечитує, а позначає пропуск | `GET /tasks/board/columns` — найдорожче читання в API, а фонові таби це типовий випадок |
| Реконект робить один refetch | те, що зрушило поки нас не було, видно лише через читання; окремий replay не потрібен |
| Реконект наш, а не вбудований в `EventSource` | той повторив би той самий URL, а токен у ньому вже прострочений |
| Коментар-рядок `: ping` кожні 25 с | ingress-nginx має типовий `proxy-read-timeout` 60 с і зрізав би простій; коментар при цьому не доходить до жодного обробника подій |
| `X-Accel-Buffering: no` на відповіді | nginx за замовчуванням буферизує проксі-відповіді і тримав би кадри до заповнення буфера |
| `resume_after` при перевідкритті; на `ChangeStreamHistoryLost` — свіжий стрім | дірка в історії нічого не варта (клієнт усе одно перечитує), а стрім, що вічно ретраїть мертвий токен, коштує всього |
| `pymongo.timeout(0)` навколо стріму, і **не** `None` | клієнт має `timeoutMS`, а pymongo читає незаданий таймаут як «візьми клієнтський» — tailing-курсор через це рвався кожні 30 с |
| `PyMongoError` → ретрай, будь-що інше → один `logger.exception` і стоп | помилка коду не лікується ретраєм, вона лише спінить лог |

`automation.state === "pending"` більше не запускає опитування — результат автоматизації
приходить тим самим каналом. Стеля 5 хв лишилась **локальним таймером**: убитий hard time
limit воркер не пише результат (див. 2.6), тож для такої картки подія не прийде ніколи, і її
треба позначити як stale.

### 3.2 Один запит на оновлення, не набір колонок

Оновлення — це один `GET /tasks/board/columns`. Виводити, «куди саме може поїхати pending-картка»,
більше не треба: відповідь і так містить усі колонки, тож хендлер може перенести задачу
куди завгодно — дошка це побачить.

Виняток — колонка, яку користувач розгорнув через «Load more» (`pages > 1`). Її перша
сторінка з board-відповіді затерла б решту, тому такі колонки перечитуються окремим
`GET /tasks/board` з `limit = pages × 10` (одним запитом, не walk по курсору) і виграють
у гонці за рахунок токена покоління.

### 3.3 Після drop

`refreshAfterMove()` — це той самий один запит по всіх колонках. Окремий обхід «якщо
картки немає ні в `from`, ні в `to`» більше не потрібен: картку видно там, де вона
опинилась, навіть якщо хендлер устиг спрацювати до відповіді на PATCH.

### 3.4 Гонки drag&drop

| Проблема | Рішення |
|---|---|
| Повільна стара відповідь перезаписує свіжу | токен покоління на колонку |
| Refresh посеред жесту висмикує картку з-під курсора | reconcile заморожений, поки `activeId !== null` |
| Refresh між drop і відповіддю PATCH смикає картку туди-сюди | картка «пришпилена» (`pinned` у `reconcile`), поки перехід у польоті |
| Повторний драг тієї ж картки → 409 | `movingIds`, `useSortable({ disabled })` |
| Відкат при помилці затирає паралельні оновлення | відкочується одна картка (`placeIn`), а не snapshot дошки |
| 409 = серверний стан розійшовся | не просто локальний відкат, а перечитування |

Чисті функції розкладки живуть у `core/data-table/kanban-containers.ts`
(`reconcile` / `placeIn` / `moveAcross`) — саме щоб їх можна було покрити тестами.

> Reconcile перезапускається за ключем `sig + pinned`, не лише за `sig`. Зняття піна — це
> момент, коли серверна розкладка нарешті має право виграти; якби тригером був тільки `sig`,
> картка лишалась би там, куди її кинули оптимістично.

### 3.5 Чого дошка не робить

Перевпорядкування карток усередині колонки прибрано навмисно: у `Task` немає поля порядку,
тож воно не зберігалось і мовчки скидалось на наступному читанні.

### 3.6 Сторінка задачі сидить на тій самій шині

`GET /api/v1/tasks/{task_id}/events` — той самий стрім і та сама подія `task_changed`, лише
підписка зареєстрована з `task_id`, тож фільтрує сервер (`BoardSubscribers.subscribe(task_id)`),
а не клієнт. Дошка — це просто підписка без id. Клієнт — `use-task-events.ts` поверх спільного
`use-event-stream.ts`, який тримає токен, дебаунс, схований таб і реконект для обох стрімів.

Що робити з кадром, вирішує чиста функція `taskChangeAction()`
(`modules/tasks/model/task-live-updates.ts`), бо форма може мати незбережені правки:

| Кадр | Форма чиста | У формі незбережені правки |
|---|---|---|
| `updated_at` новіший за версію, з якої зібрано форму | refresh (той самий, що й кнопка Refresh, разом з фідами активності) **+ тост** | банер «This task changed somewhere else» з кнопкою → та сама конфірмація, що й у Refresh |
| `updated_at` не новіший (типово — власний save, який повернувся зі стріму) | нічого | нічого |
| Кадр без `updated_at` (delete, або catch-up після реконекту) | refresh, **без тоста** | нічого — це підказка «щось могло зрушити», а не доказ чужої правки, тож банер не бреше |

Тост обов'язковий саме в лівій колонці: підміна значень, якої ніхто не оголосив, для читача
не відрізняється від «мені здалося». Текст дає друга чиста функція, `liveUpdateMessage()`:
коли кадр несе `status` і він інший — «Moved to Ready for Production», інакше «Task updated».
Тон `info` (доданий у `core/ui/toast`) — приглушений, бо це не результат дії користувача.
Обидві половини разом ніколи не спрацьовують: або сторінка застосувала зміну і сказала про це,
або не застосувала і пропонує — але не «застосувала і вимагає дії».

Базова версія (`syncedAt`) оновлюється у двох місцях: коли сторінка приймає задачу у форму
і після власного save. Без другого сторінка вважала б власний запис чужим.

---

## 4. Тести

| Де | Що покрито |
|---|---|
| `backend/tests/api/test_task_workflow.py::TestAutomationConcurrentMove` | ручне переміщення під час роботи хендлера — успіх, revert, non-performance |
| `backend/tests/api/test_task_workflow.py::TestDoneNeedsTheFilesItPlanned` | 409 на Done без файлів: задача не рухається, історії немає, VID не закривається фото, чужа папка й tombstone не рахуються |
| `backend/tests/tasks/test_deliverables.py` | саме правило: фолбек у Drive, недоступний Drive, задачі, яким нема з чим звірятися |
| `backend/tests/api/test_production_folder.py` | два читання папки — `gdrive__files` і живий Drive |
| `backend/tests/gdrive_sync/test_scanner.py` | роутинг обкладинок: фото в папці VID-задачі, не-VID задача, відео, правило по назві поза папкою задачі; фото, завантажене **раніше** за відео, і фото, назване довільно |
| `backend/tests/gdrive_sync/test_maintenance.py` | `reroute_task_posters` — dry-run, ідемпотентність, межі вибірки, зачистка `creatives.media` |
| `backend/tests/api/test_task_workflow.py::TestStatusEndpoint::test_enqueue_failure_rolls_the_move_back` | 503 + відкат + компенсаційний рядок аудиту |
| `backend/tests/api/test_tasks_board_columns.py` | `/tasks/board/columns` і `/tasks/schema`: повний набір колонок, `total` проти розміру сторінки, продовження курсора на `/tasks/board`, спільний `included` |
| `frontend/src/core/data-table/kanban-containers.test.ts` | reconcile / placeIn / moveAcross, включно з пінами |
| `frontend/src/modules/tasks/model/task-workflow.test.ts` | `canTransition`, `blockedStatuses`, `isAutomationLocked` (замок тримає лише `pending`) |
| `frontend/src/modules/tasks/model/task-fields.test.ts` | `options` поля `status` дорівнюють `STATUS_OPTIONS`; іконка на кожну колонку і жодної зайвої (розд. 3.0.1) |
| `backend/tests/api/test_board_events.py` | `updated_at` у кадрі, фільтрація підписки по `task_id`, токен і розбір id на `/tasks/{id}/events` |
| `frontend/src/modules/tasks/model/task-live-updates.test.ts` | `taskChangeAction()` — refresh / notify / ignore; `liveUpdateMessage()` — текст тоста |
| `frontend/src/modules/tasks/components/task-edit-page.test.tsx` | що сторінка задачі робить з кадром: refresh + тост, банер, ігнор власного save, мовчання на ручний Refresh |

Хелпер `_insert_automating()` у бекенд-тестах не косметичний: він кладе задачу в
`ready_for_production` з `automation.state = pending` — рівно той стан, у якому API лишає задачу,
коли ставить хендлер у чергу, і проти якого CAS відкату порівнює. Тест, який кладе задачу в
`backlog` і кличе `run_automation` напряму, описує стан, якого в проді не буває.

Фронтенд-раннер — `vitest`: `*.test.ts` — чисті функції в node, `*.test.tsx` — компоненти в
jsdom (`npm test`). Решта фронтенду й далі перевіряється через `tsc --noEmit` + `eslint`.

---

## 5. Ручна перевірка

Потрібні `api + worker + redis + mongo` (`docker compose up`).

1. Performance-задача з усіма полями → в `Backlog`.
2. Drop у **Ready for Production**: картка одразу там з «Automation running…», решта дошки **не** перемальовується.
3. Без F5 за ≤3 с напис зникає, приходить тост «Automation finished», задача лишається в
   **Ready for Production** — і форма знову редагована.
4. Задача з `count = 0`: сама повертається в **Backlog** з текстом помилки на картці.
5. **Гонка (2.1):** `docker compose stop worker` → drop у Ready for Production → вручну далі в
   In Progress → `start worker`. Задача має лишитись у **In Progress**.
6. **Подвійний драг (3.4):** перетягнути й одразу спробувати ще раз — друга спроба заблокована, 409 немає.
7. Нелегальні колонки приглушені, drop не спрацьовує.
8. **Сторінка задачі (3.6):** відкрити задачу у двох вкладках. Save у першій → друга без F5
   показує нові значення і тост («Task updated», а на зміну статусу — «Moved to …»); якщо в
   другій є незбережена правка — замість підміни з'являється банер.

---

## 6. Індекси

Колонка — це `{status: X}` з сортуванням за `created_at desc` і добором за `_id`. Одинарних
індексів для цього мало: Mongo або бере `status` і сортує колонку в пам'яті, або йде по
`created_at` і сканує, доки не набере потрібний статус. Тому в `kumquat_tasks/indexes.py`
є композитний `(status, created_at, _id)` — фільтр, сортування і tiebreaker в одному індексі.

Заміряно на 50 000 задач, одна сторінка колонки (`explain`, `executionStats`):

| | docs examined | план |
|---|---|---|
| тільки одинарні | 5654 | `SORT → FETCH → IXSCAN(status_1)` |
| з композитним | 11 | `LIMIT → FETCH → IXSCAN(status_1_created_at_1__id_1)` |

Індекс висхідний по всіх трьох полях: дошка читає його у зворотному напрямку для свого
`created_at desc`, і той самий індекс обслуговує обидва напрямки сортування.

**Лічильники колонок навмисно не агрегація.** `$group` по `status` відповідає однією
командою, але не може використати індекс для групування і читає кожен документ (COLLSCAN).
Вісім `count_documents` по рівності відповідають з індексу `status` і виконуються
паралельно в межах одного запиту. Заміряно: 300 000 задач — 11 мс проти 104 мс на користь
count-ів, і розрив росте з розміром колекції.

---

## 7. Відомий борг

`filter_schema` у відповіді `/tasks/schema` рахується щоразу: два повні читання колекції
products (`filter_options()` і `vocabularies()` роблять по своєму `list()`) плюс два
`distinct` по задачах. Тепер це один раз за сесію замість дев'яти разів на кожен рефреш,
тому не критично, але сам ендпоінт — очевидний кандидат на кешування відповіді.
