241 lines
24 KiB
Markdown
241 lines
24 KiB
Markdown
## MODIFIED Requirements
|
||
|
||
### Requirement: Процесс и доступ
|
||
`sdlc-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
|
||
`~/.local/share/sdlc-hub/` (переопределяется `SDLC_HUB_HOME`). Все маршруты API SHALL требовать
|
||
`Authorization: Bearer <token>`: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента,
|
||
маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё
|
||
остальное. `sdlc-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 и PATCH, отвечают 501 стандартной страницей `http.server`, не
|
||
JSON.
|
||
|
||
#### Scenario: Чужой токен
|
||
- **WHEN** токеном агента `jira` вызван `GET /issues`
|
||
- **THEN** 401
|
||
|
||
#### Scenario: Неподдерживаемый метод
|
||
- **WHEN** `DELETE /issues/AURORA-1`
|
||
- **THEN** 501, тело не JSON; база не меняется
|
||
|
||
#### Scenario: Страница без токена
|
||
- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть
|
||
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет
|
||
|
||
#### Scenario: Токены после миграции
|
||
- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `sdlc-hub tokens`
|
||
- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki`
|
||
|
||
### Requirement: Реле через общую папку
|
||
`sdlc-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле
|
||
бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если
|
||
каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует —
|
||
записать одно предупреждение в лог и проверять снова.
|
||
|
||
Обмен:
|
||
- запрос агента — файл `req/<id>.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC
|
||
(ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
|
||
- реле SHALL брать запрос переименованием в `work/<id>.json` и обрабатывать каждый взятый запрос в отдельном потоке;
|
||
- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до
|
||
`deadline` осталось не меньше 35 с, иначе выбрасываться без ответа;
|
||
- остальные запросы реле SHALL пересылать в свой HTTP на `<host>:<port>` процесса `serve` (`127.0.0.1`, если `serve`
|
||
слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ
|
||
`resp/<id>.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и
|
||
переименование; затем удалять `work/<id>.json`;
|
||
- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст
|
||
ошибки в `body`;
|
||
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
|
||
|
||
При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL
|
||
удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL
|
||
только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
|
||
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
|
||
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
|
||
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
|
||
|
||
#### Scenario: Запрос через папку
|
||
- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с
|
||
- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён
|
||
|
||
#### Scenario: Чужой токен через папку
|
||
- **WHEN** в файле запроса `GET /issues` с агентским токеном
|
||
- **THEN** ответ со `status = 401`, как по HTTP
|
||
|
||
#### Scenario: Просроченный claim
|
||
- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с
|
||
- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued`
|
||
|
||
#### Scenario: Долгий claim не задерживает другие запросы
|
||
- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings`
|
||
- **THEN** ответ на `settings` пишется, не дожидаясь `claim`
|
||
|
||
#### Scenario: Папка не подключена
|
||
- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился
|
||
- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы
|
||
|
||
#### Scenario: Сбой протокола своего HTTP
|
||
- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`)
|
||
- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу
|
||
|
||
#### Scenario: Повторная подготовка не трогает взятые запросы
|
||
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
|
||
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»
|
||
|
||
### Requirement: Публикация версии агента
|
||
`sdlc-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`;
|
||
неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ,
|
||
ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в
|
||
`<SDLC_HUB_HOME>/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<N>, sha256 <первые 12 символов>…,
|
||
<size> байт»; при ошибке — сообщение и код 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<to> на v<from>: <error>»
|
||
(`rolled_back`), «v<to> отвергнута: <error>» (`rejected`), иначе `null`.
|
||
|
||
#### Scenario: Публикация новой версии
|
||
- **WHEN** опубликована v5, `sdlc-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
|
||
- **THEN** в `release/jira/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
|
||
sha256 …»; код 0
|
||
|
||
#### Scenario: Публикация eva-agent
|
||
- **WHEN** `sdlc-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** `sdlc-hub agent publish --module nope agent.py`
|
||
- **THEN** код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; `release/` не изменён
|
||
|
||
#### Scenario: Версия не выше опубликованной
|
||
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
|
||
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
|
||
|
||
#### Scenario: Нет строки версии
|
||
- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками
|
||
- **THEN** сообщение об ошибке, код 1; `release/` не изменён
|
||
|
||
#### Scenario: Диск полон при записи манифеста
|
||
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
|
||
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
|
||
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет
|
||
|
||
#### Scenario: Негодный манифест
|
||
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
|
||
- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте
|
||
|
||
#### Scenario: Агент без запускателя
|
||
- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null`
|
||
- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную»
|
||
|
||
#### Scenario: Хаб перезапущен, агент ещё не выходил на связь
|
||
- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было
|
||
- **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/sdlc_hub/web/app/`, читая файлы
|
||
с диска на каждый запрос:
|
||
- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/`, не
|
||
существует или содержит нулевой байт, — 404; `Content-Type` по расширению (`.js` — `text/javascript; charset=utf-8`, `.css` —
|
||
`text/css; charset=utf-8`, `.svg` — `image/svg+xml`, `.png` — `image/png`, `.ico` — `image/x-icon`, `.woff2` —
|
||
`font/woff2`, `.map` — `application/json`, прочее — `application/octet-stream`);
|
||
`Cache-Control: public, max-age=31536000, immutable`;
|
||
- любой другой `GET /app` или `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`;
|
||
- если `index.html` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не
|
||
собрано и нужно выполнить `make web` в каталоге sdlc-hub;
|
||
- `GET /` и `GET /links` — 302 с `Location: /app/board`.
|
||
Сборка приложения не хранится в git; её создаёт `make web`.
|
||
|
||
#### Scenario: Файл сборки
|
||
- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js`
|
||
- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла
|
||
|
||
#### Scenario: Путь приложения
|
||
- **WHEN** запрошен `GET /app/issues/AURORA-1620`
|
||
- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache`
|
||
|
||
#### Scenario: Выход за каталог сборки
|
||
- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`)
|
||
- **THEN** 404, содержимое файлов вне `assets/` не отдаётся
|
||
|
||
#### Scenario: Приложение не собрано
|
||
- **WHEN** каталога сборки нет, запрошен `GET /app/board`
|
||
- **THEN** 503, тело — HTML с подсказкой `make web`
|
||
|
||
#### Scenario: Старый адрес доски
|
||
- **WHEN** запрошен `GET /links` или `GET /`
|
||
- **THEN** 302, `Location: /app/board`
|
||
|
||
#### Scenario: Нулевой байт в пути
|
||
- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js`
|
||
- **THEN** 404 `not_found`, а не 500
|
||
|
||
## ADDED Requirements
|
||
|
||
### Requirement: Каталог данных прежнего имени
|
||
Если `SDLC_HUB_HOME` не задан, `~/.local/share/sdlc-hub/` не существует, а `~/.local/share/jira-hub/` существует, любая
|
||
команда `sdlc-hub`, которой нужен каталог данных (`serve`, `mcp`, `tokens`, `init`, `import`, `agent publish`), SHALL
|
||
завершиться с кодом 2 и текстом в stderr: каталог данных лежит под прежним именем, перенести его —
|
||
`scripts/cutover.sh` (или `mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub` при остановленном хабе). Команда MUST NOT
|
||
создавать новый каталог, переносить или менять прежний. Если существуют оба каталога, используется новый, в stderr —
|
||
предупреждение, что прежний можно удалить после проверки.
|
||
|
||
#### Scenario: Код обновлён, каталог не перенесён
|
||
- **WHEN** есть только `~/.local/share/jira-hub/`, запущен `sdlc-hub serve`
|
||
- **THEN** код выхода 2, в stderr — подсказка о переносе; `~/.local/share/sdlc-hub/` не создан, прежний каталог не тронут
|
||
|
||
#### Scenario: Задан SDLC_HUB_HOME
|
||
- **WHEN** `SDLC_HUB_HOME=/tmp/x`, есть `~/.local/share/jira-hub/`
|
||
- **THEN** команда работает с `/tmp/x`, проверка прежнего каталога не выполняется
|
||
|
||
### Requirement: Переключение живого хаба
|
||
`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL
|
||
только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке:
|
||
1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал
|
||
`git pull --ff-only` прямо перед запуском: скрипт приходит этим pull; неотслеживаемая прежняя сборка `src/jira_hub/`
|
||
без tracked-файлов не считается грязью); в Gitea доступен `aep/sdlc-hub` (владелец переименовал репозиторий; адрес
|
||
выводится из `origin` заменой `/aep/jira-hub` на `/aep/sdlc-hub`, протокол сохраняется, `CUTOVER_REMOTE`
|
||
переопределяет); есть `uv`, `make`, `node`, `npm`, `claude`, `launchctl`; `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют;
|
||
2. остановить launchd `ru.aepif.jira-hub`, дождаться закрытия порта и переименовать его plist в `*.pre-sdlc`;
|
||
3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`;
|
||
4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, удалить неотслеживаемый `src/jira_hub`,
|
||
`git worktree repair`, `origin` → `aep/sdlc-hub`, пересоздать `.venv` (`uv sync`), `make web`;
|
||
5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project <checkout> sdlc-hub serve`, лог
|
||
`~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек;
|
||
6. в проектах-потребителях (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk` и каждый
|
||
`~/projects/*/.mcp.json`, где есть `jira-hub`, например `sfm_foxpro`; сухой прогон печатает полный список): в `.mcp.json` заменить записи
|
||
`jira-hub` → `sdlc-jira` (`sdlc-hub mcp jira`) и `jira-hub-wiki` → `sdlc-wiki` (`sdlc-hub mcp wiki`) с новым путём; в
|
||
`.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` →
|
||
`mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена
|
||
серверов и инструментов. Прочие файлы проектов (заметки, отчёты) не трогаются. Перед правкой файл копируется рядом
|
||
с суффиксом `.pre-sdlc`; коммиты скрипт не делает;
|
||
7. в user scope Claude Code заменить `jira-hub` на `sdlc-jira` (`claude mcp remove`/`add`).
|
||
Каждый выполненный шаг SHALL печататься; в конце — сводка, что осталось владельцу (`/mcp` reconnect в открытых сессиях,
|
||
`codegraph init` в checkout и worktree, удалить копии `.pre-sdlc` после проверки). Шаги откатываются в обратном порядке по инструкции из README.
|
||
|
||
#### Scenario: Сухой прогон
|
||
- **WHEN** `bash scripts/cutover.sh` без аргументов
|
||
- **THEN** напечатаны шаги и проверки, файловая система, launchd и конфигурации Claude не изменены
|
||
|
||
#### Scenario: Репозиторий в Gitea не переименован
|
||
- **WHEN** `bash scripts/cutover.sh --apply`, `aep/sdlc-hub` в Gitea не найден
|
||
- **THEN** скрипт останавливается на шаге 1 с подсказкой, launchd и каталоги не тронуты
|