Устаревшие ключи настроек удаляет ensure_defaults; выкатка схемы v8 в документах (#104)

I-1, I-2 финального ревью: старые MCP-сессии возвращали status_labels/transitions_map
после миграции; окно до kickstart описано для v8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 17:22:44 +04:00
parent a5d9526b4f
commit 9aaf3cee50
10 changed files with 62 additions and 17 deletions

View File

@ -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:<n>`; смысл типов команд берёт у модуля через `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`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
## Открытые вопросы

View File

@ -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/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно

View File

@ -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` исправлено отставание номера версии схемы. Цена ошибки — нет.

View File

@ -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.

View File

@ -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` их не

View File

@ -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 — плохо)

View File

@ -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:

View File

@ -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,

View File

@ -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'")

View File

@ -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()