diff --git a/openspec/changes/wiki-module/.openspec.yaml b/openspec/changes/archive/2026-10-24-wiki-module/.openspec.yaml similarity index 100% rename from openspec/changes/wiki-module/.openspec.yaml rename to openspec/changes/archive/2026-10-24-wiki-module/.openspec.yaml diff --git a/openspec/changes/wiki-module/design.md b/openspec/changes/archive/2026-10-24-wiki-module/design.md similarity index 100% rename from openspec/changes/wiki-module/design.md rename to openspec/changes/archive/2026-10-24-wiki-module/design.md diff --git a/openspec/changes/wiki-module/proposal.md b/openspec/changes/archive/2026-10-24-wiki-module/proposal.md similarity index 100% rename from openspec/changes/wiki-module/proposal.md rename to openspec/changes/archive/2026-10-24-wiki-module/proposal.md diff --git a/openspec/changes/wiki-module/specs/data-model/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/data-model/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/data-model/spec.md diff --git a/openspec/changes/wiki-module/specs/hub-api/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/hub-api/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/hub-api/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/hub-api/spec.md diff --git a/openspec/changes/wiki-module/specs/mcp/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/mcp/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/mcp/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/mcp/spec.md diff --git a/openspec/changes/wiki-module/specs/modules/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/modules/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/modules/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/modules/spec.md diff --git a/openspec/changes/wiki-module/specs/web-app/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/web-app/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/web-app/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/web-app/spec.md diff --git a/openspec/changes/wiki-module/specs/wiki/spec.md b/openspec/changes/archive/2026-10-24-wiki-module/specs/wiki/spec.md similarity index 100% rename from openspec/changes/wiki-module/specs/wiki/spec.md rename to openspec/changes/archive/2026-10-24-wiki-module/specs/wiki/spec.md diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index f301515..ecb9b5d 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -161,13 +161,15 @@ MUST NOT менять `issue_note`. При завершении `ensure_issue` Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, `issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed` -по пределу выдач — проставить `finished_at`. Таблица `agent_status` (одна строка) SHALL хранить `last_seen_at`, -`agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP `agent_status` SHALL -отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в -`claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). +по пределу выдач — проставить `finished_at`. Таблица `agent_status` SHALL хранить по строке на агента (ключ `agent` — +имя модуля, см. `modules`, «Агенты модулей»): `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`, +`last_call_at` (время последнего обращения агента к внешней системе; от jira-agent приходит полем `last_jira_call_at`). +Через API и MCP статус агента SHALL отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и +списком `redelivered` — команд его модуля в `claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, +`deliveries`). #### Scenario: Агент давно не выходил на связь -- **WHEN** `last_seen_at` старше 5 минут +- **WHEN** `last_seen_at` агента `jira` старше 5 минут - **THEN** `agent_status` через API и MCP отдаётся с `online = false` #### Scenario: Довыдача refresh не удваивает счётчики @@ -178,6 +180,10 @@ SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started - **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач» - **THEN** у его `sync_run` проставлен `finished_at` +#### Scenario: Статусы агентов раздельно +- **WHEN** агент `jira` выходил на связь минуту назад, агент `wiki` — никогда +- **THEN** `GET /agents` отдаёт `jira` с `online = true` и `wiki` с `online = false`, `last_seen_at = null` + ### Requirement: Настройки Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, `transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой @@ -220,15 +226,17 @@ SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started ### Requirement: Проекты Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: -`label` (PK, метка Jira), `name`, `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — -тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория -Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть -непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL -записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания -хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: -`project = AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем -label, через `, `; без проектов базовой JQL -нет (`null`). +`label` (PK, метка Jira), `name`, `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и +управляющих символов (категория Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие +без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть непустым после обрезки пробелов, до 100 +символов. Метку и название после создания хаб не меняет (переименования и удаления нет). + +Доска проекта SHALL храниться в таблице модуля jira `jira_project`: `label` (PK, ссылка на проект), `board_id` (номер +доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — тип ещё не известен). `board_id` — целое больше нуля +или `NULL`, после создания не меняется; `board_type` SHALL записываться только по результату `refresh` от агента (см. +`agent`). Миграция v7 SHALL перенести `board_id` и `board_type` всех проектов из `project` в `jira_project`. Базовая JQL +SHALL вычисляться из проектов: `project = AND labels in ("<метка>", …)` — каждая метка в двойных +кавычках, метки по created_at, затем label, через `, `; без проектов базовой JQL нет (`null`). Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в @@ -259,6 +267,11 @@ label, через `, `; без проектов базовой JQL - **WHEN** есть проект `AWPCA`, создаётся проект `awpca` - **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: Журнал изменений задач Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом), diff --git a/openspec/specs/hub-api/spec.md b/openspec/specs/hub-api/spec.md index bc06039..d2a37e5 100644 --- a/openspec/specs/hub-api/spec.md +++ b/openspec/specs/hub-api/spec.md @@ -8,14 +8,17 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача ### Requirement: Процесс и доступ `jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в `~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать -`Authorization: Bearer `: агентский токен открывает только `/agent/*`, клиентский — всё остальное. Исключение — +`Authorization: Bearer `: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента, +маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё +остальное. `jira-hub tokens` SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение — раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или `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: Чужой токен -- **WHEN** агентским токеном вызван `GET /issues` +- **WHEN** токеном агента `jira` вызван `GET /issues` - **THEN** 401 #### Scenario: Неподдерживаемый метод @@ -26,6 +29,10 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача - **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть - **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет +#### Scenario: Токены после миграции +- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `jira-hub tokens` +- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki` + ### Requirement: Маршруты агента Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): - `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий @@ -333,29 +340,42 @@ SHALL скопировать файл базы в `backups/<дата-время> - **THEN** 401 ### Requirement: Публикация версии агента -`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка -`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/release/agent.py` -(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и -переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev` -и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть -`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать -публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного -`serve` и печатать «опубликована v, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, -опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест -годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов -`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый -текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет» -(`GET /agent/release` — 404 `no_release`). `GET /agent` 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 на v: » (`rolled_back`), -«v отвергнута: » (`rejected`), иначе `null`. +`jira-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`; +неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ, +ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в +`/release/<модуль>/agent.py` (каталоги 0700), затем `manifest.json` `{version, sha256, size, +published_at}`; оба — через временный файл с `fsync` и переименование, манифест последним. Прежний `agent.py` на время +публикации SHALL переименовываться в `agent.py.prev` и удаляться после записи манифеста; если запись нового файла или +манифеста не удалась (`OSError`), хаб SHALL вернуть `agent.py.prev` на место `agent.py`, удалить временные файлы и +завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя +версия. Команда SHALL работать без запущенного `serve` и печатать «опубликована v, sha256 <первые 12 символов>…, + байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести +прежнюю публикацию `release/agent.py` и `release/manifest.json` в `release/jira/`, если `release/jira/` ещё нет; ошибка +переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом `claim`, +`GET /agent/release` и `GET /agent`; манифест годен, только если это объект, `version` — целое число (не логическое) не +меньше 1, `sha256` — строка из 64 символов `[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и +предупреждение в лог — одно на каждый новый текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем +sha256, означает «публикации нет» (`GET /agent/release` — 404 `no_release`). `GET /agent?module=<модуль>` (без +параметра — `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 на v: » +(`rolled_back`), «v отвергнута: » (`rejected`), иначе `null`. #### Scenario: Публикация новой версии - **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 +#### 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: Версия не выше опубликованной - **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` - **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён @@ -367,7 +387,7 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report #### Scenario: Диск полон при записи манифеста - **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError` - **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8, - `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет + `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет #### Scenario: Негодный манифест - **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`; после первого `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: Раздача веб-приложения Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_hub/web/app/`, читая файлы с диска на каждый запрос: @@ -459,22 +483,33 @@ sha256 (без расширения и имени вложения: имя за ### Requirement: Маршруты проектов Хаб SHALL предоставлять под клиентским токеном: -- `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; - `issues_count` — задачи зеркала с меткой проекта. -- `POST /projects` `{label, name, board_id?, author?}` → 201 `{project, batch_id}`: в одной транзакции проект - записывается и ставится пакет «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку; - автор пакета — `author` (по умолчанию `api`). Неверные поля (см. `data-model`, «Проекты») — 422 с указанием поля; - метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакета нет. +- `GET /projects` → список проектов по `created_at`, затем `label`; каждый — плоский объект из полей ядра (`label`, + `name`, `created_at`) и полей всех модулей (`modules`, «Поля проекта от модулей»). Модуль jira даёт `board_id`, + `board_type`, `issues_count` (задачи зеркала с меткой проекта), модуль wiki — `wiki_root`, `wiki_root_title`, + `wiki_pages`, `wiki_synced_at`, `wiki_sync_error`. +- `POST /projects` `{label, name, author?, <поля модулей>}` → 201 `{project, batch_id}`: в одной транзакции проект + записывается, модули записывают свои поля и ставят свои пакеты; `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: Новый проект - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` -- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`; в очереди пакет автора `viewer` - с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` +- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`, `wiki_root = null`; в очереди + пакет автора `viewer` с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` #### Scenario: Проект без доски - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` - **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: Повтор метки - **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть - **THEN** 409, пакет не поставлен @@ -483,6 +518,18 @@ sha256 (без расширения и имени вложения: имя за - **WHEN** `POST /projects {label: "STOCKS_DRRK", 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: Приём журнала изменений Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом `{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from, diff --git a/openspec/specs/mcp/spec.md b/openspec/specs/mcp/spec.md index d6e6bf7..018a6d9 100644 --- a/openspec/specs/mcp/spec.md +++ b/openspec/specs/mcp/spec.md @@ -247,3 +247,42 @@ user scope Claude Code как `jira-hub` (`uv run --project <путь> jira-hub скачано) и #31338 «запись.mp4» на 25 МБ - **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у комментария 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=», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами | +| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент | +| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-/<имя>`; иначе состояние и подсказка повторить | +| `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 diff --git a/openspec/specs/modules/spec.md b/openspec/specs/modules/spec.md index 0af7a48..b5c405e 100644 --- a/openspec/specs/modules/spec.md +++ b/openspec/specs/modules/spec.md @@ -26,3 +26,63 @@ - **WHEN** хаб после перестройки стартует на базе v6 с агентом v14 - **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`, подготовка снята, команда остаётся выданной diff --git a/openspec/specs/web-app/spec.md b/openspec/specs/web-app/spec.md index 940dc4b..3e78a0b 100644 --- a/openspec/specs/web-app/spec.md +++ b/openspec/specs/web-app/spec.md @@ -457,12 +457,13 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха 234,4 КБ»; щелчок по любой из них скачивает файл ### Requirement: Проект в приложении -Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в -адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть -в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. -Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и выпадающий список -проектов вместо данных. Проектов нет — доска и список SHALL показывать приглашение создать проект («+ Новый проект…»). -Переходы «Доска связей» / «Задачи» SHALL сохранять текущий проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит. +Доска, список задач и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром +`project` в адресе `/app/board`, `/app/issues` и `/app/wiki`; без параметра — последний выбранный в этом браузере +(`localStorage`), если он есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес +без новой записи истории. Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» +и выпадающий список проектов вместо данных. Проектов нет — доска, список и вики SHALL показывать приглашение создать +проект («+ Новый проект…»). Переходы между «Доска связей», «Задачи» и «Вики» SHALL сохранять текущий проект. Карточка +задачи (`/app/issues/{key}`) от проекта не зависит. Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — `POST /refresh {full: true}`, обновляет все проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта; @@ -471,9 +472,11 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха «Спринт» нет — показаны все задачи». Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка, -название, номер доски Jira (необязательно; подсказка «scrum или kanban определится сам»). «Создать» — `POST /projects` -с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся открытым; успех — окно -закрывается, текущим становится новый проект, боковая панель показывает ход пакета обновления из ответа (`batch_id`). +название и поля модулей — номер доски Jira (необязательно; подсказка «scrum или kanban определится сам») и корень вики +(необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Пустое необязательное поле в запрос не попадает. +«Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся +открытым; успех — окно закрывается, текущим становится новый проект, боковая панель показывает ход пакета обновления из +ответа (`batch_id`). #### Scenario: Первый вход после выкатки - **WHEN** открыт `/app/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK` @@ -488,10 +491,14 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха - **THEN** «проект NOPE не найден» и список проектов; запроса `GET /issues` нет #### Scenario: Создание проекта из формы -- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска пусто; нажато «Создать» +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать» - **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`; боковая панель показывает «пакет #N выполняется» +#### Scenario: Проект с корнем вики из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}` + #### Scenario: Метка занята - **WHEN** в форме введена метка `awpca`, хаб ответил 409 - **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба @@ -658,3 +665,31 @@ Semibold 14/20, заголовок страницы Bold 20/28. Страницы #### Scenario: Агент старой версии - **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14» - **THEN** под вложением этот текст и «Повторить» + +### Requirement: Вики проекта +Приложение SHALL иметь страницу «Вики» (`/app/wiki?project=<метка>`, пункт боковой панели; текущий проект — как у доски +и задач, «Проект в приложении»). Страница SHALL показывать: +- дерево страниц проекта (`GET /wiki/tree`) слева, свёрнутое до первого уровня; узел раскрывается по клику; +- выбранную страницу справа (`GET /wiki/pages/{code}`): заголовок, дата синхронизации, ссылка «открыть в Eva», + Markdown с таблицами, без сырого HTML; ссылки `page:<код>` открывают страницу вики (код в адресе `&page=<код>`), + ссылки `attachment:` скачивают вложение (запрос, опрос состояния, сохранение файла браузером), состояние + `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=согласов`; очистка строки возвращает дерево diff --git a/openspec/specs/wiki/spec.md b/openspec/specs/wiki/spec.md new file mode 100644 index 0000000..e43e437 --- /dev/null +++ b/openspec/specs/wiki/spec.md @@ -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 лежать в `/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог +`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня +номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей — +суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# ` и текст. В +корне копии — `_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`