From 9aaf3cee502e0ccce59a890f0985b98702d909d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 17:22:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=88=D0=B8=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D1=82=20ensure=5Fdefaults;=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BA=D0=B0=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=20v8=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=85=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1, I-2 финального ревью: старые MCP-сессии возвращали status_labels/transitions_map после миграции; окно до kickstart описано для v8. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 9 ++++---- README.md | 6 ++--- .../changes/fact-status-removal/design.md | 23 +++++++++++++++---- .../changes/fact-status-removal/proposal.md | 7 +++--- .../specs/data-model/spec.md | 8 ++++++- src/sdlc_hub/core/modules.py | 1 + src/sdlc_hub/core/settings.py | 3 +++ src/sdlc_hub/modules/jira/__init__.py | 1 + src/sdlc_hub/modules/jira/settings.py | 6 ++++- tests/test_db.py | 15 ++++++++++++ 10 files changed, 62 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4530ed8..1004f56 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` | | `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib | | `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, `agent_modules()`, `DEFAULT_AGENT`, проверка конфликтов (типы команд, маршруты, ключи настроек, поля проекта) | `modules`; модули — ленивым импортом внутри функции | -| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v7; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | +| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v8; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | | `settings` | настройки (`setting`), токены (клиентский + по агенту на модуль: `setting.tokens = {client, agents: {<модуль>: <токен>}}`), умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) | | `projects` | проекты-метки: таблица `project` (`label`, `name`, `created_at`), проверка метки, создание и правка (`PATCH`) с полями модулей, список = поля ядра + `project_view` модулей | `db`, `registry` | | `queue` | механика очереди: пакеты, выдача (`claim`), отчёты, отмена, повтор, временные ключи `new:`; смысл типов команд берёт у модуля через `CommandHooks` (`registry.module_of`) | `backup`, `db`, `registry`, `settings`, `errors` | @@ -75,7 +75,7 @@ `core/modules.py` задаёт две структуры; ядро знает только их. -- `Module(name, schema, settings_defaults, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. +- `Module(name, schema, settings_defaults, settings_obsolete, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. - Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра. - `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. - `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта. @@ -152,9 +152,10 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `core/api`, `modules/*/api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`. -## Выкатка и откат версии со схемой v7 +## Выкатка и откат версии с новой схемой (v7, v8) - Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить. -- До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её до v7, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены). +- До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её, а хаб на прежнем коде с такой базой не работает (v7 — `agent_status`, токены; v8 — нет колонки `issue_note.fact_status`: 500 на `GET /issues`, `/board`, запись заметки). +- После kickstart MCP-сессии со старым кодом до reconnect открывают базу своими умолчаниями: ключи, удалённые миграцией, они могут вернуть — их убирает `ensure_defaults` нового кода (`Module.settings_obsolete`). - Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. ## Открытые вопросы diff --git a/README.md b/README.md index 5218abb..a012c99 100644 --- a/README.md +++ b/README.md @@ -245,9 +245,9 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub make web ``` -Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не -запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде -с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — +Порядок важен для версий с новой схемой (v7 — модуль wiki, v8 — без статуса по факту): сразу после `git pull` — kickstart, +потом `make web`. До kickstart не запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и +переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно diff --git a/openspec/changes/fact-status-removal/design.md b/openspec/changes/fact-status-removal/design.md index d2971ce..596daa1 100644 --- a/openspec/changes/fact-status-removal/design.md +++ b/openspec/changes/fact-status-removal/design.md @@ -57,12 +57,16 @@ jira-agent (`../jira-agent/src/agent.py`) ни `status_labels`, ни `transition отстало от кода ещё в wiki-module (v7) и ломалось бы при каждой новой версии. 10. **Документация и тексты в коде правятся вместе с кодом:** README (описание `import`, строки `GET /issues` и - `GET /board`), docstring `mirror.board_snapshot`, `mcp.issues`, `mcp.notes_set`, обе строки чтения CSV в + `GET /board`), docstring `mirror.board`, `mcp.issues`, `mcp.notes_set`, обе строки чтения CSV в `importer.py`. Исторические планы `docs/superpowers/plans/*.md` и архив `openspec/changes/archive/` не правятся; итоговая проверка — `grep -rn "fact_status\|status_labels\|transitions_map\|по факту" src web/src web/scripts README.md ARCHITECTURE.md openspec/specs` даёт только намеренные остатки (страж `MODULE_TERMS`, тест параметра из адреса, текст спек «статуса по факту нет»). +11. **Устаревшие ключи настроек объявляет модуль** (`Module.settings_obsolete`, у jira — `status_labels`, + `transitions_map`); `core.settings.ensure_defaults` удаляет их при каждом вызове, `settings_version` не поднимает + (агенту ключи не передаются). Ядро имён ключей не знает. + ## Справка: пути переходов из `transitions_map` Значение настройки на живом хабе 24.10.2026 — пути по workflow AURORA (статус Jira, в который переводили задачу, @@ -84,10 +88,16 @@ jira-agent (`../jira-agent/src/agent.py`) ни `status_labels`, ни `transition ## Risks / Trade-offs - **Значения статуса по факту теряются** — решение владельца; восстановить можно только из `backups/`. -- **Старый процесс хаба на базе v8.** Если хаб старой версии откроет базу после миграции, его `ensure_defaults` снова - создаст `status_labels`/`transitions_map` с пустыми значениями, а запись заметки упадёт на отсутствующей колонке. - Окно — только между `git pull` и `kickstart`; MCP-процессы старой версии ходят через HTTP и базу не открывают. - Принимается. +- **Новый код до kickstart.** MCP-сервер и CLI открывают базу сами (`HubClient.from_home()` → `db.connect` + + `ensure_defaults`). Новая сессия Claude, `/mcp` → reconnect или `sdlc-hub tokens` между `git pull` и `kickstart` + мигрируют базу до v8, а хаб на прежнем коде отвечает 500 на `GET /issues`, `GET /board`, `PUT /issues/{key}/note` + (нет колонки). Порядок выкатки — `git pull` → сразу `kickstart` (README, ARCHITECTURE.md, раздел о выкатке схемы). +- **Старый код после kickstart.** Долгоживущие MCP-сессии со старым кодом при каждом вызове открывают базу и своим + `ensure_defaults` снова вставляют `status_labels`/`transitions_map` (`{}`); миграция уже прошла и не повторится, а + удалить ключ через API нельзя (I-1 финального ревью). Поэтому ключи объявлены устаревшими модулем + (`Module.settings_obsolete`), и `ensure_defaults` нового кода удаляет их при каждом вызове — при старте хаба и в каждой + новой MCP-сессии (design #11). Пока жива старая сессия, ключи могут появляться между её вызовами и вызовами нового кода; + агенту они не уходят (`AGENT_KEYS` нового кода). - **Сессии MCP со старым кодом** до `/mcp` → reconnect показывают параметр `fact_status`; вызов с ним вернёт пустой фильтр (хаб параметр игнорирует), `notes_set` с ним — 422. @@ -103,4 +113,7 @@ jira-agent (`../jira-agent/src/agent.py`) ни `status_labels`, ни `transition - 24.10.2026 — R-4 (design #3, #4): запись поля в заметку — 422, фильтр в `GET /issues` — игнорируется. Обоснование: общие правила хаба для неизвестных полей тела и параметров. Цена ошибки — правка одного обработчика. - 24.10.2026 — R-5: M-3, M-4, M-5 из #60 в change не входят и остаются в #60. Цена ошибки — отдельная мелкая задача. +- 24.10.2026 — R-7 (design #11): после I-1 финального ревью — устаревшие ключи удаляет каждый `ensure_defaults`, без + подъёма `settings_version`. Обоснование: старые MCP-сессии возвращают ключи, а удалить их иначе нечем. Цена ошибки — + поле контракта модуля, которое придётся убрать. - 24.10.2026 — R-6 (design #9): в `data-model` исправлено отставание номера версии схемы. Цена ошибки — нет. diff --git a/openspec/changes/fact-status-removal/proposal.md b/openspec/changes/fact-status-removal/proposal.md index 88e3a23..60d3be6 100644 --- a/openspec/changes/fact-status-removal/proposal.md +++ b/openspec/changes/fact-status-removal/proposal.md @@ -14,14 +14,15 @@ - **База, схема v8:** миграция v7 → v8 удаляет колонку `issue_note.fact_status` и ключи `status_labels`, `transitions_map` из `setting`, поднимает `settings_version`; копия базы — в `backups/` по общему правилу. - **Настройки:** `status_labels` и `transitions_map` уходят из умолчаний модуля jira и из ключей для агента; - `sdlc-hub init --config` их не переносит. + `sdlc-hub init --config` их не переносит; модуль объявляет их устаревшими (`Module.settings_obsolete`), и хаб удаляет их + при каждом `ensure_defaults`, если их вернул процесс прежней версии. - **API:** `GET /issues` без фильтра и поля `fact_status` (параметр игнорируется, как любой неизвестный); `GET /board` без `fact_status`; `PUT /issues/{key}/note` с `fact_status` — 422 «неизвестные поля заметки». - **MCP `sdlc-jira`:** `issues` — без колонки «статус по факту» и параметра `fact_status`; `notes_set` — без параметра `fact_status`; `issue` не выводит его в заметках. - **Импорт** `sdlc-hub import` пропускает колонку «Статус по факту» CSV. -- **Веб:** из типов (`Note`, `BoardIssue`) и фикстур уходят `fact_status` и `status_labels`; поведение страниц не - меняется. +- **Веб:** типов с полем нет с #57; из фикстур уходят `fact_status` и `status_labels`, сидер тестового хаба не пишет + поле в заметки; поведение страниц не меняется. - **Спеки и документация:** `data-model`, `hub-api`, `mcp`, `migration`, `modules`, `web-app`, README, ARCHITECTURE.md. В `data-model` исправляется отставание: «текущая — 6» при коде v7. diff --git a/openspec/changes/fact-status-removal/specs/data-model/spec.md b/openspec/changes/fact-status-removal/specs/data-model/spec.md index cb07bf8..06e4d6d 100644 --- a/openspec/changes/fact-status-removal/specs/data-model/spec.md +++ b/openspec/changes/fact-status-removal/specs/data-model/spec.md @@ -109,7 +109,8 @@ MUST NOT менять `issue_note`. При завершении `ensure_issue` по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование «Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции); -`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +Ключи, которые модуль объявил устаревшими (у jira — `status_labels`, `transitions_map`), хаб SHALL удалять при каждом +заполнении умолчаний, не меняя `settings_version`. `GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через `PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект `{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига @@ -140,6 +141,11 @@ MUST NOT менять `issue_note`. При завершении `ensure_issue` - **WHEN** создан проект `STOCKS_DRRK` - **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками +#### Scenario: Ключи вернул процесс прежней версии +- **WHEN** база v8, процесс со старым кодом (MCP-сессия до reconnect) вставил `status_labels = {}` и + `transitions_map = {}`, затем вызван `ensure_defaults` текущего кода (старт хаба, новая MCP-сессия) +- **THEN** ключей в `setting` нет, `settings_version` прежний + #### Scenario: Ключей статуса по факту нет - **WHEN** хаб открыл базу v8 - **THEN** `GET /settings` не содержит `status_labels` и `transitions_map`; `GET /agent/settings` агенту `jira` их не diff --git a/src/sdlc_hub/core/modules.py b/src/sdlc_hub/core/modules.py index 3f447ac..e26f1c7 100644 --- a/src/sdlc_hub/core/modules.py +++ b/src/sdlc_hub/core/modules.py @@ -50,6 +50,7 @@ class Module: name: str schema: str = "" settings_defaults: dict = field(default_factory=dict) + settings_obsolete: tuple[str, ...] = () # ключи прежних версий: ensure_defaults их удаляет agent_keys: tuple[str, ...] = () agent_min_version_key: str | None = None # ключ настроек с минимальной версией агента модуля validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо) diff --git a/src/sdlc_hub/core/settings.py b/src/sdlc_hub/core/settings.py index 54bebc6..8165d9c 100644 --- a/src/sdlc_hub/core/settings.py +++ b/src/sdlc_hub/core/settings.py @@ -68,6 +68,9 @@ def validate(key, value) -> None: def ensure_defaults(conn) -> None: for key, value in defaults().items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) + for m in registry.modules(): # ключи, которые процесс прежней версии мог вернуть своими умолчаниями после миграции + for key in m.settings_obsolete: + conn.execute("DELETE FROM setting WHERE key = ?", (key,)) tokens = get(conn, "tokens") or {} changed = False if "client" not in tokens: diff --git a/src/sdlc_hub/modules/jira/__init__.py b/src/sdlc_hub/modules/jira/__init__.py index 03f1bef..606efe5 100644 --- a/src/sdlc_hub/modules/jira/__init__.py +++ b/src/sdlc_hub/modules/jira/__init__.py @@ -13,6 +13,7 @@ MODULE = Module( name="jira", schema=schema.SQL, settings_defaults=settings.DEFAULTS, + settings_obsolete=settings.OBSOLETE, agent_keys=tuple(settings.AGENT_KEYS), agent_min_version_key="min_agent_version", validate_setting=settings.validate_setting, diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py index dbb0af6..6f110e9 100644 --- a/src/sdlc_hub/modules/jira/settings.py +++ b/src/sdlc_hub/modules/jira/settings.py @@ -23,6 +23,10 @@ DEFAULTS = { "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } +# Ключи прежних версий (fact-status-removal): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом +# (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями +OBSOLETE = ("status_labels", "transitions_map") + # Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", "priority_map", "priority_value_key", "labels", "kind_labels", "comment_marker", @@ -88,7 +92,7 @@ def migrate_v7(conn) -> None: удаляются безвозвратно; settings_version поднимается, если удалён хотя бы один ключ и строка версии есть.""" if "fact_status" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")}: conn.execute("ALTER TABLE issue_note DROP COLUMN fact_status") - gone = conn.execute("DELETE FROM setting WHERE key IN ('status_labels', 'transitions_map')").rowcount + gone = conn.execute(f"DELETE FROM setting WHERE key IN ({', '.join('?' * len(OBSOLETE))})", OBSOLETE).rowcount if gone: conn.execute("UPDATE setting SET value = CAST(value AS INTEGER) + 1 WHERE key = 'settings_version'") diff --git a/tests/test_db.py b/tests/test_db.py index 8417898..3c67f36 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -442,3 +442,18 @@ def test_v8_reconnect_is_stable(tmp_path): assert settings.get(conn, "settings_version") == 13 assert "status_labels" not in settings.get_all(conn) and "transitions_map" not in settings.get_all(conn) conn.close() + + +def test_obsolete_keys_removed_by_ensure_defaults(tmp_path): + """I-1: процесс со старым кодом (MCP-сессия до reconnect) после миграции снова вставляет status_labels/transitions_map + своим ensure_defaults; новый ensure_defaults (старт хаба, новый MCP) их убирает.""" + path = tmp_path / "hub.sqlite" + _v7_database(path) + conn = db.connect(path) + for key in ("status_labels", "transitions_map"): # как INSERT OR IGNORE старых DEFAULTS + conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, '{}')", (key,)) + version = settings.get(conn, "settings_version") + settings.ensure_defaults(conn) + assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn)) + assert settings.get(conn, "settings_version") == version + conn.close()