feat(ai-roles): импортируемый мультиязычный каталог ролей агента #222

Merged
vvzvlad merged 6 commits from feature/agent-roles-catalog into develop 2026-06-27 02:39:28 +03:00

Что это

Импорт проверенных ролей агента из каталога: админ листает каталог, затягивает роли/комплекты в workspace и может обновить уже импортированную роль, когда в каталоге вышла новая версия. Каталог мультиязычный — язык выбирается при импорте.

Каталог

  • Отдельный артефакт: index.json (манифест — комплекты, языки, версии ролей) + bundles/<id>/<lang>.json (контент).
  • Версия живёт в index.json → «есть обновление» проверяется одним запросом.
  • Источник задаётся через env AI_AGENT_ROLES_CATALOG_URL: http(s)://... → удалённый fetch; пусто/путь → локальная папка agent-roles-catalog/ (дефолт для dev).
  • Засеян существующими 7 русскими ролями (комплекты «Редакторский набор» + «Исследование») с переводом на EN.

Сервер

  • Миграция: nullable jsonb-колонка source на ai_agent_roles ({ slug, language, version }; null — роль создана вручную).
  • Провайдер каталога: удалённый fetch с таймаутом и потоковым размерным капом; защита от path-traversal/SSRF (^[a-z0-9-]+$ до построения пути).
  • 4 admin-эндпоинта: catalog, catalog/bundle, import, update-from-catalog.
  • Импорт/обновление сопоставляются по slug + language; обновление перезаписывает контент, но не трогает enabled и аккуратно обходит коллизию имён.

Клиент

  • Модалка каталога: селектор языка (дефолт — локаль интерфейса), комплекты-аккордеоны, состояния роли Import / Installed / Update vX→vY.
  • Кнопка «Import from catalog» и CTA на пустом списке в панели ролей.
  • Строки i18n в en-US и ru-RU.

Как включить GitHub-источник

Выложить папку agent-roles-catalog/ в репозиторий и задать AI_AGENT_ROLES_CATALOG_URL на raw-базу. Для dev ничего настраивать не нужно — читается локальная папка.

Проверка

  • Сервер: tsc чисто; jest по ai-agent-roles — все тесты зелёные (сервис + репозиторий + провайдер + контроллер), включая path-traversal-гард, стриминговый кап, конфликты импорта (skip/rename/already-installed) и сценарии обновления (up-to-date / not-in-catalog / language-unavailable / коллизия имени).
  • Клиент: tsc чисто; оба translation.json валидны.
  • agent-roles-catalog/scripts/check.mjs → OK (уникальность slug по всему каталогу, соответствие index ↔ языковым файлам).

Замечание по миграции

Нужно прогнать новую миграцию (20260626T120000-ai-agent-roles-catalog-source) — добавляет колонку source.

🤖 Generated with Claude Code

## Что это Импорт проверенных ролей агента из каталога: админ листает каталог, затягивает роли/комплекты в workspace и может **обновить** уже импортированную роль, когда в каталоге вышла новая версия. Каталог мультиязычный — язык выбирается при импорте. ## Каталог - Отдельный артефакт: `index.json` (манифест — комплекты, языки, версии ролей) + `bundles/<id>/<lang>.json` (контент). - Версия живёт в `index.json` → «есть обновление» проверяется одним запросом. - Источник задаётся через env `AI_AGENT_ROLES_CATALOG_URL`: `http(s)://...` → удалённый fetch; пусто/путь → локальная папка `agent-roles-catalog/` (дефолт для dev). - Засеян существующими 7 русскими ролями (комплекты «Редакторский набор» + «Исследование») с переводом на EN. ## Сервер - Миграция: nullable jsonb-колонка `source` на `ai_agent_roles` (`{ slug, language, version }`; `null` — роль создана вручную). - Провайдер каталога: удалённый fetch с таймаутом и **потоковым размерным капом**; защита от path-traversal/SSRF (`^[a-z0-9-]+$` до построения пути). - 4 admin-эндпоинта: `catalog`, `catalog/bundle`, `import`, `update-from-catalog`. - Импорт/обновление сопоставляются по `slug` + `language`; обновление перезаписывает контент, но **не трогает `enabled`** и аккуратно обходит коллизию имён. ## Клиент - Модалка каталога: селектор языка (дефолт — локаль интерфейса), комплекты-аккордеоны, состояния роли Import / Installed / Update vX→vY. - Кнопка «Import from catalog» и CTA на пустом списке в панели ролей. - Строки i18n в en-US и ru-RU. ## Как включить GitHub-источник Выложить папку `agent-roles-catalog/` в репозиторий и задать `AI_AGENT_ROLES_CATALOG_URL` на raw-базу. Для dev ничего настраивать не нужно — читается локальная папка. ## Проверка - Сервер: `tsc` чисто; jest по `ai-agent-roles` — все тесты зелёные (сервис + репозиторий + провайдер + контроллер), включая path-traversal-гард, стриминговый кап, конфликты импорта (skip/rename/already-installed) и сценарии обновления (up-to-date / not-in-catalog / language-unavailable / коллизия имени). - Клиент: `tsc` чисто; оба `translation.json` валидны. - `agent-roles-catalog/scripts/check.mjs` → OK (уникальность slug по всему каталогу, соответствие index ↔ языковым файлам). ## Замечание по миграции Нужно прогнать новую миграцию (`20260626T120000-ai-agent-roles-catalog-source`) — добавляет колонку `source`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 19:29:14 +03:00
Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.

Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.

Server:
- migration: nullable jsonb `source` column on ai_agent_roles
  ({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
  read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`

Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 19:31:16 +03:00
Ghost added 1 commit 2026-06-26 22:32:47 +03:00
- Rename catalog-source migration 20260626T120000 -> T150000 so it sorts
  after develop's latest migration (T140000-page-temporary-notes); the old
  timestamp predated ai-chat-message-status/share-aliases and tripped
  Kysely's #ensureMigrationsInOrder, aborting server boot.
- Provider: inject a Nest Logger and log the real cause (incl. response
  status) in the parseJson / readLocal / fetchRemote catch blocks, and
  propagate a useful cause into the BadGatewayException message; add a
  shortError helper (robust to jest's realm-shifted Error-likes).
- Provider: replace the manual Uint8Array assembly with
  Buffer.concat(chunks).toString('utf8'); keep the streaming size cap.
- Controller spec: add admin-gate coverage for the 4 catalog routes
  (catalog/catalogBundle/import/updateFromCatalog) - non-admin Forbidden +
  service untouched, admin delegates with the right args.
- Service spec: add getCatalog/getCatalogBundle tests covering the
  localized() three-tier fallback, the sorted language union, the
  missing-bundle BadGateway, and the role-version default.
- Provider spec: add remote fetch-rejects and non-ok (503) error branches.
- Service: drop the dead Date.now() tail in freeName (now an explicit
  unreachable throw) and extract a shared isUniqueViolation() predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feature/agent-roles-catalog from ef678e34e1 to 8be8279809 2026-06-26 22:38:38 +03:00 Compare
Ghost added 1 commit 2026-06-26 23:16:08 +03:00
- CHANGELOG: document the importable multilingual agent-roles catalog under
  [Unreleased] (browse/import/update, 4 new endpoints, source column, the new
  AI_AGENT_ROLES_CATALOG_URL env var) (#222).
- Fix importFromCatalog docstring: a role is skipped only on source.slug AND
  source.language; another language of the same slug still imports.
- Provider: map a timeout/abort (or any failure) during the response-BODY read
  to a logged BadGatewayException, so a slow/dripping source yields a 502, not a
  generic 500. Existing too-large BadGateway cases are rethrown as-is.
- Service: inject a Nest Logger and log the root cause (with workspaceId/
  bundleId/slug) on a non-23505 insert error during import.
- Modal: hoist the duplicated i18n base-subtag into a single baseLang const.
- Tests: AbortError body-read -> BadGateway; null-body text() fallback (under
  and over cap); invalid-JSON and malformed-index BadGateway; non-23505 import
  error -> generic message + logged root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 23:40:46 +03:00
Item 1 (concurrency-safe import): add a partial UNIQUE index on
(workspace_id, source->>'slug', source->>'language') WHERE source IS NOT NULL
AND deleted_at IS NULL, so two concurrent imports of the same bundle can no
longer create duplicate roles for one catalog slug+language. The in-memory
installedKeys snapshot cannot see a sibling request's writes; the index is the
backstop. importFromCatalog now catches the 23505 from THIS index (keyed off
the constraint name) and treats it as "already installed" -> skip, batch
continues. A 23505 from the name-uniqueness index keeps its existing friendly
per-role error behavior (distinguished by constraint name; an indeterminate
23505 falls back to that path, so no regression).

Item 2 (single source validator): strengthen parseSource into THE single form
validator for the source jsonb column -> returns a fully-valid RoleSource | null
(slug/language non-empty strings, version a number). The service's weaker
roleSource is removed and both layers share the RoleSource type (defined in the
db entity.types module both already import AiAgentRole from, so no import
cycle). normalizeRow / the read path now only ever yield a valid RoleSource or
null; a malformed stored source normalizes to null (tolerated by the service).

Tests: parseSource null for {} / {slug:123} / {slug:'a'} / empty-string keys /
string version, typed value for a full valid shape; service test that a
source-uniqueness 23505 is skipped (not errored) and the batch continues.
Verified the partial index rejects a duplicate source-not-null row but allows
two source-NULL rows, and the migration up/down run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 01:01:49 +03:00
MUST-FIX
- isSourceUniqueViolation read the wrong error field: kysely-postgres-js
  (postgres@3.4.8) puts the violated constraint on `constraint_name`, not
  node-postgres' `.constraint`, so a concurrent same-slug+language import's
  23505 was never recognized as a source-collision and surfaced a false
  "name already exists" error. Now read `constraint_name` (with `.constraint`
  as a fallback for other drivers). Fix the faked test fixture (it built the
  error with the same wrong `.constraint` field, masking the bug): it now
  uses `constraint_name`, so the test genuinely exercises the skip path and
  FAILS against the unfixed code.
- Extract the catalog modal's role-state computation into a pure
  `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors
  role-launch.ts) and cover it with vitest: import / installed / update /
  same-slug-different-language.

SUGGESTIONS
- Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring
  the server; narrow the consumer via `"reason" in result` (the boolean
  discriminant does not narrow under strictNullChecks:false).
- README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL
  (remote http(s) base / local path / empty => in-repo folder).
- check.mjs: drop the redundant `const key = slug` alias.
- Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation
  (4 branches) via renderHook with a mocked service.
- Cover importFromCatalog "bundle not in index" => BadGateway.
- Cover updateFromCatalog "slug in index but missing in bundle file" =>
  not-in-catalog.

ARCHITECTURE
- Extract the shared catalog read prefix: a private `loadBundleById`
  (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle
  and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the
  import insert and update patch. The three orchestrations and their distinct
  write paths stay separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 02:36:51 +03:00
ITEM 1: cover useImportAiRolesFromCatalogMutation onSuccess notifications.
Add import-from-catalog-message.test.tsx (twin of update-from-catalog-message)
asserting the always-shown summary (errors:[]) and the additional red
"Failed to import N role(s)" notification when result.errors is non-empty.

ITEM 2: pass redirect:'error' to the remote catalog fetch in fetchRemote so a
compromised-but-trusted upstream cannot 3xx the fetch into the internal network
(redirect-SSRF). Add provider specs asserting the option is passed and that a
redirect rejection maps to BadGatewayException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad merged commit b6630deb32 into develop 2026-06-27 02:39:28 +03:00
Sign in to join this conversation.