chore(openspec): архив wiki-module (#91)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 10:05:32 +04:00
parent 3b88b8be2a
commit be85975af4
15 changed files with 407 additions and 54 deletions

View File

@ -161,13 +161,15 @@ MUST NOT менять `issue_note`. При завершении `ensure_issue`
Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`,
`issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб `issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб
SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed` SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed`
по пределу выдач — проставить `finished_at`. Таблица `agent_status` (одна строка) SHALL хранить `last_seen_at`, по пределу выдач — проставить `finished_at`. Таблица `agent_status` SHALL хранить по строке на агента (ключ `agent` —
`agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP `agent_status` SHALL имя модуля, см. `modules`, «Агенты модулей»): `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`,
отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в `last_call_at` (время последнего обращения агента к внешней системе; от jira-agent приходит полем `last_jira_call_at`).
`claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). Через API и MCP статус агента SHALL отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и
списком `redelivered` — команд его модуля в `claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`,
`deliveries`).
#### Scenario: Агент давно не выходил на связь #### Scenario: Агент давно не выходил на связь
- **WHEN** `last_seen_at` старше 5 минут - **WHEN** `last_seen_at` агента `jira` старше 5 минут
- **THEN** `agent_status` через API и MCP отдаётся с `online = false` - **THEN** `agent_status` через API и MCP отдаётся с `online = false`
#### Scenario: Довыдача refresh не удваивает счётчики #### Scenario: Довыдача refresh не удваивает счётчики
@ -178,6 +180,10 @@ SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started
- **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач» - **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач»
- **THEN** у его `sync_run` проставлен `finished_at` - **THEN** у его `sync_run` проставлен `finished_at`
#### Scenario: Статусы агентов раздельно
- **WHEN** агент `jira` выходил на связь минуту назад, агент `wiki` — никогда
- **THEN** `GET /agents` отдаёт `jira` с `online = true` и `wiki` с `online = false`, `last_seen_at = null`
### Requirement: Настройки ### Requirement: Настройки
Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой `transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
@ -220,15 +226,17 @@ SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started
### Requirement: Проекты ### Requirement: Проекты
Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой:
`label` (PK, метка Jira), `name`, `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — `label` (PK, метка Jira), `name`, `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и
тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория управляющих символов (категория Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие
Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть непустым после обрезки пробелов, до 100
непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL символов. Метку и название после создания хаб не меняет (переименования и удаления нет).
записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания
хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: Доска проекта SHALL храниться в таблице модуля jira `jira_project`: `label` (PK, ссылка на проект), `board_id` (номер
`project = <project_key> AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — тип ещё не известен). `board_id` — целое больше нуля
label, через `, `; без проектов базовой JQL или `NULL`, после создания не меняется; `board_type` SHALL записываться только по результату `refresh` от агента (см.
нет (`null`). `agent`). Миграция v7 SHALL перенести `board_id` и `board_type` всех проектов из `project` в `jira_project`. Базовая JQL
SHALL вычисляться из проектов: `project = <project_key> AND labels in ("<метка>", …)` — каждая метка в двойных
кавычках, метки по created_at, затем label, через `, `; без проектов базовой JQL нет (`null`).
Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без
метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в
@ -259,6 +267,11 @@ label, через `, `; без проектов базовой JQL
- **WHEN** есть проект `AWPCA`, создаётся проект `awpca` - **WHEN** есть проект `AWPCA`, создаётся проект `awpca`
- **THEN** отказ «проект с такой меткой уже есть», проект один - **THEN** отказ «проект с такой меткой уже есть», проект один
#### Scenario: Доски после миграции v7
- **WHEN** база v6 с проектами `AWPCA` (`board_id = 4216`, `scrum`) и `sfm_kedr` (без доски) мигрирует в v7
- **THEN** в `project` нет колонок `board_id`, `board_type`; в `jira_project` — `AWPCA` 4216 `scrum` и `sfm_kedr` `NULL`
`NULL`; перед миграцией сделана копия базы
### Requirement: Журнал изменений задач ### Requirement: Журнал изменений задач
Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id
записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом), записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом),

View File

@ -8,14 +8,17 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача
### Requirement: Процесс и доступ ### Requirement: Процесс и доступ
`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в `jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать `~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать
`Authorization: Bearer <token>`: агентский токен открывает только `/agent/*`, клиентский — всё остальное. Исключение — `Authorization: Bearer <token>`: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента,
маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё
остальное. `jira-hub tokens` SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение —
раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без
данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или
`Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки `Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки
дополнительно отвечает 502. Методы, кроме GET, POST и PUT, отвечают 501 стандартной страницей `http.server`, не JSON. дополнительно отвечает 502. Методы, кроме GET, POST, PUT и PATCH, отвечают 501 стандартной страницей `http.server`, не
JSON.
#### Scenario: Чужой токен #### Scenario: Чужой токен
- **WHEN** агентским токеном вызван `GET /issues` - **WHEN** токеном агента `jira` вызван `GET /issues`
- **THEN** 401 - **THEN** 401
#### Scenario: Неподдерживаемый метод #### Scenario: Неподдерживаемый метод
@ -26,6 +29,10 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача
- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть - **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет - **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет
#### Scenario: Токены после миграции
- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `jira-hub tokens`
- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki`
### Requirement: Маршруты агента ### Requirement: Маршруты агента
Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий - `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий
@ -333,29 +340,42 @@ SHALL скопировать файл базы в `backups/<дата-время>
- **THEN** 401 - **THEN** 401
### Requirement: Публикация версии агента ### Requirement: Публикация версии агента
`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка `jira-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`;
`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `<JIRA_HUB_HOME>/release/agent.py` неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ,
(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в
переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev` `<JIRA_HUB_HOME>/release/<модуль>/agent.py` (каталоги 0700), затем `manifest.json` `{version, sha256, size,
и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть published_at}`; оба — через временный файл с `fsync` и переименование, манифест последним. Прежний `agent.py` на время
`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать публикации SHALL переименовываться в `agent.py.prev` и удаляться после записи манифеста; если запись нового файла или
публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного манифеста не удалась (`OSError`), хаб SHALL вернуть `agent.py.prev` на место `agent.py`, удалить временные файлы и
`serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…, <size> байт»; при ошибке — сообщение и код 1, завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя
опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест версия. Команда SHALL работать без запущенного `serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…,
годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов <size> байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести
`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый прежнюю публикацию `release/agent.py` и `release/manifest.json` в `release/jira/`, если `release/jira/` ещё нет; ошибка
текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет» переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом `claim`,
(`GET /agent/release` — 404 `no_release`). `GET /agent` SHALL дополняться полями `release` (`{version, sha256, `GET /agent/release` и `GET /agent`; манифест годен, только если это объект, `version` — целое число (не логическое) не
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` с момента запуска хаба) меньше 1, `sha256` — строка из 64 символов `[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и
и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент предупреждение в лог — одно на каждый новый текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем
онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>» (`rolled_back`), sha256, означает «публикации нет» (`GET /agent/release` — 404 `no_release`). `GET /agent?module=<модуль>` (без
«v<to> отвергнута: <error>» (`rejected`), иначе `null`. параметра — `jira`) и каждый элемент `GET /agents` SHALL дополняться полями `release` (`{version, sha256,
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` этого агента с момента
запуска хаба) и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии
агента, агент онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>»
(`rolled_back`), «v<to> отвергнута: <error>» (`rejected`), иначе `null`.
#### Scenario: Публикация новой версии #### Scenario: Публикация новой версии
- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` - **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6, - **THEN** в `release/jira/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
sha256 …»; код 0 sha256 …»; код 0
#### Scenario: Публикация eva-agent
- **WHEN** `jira-hub agent publish --module wiki eva-agent/src/agent.py` с `AGENT_VERSION = 1`, опубликована jira v14
- **THEN** в `release/wiki/` — v1; `claim` агента `wiki` получает `agent_release {version: 1, …}`, `claim` агента `jira` —
`{version: 14, …}`
#### Scenario: Неизвестный модуль
- **WHEN** `jira-hub agent publish --module nope agent.py`
- **THEN** код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; `release/` не изменён
#### Scenario: Версия не выше опубликованной #### Scenario: Версия не выше опубликованной
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` - **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён - **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
@ -367,7 +387,7 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report
#### Scenario: Диск полон при записи манифеста #### Scenario: Диск полон при записи манифеста
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError` - **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8, - **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет
#### Scenario: Негодный манифест #### Scenario: Негодный манифест
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim` - **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
@ -382,6 +402,10 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report
- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого - **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого
`claim` — `agent_reported = true`, `launcher_version` из `claim` `claim` — `agent_reported = true`, `launcher_version` из `claim`
#### Scenario: Перенос прежней публикации
- **WHEN** хаб с `release/agent.py` v14 и манифестом без `release/jira/` запущен на новом коде
- **THEN** публикация v14 лежит в `release/jira/`, `claim` агента `jira` получает `agent_release {version: 14, …}`
### Requirement: Раздача веб-приложения ### Requirement: Раздача веб-приложения
Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_hub/web/app/`, читая файлы Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_hub/web/app/`, читая файлы
с диска на каждый запрос: с диска на каждый запрос:
@ -459,22 +483,33 @@ sha256 (без расширения и имени вложения: имя за
### Requirement: Маршруты проектов ### Requirement: Маршруты проектов
Хаб SHALL предоставлять под клиентским токеном: Хаб SHALL предоставлять под клиентским токеном:
- `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; - `GET /projects` → список проектов по `created_at`, затем `label`; каждый — плоский объект из полей ядра (`label`,
`issues_count` — задачи зеркала с меткой проекта. `name`, `created_at`) и полей всех модулей (`modules`, «Поля проекта от модулей»). Модуль jira даёт `board_id`,
- `POST /projects` `{label, name, board_id?, author?}` → 201 `{project, batch_id}`: в одной транзакции проект `board_type`, `issues_count` (задачи зеркала с меткой проекта), модуль wiki — `wiki_root`, `wiki_root_title`,
записывается и ставится пакет «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку; `wiki_pages`, `wiki_synced_at`, `wiki_sync_error`.
автор пакета — `author` (по умолчанию `api`). Неверные поля (см. `data-model`, «Проекты») — 422 с указанием поля; - `POST /projects` `{label, name, author?, <поля модулей>}` → 201 `{project, batch_id}`: в одной транзакции проект
метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакета нет. записывается, модули записывают свои поля и ставят свои пакеты; `batch_id` — пакет первого модуля, вернувшего пакет
(у jira — «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку). Автор пакетов —
`author` (по умолчанию `api`). Неизвестное поле, неверные поля ядра (см. `data-model`, «Проекты») или модуля — 422 с
указанием поля; метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакетов нет.
- `PATCH /projects/{label}` `{<изменяемые поля модулей>, author?}` → 200 `{project, batch_ids}`: модули правят свои поля
и ставят пакеты в одной транзакции. Неизменяемое поле (метка, название, `board_id`) — 422 «<поле> после создания не
меняется»; неизвестное поле или неверное значение — 422 с указанием поля; проекта нет — 404.
#### Scenario: Новый проект #### Scenario: Новый проект
- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA`
- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`; в очереди пакет автора `viewer` - **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`, `wiki_root = null`; в очереди
с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` пакет автора `viewer` с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}`
#### Scenario: Проект без доски #### Scenario: Проект без доски
- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}`
- **THEN** 201, `board_id = null` - **THEN** 201, `board_id = null`
#### Scenario: Проект с корнем вики
- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234"}`
- **THEN** 201; в очереди пакет `refresh` и пакет `wiki_sync {label: "STOCKS_DRRK", root: "DOC-001234"}`; `batch_id` —
пакет `refresh`
#### Scenario: Повтор метки #### Scenario: Повтор метки
- **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть - **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть
- **THEN** 409, пакет не поставлен - **THEN** 409, пакет не поставлен
@ -483,6 +518,18 @@ sha256 (без расширения и имени вложения: имя за
- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}` - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}`
- **THEN** 422 с указанием поля `name` - **THEN** 422 с указанием поля `name`
#### Scenario: Неизвестное поле
- **WHEN** `POST /projects {label: "X", name: "X", board: 5}`
- **THEN** 422 с указанием поля `board`, проекта нет
#### Scenario: Корень вики у существующего проекта
- **WHEN** `PATCH /projects/AWPCA {wiki_root: "DOC-000777"}`
- **THEN** 200, `batch_ids` — один пакет `wiki_sync`; `GET /projects` отдаёт `AWPCA` с `wiki_root = DOC-000777`
#### Scenario: Доску не меняют
- **WHEN** `PATCH /projects/AWPCA {board_id: 1}`
- **THEN** 422 «board_id после создания не меняется», доска прежняя
### Requirement: Приём журнала изменений ### Requirement: Приём журнала изменений
Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом
`{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from, `{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from,

View File

@ -247,3 +247,42 @@ user scope Claude Code как `jira-hub` (`uv run --project <путь> jira-hub
скачано) и #31338 «запись.mp4» на 25 МБ скачано) и #31338 «запись.mp4» на 25 МБ
- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у - **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у
комментария 101 строка «файлы: #31337 скрин 1.png» комментария 101 строка «файлы: #31337 скрин 1.png»
### Requirement: MCP вики
`jira-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `jira-hub-wiki`,
ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: jira-hub serve».
Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты:
| Инструмент | Параметры | Ответ |
|---|---|---|
| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен |
| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `jira-hub-wiki` |
| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации |
| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер |
| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=<N>», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами |
| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент |
| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-<id>/<имя>`; иначе состояние и подсказка повторить |
| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог |
| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) |
Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня
(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без
синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `jira-hub`
(модуль jira) SHALL остаться прежним.
#### Scenario: Поиск из сессии awp-ca
- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}`
- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…`
#### Scenario: Длинная страница
- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов
- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки
продолжения
#### Scenario: Корня нет
- **WHEN** сессия `sfm_kedr` без корня, вызван `tree`
- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет
#### Scenario: Сервер jira не изменился
- **WHEN** запущен `jira-hub mcp --project AWPCA`
- **THEN** имя сервера `jira-hub`, список инструментов и описания совпадают с эталоном до change

View File

@ -26,3 +26,63 @@
- **WHEN** хаб после перестройки стартует на базе v6 с агентом v14 - **WHEN** хаб после перестройки стартует на базе v6 с агентом v14
- **THEN** схема остаётся v6, копия базы не создаётся; адреса API, веба, ответы и инструменты MCP — прежние; агент берёт - **THEN** схема остаётся v6, копия базы не создаётся; адреса API, веба, ответы и инструменты MCP — прежние; агент берёт
команды и отчитывается как раньше команды и отчитывается как раньше
### Requirement: Агенты модулей
У каждого подключённого модуля, объявляющего типы команд, SHALL быть свой агент; имя агента — имя модуля (`jira` —
jira-agent, `wiki` — eva-agent). Агент SHALL определяться по токену: `setting.tokens = {client, agents: {<модуль>:
<токен>}}`, недостающий токен агента хаб создаёт при старте. Хаб SHALL для каждого агента отдельно:
- выдавать в `claim` только команды типов его модуля, с одной командой в полёте на агента (агенты работают
параллельно);
- хранить строку `agent_status` (ключ — имя агента);
- держать опубликованный релиз `release/<модуль>/` и состояние самообновления;
- отдавать в `GET /agent/settings` ключи ядра и ключи для агента только своего модуля;
- пускать на маршруты модуля со scope `agent` только агента этого модуля (токен другого агента — 401).
Миграция v7 SHALL сохранить прежний агентский токен как токен агента `jira`, перенести строку `agent_status` в агента
`jira` и прежнюю публикацию `release/agent.py` в `release/jira/`. jira-agent v14 SHALL работать без обновления.
#### Scenario: jira-agent после выкатки
- **WHEN** хаб v6 с агентским токеном T и публикацией v14 обновлён до v7, jira-agent v14 шлёт `claim` с токеном T
- **THEN** 200; ответ содержит `agent_release {version: 14, …}`; `GET /agent` показывает агента `jira` с версией 14
#### Scenario: Агенты не берут чужие команды
- **WHEN** в очереди пакет 1 с `ensure_issue` и пакет 2 с `wiki_sync`, оба `queued`; агент `wiki` шлёт `claim`
- **THEN** выдан `wiki_sync`; следующий `claim` агента `jira` получает `ensure_issue`, хотя `wiki_sync` ещё `claimed`
#### Scenario: Чужой маршрут агента
- **WHEN** токеном агента `wiki` вызван `POST /agent/state`
- **THEN** 401, зеркало не меняется
#### Scenario: Настройки только своего модуля
- **WHEN** токеном агента `wiki` вызван `GET /agent/settings`
- **THEN** в ответе есть `wiki_pacing` и `settings_version`, ключей jira (`project_key`, `transitions_map`) нет
### Requirement: Поля проекта от модулей
Проект ядра SHALL состоять из `label`, `name`, `created_at`; остальные поля проекта SHALL объявлять модули: имя, тип,
проверку, изменяемость после создания, подпись и подсказку. Модуль SHALL сам хранить свои поля и отдавать их значения
(включая вычисляемые) для списка проектов. При старте хаб SHALL отвергать два модуля с одним именем поля или поле
модуля с именем поля ядра — ошибка старта с именем поля и модулей. В ядре SHALL NOT оставаться знания о полях модулей
(доска, число задач, корень вики).
#### Scenario: Конфликт полей
- **WHEN** два модуля объявляют поле проекта `root`
- **THEN** хаб не стартует, ошибка называет поле и оба модуля
#### Scenario: Поля jira прежние
- **WHEN** после миграции v7 вызван `GET /projects`
- **THEN** у `AWPCA` `board_id = 4216`, `board_type = scrum`, `issues_count` — как до миграции
### Requirement: Подготовка отчёта команды модулем
Контракт `CommandHooks` SHALL содержать два необязательных хука. `before_result(conn, cmd, status, result, files_dir)`
SHALL вызываться ядром при отчёте по выданной команде до транзакции отчёта, чтобы модуль готовил тяжёлую работу (перевод,
запись файлов) вне блокировки базы; `ValueError` из него SHALL отдаваться как ошибка сервера, а не как неверный статус
отчёта. `abort_result(conn, cmd)` SHALL вызываться, когда отчёт не дошёл до `after_result` (ранний выход, исключение), и
снимать подготовку `before_result`. Модуль без этих хуков SHALL работать как прежде.
#### Scenario: Подготовка до транзакции
- **WHEN** агент шлёт отчёт `done` по команде типа модуля с `before_result`
- **THEN** `before_result` вызван до `BEGIN IMMEDIATE`, после фиксации транзакции — `after_result`
#### Scenario: Отчёт прерван
- **WHEN** `before_result` выполнен, а транзакция отчёта завершилась исключением
- **THEN** вызван `abort_result`, подготовка снята, команда остаётся выданной

View File

@ -457,12 +457,13 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха
234,4 КБ»; щелчок по любой из них скачивает файл 234,4 КБ»; щелчок по любой из них скачивает файл
### Requirement: Проект в приложении ### Requirement: Проект в приложении
Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в Доска, список задач и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром
адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть `project` в адресе `/app/board`, `/app/issues` и `/app/wiki`; без параметра — последний выбранный в этом браузере
в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. (`localStorage`), если он есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес
Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и выпадающий список без новой записи истории. Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден»
проектов вместо данных. Проектов нет — доска и список SHALL показывать приглашение создать проект («+ Новый проект…»). и выпадающий список проектов вместо данных. Проектов нет — доска, список и вики SHALL показывать приглашение создать
Переходы «Доска связей» / «Задачи» SHALL сохранять текущий проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит. проект («+ Новый проект…»). Переходы между «Доска связей», «Задачи» и «Вики» SHALL сохранять текущий проект. Карточка
задачи (`/app/issues/{key}`) от проекта не зависит.
Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — `POST /refresh {full: true}`, обновляет все Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — `POST /refresh {full: true}`, обновляет все
проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта; проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта;
@ -471,9 +472,11 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха
«Спринт» нет — показаны все задачи». «Спринт» нет — показаны все задачи».
Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка, Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка,
название, номер доски Jira (необязательно; подсказка «scrum или kanban определится сам»). «Создать» — `POST /projects` название и поля модулей — номер доски Jira (необязательно; подсказка «scrum или kanban определится сам») и корень вики
с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся открытым; успех — окно (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Пустое необязательное поле в запрос не попадает.
закрывается, текущим становится новый проект, боковая панель показывает ход пакета обновления из ответа (`batch_id`). «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся
открытым; успех — окно закрывается, текущим становится новый проект, боковая панель показывает ход пакета обновления из
ответа (`batch_id`).
#### Scenario: Первый вход после выкатки #### Scenario: Первый вход после выкатки
- **WHEN** открыт `/app/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK` - **WHEN** открыт `/app/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK`
@ -488,10 +491,14 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха
- **THEN** «проект NOPE не найден» и список проектов; запроса `GET /issues` нет - **THEN** «проект NOPE не найден» и список проектов; запроса `GET /issues` нет
#### Scenario: Создание проекта из формы #### Scenario: Создание проекта из формы
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска пусто; нажато «Создать» - **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать»
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`; - **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`;
боковая панель показывает «пакет #N выполняется» боковая панель показывает «пакет #N выполняется»
#### Scenario: Проект с корнем вики из формы
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать»
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`
#### Scenario: Метка занята #### Scenario: Метка занята
- **WHEN** в форме введена метка `awpca`, хаб ответил 409 - **WHEN** в форме введена метка `awpca`, хаб ответил 409
- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба - **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба
@ -658,3 +665,31 @@ Semibold 14/20, заголовок страницы Bold 20/28. Страницы
#### Scenario: Агент старой версии #### Scenario: Агент старой версии
- **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14» - **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14»
- **THEN** под вложением этот текст и «Повторить» - **THEN** под вложением этот текст и «Повторить»
### Requirement: Вики проекта
Приложение SHALL иметь страницу «Вики» (`/app/wiki?project=<метка>`, пункт боковой панели; текущий проект — как у доски
и задач, «Проект в приложении»). Страница SHALL показывать:
- дерево страниц проекта (`GET /wiki/tree`) слева, свёрнутое до первого уровня; узел раскрывается по клику;
- выбранную страницу справа (`GET /wiki/pages/{code}`): заголовок, дата синхронизации, ссылка «открыть в Eva»,
Markdown с таблицами, без сырого HTML; ссылки `page:<код>` открывают страницу вики (код в адресе `&page=<код>`),
ссылки `attachment:<id>` скачивают вложение (запрос, опрос состояния, сохранение файла браузером), состояние
`failed` — текст ошибки рядом со ссылкой;
- поиск над деревом (`GET /wiki/search`): список результатов с фрагментами вместо дерева, пока строка поиска не пуста;
- кнопку «Обновить вики» (`POST /wiki/sync`) и плашку хода синхронизации в боковой панели; ответ 409 — плашка текущего
пакета;
- проекта без корня — форму «Корень вики (DOC-… или ссылка)» с кнопкой «Привязать» (`PATCH /projects/{label}`); 422 —
текст ошибки у поля;
- корень есть, синхронизаций нет — «вики ещё не синхронизирована» и кнопку «Обновить вики»;
- последняя синхронизация с ошибкой — плашку с текстом ошибки над страницей.
#### Scenario: Открыть страницу по ссылке
- **WHEN** на странице DOC-3 нажата ссылка `page:DOC-2`
- **THEN** адрес `/app/wiki?project=AWPCA&page=DOC-2`, справа DOC-2, в дереве DOC-2 выделен
#### Scenario: Проект без корня
- **WHEN** открыта `/app/wiki?project=sfm_kedr`, корня нет; введено `DOC-001234`, нажато «Привязать»
- **THEN** `PATCH /projects/sfm_kedr {wiki_root: "DOC-001234"}`; боковая панель показывает ход пакета синхронизации
#### Scenario: Поиск
- **WHEN** в строке поиска «согласов»
- **THEN** вместо дерева — результаты `GET /wiki/search?project=AWPCA&q=согласов`; очистка строки возвращает дерево

159
openspec/specs/wiki/spec.md Normal file
View File

@ -0,0 +1,159 @@
# wiki Specification
## Purpose
Модуль `wiki`: корень EVA Wiki у проекта, Markdown-копия дерева страниц, синхронизация через агента `wiki` (eva-agent), поиск, вложения по запросу и маршруты вики.
## Requirements
### Requirement: Корень вики проекта
Модуль `wiki` SHALL давать проекту поле `wiki_root` — код корневого документа Eva вида `DOC-<цифры>` (регистр
приводится к верхнему; ссылка на документ Eva с таким кодом принимается и сводится к коду) или `null`. Поле
необязательно при создании и изменяемо после (`PATCH /projects/{label}`). Задание или смена корня SHALL ставить пакет
синхронизации проекта; смена корня SHALL оставлять прежнюю копию до успешной синхронизации нового. Сброс в `null`
SHALL удалять копию проекта, его страницы, индекс и строки вложений. Модуль SHALL хранить для проекта `root_title`,
`synced_at`, `pages_count`, `sync_error`.
#### Scenario: Ссылка вместо кода
- **WHEN** `PATCH /projects/AWPCA {wiki_root: "https://eva.example.ru/project/Document/doc-001234"}`
- **THEN** 200, `wiki_root = DOC-001234`, поставлен `wiki_sync {label: AWPCA, root: DOC-001234}`
#### Scenario: Неверный код
- **WHEN** `PATCH /projects/AWPCA {wiki_root: "страница про статусы"}`
- **THEN** 422 с указанием поля `wiki_root`, корень прежний, пакета нет
#### Scenario: Сброс корня
- **WHEN** у `AWPCA` синхронизировано 120 страниц, `PATCH /projects/AWPCA {wiki_root: null}`
- **THEN** каталога `wiki/AWPCA/` нет, страниц и вложений проекта в базе нет; `GET /projects` — `wiki_root = null`
### Requirement: Синхронизация вики
Команда `wiki_sync {label, root}` SHALL быть командой чтения модуля `wiki` (без копии базы) в пакете с `project =
label`. Её SHALL ставить: `POST /wiki/sync {project}`, задание или смена корня, фоновая работа хаба — по каждому
проекту с корнем, если с последней успешной или неудачной синхронизации прошло больше `wiki_auto_sync_hours`
(настройка модуля, по умолчанию 24; `0` — выключено). Пока у проекта есть пакет `wiki_sync` не в терминальном статусе,
новый не ставится: `POST /wiki/sync` отвечает 409 `sync_in_progress` с `batch_id` текущего. Смена или сброс корня
SHALL отменять невыданные (`queued`) пакеты `wiki_sync` проекта и при новом корне ставить новый; отчёт `done` по
команде, чей `root` не равен текущему корню проекта, SHALL отбрасываться без изменения копии и данных
(`result.discarded = "корень сменился"`).
Агент `wiki` SHALL прислать все страницы поддерева корня (включая корень) пачками `POST /agent/wiki/pages {command_id,
pages}` — каждая страница `{code, eva_id, title, parent_code, orderno, url, html, attachments: [{ref, url, name}],
links: [{ref, code}]}`, где `attachments` — ссылки и картинки на файлы Eva, `links` — ссылки на документы Eva, `ref` —
значение атрибута `src`/`href` ровно как в `html`, `url` — абсолютный адрес, `name` — `alt`/`title`, текст ссылки или
имя файла; в пачке не больше 50 страниц, — и отчитаться `done` с `result = {root_title, codes, eva_calls}`, где
`codes` — коды всех страниц дерева в порядке обхода. Хаб SHALL при приёме пачки переводить HTML в Markdown вне
транзакции записи и складывать результат во временную стадию команды, не трогая копию. Пачка по
команде не в `claimed` или не `wiki_sync` — 409; неверная форма страницы — 422 `bad_pages` с кодом страницы и полем.
По отчёту `done` хаб SHALL одной операцией заменить копию проекта (см. «Markdown-копия вики»), страницы, индекс поиска и
вложения и записать `synced_at`, `pages_count`, `root_title`, очистив `sync_error`. Код из `codes` без присланной
страницы — команда `failed` «нет страницы <код>». Отчёт `failed` или перевод в `failed` по пределу выдач SHALL записать
`sync_error`; копия, страницы и вложения остаются прежними. Повторная выдача команды SHALL очищать её стадию. Стадия
очищается при любом терминальном статусе команды.
#### Scenario: Первая синхронизация
- **WHEN** у `AWPCA` корень DOC-1 с детьми DOC-2, DOC-3; агент прислал три страницы одной пачкой и `done {root_title:
"АРМ КА", codes: [DOC-1, DOC-2, DOC-3]}`
- **THEN** в `wiki/AWPCA/` три `index.md`; `GET /projects` — `wiki_pages = 3`, `wiki_root_title = АРМ КА`,
`wiki_synced_at` заполнен
#### Scenario: Неудачная синхронизация не портит копию
- **WHEN** копия `AWPCA` из 3 страниц, следующая синхронизация завершилась `failed` «Eva: HTTP 503»
- **THEN** копия и поиск прежние; `wiki_sync_error = "Eva: HTTP 503"`
#### Scenario: Потерянная пачка
- **WHEN** отчёт `done` с `codes: [DOC-1, DOC-2]`, пачки пришли только для DOC-1
- **THEN** команда `failed` «нет страницы DOC-2», копия прежняя
#### Scenario: Страница удалена в Eva
- **WHEN** в прошлой копии были DOC-1, DOC-2, DOC-3, новая синхронизация вернула DOC-1, DOC-3
- **THEN** каталога DOC-2 нет, поиск его не находит, его вложения удалены
#### Scenario: Корень исправлен, пока агент не установлен
- **WHEN** у `AWPCA` корень DOC-12 и пакет `wiki_sync` в `queued`; `PATCH /projects/AWPCA {wiki_root: "DOC-21"}`
- **THEN** прежний пакет `cancelled`, поставлен `wiki_sync {root: DOC-21}`
#### Scenario: Поздний отчёт по старому корню
- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту, корень сменён на DOC-21, агент отчитался `done`
- **THEN** копия и страницы `AWPCA` не изменились, команда `done` с `result.discarded`
#### Scenario: Повторный запрос синхронизации
- **WHEN** пакет `wiki_sync` проекта `AWPCA` выполняется, вызван `POST /wiki/sync {project: AWPCA}`
- **THEN** 409 `sync_in_progress` с `batch_id` текущего пакета, нового пакета нет
### Requirement: Markdown-копия вики
Копия проекта SHALL лежать в `<JIRA_HUB_HOME>/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог
`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня
номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей —
суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# <title>` и текст. В
корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом
(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте.
HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva
с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а
ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на
`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный
`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает.
#### Scenario: Раскладка
- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2)
- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке
DOC-1, DOC-3, DOC-2
#### Scenario: Картинка на странице
- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">`
- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан
#### Scenario: Прикреплённый файл с миниатюрой
- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и
ссылка на `…/<uuid>/model.xlsx`
- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет
#### Scenario: Ссылка на другую страницу
- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>`
- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)`
### Requirement: Вложения вики по запросу
`POST /wiki/attachments/{id}/fetch` SHALL ставить пакет из одной команды чтения `wiki_fetch {attachment_id, url}` и
переводить вложение в `fetching`; для `fetching` — ответ текущим состоянием без нового пакета, для `ready` — сразу
`ready`. Агент `wiki` SHALL скачать файл с токеном Eva, отказаться от файла больше предела хранилища ядра (10 МБ) с
ошибкой «файл больше 10 МБ — откройте в Eva», загрузить файл `POST /agent/files` и отчитаться `done {file, size}`. Хаб
SHALL проверить, что файл есть в `files/` и размер совпадает, и перевести вложение в `ready`; иначе команда и вложение
`failed`. Адрес не на хосте Eva из конфига агента агент SHALL отвергать без запроса (`failed` «чужой хост»).
Файлы вложений в `ready` SHALL считаться ссылками для уборки `files/`. `GET /wiki/attachments/{id}` отдаёт
`{id, name, page_code, url, state, size, error}`, `GET /wiki/attachments/{id}/content` — содержимое (`ready`) или 409
`not_ready`.
#### Scenario: Скачивание картинки
- **WHEN** вложение 7 в `none`, `POST /wiki/attachments/7/fetch`, агент прислал файл 300 КБ
- **THEN** вложение 7 `ready`, `size = 307200`, `GET /wiki/attachments/7/content` — файл
#### Scenario: Слишком большой файл
- **WHEN** агент отчитался `failed` «файл больше 10 МБ — откройте в Eva»
- **THEN** вложение `failed` с этим текстом; `GET …/content` — 409 `not_ready`
#### Scenario: Вложение переживает синхронизацию
- **WHEN** вложение 7 `ready`, синхронизация вернула ту же страницу с той же ссылкой
- **THEN** у вложения тот же `id` 7 и `state = ready`, файл в `files/` не убран
### Requirement: Маршруты вики
Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409
`no_root`):
- `GET /wiki/status?project=` — `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`;
- `GET /wiki/tree?project=` — `{"pages": [{code, title, depth, parent_code, size}]}` в порядке обхода;
- `GET /wiki/pages/{code}?project=` — `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state,
size, error}]}`; страницы нет в копии — 404;
- `GET /wiki/search?project=&q=&limit=` (`limit` 1–50, по умолчанию 10) — `{"results": [{code, title, path, snippet}]}` по
убыванию релевантности; слова запроса ищутся по префиксу, пустой запрос — 422;
- `POST /wiki/sync {project, author?}` → 201 `{batch_id}` (409 — см. «Синхронизация вики»);
- маршруты вложений — см. «Вложения вики по запросу».
Под токеном агента `wiki` — `POST /agent/wiki/pages` (см. «Синхронизация вики»).
#### Scenario: Поиск
- **WHEN** в копии `AWPCA` страница DOC-3 «Статусы» содержит «статус «На согласовании» ставит аналитик»;
`GET /wiki/search?project=AWPCA&q=согласов`
- **THEN** первым — DOC-3 с фрагментом, содержащим «На согласовании»
#### Scenario: Проект без корня
- **WHEN** у `sfm_kedr` нет корня, `GET /wiki/tree?project=sfm_kedr`
- **THEN** 409 `no_root`