Compare commits

..

12 Commits

Author SHA1 Message Date
claude code agent 227
e682bbccd1 fix(share): order swap delete-before-update and distinguish unique violations
Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:52:33 +03:00
claude code agent 227
9d2bec8eb8 fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update
the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by
name and, if free, INSERTed a brand-new row, leaving the page with multiple
alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no
`ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale)
row. Every edit also spawned an orphan row that kept a live `/l/<old>` link
forever. Regression of #205.

Enforce the invariant "a page has EXACTLY ONE custom address":
- `setAlias()` now resolves the page's current alias row and RENAMES it in
  place when the requested name is free (insert only when the page has none),
  keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED`
  + confirmed-retarget flow, and after any successful write DELETEs all other
  alias rows for the page (self-heal). Runs in one transaction so the page is
  never transiently empty or duplicated.
- repo: add `updateAlias` (rename) and `deleteOthersForPage`; make
  `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`.
- migration: dedup existing rows (keep newest per page) + a PARTIAL unique
  index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling
  aliases still coexist while live ones are one-per-page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:51:51 +03:00
b6630deb32 Merge pull request 'feat(ai-roles): импортируемый мультиязычный каталог ролей агента' (#222) from feature/agent-roles-catalog into develop
Reviewed-on: #222
2026-06-27 02:39:27 +03:00
claude code agent 227
7ef98a663b Address PR #222 review: import-mutation notification tests + redirect-SSRF hardening
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>
2026-06-27 02:36:28 +03:00
109ab10fc5 Merge pull request 'fix(temporary-notes): tree clock marker updates without reload + mobile-friendly full-width create buttons' (#225) from fix/temporary-notes-ui into develop
Reviewed-on: #225
2026-06-27 01:39:10 +03:00
claude code agent 227
2b7c861f78 Address PR #222 re-review: fix source-uniqueness detection + coverage/cleanups
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>
2026-06-27 01:01:29 +03:00
claude code agent 227
d181b5c4ff test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work.

Tests:
- tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a)
  server node inserted WITHOUT a deadline + create response carries one => node
  gains the deadline; (b) node already has a deadline => not overwritten, prev
  returned by reference.
- ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried
  when present and pinned to null (`?? null`) when absent.
- page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree
  node's temporaryExpiresAt, and leaves the atom value at the same reference when
  the id is absent from the loaded tree (no write).

Refactor (closes the drift bug-class at the root):
- Client: extract one canonical pageToTreeNode(page, overrides) mapper in
  tree/utils and route buildTree, handleCreate's optimistic insert, the restore
  mutation and the duplicate handler through it. Restore stays permanent (server
  nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer)
  — both now reflect the server without a reload, where before they dropped the
  field entirely.
- Server: extract one toTreeNodeSnapshot(page) helper called by both the
  PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast
  (ws-tree.service), so the optional temporaryExpiresAt can't drift between the
  two literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:58:40 +03:00
claude code agent 227
12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/
disappear until a page reload when a note's temporary state changed.

- Make/unmake permanent from the page header menu and the in-page banner went
  through syncTemporaryExpiresInCache(), which patched the page query cache but
  never touched treeDataAtom, so the sidebar node kept its stale
  temporaryExpiresAt. Patch the tree node there too (via jotai's default store),
  so the marker updates without a reload.
- Creating a note as temporary showed no marker until reload: the create flow's
  cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree
  rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's
  marker with undefined. Carry temporaryExpiresAt in that cached entry.
- Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED
  snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching
  the space also render the marker immediately, and harden handleCreate's
  idempotency guard to patch the deadline if the broadcast won the insert race.

Issue 2 — the home and space-overview "New note" / "New temporary note" buttons
sat side-by-side and the temporary label clipped on narrow mobile widths. Lay
them out full-width, stacked vertically, and tint the temporary button orange
(matching the clock marker + banner) while the regular one stays neutral gray.

Tests: extend tree-socket-reducers.test.ts (addTreeNode carries
temporaryExpiresAt). Verified live with Playwright: marker appears on create and
toggles both ways with no reload; mobile buttons are stacked, full-width,
unclipped, and differently colored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:19 +03:00
claude code agent 227
26ca19f89e agent-roles: concurrency-safe catalog import + unified source validator
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>
2026-06-26 23:40:25 +03:00
claude code agent 227
50e79275e1 Address review on agent-roles catalog: changelog, docs, BadGateway on body-read abort
- 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>
2026-06-26 23:15:45 +03:00
claude code agent 227
8be8279809 Address PR #222 review: migration order, provider logging, catalog tests
- 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>
2026-06-26 22:36:26 +03:00
claude_code
19f84ca0e7 feat(ai-roles): add importable, multilingual agent roles catalog
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>
2026-06-26 22:36:26 +03:00
52 changed files with 4203 additions and 118 deletions

View File

@@ -132,6 +132,13 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL => the catalog is fetched
# remotely (e.g. the raw GitHub base URL of the catalog repo); any other value
# => a local filesystem directory. Empty (default) => the in-repo
# ./agent-roles-catalog folder (dev). Used by the admin "import role from
# catalog" feature only.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000

View File

@@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
catalog of agent roles, grouped into bundles and offered in several languages,
and import the ones they want into the workspace (with skip-or-rename handling
for name collisions); the same role in a different language imports as a
separate install. An imported role remembers its catalog origin and offers a
one-click update when the catalog ships a newer revision. Backed by four new
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
`/catalog/bundle` (read one bundle's roles), `/import`, and
`/update-from-catalog` — and a new `source` column linking a role to its
catalog slug/language/version. The catalog source is configurable via the new
`AI_AGENT_ROLES_CATALOG_URL` env var (an `http(s)://` base URL fetches it
remotely; otherwise a local directory; empty defaults to the in-repo
`agent-roles-catalog/` folder — see `.env.example`). (#222)
### Fixed
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a

View File

@@ -0,0 +1,149 @@
# Agent roles catalog
This directory is **data, not application code**. It holds the content of an
"agent roles catalog": reusable agent role definitions (system prompts plus a
little metadata), grouped into bundles and translated into one or more
languages. A separate server reads these files and serves them; nothing here is
executable application logic except the validation script.
## File layout
```
agent-roles-catalog/
index.json # the catalog manifest: bundles, languages, role versions
bundles/
<bundle-id>/
<lang>.json # one file per declared language (e.g. ru.json, en.json)
scripts/
check.mjs # validates the catalog (no dependencies)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-editor,
copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
## How it's served
The server does not bundle this data; it reads it at request time from a single
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of
three sources:
- **`http(s)://…`** — a REMOTE base URL. The server fetches `<base>/index.json`
for the manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened
bundle file (e.g. the raw GitHub base of the catalog repo in production).
- **any other non-empty value** — a LOCAL filesystem directory; the same
`index.json` / `bundles/<id>/<lang>.json` paths are read from disk.
- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder
(this directory), i.e. local dev reads these files directly.
In every case the layout below is what the server expects, and the fetched JSON
is re-validated server-side (the catalog is treated as untrusted input). See
`.env.example` for the variable and the CHANGELOG for the rollout.
## `index.json` schema
```jsonc
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial", // unique bundle id; matches bundles/<id>/
"name": { "ru": "...", "en": "..." }, // localized display name
"description": { "ru": "...", "en": "..." },
"languages": ["ru", "en"], // which <lang>.json files must exist
"roles": [
{ "slug": "structural-editor", "version": 1 }
// ...
]
}
]
}
```
`version` lives **here, in index.json**, per role. Bump it whenever a role's
content (instructions, name, description, etc.) changes, so consumers can detect
updates.
## Bundle (`<lang>.json`) schema
```jsonc
{
"schemaVersion": 1,
"language": "ru",
"roles": [
{
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
"emoji": "🧱",
"name": "...", // REQUIRED, localized
"description": "...", // localized
"instructions": "...", // REQUIRED, the system prompt, localized
"autoStart": true, // whether the role starts working immediately
"launchMessage": "..." // first message sent on launch (or null)
}
]
}
```
Notes:
- `modelConfig` is intentionally absent; the server treats an absent
`modelConfig` as `null`.
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
files of the same bundle. Only `name`, `description`, `instructions`, and
`launchMessage` are translated.
## Slug uniqueness
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
bundle. A slug appears once per language file of its bundle (same slug in
`ru.json` and `en.json`), but no two different bundles may share a slug.
`scripts/check.mjs` enforces this.
## How to add things
### Add a role to an existing bundle
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
`slug` and `version: 1`.
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
bundle, translating `name`, `description`, `instructions`, and
`launchMessage`.
3. Run the check (see below).
### Add a bundle
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
`languages`, `roles`).
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
object per `roles[]` entry.
3. Run the check.
### Add a language to a bundle
1. Add the language code to that bundle's `languages[]` in `index.json`.
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
translated.
3. Run the check.
### Change a role's content
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
`version`** in `index.json`.
## Validating
From this directory:
```sh
node scripts/check.mjs # or: npm run check
```
It fails (exit code 1) if any slug is duplicated across the catalog, if a
bundle's index `roles[]` don't match the slugs present in each language file, if
a declared language file is missing, or if any role is missing a required field
(`slug`, `name`, `instructions`). It prints `OK` on success.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, грамматика, факты, корректура и нарратив.",
"en": "The full article-editing cycle: structure, style, grammar, facts, proofreading, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 1 },
{ "slug": "line-editor", "version": 1 },
{ "slug": "copy-editor", "version": 1 },
{ "slug": "fact-checker", "version": 1 },
{ "slug": "proofreader", "version": 1 },
{ "slug": "narrator", "version": 1 }
]
},
{
"id": "research",
"name": { "ru": "Исследование", "en": "Research" },
"description": {
"ru": "Глубокое исследование темы с подготовкой отчёта.",
"en": "Deep research on a topic with a prepared report."
},
"languages": ["ru", "en"],
"roles": [ { "slug": "researcher", "version": 1 } ]
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "agent-roles-catalog",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check.mjs"
}
}

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Validates the agent roles catalog.
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
// between a bundle's index roles[] and the slugs present in each language
// file, a missing declared language file, or a role missing required fields.
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
const errors = [];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
errors.push(`Cannot read/parse ${path}: ${err.message}`);
return null;
}
}
const indexPath = join(catalogDir, "index.json");
if (!existsSync(indexPath)) {
console.error(`Missing index.json at ${indexPath}`);
process.exit(1);
}
const index = readJson(indexPath);
if (!index) {
for (const e of errors) console.error(e);
process.exit(1);
}
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
if (bundles.length === 0) {
errors.push("index.json has no bundles[]");
}
// Track every slug seen across the whole catalog to detect duplicates.
const slugSeen = new Map(); // slug -> "bundleId/lang"
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) {
errors.push("A bundle in index.json is missing an id");
continue;
}
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
// Duplicate slugs inside the bundle index roles[].
const indexSlugSet = new Set(indexSlugs);
if (indexSlugSet.size !== indexSlugs.length) {
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
}
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
if (languages.length === 0) {
errors.push(`Bundle "${bundleId}" declares no languages`);
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) {
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
continue;
}
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
const fileSlugs = roles.map((r) => r && r.slug);
// (d) Required fields per role.
for (const role of roles) {
for (const field of ["slug", "name", "instructions"]) {
if (role == null || role[field] == null || role[field] === "") {
errors.push(
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
);
}
}
}
// (b) index roles[] must match the slugs present in each language file.
const fileSlugSet = new Set(fileSlugs);
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
if (missingInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
);
}
if (extraInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
);
}
// (a) Duplicate slugs across the whole catalog.
for (const slug of fileSlugs) {
if (!slug) continue;
const where = `${bundleId}/${lang}`;
// Only flag duplicates across DIFFERENT bundles or files; the same slug
// is expected to appear once per language file of the same bundle.
if (slugSeen.has(slug)) {
const prev = slugSeen.get(slug);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
);
}
} else {
slugSeen.set(slug, where);
}
}
}
}
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("OK");

View File

@@ -1346,5 +1346,22 @@
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later"
"Too many requests, please try again later": "Too many requests, please try again later",
"Import from catalog": "Import from catalog",
"Browse the catalog": "Browse the catalog",
"Role catalog": "Role catalog",
"On name conflict": "On name conflict",
"Skip": "Skip",
"Import": "Import",
"Installed": "Installed",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog"
}

View File

@@ -1203,5 +1203,23 @@
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
"Import from catalog": "Импорт из каталога",
"Browse the catalog": "Открыть каталог",
"Role catalog": "Каталог ролей",
"On name conflict": "При конфликте имён",
"Skip": "Пропустить",
"Import": "Импортировать",
"Installed": "Установлено",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге"
}

View File

@@ -13,21 +13,40 @@ import {
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles,
importAiRolesFromCatalog,
renameAiChat,
updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
// Catalog reads resolve bundle names per language, so the language is part of
// the cache key (a language switch refetches rather than reusing stale names).
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
"ai-role-catalog",
language,
];
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
bundleId: string,
language: string,
) => ["ai-role-catalog-bundle", bundleId, language];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
},
});
}
/**
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
* fetch runs only when the catalog modal is open. The catalog can 502 when the
* curated source is unreachable; callers handle the error state in the UI.
*/
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
return useQuery<IAiRoleCatalog, Error>({
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
queryFn: () => getAiRoleCatalog(language),
enabled,
});
}
/**
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
* fetch only runs when a bundle is actually expanded.
*/
export function useAiRoleCatalogBundleQuery(
bundleId: string,
language: string,
enabled: boolean,
) {
return useQuery<IAiRoleCatalogBundle, Error>({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled,
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
notifications.show({
color: "red",
message: t("Failed to import {{count}} role(s)", {
count: result.errors.length,
}),
});
}
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// Imported roles can appear in the chat picker / badges.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
mutationFn: (id) => updateAiRoleFromCatalog(id),
onSuccess: (result) => {
// The server returns updated:false with a reason for a no-op (already
// up to date / removed from catalog / language no longer offered). Map
// each reason to a specific message instead of a generic "up to date".
// Narrow the discriminated union via `"reason" in result` (the `updated`
// boolean discriminant does not narrow under this project's
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
// union, so the comparisons below are compiler-checked.
let message: string;
if (!("reason" in result)) {
message = t("Updated to the latest version");
} else if (result.reason === "not-in-catalog") {
message = t("This role is no longer in the catalog");
} else if (result.reason === "language-unavailable") {
message = t("This language is no longer available in the catalog");
} else {
// "up-to-date" (the only remaining reason).
message = t("Already up to date");
}
notifications.show({ message });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
// the result carries partial errors. These tests pin both branches via
// renderHook with a mocked service (twin precedent:
// update-from-catalog-message.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key with interpolated values so we assert against the exact
// English message strings (mirrors react-i18next's default interpolation).
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
: key,
}),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
importAiRolesFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
updateAiRoleFromCatalog: vi.fn(),
}));
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleImportResult) {
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useImportAiRolesFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate({
bundleId: "general",
language: "en",
conflict: "rename",
});
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Imported 3, renamed 1, skipped 2",
});
});
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
await runMutation({
created: 1,
renamed: 0,
skipped: 0,
errors: [
{ slug: "a", message: "name taken" },
{ slug: "b", message: "name taken" },
],
});
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
message: "Imported 1, renamed 0, skipped 0",
});
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
color: "red",
message: "Failed to import 2 role(s)",
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
// a user-facing notification message. These tests pin each of the four branches
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key so we assert against the exact English message strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
updateAiRoleFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
importAiRolesFromCatalog: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
}));
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useUpdateAiRoleFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate("role-1");
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updated:true -> 'Updated to the latest version'", async () => {
await runMutation({
updated: true,
fromVersion: 1,
toVersion: 2,
role: { id: "role-1" } as never,
});
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Updated to the latest version",
});
});
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
await runMutation({ updated: false, reason: "not-in-catalog" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This role is no longer in the catalog",
});
});
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
await runMutation({ updated: false, reason: "language-unavailable" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This language is no longer available in the catalog",
});
});
it("up-to-date -> 'Already up to date'", async () => {
await runMutation({ updated: false, reason: "up-to-date" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Already up to date",
});
});
});

View File

@@ -6,8 +6,13 @@ import {
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -112,3 +117,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
});
return req.data;
}
/**
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
* Browse a curated catalog, import roles/bundles into the workspace, and update
* an imported role when the catalog ships a newer version. Same `{ data }`
* unwrap convention as above.
*/
/** Browse the catalog, optionally localized to `language`. */
export async function getAiRoleCatalog(
language?: string,
): Promise<IAiRoleCatalog> {
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
language,
});
return req.data;
}
/** Open one catalog bundle in a language (role content + versions). */
export async function getAiRoleCatalogBundle(
bundleId: string,
language: string,
): Promise<IAiRoleCatalogBundle> {
const req = await api.post<IAiRoleCatalogBundle>(
"/ai-chat/roles/catalog/bundle",
{ bundleId, language },
);
return req.data;
}
/** Import roles from a catalog bundle into the workspace (admin). */
export async function importAiRolesFromCatalog(
payload: IAiRoleImportPayload,
): Promise<IAiRoleImportResult> {
const req = await api.post<IAiRoleImportResult>(
"/ai-chat/roles/import",
payload,
);
return req.data;
}
/** Update an already-imported role from its catalog source (admin). */
export async function updateAiRoleFromCatalog(
id: string,
): Promise<IAiRoleUpdateFromCatalogResult> {
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
"/ai-chat/roles/update-from-catalog",
{ id },
);
return req.data;
}

View File

@@ -57,10 +57,79 @@ export interface IAiRole {
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one.
// Admin-only (present only in the admin list view); the picker view omits it.
// The admin UI compares `version` against the catalog to offer an update.
source?: { slug: string; language: string; version: number } | null;
createdAt?: string;
updatedAt?: string;
}
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
export interface IAiRoleCatalogBundleSummary {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}
/** The browsable catalog index (mirrors `getCatalog()`). */
export interface IAiRoleCatalog {
languages: string[];
bundles: IAiRoleCatalogBundleSummary[];
}
/** A single role inside an opened catalog bundle (localized content + version). */
export interface IAiRoleCatalogRole {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
export interface IAiRoleCatalogBundle {
bundleId: string;
language: string;
roles: IAiRoleCatalogRole[];
}
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
export interface IAiRoleImportPayload {
bundleId: string;
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
slugs?: string[];
conflict: "skip" | "rename";
}
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}
/**
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
* to a specific message; a successful update carries the version bump + new role.
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
* comparisons be compiler-checked.
*/
export type IAiRoleUpdateFromCatalogResult =
| {
updated: false;
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
}
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// Build a workspace role with a catalog source. Fields irrelevant to the
// install-state decision are filled with harmless defaults.
function installedRole(
source: { slug: string; language: string; version: number },
overrides: Partial<IAiRole> = {},
): IAiRole {
return {
id: `role-${source.slug}-${source.language}`,
name: source.slug,
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
source,
...overrides,
};
}
const catalogRole = { slug: "writer", version: 3 };
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
// pure function so the import/installed/update decision is testable directly.
describe("catalogRoleInstallState", () => {
it("no matching installed role -> import", () => {
const result = catalogRoleInstallState(catalogRole, [], "en");
expect(result).toEqual({ state: "import" });
});
it("same slug + language, installed version > catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version == catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 3,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version < catalog -> update (from/to)", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 1,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({
state: "update",
installed,
fromVersion: 1,
toVersion: 3,
});
});
it("same slug but DIFFERENT language -> import (a separate install)", () => {
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
// fresh import, not treat the ru copy as already installed.
const installed = installedRole({
slug: "writer",
language: "ru",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "import" });
});
it("matches the right language when the same slug is installed in several", () => {
const ru = installedRole(
{ slug: "writer", language: "ru", version: 5 },
{ id: "ru-role" },
);
const en = installedRole(
{ slug: "writer", language: "en", version: 1 },
{ id: "en-role" },
);
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
expect(result).toEqual({
state: "update",
installed: en,
fromVersion: 1,
toVersion: 3,
});
});
it("ignores manually-created roles (no source) sharing the name", () => {
const manual = installedRole(
{ slug: "writer", language: "en", version: 9 },
{ source: null },
);
const result = catalogRoleInstallState(catalogRole, [manual], "en");
expect(result).toEqual({ state: "import" });
});
});

View File

@@ -0,0 +1,49 @@
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* The install state of a single catalog role relative to the workspace's
* existing roles. Extracted as a pure function so the catalog modal's role-state
* computation is unit-testable without mounting the component (mirrors the
* `roleLaunchMessage` precedent in role-launch.ts).
*
* A catalog role is matched to an installed role by BOTH `source.slug` and
* `source.language`: the same slug in a different language is a separate install
* (so it shows as "import", not "installed"). When matched, the installed source
* version decides the state:
* - no match -> "import"
* - matched & installed version >= catalog version -> "installed"
* - matched & installed version < catalog version -> "update" (from -> to)
*/
export type CatalogRoleInstallState =
| { state: "import" }
| { state: "installed"; installed: IAiRole }
| {
state: "update";
installed: IAiRole;
fromVersion: number;
toVersion: number;
};
export function catalogRoleInstallState(
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
workspaceRoles: IAiRole[],
language: string,
): CatalogRoleInstallState {
const installed = workspaceRoles.find(
(r) => r.source?.slug === role.slug && r.source?.language === language,
);
if (!installed) return { state: "import" };
const fromVersion = installed.source?.version ?? 0;
if (fromVersion >= role.version) {
return { state: "installed", installed };
}
return {
state: "update",
installed,
fromVersion,
toVersion: role.version,
};
}

View File

@@ -1,4 +1,4 @@
import { Button, Group, Menu, Text } from "@mantine/core";
import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
@@ -21,11 +21,15 @@ function CreateNoteButton({
temporary,
label,
icon,
color,
}: {
writableSpaces: ISpace[];
temporary: boolean;
label: string;
icon: ReactNode;
// Mantine color token; lets the temporary action tint toward the warm
// orange/amber used by the clock marker + banner while "New note" stays neutral.
color: string;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -54,7 +58,8 @@ function CreateNoteButton({
<Button
size="md"
variant="light"
color="gray"
color={color}
fullWidth
leftSection={icon}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
@@ -71,7 +76,8 @@ function CreateNoteButton({
<Button
size="md"
variant="light"
color="gray"
color={color}
fullWidth
leftSection={icon}
loading={isPending}
>
@@ -109,8 +115,10 @@ function CreateNoteButton({
// Prominent home-screen actions to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several. Renders two equal-width buttons: a regular note and a
// temporary note (which auto-moves to Trash after the workspace lifetime).
// when there are several. Renders two full-width, vertically stacked buttons: a
// neutral regular note and an orange-tinted temporary note (which auto-moves to
// Trash after the workspace lifetime). Stacking full-width keeps the longer
// "New temporary note" label from clipping on narrow mobile widths.
export default function NewNoteButton() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 100 });
@@ -121,19 +129,21 @@ export default function NewNoteButton() {
if (writableSpaces.length === 0) return null;
return (
<Group grow gap="sm">
<Stack gap="sm">
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={false}
label={t("New note")}
icon={<IconPlus size={18} />}
color="gray"
/>
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={true}
label={t("New temporary note")}
icon={<IconHourglass size={18} />}
color="orange"
/>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getDefaultStore } from "jotai";
// Mock the app entry so importing the query module doesn't boot the whole app
// (it only needs queryClient's cache methods, which we stub here). The spies are
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(() => undefined as unknown),
invalidateQueries: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData, getQueryData, invalidateQueries },
}));
import { syncTemporaryExpiresInCache } from "./page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
({
id,
slugId,
name: id,
position: "a0",
spaceId: "space-1",
parentPageId: null,
hasChildren: false,
children: [],
}) as unknown as SpaceTreeNode;
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
beforeEach(() => {
vi.clearAllMocks();
getQueryData.mockReturnValue(undefined);
});
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
store.set(treeDataAtom, tree);
const deadline = "2026-07-01T00:00:00.000Z";
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
const next = store.get(treeDataAtom);
// A new atom value was written...
expect(next).not.toBe(tree);
// ...the matching node gained the deadline...
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
// ...and the untouched sibling is unchanged.
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
});
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1")];
store.set(treeDataAtom, tree);
syncTemporaryExpiresInCache(
{ id: "not-in-tree", slugId: "missing" },
"2026-07-01T00:00:00.000Z",
);
// treeModel.update is a no-op (same reference) for an unknown id, so the
// guard skips the store write entirely — same reference back.
expect(store.get(treeDataAtom)).toBe(tree);
});
});

View File

@@ -1,5 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { getDefaultStore } from "jotai";
import {
toggleTemplate,
toggleTemporary,
@@ -9,6 +10,9 @@ import type {
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
/**
* After toggling a note's temporary state, mirror the new deadline into the
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
});
}
}
// Patch the in-memory sidebar tree node so its temporary clock marker
// appears/disappears immediately — WITHOUT a reload. The page cache update
// above only drives the in-page banner/menu; the sidebar reads
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
// store the sidebar's hooks read from. `treeModel.update` returns the same
// reference (a no-op) when the page isn't in the currently loaded tree.
const store = getDefaultStore();
const prevTree = store.get(treeDataAtom);
const nextTree = treeModel.update(prevTree, page.id, {
temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),

View File

@@ -176,8 +176,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: page.id,
temporary: next,
});
// Reflect the new deadline in the page cache so the menu label flips and
// any banner updates. The sidebar icon refreshes via its own query.
// Reflect the new deadline in the page cache (menu label + banner) AND in
// the sidebar tree node so its clock marker updates immediately, no reload.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({
message: next

View File

@@ -32,7 +32,7 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
// Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
slugId: restoredPage.slugId,
// Create the tree node data with hasChildren from backend. Routed
// through the canonical mapper so the field copy stays in lockstep with
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
// page is made permanent), so the mapper carries that null through and
// the node correctly shows no clock marker.
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false,
children: [],
};
});
// Determine the parent and index
const parentId = restoredPage.parentPageId || null;
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
slugId: data.slugId,
spaceId: data.spaceId,
title: data.title,
// Carry the death-timer deadline so a note created as temporary keeps its
// sidebar clock marker when the tree is rebuilt from this cached entry
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
// node's marker with `undefined`, hiding it until a reload.
temporaryExpiresAt: data.temporaryExpiresAt,
};
let queryKey: QueryKey = null;

View File

@@ -37,6 +37,7 @@ import {
} from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css";
@@ -130,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
// Routed through the canonical mapper so the field copy stays in lockstep
// with buildTree. The server does NOT arm a death timer on duplicate (the
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
// carries that null through and the duplicated node correctly shows no
// clock marker — matching the server without a reload.
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
canEdit: true,
children: [],
};
});
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),

View File

@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import {
useCreatePageMutation,
@@ -139,18 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
throw new Error("Failed to create page");
}
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
// Route through the canonical mapper so the field copy (esp.
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
// optimistic insert) can't drift from buildTree. `name: ""` because a
// freshly created page is untitled; `hasChildren: false` because it has no
// children yet.
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
// Show the temporary-note icon immediately on optimistic insert.
temporaryExpiresAt: createdPage.temporaryExpiresAt,
children: [],
};
});
// Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling
@@ -173,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first.
setData((prev) => {
if (treeModel.find(prev, newNode.id)) return prev;
const existing = treeModel.find(prev, newNode.id);
if (existing) {
// The server `addTreeNode` broadcast won the race and already inserted
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
// a temporary note WITHOUT its clock marker until reload; patch it on
// from the authoritative create response so the marker shows now.
if (
newNode.temporaryExpiresAt &&
!(existing as SpaceTreeNode).temporaryExpiresAt
) {
return treeModel.update(prev, newNode.id, {
temporaryExpiresAt: newNode.temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
}
return prev;
}
return treeModel.insert(prev, parentId, newNode, lastIndex);
});

View File

@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
});
});
// handleCreate race-guard temporaryExpiresAt patch: when the server's
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
// updater runs, the updater must not re-insert. Two sub-branches:
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
// omitted it) while the authoritative create response DOES → patch the
// deadline on so the clock marker shows now, without a reload.
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
// `prev` by reference (a no-op write).
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
// Mirrors the setData updater in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: TN[],
parentId: string | null,
node: TN,
index: number,
): TN[] => {
const existing = treeModel.find(tree, node.id) as TN | null;
if (existing) {
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
return treeModel.update(tree, node.id, {
temporaryExpiresAt: node.temporaryExpiresAt,
});
}
return tree;
}
return treeModel.insert(tree, parentId, node, index);
};
const fixtureTN: TN[] = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
];
const deadline = "2026-07-01T00:00:00.000Z";
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
// Server broadcast won the race and inserted the node WITHOUT a deadline.
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
});
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
undefined,
);
// The authoritative create response carries the deadline.
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
const patched = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// A new reference (the patch wrote) and the node now has the deadline...
expect(patched).not.toBe(afterServer);
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
deadline,
);
// ...and still exactly one node (no duplicate re-insert).
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
});
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
const existingDeadline = deadline;
// The node already exists WITH a deadline (the broadcast carried it).
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
temporaryExpiresAt: existingDeadline,
});
// The create response carries a DIFFERENT deadline; the guard must ignore it.
const created: TN = {
id: "new",
name: "",
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
};
const after = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// prev returned by reference (no write) and the original deadline is kept.
expect(after).toBe(afterServer);
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
existingDeadline,
);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in

View File

@@ -9,26 +9,45 @@ export function sortPositionKeys(keys: any[]) {
});
}
/**
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
* materialises a tree node from a page (buildTree, the optimistic insert in
* handleCreate, restore, duplicate) routes through here so the field copy —
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
* so restore (which the server nulls) stays permanent and a temporary create
* keeps its clock marker without a reload.
*/
export function pageToTreeNode(
page: IPage,
overrides?: Partial<SpaceTreeNode>,
): SpaceTreeNode {
return {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
...overrides,
};
}
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = [];
pages.forEach((page) => {
pageMap[page.id] = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
};
pageMap[page.id] = pageToTreeNode(page);
});
// Defense-in-depth: a duplicate id in `pages` would push two references to the

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Group } from "@mantine/core";
import { Button, Stack } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -45,12 +45,16 @@ export default function SpaceCreateNoteButtons() {
.finally(() => setPending(null));
};
// Two full-width, vertically stacked buttons: a neutral regular note and an
// orange-tinted temporary note. Stacking full-width keeps the longer "New
// temporary note" label from clipping on narrow mobile widths.
return (
<Group grow gap="sm">
<Stack gap="sm">
<Button
size="md"
variant="light"
color="gray"
fullWidth
leftSection={<IconPlus size={18} />}
loading={pending === "regular"}
disabled={pending !== null}
@@ -61,7 +65,8 @@ export default function SpaceCreateNoteButtons() {
<Button
size="md"
variant="light"
color="gray"
color="orange"
fullWidth
leftSection={<IconHourglass size={18} />}
loading={pending === "temporary"}
disabled={pending !== null}
@@ -69,6 +74,6 @@ export default function SpaceCreateNoteButtons() {
>
{t("New temporary note")}
</Button>
</Group>
</Stack>
);
}

View File

@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
"child",
]);
});
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
// A note created as temporary broadcasts addTreeNode with the death-timer
// deadline in its payload; the receiver's inserted node must keep it so
// space-tree-row renders the orange clock marker immediately.
const tree = roots();
const expiresAt = "2026-06-27T21:00:00.000Z";
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
});
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});

View File

@@ -0,0 +1,407 @@
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
Alert,
Badge,
Button,
Center,
Checkbox,
Group,
Loader,
Modal,
Radio,
Select,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useAiRoleCatalogBundleQuery,
useAiRoleCatalogQuery,
useImportAiRolesFromCatalogMutation,
useUpdateAiRoleFromCatalogMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
IAiRole,
IAiRoleCatalogBundleSummary,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
interface AiAgentRolesCatalogModalProps {
opened: boolean;
onClose: () => void;
// The current admin role list (full view, including `source`). Used to compute
// each catalog role's install state (import / installed / update available).
roles: IAiRole[];
}
/** How a name collision with an existing role is handled on import. */
type Conflict = "skip" | "rename";
/**
* Admin modal: browse the curated role catalog, import roles, and update an
* imported role when the catalog ships a newer version.
*
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
* Accordion panel has its own "Import" button that imports only that bundle's
* checked roles — the simplest mapping to the one-bundle-per-call API and the
* clearest UX. Selection state is tracked per bundle.
*/
export default function AiAgentRolesCatalogModal({
opened,
onClose,
roles,
}: AiAgentRolesCatalogModalProps) {
const { t, i18n } = useTranslation();
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
// language both when seeding and when reconciling against offered languages.
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
// Fetch the catalog only while the modal is open. `language` drives both the
// catalog query (bundle names) and bundle reads (role content). Seed it
// synchronously from the base subtag so the first fetch already uses the
// user's language; the effect below still reconciles against the catalog's
// offered languages once they load.
const [language, setLanguage] = useState<string>(() => baseLang);
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
// On name conflict: Skip (default) or Rename to a free " (N)" name.
const [conflict, setConflict] = useState<Conflict>("skip");
// The currently expanded bundle id (Accordion is single-open: one bundle's
// roles are fetched at a time).
const [expanded, setExpanded] = useState<string | null>(null);
// Per-bundle selected slugs (import-state roles checked for import).
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
const languages = catalogQuery.data?.languages;
// Pick a sensible default language from the catalog once it loads: the i18n
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
useEffect(() => {
if (!languages || languages.length === 0) return;
if (language && languages.includes(language)) return;
const preferred = languages.includes(baseLang)
? baseLang
: languages.includes("en")
? "en"
: languages[0];
setLanguage(preferred);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [languages]);
// Reset per-language UI state when the language changes (the bundle content,
// hence the install computations, are language-specific).
useEffect(() => {
setExpanded(null);
setSelected({});
}, [language]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Role catalog")}
size="lg"
>
<Stack>
<Select
label={t("Language")}
data={languages ?? []}
value={language || null}
onChange={(value) => value && setLanguage(value)}
allowDeselect={false}
disabled={!languages || languages.length === 0}
comboboxProps={{ withinPortal: true }}
/>
<Radio.Group
label={t("On name conflict")}
value={conflict}
onChange={(value) => setConflict(value as Conflict)}
>
<Group mt="xs">
<Radio value="skip" label={t("Skip")} />
<Radio value="rename" label={t("Rename")} />
</Group>
</Radio.Group>
{catalogQuery.isLoading && (
<Center py="lg">
<Loader size="sm" />
</Center>
)}
{catalogQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
<Text size="sm" c="dimmed">
{t("No bundles available")}
</Text>
)}
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
<Accordion
variant="separated"
value={expanded}
onChange={setExpanded}
>
{catalogQuery.data.bundles.map((bundle) => (
<BundlePanel
key={bundle.id}
bundle={bundle}
language={language}
expanded={expanded === bundle.id}
roles={roles}
conflict={conflict}
selected={selected[bundle.id]}
onToggleSlug={(slug, checked) =>
setSelected((prev) => {
const next = new Set(prev[bundle.id] ?? []);
if (checked) next.add(slug);
else next.delete(slug);
return { ...prev, [bundle.id]: next };
})
}
onSetSelected={(slugs) =>
setSelected((prev) => ({
...prev,
[bundle.id]: new Set(slugs),
}))
}
/>
))}
</Accordion>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
</Group>
</Stack>
</Modal>
);
}
interface BundlePanelProps {
bundle: IAiRoleCatalogBundleSummary;
language: string;
expanded: boolean;
roles: IAiRole[];
conflict: Conflict;
selected: Set<string> | undefined;
onToggleSlug: (slug: string, checked: boolean) => void;
onSetSelected: (slugs: string[]) => void;
}
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
function BundlePanel({
bundle,
language,
expanded,
roles,
conflict,
selected,
onToggleSlug,
onSetSelected,
}: BundlePanelProps) {
const { t } = useTranslation();
// Only fetch this bundle's roles once it is actually expanded.
const bundleQuery = useAiRoleCatalogBundleQuery(
bundle.id,
language,
expanded && !!language,
);
const importMutation = useImportAiRolesFromCatalogMutation();
const updateMutation = useUpdateAiRoleFromCatalogMutation();
// Compute each catalog role's install state against the current workspace
// roles (matched by source.slug + source.language). The decision lives in the
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
const computed = useMemo(() => {
const list = bundleQuery.data?.roles ?? [];
return list.map((role) => ({
role,
...catalogRoleInstallState(role, roles, language),
}));
}, [bundleQuery.data, roles, language]);
// Default-check every importable role once the bundle content arrives (unless
// the user already touched the selection for this bundle).
useEffect(() => {
if (!bundleQuery.data || selected !== undefined) return;
onSetSelected(
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bundleQuery.data]);
const importableSlugs = computed
.filter((c) => c.state === "import")
.map((c) => c.role.slug);
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
function handleImport() {
importMutation.mutate({
bundleId: bundle.id,
language,
slugs: checkedSlugs,
conflict,
});
}
return (
<Accordion.Item value={bundle.id}>
<Accordion.Control>
<Stack gap={2}>
<Text fw={500}>{bundle.name}</Text>
{bundle.description && (
<Text size="xs" c="dimmed">
{bundle.description}
</Text>
)}
</Stack>
</Accordion.Control>
<Accordion.Panel>
{bundleQuery.isLoading && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{bundleQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{bundleQuery.data && (
<Stack gap="xs">
{computed.map((entry) => (
<CatalogRoleRow
key={entry.role.slug}
role={entry.role}
state={entry.state}
checked={
entry.state === "import"
? !!selected?.has(entry.role.slug)
: false
}
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
fromVersion={
entry.state === "update" ? entry.fromVersion : undefined
}
onUpdate={
entry.state === "update"
? () => updateMutation.mutate(entry.installed.id)
: undefined
}
updating={updateMutation.isPending}
/>
))}
<Group justify="flex-end" mt="xs">
<Button
size="xs"
onClick={handleImport}
loading={importMutation.isPending}
disabled={checkedSlugs.length === 0}
>
{t("Import")}
</Button>
</Group>
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
);
}
interface CatalogRoleRowProps {
role: IAiRoleCatalogRole;
state: "import" | "installed" | "update";
checked: boolean;
onToggle: (checked: boolean) => void;
// The installed role's current source version (only set in the "update" state).
fromVersion?: number;
onUpdate?: () => void;
updating: boolean;
}
/** A single catalog role row with its install-state affordance. */
function CatalogRoleRow({
role,
state,
checked,
onToggle,
fromVersion,
onUpdate,
updating,
}: CatalogRoleRowProps) {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
{state === "import" && (
<Checkbox
checked={checked}
onChange={(event) => onToggle(event.currentTarget.checked)}
aria-label={role.name}
/>
)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{role.emoji ? `${role.emoji} ` : ""}
{role.name}
</Text>
{role.description && (
<Text size="xs" c="dimmed">
{role.description}
</Text>
)}
</Stack>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
{state === "installed" && (
<Badge size="sm" variant="light" color="gray">
{t("Installed")}
</Badge>
)}
{state === "update" && (
<>
<Badge size="sm" variant="light" color="blue">
{t("v{{from}} → v{{to}}", {
from: fromVersion ?? 0,
to: role.version,
})}
</Badge>
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
{t("Update")}
</Button>
</>
)}
</Group>
</Group>
);
}

View File

@@ -13,7 +13,12 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import {
IconPackageImport,
IconPencil,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
@@ -23,6 +28,7 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
/**
* Admin section: list / add / edit / delete reusable agent roles. A role
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
const deleteMutation = useDeleteAiRoleMutation();
const [opened, { open, close }] = useDisclosure(false);
// Separate disclosure for the catalog (import/update) modal.
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
useDisclosure(false);
// The role being edited; undefined => the modal is in "create" mode.
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
/>
<Text fw={600}>{t("Agent roles")}</Text>
</Group>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
<Group gap="xs" wrap="nowrap">
<Button
leftSection={<IconPackageImport size={16} />}
variant="default"
size="xs"
onClick={openCatalog}
>
{t("Import from catalog")}
</Button>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
</Group>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
</Text>
{!isLoading && (!roles || roles.length === 0) && (
<Text size="sm" c="dimmed" mt="sm">
{t("No roles configured")}
</Text>
<Group gap="sm" mt="sm" align="center">
<Text size="sm" c="dimmed">
{t("No roles configured")}
</Text>
<Button
leftSection={<IconPackageImport size={16} />}
variant="light"
size="xs"
onClick={openCatalog}
>
{t("Browse the catalog")}
</Button>
</Group>
)}
<Stack gap="xs" mt="sm">
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
{/* Remount the form per target so its internal state re-hydrates. */}
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
</Modal>
<AiAgentRolesCatalogModal
opened={catalogOpened}
onClose={closeCatalog}
roles={roles ?? []}
/>
</Paper>
);
}

View File

@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
create: jest.fn().mockResolvedValue({ id: 'r1' }),
update: jest.fn().mockResolvedValue({ id: 'r1' }),
remove: jest.fn().mockResolvedValue({ success: true }),
getCatalog: jest.fn().mockResolvedValue({ languages: [], bundles: [] }),
getCatalogBundle: jest.fn().mockResolvedValue({ roles: [] }),
importFromCatalog: jest.fn().mockResolvedValue({ created: 0 }),
updateFromCatalog: jest.fn().mockResolvedValue({ updated: false }),
};
const controller = new AiAgentRolesController(
rolesService as never,
@@ -109,6 +113,90 @@ describe('AiAgentRolesController admin gate', () => {
});
});
// Catalog routes (browse + import) are ALL admin-only: a non-admin caller must
// get ForbiddenException with the service untouched; an admin delegates with
// the right arguments (import/update-from-catalog carry workspace.id).
describe('catalog routes admin gate', () => {
const catalogDto = { language: 'en' } as never;
const bundleDto = { bundleId: 'general', language: 'en' } as never;
const importDto = {
bundleId: 'general',
language: 'en',
conflict: 'skip',
} as never;
const updateDto = { id: 'r1' } as never;
describe('non-admin is rejected and the service is NOT called', () => {
it('catalog', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.catalog(catalogDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.getCatalog).not.toHaveBeenCalled();
});
it('catalog/bundle', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.catalogBundle(bundleDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.getCatalogBundle).not.toHaveBeenCalled();
});
it('import', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.import(importDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.importFromCatalog).not.toHaveBeenCalled();
});
it('update-from-catalog', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.updateFromCatalog(updateDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.updateFromCatalog).not.toHaveBeenCalled();
});
});
describe('admin delegates to the service', () => {
it('catalog passes the requested language', async () => {
const { controller, rolesService } = makeController(true);
await controller.catalog(catalogDto, user, workspace);
expect(rolesService.getCatalog).toHaveBeenCalledWith('en');
});
it('catalog/bundle passes bundleId + language', async () => {
const { controller, rolesService } = makeController(true);
await controller.catalogBundle(bundleDto, user, workspace);
expect(rolesService.getCatalogBundle).toHaveBeenCalledWith(
'general',
'en',
);
});
it('import passes workspace.id + user.id + dto', async () => {
const { controller, rolesService } = makeController(true);
await controller.import(importDto, user, workspace);
expect(rolesService.importFromCatalog).toHaveBeenCalledWith(
'ws-1',
'u1',
importDto,
);
});
it('update-from-catalog passes workspace.id + dto', async () => {
const { controller, rolesService } = makeController(true);
await controller.updateFromCatalog(updateDto, user, workspace);
expect(rolesService.updateFromCatalog).toHaveBeenCalledWith(
'ws-1',
updateDto,
);
});
});
});
describe('list (member-reachable)', () => {
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
const { controller, rolesService } = makeController(false);

View File

@@ -22,6 +22,12 @@ import {
CreateAgentRoleDto,
UpdateAgentRoleDto,
} from './dto/agent-role.dto';
import {
CatalogBundleDto,
CatalogQueryDto,
ImportFromCatalogDto,
UpdateFromCatalogDto,
} from './dto/agent-role-catalog.dto';
/** Path/body param for the per-role routes (update/delete). */
class AgentRoleIdDto {
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id);
}
// --- Catalog (admin-only): browse + import + update imported roles. ---
/** Browse the curated catalog (localized to dto.language). */
@HttpCode(HttpStatus.OK)
@Post('catalog')
async catalog(
@Body() dto: CatalogQueryDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalog(dto.language);
}
/** Open one catalog bundle in a language (role content + versions). */
@HttpCode(HttpStatus.OK)
@Post('catalog/bundle')
async catalogBundle(
@Body() dto: CatalogBundleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalogBundle(dto.bundleId, dto.language);
}
/** Import roles from a catalog bundle into the workspace. */
@HttpCode(HttpStatus.OK)
@Post('import')
async import(
@Body() dto: ImportFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.importFromCatalog(workspace.id, user.id, dto);
}
/** Update an already-imported role from its catalog source. */
@HttpCode(HttpStatus.OK)
@Post('update-from-catalog')
async updateFromCatalog(
@Body() dto: UpdateFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.updateFromCatalog(workspace.id, dto);
}
}

View File

@@ -1,16 +1,19 @@
import { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
/**
* Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
* role picker. AiAgentRoleRepo (DatabaseModule, global) and
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit
* imports. The stream-time role resolution + model override live in
* AiChatService / AiService; this module only hosts the management API.
* role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
* (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
* EnvironmentService (EnvironmentModule, global — used by the catalog provider)
* are resolved without explicit imports. The stream-time role resolution +
* model override live in AiChatService / AiService; this module only hosts the
* management API.
*/
@Module({
controllers: [AiAgentRolesController],
providers: [AiAgentRolesService],
providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
})
export class AiAgentRolesModule {}

View File

@@ -1,4 +1,9 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import {
BadGatewayException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { AiAgentRolesService } from './ai-agent-roles.service';
import type { AiAgentRole } from '@docmost/db/types/entity.types';
import type {
@@ -27,12 +32,22 @@ describe('AiAgentRolesService guards', () => {
enabled: true,
autoStart: true,
launchMessage: null,
source: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
} as AiAgentRole;
}
// A stubbed catalog provider; the CRUD tests never reach it (they exercise
// create/update/remove/list only), so the methods just reject if hit.
function makeCatalog() {
return {
fetchIndex: jest.fn(),
fetchBundle: jest.fn(),
};
}
function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
const repo = {
findById: jest.fn().mockResolvedValue(opts.existing),
@@ -41,8 +56,9 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn().mockResolvedValue(undefined),
listByWorkspace: jest.fn().mockResolvedValue([]),
};
const service = new AiAgentRolesService(repo as never);
return { service, repo };
const catalog = makeCatalog();
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
describe('update', () => {
@@ -163,6 +179,7 @@ describe('AiAgentRolesService guards', () => {
enabled: false,
autoStart: true,
launchMessage: null,
source: null,
createdAt,
updatedAt,
});
@@ -397,7 +414,7 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(rows),
};
const service = new AiAgentRolesService(repo as never);
const service = new AiAgentRolesService(repo as never, makeCatalog() as never);
return { service, repo };
}
@@ -461,4 +478,630 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(ConflictException);
});
});
// ---------------------------------------------------------------------------
// Catalog: import (skip / rename / already-installed) and update reconciliation
// against a MOCKED catalog provider + mocked repo (mirrors the CRUD style).
// ---------------------------------------------------------------------------
describe('importFromCatalog', () => {
function catalogRole(over: Record<string, unknown> = {}) {
return {
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
...over,
};
}
function makeImportService(opts: {
indexRoles?: { slug: string; version: number }[];
bundleRoles?: Record<string, unknown>[];
existing?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: opts.indexRoles ?? [{ slug: 'researcher', version: 3 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [catalogRole()],
};
const repo = {
findById: jest.fn(),
insert: jest.fn().mockImplementation((v) => Promise.resolve(makeRow(v))),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.existing ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const dto = (over: Record<string, unknown> = {}) =>
({
bundleId: 'general',
language: 'en',
conflict: 'skip',
...over,
}) as never;
it('inserts a new role with source { slug, language, version } from the index', async () => {
const { service, repo } = makeImportService({});
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(res.errors).toEqual([]);
const values = repo.insert.mock.calls[0][0];
expect(values.source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
expect(values.enabled).toBe(true);
});
it('already-installed catalog slug => skipped (no insert)', async () => {
const existing = [
makeRow({
id: 'r-existing',
name: 'Old researcher',
source: { slug: 'researcher', language: 'en', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('same slug installed in a DIFFERENT language => NOT skipped (separate install)', async () => {
// Installed as `ru`; importing the `en` variant of the same slug must
// still import (dedup key is slug+language, matching the client UI).
const existing = [
makeRow({
id: 'r-ru',
name: 'Исследователь',
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert.mock.calls[0][0].source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
});
it('name collision + conflict:skip => skipped (no insert)', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'skip' }),
);
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('name collision + conflict:rename => inserts under " (2)"', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'rename' }),
);
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 1 });
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
});
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
const { service, repo } = makeImportService({
bundleRoles: [catalogRole()],
});
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ slugs: ['researcher', 'ghost'] }),
);
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'ghost', message: 'Role not found in catalog bundle' },
]);
expect(repo.insert).toHaveBeenCalledTimes(1);
});
it('insert unique-violation (23505) is recorded as an error, import continues', async () => {
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
repo.insert
.mockRejectedValueOnce({ code: '23505' })
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'a', message: 'A role with this name already exists' },
]);
});
it('source-uniqueness 23505 (concurrent import of same slug+language) => skipped, NOT an error, batch continues', async () => {
// Two parallel imports of the same bundle each build installedKeys from a
// stale snapshot, so both reach the insert for slug 'a'. The DB partial
// unique index on (workspace, source->>slug, source->>language) rejects the
// loser with a 23505 carrying the source-index constraint name. That must
// be treated as "already installed" (skip), not a per-role error, and the
// rest of the batch (slug 'b') must still import.
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
// The kysely-postgres-js driver surfaces the violated constraint on
// `constraint_name` (not node-postgres' `.constraint`), matching prod.
const sourceRace = Object.assign(new Error('duplicate key'), {
code: '23505',
constraint_name: 'ai_agent_roles_workspace_source_unique',
});
repo.insert
.mockRejectedValueOnce(sourceRace)
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
expect(res.errors).toEqual([]);
// Both inserts were attempted (the batch did not abort on the 23505).
expect(repo.insert).toHaveBeenCalledTimes(2);
});
it('non-unique insert error => generic message, root cause logged, import continues', async () => {
const logSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
try {
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
// A non-23505 failure (e.g. a not-null violation) on the first insert.
const boom = Object.assign(new Error('null value in column'), {
code: '23502',
});
repo.insert
.mockRejectedValueOnce(boom)
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
// The generic (non-409) user-facing message; the second role still imports.
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'a', message: 'Failed to import role' },
]);
// The root cause was logged with the slug for diagnosis.
expect(logSpy).toHaveBeenCalledTimes(1);
expect(String(logSpy.mock.calls[0][0])).toContain('slug=a');
} finally {
logSpy.mockRestore();
}
});
it('bundleId absent from the index => BadGateway (no insert)', async () => {
// The requested bundle is not listed in the fetched index (a stale client
// or an index/bundle drift); the import must surface a 502 rather than
// silently doing nothing or dereferencing a missing meta.
const { service, repo } = makeImportService({});
await expect(
service.importFromCatalog('ws-1', 'u1', dto({ bundleId: 'missing' })),
).rejects.toBeInstanceOf(BadGatewayException);
expect(repo.insert).not.toHaveBeenCalled();
});
});
describe('updateFromCatalog', () => {
function makeUpdateService(opts: {
role?: AiAgentRole;
indexBundles?: unknown[];
bundleRoles?: Record<string, unknown>[];
others?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: opts.indexBundles ?? [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [
{ slug: 'researcher', name: 'Researcher v5', instructions: 'new' },
],
};
const repo = {
findById: jest.fn().mockResolvedValue(opts.role),
insert: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.others ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const imported = (version: number, over: Partial<AiAgentRole> = {}) =>
makeRow({
id: 'r1',
name: 'Researcher',
source: { slug: 'researcher', language: 'en', version } as never,
...over,
});
it('role not imported from catalog (source null) => BadRequest', async () => {
const { service } = makeUpdateService({ role: makeRow({ source: null }) });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('role not found => BadRequest', async () => {
const { service } = makeUpdateService({ role: undefined });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('catalog version <= source.version => up-to-date (no update)', async () => {
const { service, repo } = makeUpdateService({ role: imported(5) });
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'up-to-date' });
expect(repo.update).not.toHaveBeenCalled();
});
it('slug no longer listed in any bundle => not-in-catalog', async () => {
const { service, repo } = makeUpdateService({
role: imported(1),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'other', version: 9 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
expect(repo.update).not.toHaveBeenCalled();
});
it('source.language no longer offered by the bundle => language-unavailable', async () => {
const { service, repo } = makeUpdateService({
role: imported(1, {
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'language-unavailable' });
expect(repo.update).not.toHaveBeenCalled();
});
it('newer version => updates content + bumps source.version, returns versions', async () => {
const role = imported(1);
const { service, repo } = makeUpdateService({ role });
// The post-update re-fetch returns the bumped row.
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(
imported(5, { name: 'Researcher v5', instructions: 'new' }),
);
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toMatchObject({
updated: true,
fromVersion: 1,
toVersion: 5,
});
const patch = repo.update.mock.calls[0][2];
expect(patch.source).toEqual({
slug: 'researcher',
language: 'en',
version: 5,
});
expect(patch.name).toBe('Researcher v5');
// enabled is never touched by an update-from-catalog.
expect('enabled' in patch).toBe(false);
});
it('slug listed in the index but missing from the bundle file => not-in-catalog', async () => {
// Index/bundle drift: the index still advertises a newer `researcher`
// (v5 > installed v1) in an offered language, but the fetched bundle file
// no longer contains that slug. The update must no-op as not-in-catalog,
// not throw or write a half-resolved role.
const { service, repo } = makeUpdateService({
role: imported(1),
bundleRoles: [
{ slug: 'someone-else', name: 'Other', instructions: 'x' },
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
expect(repo.update).not.toHaveBeenCalled();
});
it('new catalog name collides with another live role => keeps current name', async () => {
const role = imported(1);
const other = makeRow({ id: 'r2', name: 'Researcher v5' });
const { service, repo } = makeUpdateService({ role, others: [role, other] });
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(imported(5));
await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
// The colliding catalog name is dropped; the current name is kept.
expect(repo.update.mock.calls[0][2].name).toBe('Researcher');
});
});
// ---------------------------------------------------------------------------
// Catalog browse (getCatalog / getCatalogBundle) against a MOCKED provider.
// Covers the localized() three-tier fallback (requested lang -> en -> first ->
// null), the sorted union of bundle languages, the missing-bundle BadGateway,
// and the role-version default.
// ---------------------------------------------------------------------------
describe('getCatalog', () => {
function makeBrowseService(index: unknown) {
const repo = {
findById: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
listByWorkspace: jest.fn(),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn(),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, catalog };
}
it('returns the sorted union of every bundle language', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'A' },
languages: ['ru', 'en'],
roles: [],
},
{
id: 'b',
name: { en: 'B' },
languages: ['en', 'de'],
roles: [],
},
],
});
const res = await service.getCatalog('en');
expect(res.languages).toEqual(['de', 'en', 'ru']);
});
it('localized name uses the requested language when present', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'General', ru: 'Общие' },
description: { en: 'desc-en', ru: 'desc-ru' },
languages: ['en', 'ru'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
const res = await service.getCatalog('ru');
expect(res.bundles[0]).toMatchObject({
id: 'a',
name: 'Общие',
description: 'desc-ru',
languages: ['en', 'ru'],
roles: [{ slug: 'researcher', version: 2 }],
});
});
it('localized name falls back to en when the requested language is missing', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'General', ru: 'Общие' },
languages: ['en', 'ru'],
roles: [],
},
],
});
const res = await service.getCatalog('fr');
expect(res.bundles[0].name).toBe('General');
});
it('localized name falls back to the first available locale when en is absent', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { ru: 'Общие', de: 'Allgemein' },
languages: ['ru', 'de'],
roles: [],
},
],
});
const res = await service.getCatalog('fr');
// Neither 'fr' nor 'en' is present -> first available value.
expect(res.bundles[0].name).toBe('Общие');
});
it('empty name map => falls back to the bundle id; absent description => null', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: {},
languages: ['en'],
roles: [],
},
],
});
const res = await service.getCatalog('en');
expect(res.bundles[0].name).toBe('a');
expect(res.bundles[0].description).toBeNull();
});
});
describe('getCatalogBundle', () => {
function makeBundleService(opts: {
index: unknown;
bundle: unknown;
}) {
const repo = {
findById: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
listByWorkspace: jest.fn(),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(opts.index),
fetchBundle: jest.fn().mockResolvedValue(opts.bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, catalog };
}
const index = {
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 4 }],
},
],
};
it('missing bundle in the index => BadGateway', async () => {
const { service, catalog } = makeBundleService({
index,
bundle: { schemaVersion: 1, language: 'en', roles: [] },
});
await expect(
service.getCatalogBundle('ghost', 'en'),
).rejects.toBeInstanceOf(BadGatewayException);
expect(catalog.fetchBundle).not.toHaveBeenCalled();
});
it('maps role content with the version taken from the index', async () => {
const { service } = makeBundleService({
index,
bundle: {
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
emoji: '🔬',
autoStart: false,
launchMessage: 'go',
},
],
},
});
const res = await service.getCatalogBundle('general', 'en');
expect(res).toMatchObject({ bundleId: 'general', language: 'en' });
expect(res.roles[0]).toEqual({
slug: 'researcher',
emoji: '🔬',
name: 'Researcher',
description: null,
instructions: 'be a researcher',
autoStart: false,
launchMessage: 'go',
version: 4,
});
});
it('role absent from the index meta => version defaults to 1; autoStart defaults to true', async () => {
const { service } = makeBundleService({
index,
bundle: {
schemaVersion: 1,
language: 'en',
roles: [
{ slug: 'newcomer', name: 'Newcomer', instructions: 'hi' },
],
},
});
const res = await service.getCatalogBundle('general', 'en');
expect(res.roles[0]).toMatchObject({
slug: 'newcomer',
version: 1,
autoStart: true,
emoji: null,
launchMessage: null,
});
});
});
});

View File

@@ -1,12 +1,24 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import {
AiAgentRoleRepo,
parseSource,
} from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
import { RoleModelConfig } from './role-model-config';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
import {
CatalogBundleFile,
CatalogBundleMeta,
CatalogRole,
} from './catalog/catalog-types';
/**
* Full (admin) view of an agent role. There are no secret columns on this table
@@ -24,6 +36,10 @@ export interface AgentRoleView {
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one. The
// admin UI uses `version` to offer an UPDATE when the catalog ships a newer
// revision. Admin-only (deliberately absent from AgentRolePickerView).
source: RoleSource | null;
createdAt: Date;
updatedAt: Date;
}
@@ -56,7 +72,12 @@ export interface AgentRolePickerView {
*/
@Injectable()
export class AiAgentRolesService {
constructor(private readonly repo: AiAgentRoleRepo) {}
private readonly logger = new Logger(AiAgentRolesService.name);
constructor(
private readonly repo: AiAgentRoleRepo,
private readonly catalog: AiAgentRolesCatalogProvider,
) {}
/**
* List the workspace's roles. Admins get the full view (the settings page needs
@@ -165,6 +186,316 @@ export class AiAgentRolesService {
return { success: true };
}
// -------------------------------------------------------------------------
// Catalog (admin-only). The catalog is curated, untrusted JSON fetched +
// validated by AiAgentRolesCatalogProvider; this layer resolves localized
// text and reconciles a bundle against the workspace's existing roles.
// -------------------------------------------------------------------------
/**
* Browse the catalog. Returns the union of every bundle's languages (sorted)
* plus per-bundle metadata with `name` / `description` resolved to the
* requested `language` (fallback: 'en', then the first available locale).
*/
async getCatalog(language?: string): Promise<{
languages: string[];
bundles: {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}[];
}> {
const index = await this.catalog.fetchIndex();
const languages = Array.from(
new Set(index.bundles.flatMap((b) => b.languages)),
).sort();
const bundles = index.bundles.map((b) => ({
id: b.id,
name: localized(b.name, language) ?? b.id,
description: b.description ? localized(b.description, language) : null,
languages: b.languages,
roles: b.roles.map((r) => ({ slug: r.slug, version: r.version })),
}));
return { languages, bundles };
}
/**
* Shared read prefix for the two bundle-by-id catalog paths (getCatalogBundle /
* importFromCatalog): fetch the index, resolve the requested bundle's meta
* (502 if the index does not list it), fetch its per-language file, and build
* the slug->version map from the meta. The callers keep their own response /
* write logic; only this duplicated read is factored out here.
*/
private async loadBundleById(
bundleId: string,
language: string,
): Promise<{
meta: CatalogBundleMeta;
file: CatalogBundleFile;
versions: Map<string, number>;
}> {
const index = await this.catalog.fetchIndex();
const meta = index.bundles.find((b) => b.id === bundleId);
if (!meta) {
throw new BadGatewayException('Catalog bundle not found');
}
const file = await this.catalog.fetchBundle(bundleId, language);
return { meta, file, versions: versionMap(meta) };
}
/**
* Open one bundle in a language: returns each role's content plus the version
* taken from the index (so the client can compare against an imported role's
* source.version). A missing bundle/language => BadGateway (catalog issue).
*/
async getCatalogBundle(
bundleId: string,
language: string,
): Promise<{
bundleId: string;
language: string;
roles: {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}[];
}> {
const { file, versions } = await this.loadBundleById(bundleId, language);
return {
bundleId,
language,
roles: file.roles.map((r) => ({
slug: r.slug,
emoji: r.emoji ?? null,
name: r.name,
description: r.description ?? null,
instructions: r.instructions,
autoStart: r.autoStart ?? true,
launchMessage: r.launchMessage ?? null,
version: versions.get(r.slug) ?? 1,
})),
};
}
/**
* Import a bundle's roles into the workspace. A role is "already installed"
* (and thus skipped — updates are a separate action) only when an existing
* role matches BOTH its `source.slug` AND `source.language`: this is a
* multilingual catalog, so a different language of the same slug (e.g. the
* `ru` variant of a slug already installed as `en`) is a SEPARATE install and
* still imports. A name collision with an existing role is either skipped or
* imported under a free " (N)" name, per `dto.conflict`. Inserts run
* sequentially (the repo exposes no batch insert and the volume is tiny); a
* unique-name race still surfaces as an error entry rather than aborting the
* whole import.
*/
async importFromCatalog(
workspaceId: string,
creatorId: string,
dto: ImportFromCatalogDto,
): Promise<{
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}> {
const { file, versions } = await this.loadBundleById(
dto.bundleId,
dto.language,
);
const errors: { slug: string; message: string }[] = [];
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
let selected = file.roles;
if (dto.slugs && dto.slugs.length > 0) {
const wanted = new Set(dto.slugs);
const present = new Set(file.roles.map((r) => r.slug));
for (const slug of dto.slugs) {
if (!present.has(slug)) {
errors.push({ slug, message: 'Role not found in catalog bundle' });
}
}
selected = file.roles.filter((r) => wanted.has(r.slug));
}
const existingRoles = await this.repo.listByWorkspace(workspaceId);
// Catalog roles already installed in this workspace, keyed by slug+language
// (skip; never duplicate). The key MUST match the client install-state and
// updateFromCatalog (both match by source.slug AND source.language): the
// `ru` variant of a slug already installed as `en` is a separate install.
const installedKeys = new Set(
existingRoles
.map((r) => parseSource(r.source))
.filter((s): s is RoleSource => s !== null)
.map((s) => `${s.slug}:${s.language}`),
);
// Live role names (lowercased) for collision detection. Mutated as we
// insert so two imported roles cannot both grab the same name.
const takenNames = new Set(
existingRoles.map((r) => r.name.trim().toLowerCase()),
);
let created = 0;
let skipped = 0;
let renamed = 0;
for (const role of selected) {
// Already installed from the catalog in THIS language => skip (use
// update-from-catalog). A different language of the same slug still imports.
const installKey = `${role.slug}:${dto.language}`;
if (installedKeys.has(installKey)) {
skipped++;
continue;
}
let name = role.name.trim();
let didRename = false;
if (takenNames.has(name.toLowerCase())) {
if (dto.conflict === 'skip') {
skipped++;
continue;
}
// conflict === 'rename': find a free " (N)" suffix.
name = freeName(name, takenNames);
didRename = true;
}
const version = versions.get(role.slug) ?? 1;
try {
await this.repo.insert({
workspaceId,
creatorId,
name,
...catalogRoleContentFields(role),
enabled: true,
source: { slug: role.slug, language: dto.language, version },
});
created++;
if (didRename) renamed++;
takenNames.add(name.toLowerCase());
installedKeys.add(installKey);
} catch (err) {
// A 23505 from the source-uniqueness index means a CONCURRENT import
// already installed this exact slug+language between our snapshot
// (installedKeys) and this insert: the in-process snapshot cannot see a
// sibling request's writes, so the partial unique index is the backstop.
// Outcome is identical to the snapshot-based skip above — count it as
// skipped (already installed) and continue; do NOT abort or error.
if (isSourceUniqueViolation(err)) {
skipped++;
installedKeys.add(installKey);
continue;
}
// Otherwise: a unique-NAME race (23505 on the name index) is expected and
// self-explanatory (it becomes a friendly per-role error). Any OTHER
// insert failure is unexpected, so log the root cause with enough context
// to diagnose it — the user-facing message is deliberately generic.
if (!isUniqueViolation(err)) {
this.logger.error(
`Failed to import catalog role (workspaceId=${workspaceId} bundleId=${dto.bundleId} slug=${role.slug}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`,
);
}
errors.push({ slug: role.slug, message: importErrorMessage(err) });
}
}
return { created, skipped, renamed, errors };
}
/**
* Update an already-imported role from its catalog source when the catalog
* ships a newer version. Returns a discriminated result so the UI can explain
* a no-op (up-to-date / removed from catalog / language no longer offered).
* Never touches `enabled`; keeps the current name if the catalog's new name
* would collide with another role (avoiding the unique-name 409).
*/
async updateFromCatalog(
workspaceId: string,
dto: UpdateFromCatalogDto,
): Promise<
| { updated: false; reason: 'not-in-catalog' | 'up-to-date' | 'language-unavailable' }
| { updated: true; fromVersion: number; toVersion: number; role: AgentRoleView }
> {
const role = await this.repo.findById(dto.id, workspaceId);
if (!role) throw new BadRequestException('Role not found');
const source = parseSource(role.source);
if (!source || !source.slug) {
throw new BadRequestException('Role was not imported from the catalog');
}
const index = await this.catalog.fetchIndex();
// Find the bundle whose meta lists this slug, and its catalog version.
let meta: CatalogBundleMeta | undefined;
let currentVersion: number | undefined;
for (const b of index.bundles) {
const m = b.roles.find((r) => r.slug === source.slug);
if (m) {
meta = b;
currentVersion = m.version;
break;
}
}
if (!meta || currentVersion === undefined) {
return { updated: false, reason: 'not-in-catalog' };
}
if (currentVersion <= source.version) {
return { updated: false, reason: 'up-to-date' };
}
if (!meta.languages.includes(source.language)) {
return { updated: false, reason: 'language-unavailable' };
}
const file = await this.catalog.fetchBundle(meta.id, source.language);
const fresh = file.roles.find((r) => r.slug === source.slug);
if (!fresh) {
return { updated: false, reason: 'not-in-catalog' };
}
// Keep the current name when the catalog's new name would collide with
// another live role (avoids the unique-name 409). Same-name (case-insensitive)
// means "no rename needed".
const newName = fresh.name.trim();
let name = newName;
if (newName.toLowerCase() !== role.name.trim().toLowerCase()) {
const others = await this.repo.listByWorkspace(workspaceId);
const collision = others.some(
(r) =>
r.id !== role.id &&
r.name.trim().toLowerCase() === newName.toLowerCase(),
);
if (collision) name = role.name;
}
await this.repo.update(dto.id, workspaceId, {
name,
...catalogRoleContentFields(fresh),
// enabled is deliberately NOT changed.
source: {
slug: source.slug,
language: source.language,
version: currentVersion,
},
});
const updated = await this.repo.findById(dto.id, workspaceId);
if (!updated) throw new BadRequestException('Role not found');
return {
updated: true,
fromVersion: source.version,
toVersion: currentVersion,
role: this.toView(updated),
};
}
private toView(row: AiAgentRole): AgentRoleView {
return {
id: row.id,
@@ -176,6 +507,9 @@ export class AiAgentRolesService {
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
// parseSource yields a fully-valid RoleSource | null (the row is already
// normalized; this also keeps the field type honest without a cast).
source: parseSource(row.source),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -205,11 +539,7 @@ export class AiAgentRolesService {
* failures keep surfacing as 500s.
*/
function rethrowDuplicateName(err: unknown, name: string): never {
if (
err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
) {
if (isUniqueViolation(err)) {
throw new ConflictException(
`A role named "${name}" already exists in this workspace.`,
);
@@ -217,13 +547,120 @@ function rethrowDuplicateName(err: unknown, name: string): never {
throw err;
}
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */
function emptyToNull(value: string | undefined): string | null {
if (value === undefined) return null;
/** Whether `err` is a Postgres unique-violation (SQLSTATE 23505). */
function isUniqueViolation(err: unknown): boolean {
return (
!!err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
);
}
/**
* The partial unique index name from the
* 20260626T160000-ai-agent-roles-catalog-source-unique migration: unique on
* (workspace_id, source->>'slug', source->>'language') for catalog-imported,
* non-deleted rows. A 23505 carrying this constraint name is a source-collision
* (concurrent import of the same slug+language), distinct from a name-collision.
*/
const SOURCE_UNIQUE_CONSTRAINT = 'ai_agent_roles_workspace_source_unique';
/**
* Whether `err` is the 23505 raised by the SOURCE-uniqueness index specifically
* (vs the name-uniqueness index). The active driver (`kysely-postgres-js` over
* `postgres@3.4.8`) exposes the violated constraint name on `constraint_name`,
* so we key off that (accepting the node-postgres-style `.constraint` as a
* fallback for other drivers) — that way a source race is skipped while a name
* race still surfaces as a friendly per-role error. A 23505 with no constraint
* name (e.g. a wrapped/test error) is NOT treated as a source collision,
* preserving the existing name-race behavior.
*/
function isSourceUniqueViolation(err: unknown): boolean {
if (!isUniqueViolation(err)) return false;
const e = err as { constraint_name?: unknown; constraint?: unknown };
return (
e.constraint_name === SOURCE_UNIQUE_CONSTRAINT ||
e.constraint === SOURCE_UNIQUE_CONSTRAINT
);
}
/**
* The role-content fields shared by import (insert) and update (patch) of a
* catalog role: emoji/description/launchMessage normalized to null, model config
* normalized, autoStart defaulted. The caller adds the write-specific fields
* (`name`, `source`, and on insert `workspaceId`/`creatorId`/`enabled`).
*/
function catalogRoleContentFields(role: CatalogRole): {
emoji: string | null;
description: string | null;
instructions: string;
modelConfig: Record<string, unknown> | null;
autoStart: boolean;
launchMessage: string | null;
} {
return {
emoji: emptyToNull(role.emoji),
description: emptyToNull(role.description),
instructions: role.instructions,
modelConfig: normalizeModelConfig(role.modelConfig) as
| Record<string, unknown>
| null,
autoStart: role.autoStart ?? true,
launchMessage: emptyToNull(role.launchMessage ?? undefined),
};
}
/** '' / whitespace-only / undefined / null => null; otherwise the trimmed value. */
function emptyToNull(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/** slug -> version map from a bundle's index metadata. */
function versionMap(meta: CatalogBundleMeta): Map<string, number> {
return new Map(meta.roles.map((r) => [r.slug, r.version]));
}
/**
* Resolve a localized value `{ en, ru, ... }` to `language`, falling back to
* 'en', then the first available locale. Returns null only for an empty map.
*/
function localized(
map: Record<string, string>,
language?: string,
): string | null {
if (language && typeof map[language] === 'string') return map[language];
if (typeof map.en === 'string') return map.en;
const first = Object.values(map)[0];
return typeof first === 'string' ? first : null;
}
/**
* Find a free display name by appending " (2)", " (3)", ... when `base` is
* already taken (case-insensitive against `taken`). Caller adds the result to
* `taken` after a successful insert.
*/
function freeName(base: string, taken: Set<string>): string {
// `taken` is finite, so within `taken.size + 2` iterations a candidate index
// is guaranteed free; the 1000 cap is a defensive upper bound far above any
// realistic per-name collision count. The throw below is therefore
// unreachable in practice and only satisfies the return-type checker.
for (let n = 2; n < 1000; n++) {
const candidate = `${base} (${n})`;
if (!taken.has(candidate.toLowerCase())) return candidate;
}
throw new BadRequestException(`Too many roles named "${base}"`);
}
/** A short, safe message for an import insert failure (409 vs other). */
function importErrorMessage(err: unknown): string {
if (isUniqueViolation(err)) {
return 'A role with this name already exists';
}
return 'Failed to import role';
}
/**
* Normalize an incoming modelConfig DTO to the persisted shape, or null when
* there is no usable override (no driver and no chatModel). The DTO's @IsIn

View File

@@ -0,0 +1,357 @@
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BadGatewayException, BadRequestException } from '@nestjs/common';
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
/**
* Provider tests against a LOCAL fixture directory (no network). They cover the
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection, a
* missing file => unavailable, and — most importantly — the `^[a-z0-9-]+$`
* path-traversal guard that runs BEFORE any path is built.
*/
describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
let dir: string;
function makeProvider(source: string) {
const env = {
getAiAgentRolesCatalogSource: () => source,
};
return new AiAgentRolesCatalogProvider(env as never);
}
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-roles-catalog-'));
await fs.writeFile(
path.join(dir, 'index.json'),
JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General', ru: 'Общие' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
}),
'utf8',
);
await fs.mkdir(path.join(dir, 'bundles', 'general'), { recursive: true });
await fs.writeFile(
path.join(dir, 'bundles', 'general', 'en.json'),
JSON.stringify({
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
},
],
}),
'utf8',
);
// A malformed bundle (a role missing `instructions`) to test rejection.
await fs.writeFile(
path.join(dir, 'bundles', 'general', 'fr.json'),
JSON.stringify({
schemaVersion: 1,
language: 'fr',
roles: [{ slug: 'researcher', name: 'Chercheur' }],
}),
'utf8',
);
});
afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('fetchIndex reads + validates index.json', async () => {
const provider = makeProvider(dir);
const index = await provider.fetchIndex();
expect(index.schemaVersion).toBe(1);
expect(index.bundles[0].id).toBe('general');
expect(index.bundles[0].roles[0]).toEqual({
slug: 'researcher',
version: 2,
});
});
it('fetchBundle reads + validates a language file', async () => {
const provider = makeProvider(dir);
const bundle = await provider.fetchBundle('general', 'en');
expect(bundle.language).toBe('en');
expect(bundle.roles[0].slug).toBe('researcher');
expect(bundle.roles[0].instructions).toBe('be a researcher');
});
it('malformed bundle (missing instructions) => BadGateway', async () => {
const provider = makeProvider(dir);
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('missing file => BadGateway (unavailable)', async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle('general', 'de'),
).rejects.toBeInstanceOf(BadGatewayException);
});
it('empty source resolves to the in-repo folder (no throw building the path)', async () => {
// With an empty source the provider targets ./agent-roles-catalog under the
// cwd; that folder is created by a separate task, so a read here surfaces as
// BadGateway (unavailable) rather than a path-build error.
const provider = makeProvider('');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
describe('remote fetch streaming size cap', () => {
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});
/** A web ReadableStream that yields `chunks` (each a Uint8Array). */
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
// The provider cancels the reader on the too-large path; no-op here.
cancel() {},
});
}
/** A ReadableStream whose first read rejects (e.g. a mid-body AbortError). */
function errorStream(err: Error): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
pull() {
throw err;
},
cancel() {},
});
}
function mockResponse(opts: {
ok?: boolean;
status?: number;
headers?: Record<string, string>;
body: ReadableStream<Uint8Array> | null;
text?: string;
}): Response {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
headers: { get: (k: string) => opts.headers?.[k.toLowerCase()] ?? null },
body: opts.body,
text: async () => opts.text ?? 'unused',
} as unknown as Response;
}
it('declared Content-Length over the cap => BadGateway before reading the body', async () => {
global.fetch = jest.fn().mockResolvedValue(
mockResponse({
headers: { 'content-length': String(2_000_000) },
body: streamOf([new Uint8Array(10)]),
}),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('streamed body exceeding the cap (no/under-reported Content-Length) => BadGateway', async () => {
// 1.5 MB streamed in 256 KB chunks, with no Content-Length header.
const chunks = Array.from(
{ length: 6 },
() => new Uint8Array(256 * 1024),
);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: streamOf(chunks) })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('fetch rejects (network failure) => BadGateway (unavailable)', async () => {
global.fetch = jest
.fn()
.mockRejectedValue(new Error('ECONNREFUSED')) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('passes redirect:"error" to fetch (redirect-SSRF hardening)', async () => {
const fetchMock = jest
.fn()
.mockResolvedValue(
mockResponse({ body: streamOf([new Uint8Array(0)]) }),
);
global.fetch = fetchMock as never;
const provider = makeProvider('https://catalog.example.com');
// Body shape is irrelevant; an empty stream parses to invalid JSON and
// throws, but the fetch call (with its init) still happened.
await expect(provider.fetchIndex()).rejects.toBeDefined();
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: 'error' }),
);
});
it('redirect response rejects (redirect:"error") => BadGateway', async () => {
// With redirect:"error", the platform fetch rejects on a 3xx instead of
// following it. Simulate that: the mock rejects when asked not to follow.
global.fetch = jest.fn().mockImplementation((_url, init) => {
if (init?.redirect === 'error') {
return Promise.reject(
new TypeError('fetch failed: unexpected redirect'),
);
}
return Promise.resolve(
mockResponse({ status: 302, body: null }),
);
}) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('non-ok response (503) => BadGateway carrying the status', async () => {
global.fetch = jest.fn().mockResolvedValue(
mockResponse({ ok: false, status: 503, body: null }),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toThrow(/503/);
});
it('small streamed body parses normally (cap not hit)', async () => {
const json = JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
const index = await provider.fetchIndex();
expect(index.bundles[0].id).toBe('general');
});
it('body read aborts mid-stream (AbortError) => BadGateway (not a generic 500)', async () => {
// The 10s timer aborts the whole request; on a slow/dripping source the
// body read (reader.read()) rejects with an AbortError AFTER fetch()
// resolved. The provider must map that to BadGateway, not let it escape.
const abortErr = Object.assign(new Error('The operation was aborted'), {
name: 'AbortError',
});
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: errorStream(abortErr) })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('null body (no readable stream) => response.text() fallback parses', async () => {
const json = JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: null, text: json })) as never;
const provider = makeProvider('https://catalog.example.com');
const index = await provider.fetchIndex();
expect(index.bundles[0].id).toBe('general');
});
it('null body + text() over the cap => BadGateway (too large)', async () => {
const oversized = 'a'.repeat(1_000_001);
global.fetch = jest
.fn()
.mockResolvedValue(
mockResponse({ body: null, text: oversized }),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('invalid JSON body => BadGateway (parse failure)', async () => {
const body = streamOf([new TextEncoder().encode('{not valid json')]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('malformed index.json (valid JSON, wrong shape) => BadGateway', async () => {
// Parses as JSON but fails isCatalogIndex (schemaVersion not a number).
const body = streamOf([
new TextEncoder().encode(
JSON.stringify({ schemaVersion: 'x', bundles: [] }),
),
]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toThrow(/malformed/i);
});
});
describe('path-traversal / SSRF guard (^[a-z0-9-]+$)', () => {
const bad = ['../etc', 'a/b', 'A', 'foo.bar', 'foo_bar', '', '..'];
for (const value of bad) {
it(`rejects bundleId="${value}" with BadRequest`, async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle(value, 'en'),
).rejects.toBeInstanceOf(BadRequestException);
});
it(`rejects language="${value}" with BadRequest`, async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle('general', value),
).rejects.toBeInstanceOf(BadRequestException);
});
}
});
});

View File

@@ -0,0 +1,324 @@
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from '@nestjs/common';
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
import {
CatalogBundleFile,
CatalogBundleMeta,
CatalogIndex,
CatalogRole,
} from './catalog-types';
/** Identifier shape allowed in any path/URL segment (bundleId, language). The
* ONLY characters that can appear in a fetched path — the path-traversal and
* SSRF guard. Anything else is rejected before a path/URL is built. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Remote fetch timeout and response-size cap. A curated catalog file is tiny;
* the cap stops a hostile/misconfigured source from streaming unbounded data. */
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BYTES = 1_000_000;
/**
* Fetches + validates the agent-roles catalog from its configured source. The
* source location (EnvironmentService.getAiAgentRolesCatalogSource()) is either
* an http(s):// base URL (REMOTE) or a local filesystem directory (LOCAL; the
* empty default resolves to the in-repo `agent-roles-catalog/` folder).
*
* The catalog is UNTRUSTED input: every file is JSON-parsed and run through a
* hand-written type guard before any field is exposed, and every dynamic path
* segment is validated against SEGMENT_RE up front (path-traversal + SSRF).
*/
@Injectable()
export class AiAgentRolesCatalogProvider {
private readonly logger = new Logger(AiAgentRolesCatalogProvider.name);
constructor(private readonly environmentService: EnvironmentService) {}
/** Read + validate the top-level index (`index.json`). */
async fetchIndex(): Promise<CatalogIndex> {
const raw = await this.readRelative('index.json');
const parsed = this.parseJson(raw, 'index.json');
if (!isCatalogIndex(parsed)) {
throw new BadGatewayException(
'Agent roles catalog index is malformed (index.json)',
);
}
return parsed;
}
/** Read + validate one language file (`bundles/<bundleId>/<language>.json`). */
async fetchBundle(
bundleId: string,
language: string,
): Promise<CatalogBundleFile> {
// SECURITY: validate BEFORE building any path/URL (path-traversal + SSRF).
this.assertSegment(bundleId, 'bundleId');
this.assertSegment(language, 'language');
const rel = `bundles/${bundleId}/${language}.json`;
const raw = await this.readRelative(rel);
const parsed = this.parseJson(raw, rel);
if (!isCatalogBundleFile(parsed)) {
throw new BadGatewayException(
`Agent roles catalog bundle is malformed (${rel})`,
);
}
return parsed;
}
/** Reject a segment that is not a safe `[a-z0-9-]+` identifier. */
private assertSegment(value: string, field: string): void {
if (typeof value !== 'string' || !SEGMENT_RE.test(value)) {
throw new BadRequestException(`Invalid ${field}`);
}
}
/** JSON.parse with a clear BadGateway on malformed content. */
private parseJson(raw: string, rel: string): unknown {
try {
return JSON.parse(raw);
} catch (err) {
const reason = shortError(err);
this.logger.error(`Agent roles catalog JSON parse failed (${rel}): ${reason}`);
throw new BadGatewayException(
`Agent roles catalog file is not valid JSON (${rel}): ${reason}`,
);
}
}
/** Read a relative catalog path as text from the configured source. */
private async readRelative(rel: string): Promise<string> {
const source = this.environmentService
.getAiAgentRolesCatalogSource()
.trim();
if (/^https?:\/\//i.test(source)) {
return this.fetchRemote(source, rel);
}
const dir = source || path.join(process.cwd(), 'agent-roles-catalog');
return this.readLocal(dir, rel);
}
/** Read a local catalog file. Missing => the catalog is unavailable. */
private async readLocal(dir: string, rel: string): Promise<string> {
try {
return await fs.readFile(path.join(dir, rel), 'utf8');
} catch (err) {
const reason = shortError(err);
this.logger.error(
`Agent roles catalog local read failed (${path.join(dir, rel)}): ${reason}`,
);
throw new BadGatewayException(
`Agent roles catalog is unavailable: ${reason}`,
);
}
}
/**
* Fetch a remote catalog file with a timeout + a STREAMING size cap. The body
* is never buffered in full before the check: we reject on a too-large
* Content-Length up front, then read the stream chunk-by-chunk and abort the
* moment the running total exceeds MAX_BYTES, so a hostile/misconfigured
* source cannot make us hold an unbounded body in memory.
*/
private async fetchRemote(base: string, rel: string): Promise<string> {
const url = `${base.replace(/\/+$/, '')}/${rel}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let response: Response;
try {
// `redirect: 'error'` hardens against redirect-SSRF: a
// compromised-but-trusted upstream cannot 3xx the fetch into the
// internal network (e.g. http://169.254.169.254/...). A redirect
// response rejects here and is mapped to BadGateway below.
response = await fetch(url, {
signal: controller.signal,
redirect: 'error',
});
} catch (err) {
const reason = shortError(err);
this.logger.error(
`Agent roles catalog remote fetch failed (${rel}): ${reason}`,
);
throw new BadGatewayException(
`Agent roles catalog is unavailable: ${reason}`,
);
}
if (!response.ok) {
this.logger.error(
`Agent roles catalog remote returned ${response.status} (${rel})`,
);
throw new BadGatewayException(
`Agent roles catalog returned ${response.status}`,
);
}
// Reject a too-large declared size before reading any body bytes.
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
// Bound the actual read: a missing/lying Content-Length is caught here.
// The 10s timer aborts the WHOLE request, so a slow/dripping hostile
// source rejects reader.read() (or response.text()) with an AbortError
// mid-body. Map that — and any other read failure — to a logged
// BadGateway so the admin endpoint returns 502 (not a generic 500). The
// cap's own BadGateway is rethrown as-is (no double-wrap).
try {
if (response.body) {
return await readStreamCapped(response.body, MAX_BYTES);
}
// Edge: no readable stream — fall back to a buffered read + length check.
const text = await response.text();
if (text.length > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
return text;
} catch (err) {
if (err instanceof BadGatewayException) throw err;
const reason = shortError(err);
this.logger.error(
`Agent roles catalog body read failed (${rel}): ${reason}`,
);
throw new BadGatewayException(
`Agent roles catalog is unavailable: ${reason}`,
);
}
} finally {
clearTimeout(timer);
}
}
}
/**
* Read a web ReadableStream into a UTF-8 string, throwing as soon as the
* accumulated byte count exceeds `maxBytes` (the reader is cancelled so the
* underlying connection is released). Never buffers more than the cap + the
* final chunk before bailing out.
*/
async function readStreamCapped(
body: ReadableStream<Uint8Array>,
maxBytes: number,
): Promise<string> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.length;
if (total > maxBytes) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
chunks.push(value);
}
} finally {
// Release the stream on both the normal and the too-large/abort paths.
await reader.cancel().catch(() => undefined);
}
return Buffer.concat(chunks).toString('utf8');
}
/**
* A short, non-sensitive error string for logging/propagation: only the first
* line of the message head is kept (upstream bodies / URLs are discarded).
*/
function shortError(err: unknown): string {
let message = '';
if (typeof err === 'string') {
message = err;
} else if (
err &&
typeof err === 'object' &&
typeof (err as { message?: unknown }).message === 'string'
) {
// Read `.message` directly (works for Error instances and the realm-shifted
// Error-likes jest can hand back, where `instanceof Error` is false).
message = (err as { message: string }).message;
}
const head = (message || 'unknown error').split('\n')[0];
return head.length > 200 ? `${head.slice(0, 200)}` : head;
}
// ---------------------------------------------------------------------------
// Hand-written type guards (no zod / new deps). Each validates the exact wire
// shape declared in catalog-types.ts; anything else is rejected by the caller.
// ---------------------------------------------------------------------------
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function isStringMap(v: unknown): v is Record<string, string> {
if (!isObject(v)) return false;
return Object.values(v).every((x) => typeof x === 'string');
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
}
export function isCatalogRole(v: unknown): v is CatalogRole {
if (!isObject(v)) return false;
if (typeof v.slug !== 'string') return false;
if (typeof v.name !== 'string') return false;
if (typeof v.instructions !== 'string') return false;
if (v.emoji !== undefined && typeof v.emoji !== 'string') return false;
if (v.description !== undefined && typeof v.description !== 'string') {
return false;
}
if (v.autoStart !== undefined && typeof v.autoStart !== 'boolean') {
return false;
}
if (
v.launchMessage !== undefined &&
v.launchMessage !== null &&
typeof v.launchMessage !== 'string'
) {
return false;
}
if (
v.modelConfig !== undefined &&
v.modelConfig !== null &&
!isObject(v.modelConfig)
) {
return false;
}
return true;
}
export function isCatalogBundleFile(v: unknown): v is CatalogBundleFile {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (typeof v.language !== 'string') return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(isCatalogRole);
}
function isCatalogBundleMeta(v: unknown): v is CatalogBundleMeta {
if (!isObject(v)) return false;
if (typeof v.id !== 'string') return false;
if (!isStringMap(v.name)) return false;
if (v.description !== undefined && !isStringMap(v.description)) return false;
if (!isStringArray(v.languages)) return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(
(r) =>
isObject(r) &&
typeof r.slug === 'string' &&
typeof r.version === 'number',
);
}
export function isCatalogIndex(v: unknown): v is CatalogIndex {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (!Array.isArray(v.bundles)) return false;
return v.bundles.every(isCatalogBundleMeta);
}

View File

@@ -0,0 +1,47 @@
/**
* Catalog wire shapes. The catalog is curated, untrusted JSON (a GitHub repo or
* a local folder), so every shape is validated by a hand-written type guard in
* the provider before any field is used — no zod / new deps on the server.
*
* Localized fields (`name` / `description` at the bundle level) are
* `Record<language, string>` so one bundle serves many UI languages; per-role
* `name` / `description` are already language-specific (the bundle file is keyed
* by language).
*/
/** One role's content as shipped in a per-language bundle file. */
export interface CatalogRole {
slug: string;
emoji?: string;
name: string;
description?: string;
instructions: string;
autoStart?: boolean;
launchMessage?: string | null;
// Optional model override; same loose object shape as ai_agent_roles.model_config.
modelConfig?: Record<string, unknown> | null;
}
/** A single language file: `bundles/<id>/<language>.json`. */
export interface CatalogBundleFile {
schemaVersion: number;
language: string;
roles: CatalogRole[];
}
/** Bundle metadata as listed in the top-level index. Versions live here (per
* slug), so an UPDATE check needs only the index, not every language file. */
export interface CatalogBundleMeta {
id: string;
// Localized display name/description: { en: '...', ru: '...' }.
name: Record<string, string>;
description?: Record<string, string>;
languages: string[];
roles: { slug: string; version: number }[];
}
/** Top-level catalog index: `index.json`. */
export interface CatalogIndex {
schemaVersion: number;
bundles: CatalogBundleMeta[];
}

View File

@@ -0,0 +1,62 @@
import {
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
/** Safe identifier shape for any catalog path segment (bundleId / language).
* Mirrors SEGMENT_RE in the catalog provider — the path-traversal/SSRF guard
* is enforced both at the API boundary (here) and in the provider. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Browse the catalog, optionally localized to `language` (defaults applied in
* the service: fall back to 'en', then the first available language). */
export class CatalogQueryDto {
@IsOptional()
@IsString()
@MaxLength(16)
language?: string;
}
/** Open one catalog bundle in a specific language. */
export class CatalogBundleDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
}
/** Import roles from a catalog bundle into the workspace. */
export class ImportFromCatalogDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
@IsOptional()
@IsArray()
@IsString({ each: true })
slugs?: string[];
// How to handle a name collision with an existing (non-catalog) role:
// 'skip' leaves it; 'rename' imports under a free " (N)" name.
@IsIn(['skip', 'rename'])
conflict: 'skip' | 'rename';
}
/** Update an already-imported role from its catalog source. */
export class UpdateFromCatalogDto {
@IsUUID()
id: string;
}

View File

@@ -21,6 +21,41 @@ export interface TreeNodeSnapshot {
position: string;
spaceId: string;
parentPageId: string | null;
// Death-timer deadline carried so the `addTreeNode` broadcast shows the
// temporary-note clock marker immediately on every client (incl. the author,
// whose optimistic insert can lose the race to this broadcast). null/absent =>
// permanent.
temporaryExpiresAt?: Date | string | null;
}
/**
* Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both
* the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the
* `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same
* snapshot; routing both through here keeps the optional `temporaryExpiresAt`
* (and the `?? null` normalisation that pins a permanent note to an explicit
* null) from silently drifting between the two literals.
*/
export function toTreeNodeSnapshot(page: {
id: string;
slugId: string;
title: string | null;
icon: string | null;
position: string;
spaceId: string;
parentPageId: string | null;
temporaryExpiresAt?: Date | string | null;
}): TreeNodeSnapshot {
return {
id: page.id,
slugId: page.slugId,
title: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
};
}
export class PageEvent {

View File

@@ -0,0 +1,19 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// `source` links an imported role back to its catalog origin
// `{ slug, language, version }`. Nullable: null => a manually-created role
// (no catalog provenance). The version lets the admin UI offer an UPDATE when
// the catalog ships a newer revision of the same slug.
await db.schema
.alterTable('ai_agent_roles')
.addColumn('source', 'jsonb', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('source')
.execute();
}

View File

@@ -0,0 +1,31 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// A catalog-imported role is uniquely identified within a workspace by its
// `source.slug` + `source.language` (a multilingual catalog: the `ru` variant
// of a slug installed as `en` is a SEPARATE install — hence both keys). The
// import path skips a slug+language already installed using an in-memory
// snapshot (installedKeys), but two CONCURRENT imports of the same bundle each
// read a stale snapshot and would both insert the same slug+language,
// duplicating the role. This partial unique index is the database-level
// backstop: the second insert gets a 23505 the service treats as
// "already installed" (skip), so the two imports converge on ONE role.
//
// Partial on `source IS NOT NULL` so MANUALLY-created roles (source NULL) are
// unconstrained — there can be many of those. Also partial on
// `deleted_at IS NULL` (like the existing name-unique index) so a soft-deleted
// role does not block re-importing the same slug+language later, matching the
// app's snapshot (listByWorkspace filters out soft-deleted rows).
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS ai_agent_roles_workspace_source_unique
ON ai_agent_roles (workspace_id, (source ->> 'slug'), (source ->> 'language'))
WHERE source IS NOT NULL AND deleted_at IS NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('ai_agent_roles_workspace_source_unique')
.ifExists()
.execute();
}

View File

@@ -1,4 +1,4 @@
import { AiAgentRoleRepo } from './ai-agent-roles.repo';
import { AiAgentRoleRepo, parseSource } from './ai-agent-roles.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
@@ -132,4 +132,77 @@ describe('AiAgentRoleRepo insert/update auto-start columns', () => {
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
it('insert binds `source` (jsonb); update sets it only when present', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
source: { slug: 'researcher', language: 'en', version: 1 },
});
// jsonbBind returns a RawBuilder for a non-empty object (not null).
expect(values.mock.calls[0][0].source).not.toBeNull();
const { repo: repo2, set } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { name: 'X' });
expect('source' in set.mock.calls[0][0]).toBe(false);
const { repo: repo3, set: set3 } = makeUpdateRepo();
await repo3.update('r-1', 'ws-1', {
source: { slug: 's', language: 'en', version: 2 },
});
expect('source' in set3.mock.calls[0][0]).toBe(true);
});
});
/**
* parseSource is THE single form validator for the `source` jsonb column: a
* JSON-string (legacy double-encoded) is parsed; a FULLY-VALID object
* ({ slug, language, version }) passes through as a typed RoleSource; anything
* partial or wrong-shaped degrades to null (= manual role). This is the
* stricter-than-before guard that closes the drift where a weak `{}`/`{slug:123}`
* value used to be stamped as a valid source by the read path.
*/
describe('parseSource', () => {
it('parses a legacy double-encoded JSON string into the typed source', () => {
expect(
parseSource('{"slug":"researcher","language":"en","version":1}'),
).toEqual({ slug: 'researcher', language: 'en', version: 1 });
});
it('passes a fully-valid already-parsed object through', () => {
const obj = { slug: 's', language: 'en', version: 2 };
expect(parseSource(obj)).toEqual(obj);
});
it('returns the typed RoleSource (extra keys tolerated) for a valid shape', () => {
const src = parseSource({ slug: 's', language: 'ru', version: 3 });
expect(src).not.toBeNull();
// Narrowed to RoleSource: the fields are present and correctly typed.
expect(src?.slug).toBe('s');
expect(src?.language).toBe('ru');
expect(src?.version).toBe(3);
});
it('null / array / non-object / unparseable string => null', () => {
expect(parseSource(null)).toBeNull();
expect(parseSource([1, 2])).toBeNull();
expect(parseSource(42)).toBeNull();
expect(parseSource('not json')).toBeNull();
});
it('partial / wrong-typed shapes => null (no weak-but-typed-as-valid drift)', () => {
// Empty object: no slug/language/version.
expect(parseSource({})).toBeNull();
// slug present but not a string.
expect(parseSource({ slug: 123, language: 'en', version: 1 })).toBeNull();
// slug only, missing language + version.
expect(parseSource({ slug: 'a' })).toBeNull();
// empty-string slug / language are not valid catalog keys.
expect(parseSource({ slug: '', language: 'en', version: 1 })).toBeNull();
expect(parseSource({ slug: 'a', language: '', version: 1 })).toBeNull();
// version must be a number, not a numeric string.
expect(parseSource({ slug: 'a', language: 'en', version: '1' })).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
type ModelConfigValue = Record<string, unknown> | null;
@@ -81,6 +81,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
// Catalog origin { slug, language, version } | null. null => manual role.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
@@ -103,6 +105,9 @@ export class AiAgentRoleRepo {
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || null,
// Same cast reason as modelConfig (see above).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
source: jsonbBind(values.source) as any,
})
.returningAll()
.executeTakeFirst();
@@ -124,6 +129,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
// undefined => unchanged; null => clear; object => set.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<void> {
@@ -142,6 +149,9 @@ export class AiAgentRoleRepo {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage || null;
}
if (patch.source !== undefined) {
set.source = jsonbBind(patch.source);
}
await db
.updateTable('aiAgentRoles')
.set(set)
@@ -192,14 +202,46 @@ export function parseModelConfig(
);
}
/** Normalize a DB row so `modelConfig` is always an object or null. The cast
* bridges parseModelConfig's concrete `Record | null` to the column's broad
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
/**
* THE single form validator for the `source` jsonb column: parse the value read
* from the DB into a fully-valid {@link RoleSource} or null. Same legacy
* double-encoding self-heal as {@link parseModelConfig} (a JSON string is parsed
* once), then validates the FULL shape — `slug` and `language` non-empty
* strings, `version` a number. A null / corrupt / partially-shaped value (e.g.
* `{}`, `{ slug: 123 }`, `{ slug: 'a' }` missing language/version) degrades to
* null (= manually created, no catalog provenance), so a bad row never breaks
* the read path AND never stamps a half-built object as a valid `RoleSource`.
* Both the repo read-path and the service share this so the contract cannot
* drift between layers.
*/
export function parseSource(value: unknown): RoleSource | null {
return parseJsonbValue(value, isRoleSource);
}
/** Full-shape guard for a persisted `source` jsonb value (see parseSource). */
function isRoleSource(v: unknown): v is RoleSource {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
const obj = v as Record<string, unknown>;
return (
typeof obj.slug === 'string' &&
obj.slug.length > 0 &&
typeof obj.language === 'string' &&
obj.language.length > 0 &&
typeof obj.version === 'number'
);
}
/** Normalize a DB row so `modelConfig` and `source` are always a valid object or
* null. The casts bridge the concrete parsed types (`Record | null`,
* `RoleSource | null`) to the column's broad generated `JsonValue` type — both
* are valid JsonValues at runtime; RoleSource lacks the JsonObject index
* signature so it routes through `unknown`. */
function normalizeRow(row: AiAgentRole): AiAgentRole {
return {
...row,
modelConfig: parseModelConfig(
row.modelConfig,
) as AiAgentRole['modelConfig'],
source: parseSource(row.source) as unknown as AiAgentRole['source'],
};
}

View File

@@ -16,7 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import { TreeUpdateSnapshot } from '../../listeners/page.listener';
import {
TreeUpdateSnapshot,
toTreeNodeSnapshot,
} from '../../listeners/page.listener';
/**
* Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the
@@ -200,17 +203,10 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id],
workspaceId: result.workspaceId,
pages: [
{
id: result.id,
slugId: result.slugId,
title: result.title,
icon: result.icon,
position: result.position,
spaceId: result.spaceId,
parentPageId: result.parentPageId,
},
],
// Built via the shared snapshot helper so the field copy (and the
// death-timer deadline that shows the sidebar clock marker without a
// reload) can't drift from the `addTreeNode` broadcast literal.
pages: [toTreeNodeSnapshot(result)],
});
return result;

View File

@@ -618,6 +618,8 @@ export interface AiAgentRoles {
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
// Catalog origin of an imported role: { slug, language, version } | null. null => manually created.
source: Json | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;

View File

@@ -81,6 +81,24 @@ export type UpdatableAiMcpServer = Updateable<Omit<AiMcpServersTable, 'id'>>;
// A role replaces the persona layer of the system prompt (instructions) and may
// optionally override the chat model (`modelConfig`). Soft-deletable.
export type AiAgentRole = Selectable<AiAgentRoles>;
/**
* The validated shape of the `source` jsonb column on ai_agent_roles: the
* catalog origin of an imported role. `version` lets the admin UI offer an
* UPDATE when the catalog ships a newer revision of the same slug; null `source`
* (not this type) means a manually-created role with no catalog provenance.
*
* THE single contract for that column, shared by the repo read-path
* (`parseSource`, the only form validator) and the service, so the persisted
* shape can never be validated weakly in one layer and strongly in another.
* Defined here (a leaf db-types module both already import `AiAgentRole` from) to
* avoid an import cycle between the repo and the service.
*/
export interface RoleSource {
slug: string;
language: string;
version: number;
}
export type InsertableAiAgentRole = Insertable<AiAgentRoles>;
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>;

View File

@@ -289,6 +289,15 @@ export class EnvironmentService {
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getAiAgentRolesCatalogSource(): string {
// Catalog location. http(s):// URL => fetched remotely; anything else => a
// local filesystem directory. Defaults to the in-repo folder (dev). In prod
// set this to the raw GitHub base URL of the catalog repo. Unlike the AI_*
// getters above this is INFRA config (where the catalog lives), not
// provider/model config — so an env var here is appropriate.
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
}
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')

View File

@@ -83,6 +83,27 @@ describe('WsTreeService', () => {
);
});
it('broadcastPageCreated carries temporaryExpiresAt when the page is a temporary note', async () => {
const expiresAt = new Date('2026-07-01T00:00:00.000Z');
await service.broadcastPageCreated({ ...snapshot, temporaryExpiresAt: expiresAt });
const data =
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
// The death-timer deadline reaches receivers so the clock marker renders
// immediately (incl. the author if this broadcast wins the optimistic race).
expect(data.temporaryExpiresAt).toBe(expiresAt);
});
it('broadcastPageCreated pins temporaryExpiresAt to null for a permanent page', async () => {
// Fixture omits temporaryExpiresAt; the `?? null` must send an explicit null
// (permanent) rather than undefined, so receivers clear any stale marker.
await service.broadcastPageCreated(snapshot);
const data =
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
expect(data.temporaryExpiresAt).toBeNull();
});
it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => {
await service.broadcastPageDeleted({
...snapshot,

View File

@@ -5,6 +5,7 @@ import {
PageMovedEvent,
TreeNodeSnapshot,
TreeUpdateSnapshot,
toTreeNodeSnapshot,
} from '../database/listeners/page.listener';
@Injectable()
@@ -28,15 +29,16 @@ export class WsTreeService {
// Receivers place by `position` among already-loaded siblings, not by
// this absolute index (sender's loaded set differs from receivers').
index: 0,
// Built via the shared snapshot helper (same one page.repo uses to fill
// the event), then extended with the tree-only fields the client
// receiver consumes. The helper carries the death-timer deadline
// (normalised to null => permanent) so receivers — and the author, if
// this broadcast wins the race against the optimistic insert — render
// the temporary-note clock marker immediately, without it drifting from
// the event literal.
data: {
id: page.id,
slugId: page.slugId,
...toTreeNodeSnapshot(page),
name: page.title ?? '',
title: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
hasChildren: false,
children: [],
},