Compare commits

...

31 Commits

Author SHA1 Message Date
claude_code
904f7b4303 fix(agent-roles): bump proofreader v3 + guard against content edits without a version bump
The proofreader role content was changed (STYLE SHEET block removed) without
bumping its catalog version, so clients never saw an update. Bump proofreader
2 -> 3, and add a content-hash guard so this can't happen silently again.

- index.json: proofreader version 2 -> 3
- scripts/check.mjs: new content-hash guard. A scripts/content-hashes.json lock
  maps slug -> { version, hash } (sha256 over emoji/autoStart/name/description/
  instructions/launchMessage across all languages). check.mjs now fails when a
  role's content changed without bumping its version; the new --update-hashes
  (alias --fix) refreshes the lock but refuses to write when a bump is missing.
- check.mjs: also require every index.json role to carry a finite numeric
  version (matches the server's catalog validation), with defense-in-depth so a
  missing version can't bypass the bump guard.
- scripts/content-hashes.json: new lock artifact (not part of the served catalog).
- README.md: document the guard, the lockfile, --update-hashes, and the
  prune-then-readd limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 05:18:39 +03:00
claude_code
cac84dec9b refactor(ai-roles): make catalog URL a per-branch image default, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer
supports a local filesystem directory. The provider fetches only from an
http(s):// base URL read at runtime from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The image ships a per-branch
default for that URL (set in CI), still overridable at runtime via the env var.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  reflects the per-branch build-time default that is runtime-overridable.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage as
  the image default.
- CI: develop.yml builds with the develop raw URL; release.yml defines the main
  raw URL once in workflow env and references it from both build steps.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:54:43 +03:00
claude_code
90dd8f1481 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:54:24 +03:00
39113c9dbf Merge pull request 'fix(share): custom address edit renames in place instead of duplicating (#226)' (#227) from fix/share-alias-rename into develop
Reviewed-on: #227
2026-06-27 03:53:31 +03:00
claude_code
1367070468 refactor(agent-roles): drop style-sheet duties from copyeditor role
Remove the STYLE SHEET / СТАЙЛ-ШИТ section from the copyeditor
(proofreader) role and clean up all dangling references to it in both
the ru and en editorial bundles:
- description: drop "maintains a style sheet" / "ведёт стайл-шит"
- instructions: remove the STYLE SHEET block
- instructions: drop "record it in the style sheet" mentions in the
  WHAT YOU DO and WHEN UNSURE sections

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:46:03 +03:00
claude code agent 227
767ac9e7e2 fix(share): guard alias swap/rename against concurrent-delete race; share unique-violation helpers
Address PR #227 re-review (comment 2193).

- Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row
  reaped by a concurrent `removeAlias` between the read and the UPDATE (READ
  COMMITTED) raises `NoResultError` instead of returning `undefined`. The service
  maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a
  200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests
  cover both branches.
- Simplification: drop the redundant secondary "unexpected unique index" warn and
  the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged
  unconditionally; both index branches still distinguish "Alias already taken" vs
  ALIAS_PAGE_RACE).
- Architecture: extract `isUniqueViolation`/`violatedConstraint` into
  database/utils.ts; adopt them in the share-alias service and favorite.repo
  (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch
  and should adopt them after #227 merges (noted at the helpers). Helper unit test
  added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:33:33 +03:00
claude_code
2a4ef9267e refactor(ai-roles): bake catalog URL at image build, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no
longer supports a local filesystem directory. The provider now fetches only
from an http(s):// base URL read from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The default URL is baked
into the Docker image at build time and set per branch in CI.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  updated to reflect build-time injection, remote-only.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage.
- CI: develop.yml builds with the develop raw URL; release.yml (both build
  steps) with the main raw URL.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:32:48 +03:00
claude code agent 227
309719abc6 fix(share): show reassign hint instead of dead-end error for a taken custom address
The share modal flagged a custom address already owned by another page with a
red "This address is already in use" error driven by the availability probe.
That reads as terminal even though Save actually triggers the server's
409 `ALIAS_REASSIGN_REQUIRED` and opens the "Move custom address?" confirm
modal that retargets the address to the current page — so the reassign path was
hidden behind what looked like a hard stop.

Replace the red error with an informational description hint ("This address is
in use. Saving will move it to this page.") and keep Save enabled, so the
existing confirm-reassign flow is discoverable. Renaming to a FREE name was
already correct (the probe returns available -> no error -> server renames the
single row in place); this only changes the taken-name presentation.

Verified end-to-end in a real browser against a live stand on this branch:
- A (free rename `test`->`test2`): 200, same alias row renamed in place, link
  becomes `/l/test2`, no error, exactly one DB row for the page.
- B (`test2` owned by another page): hint shown (no dead-end error), Save ->
  409 ALIAS_REASSIGN_REQUIRED -> "Move custom address?" modal -> confirm -> 200,
  the single row retargets, one row each.
- C (same-name re-save): Save disabled (no-op); first-time set inserts.

Add a client component test covering both branches (taken name -> hint not
error + Save enabled; 409 -> reassign modal -> confirm sends confirmReassign).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:24:00 +03:00
claude_code
3511301331 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:12:27 +03:00
claude_code
b65ca6d7dd chore(agent-roles-catalog): merge copy-editor into proofreader, refresh editorial roles
Merge the copy-editor (📐) and proofreader (🧹 "Корректор") editorial roles
into a single role. Keep slug `proofreader`, drop slug `copy-editor`, and set
the merged role's emoji to 📐.

- index.json: remove copy-editor; bump structural-editor, line-editor,
  fact-checker, proofreader to version 2 (narrator unchanged); update editorial
  bundle description (ru/en).
- bundles/editorial/{ru,en}.json: delete copy-editor; refresh emoji/name/
  description/instructions of structural-editor, line-editor, fact-checker and
  the merged proofreader verbatim from gitmost-agenty-ru.md / gitmost-agents-en.md;
  preserve autoStart and launchMessage; leave narrator untouched.
- README.md: drop copy-editor from the editorial role list.

Validated with scripts/check.mjs (OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:12:14 +03:00
4a3819373d Merge pull request 'feat(ai-chat): auto-open last chat bound to the document (#191)' (#209) from feat/191-chat-doc-binding into develop
Reviewed-on: #209
2026-06-27 02:56:31 +03:00
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
claude_code
e9409e245b style(share): drop divider line from custom-address prefix
The right border on the address prefix read as a stray vertical line
between the domain and the slug. Remove it and rely on the subtle
prefix background alone to separate the two parts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:08 +03:00
claude_code
fa6a87e22d test(ai-chat): cover MessageList parent-side signature snapshot (#224)
PR #224 fixed an AI-chat streaming-render regression by moving the React.memo
content signature into the parent: MessageList now snapshots
messageSignature(message) per render and passes it to MessageItem as the
immutable `signature` prop. The existing memo tests only SIMULATED that
parent half by hardcoding `signature={messageSignature(message)}` in their
harness; the real MessageList was never exercised (chat-thread.test.tsx mocks
it out, and there was no message-list.test).

Add message-list.test.tsx that mounts the REAL MessageList (without mocking
MessageItem or messageSignature) and asserts that an in-place mutation of a
reused message object surfaces on re-render. This guards the parent-side
contract: re-caching the signature on message identity (stable across deltas
while parts mutate) would refreeze the row, and this test would fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:01 +03:00
claude_code
0fc9c4a998 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 22:09:22 +03:00
claude_code
40b8f7922a feat(client): quick-create regular and temporary notes from Home and Space screens
Add fast note-creation entry points alongside the existing space-sidebar
actions.

- Home: refactor new-note-button.tsx into a reusable inner CreateNoteButton
  (parametrized by `temporary`/label/icon, keeps the 0/1/many writable-space
  resolution and space-picker dropdown) and render two equal-width buttons via
  `Group grow` — a regular note and a temporary note (IconHourglass).
- Space overview: new SpaceCreateNoteButtons component with two buttons that
  create a regular/temporary note directly in the current space and open it,
  reusing useTreeMutation.handleCreate (optimistic sidebar-tree insert +
  navigation). Permission-gated to members who can manage pages; a local
  pending state shows a per-button spinner and disables both to prevent a
  double-create. Wired into space-home.tsx above the tabs.
- Reuse existing i18n keys (no new strings): "New note", "New temporary note",
  "Create in space".
- Docs: add a CHANGELOG [Unreleased] entry and a "Temporary notes" roadmap
  bullet to README.md and README.ru.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:09 +03:00
08c70cf550 Merge pull request 'fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression)' (#224) from fix/ai-chat-empty-render into develop
Reviewed-on: #224
2026-06-26 22:09:05 +03:00
claude_code
276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.

Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
  remove generativeAi from update DTO and workspace service (update block,
  delete line, cloud default now { ai: { chat: true } }); fix repo comment;
  migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
  dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
  usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
  remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
claude code agent 227
c64d7f315e fix(ai-chat): open chat window before resolving the bound chat (#191)
Address PR #209 review.

- use-open-ai-chat.ts: call setWindowOpen(true) before awaiting
  getBoundChat so the header button feels instant on slow connections;
  the chat switch (setActiveChatId/setDraft/setSelectedRoleId) is applied
  after the round-trip resolves. Also drop the redundant no-op
  setWindowOpen(true) in the already-open branch (bare early return).
- CHANGELOG.md: document the header AI-chat button auto-opening the
  latest chat bound to the current document under [Unreleased]/Added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:02:15 +03:00
claude code agent 227
7a7aa79eab feat(ai-chat): auto-open last chat bound to the document (#191)
On opening the floating AI-chat window from the header on a document page,
auto-open the LAST chat bound to that document. Binding reuses the existing
ai_chats.page_id (no migration): the bound chat is the requesting user's
most-recent non-deleted chat created on that page, so a new chat on the page
becomes the bound one for free. Resolution happens only on a genuine
closed -> open transition; the provenance badge deep-link is untouched.

Server: AiChatRepo.findLatestByPage + POST /ai-chat/bound-chat (BoundChatDto),
both read-only and owner/workspace-scoped.
Client: getBoundChat service + useOpenAiChatForCurrentPage hook wired into the
app-header entry point (fail-soft to a fresh chat; draft/role cleared only on a
real switch).
Tests: repo scoping/ordering, controller wiring, and hook behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:01:38 +03:00
100 changed files with 6360 additions and 346 deletions

View File

@@ -132,6 +132,14 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account # 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. # (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
# baked into the Docker image at build time per branch (see the Dockerfile ARG
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
# admin feature is unavailable. Local-filesystem sources are no longer supported.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer. # Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues. # A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000 # AI_EMBEDDING_TIMEOUT_MS=120000

View File

@@ -52,6 +52,7 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
build-args: | build-args: |
APP_VERSION=${{ steps.version.outputs.value }} APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: true push: true
tags: ${{ env.IMAGE }}:develop tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64 cache-from: type=gha,scope=develop-amd64

View File

@@ -17,6 +17,7 @@ permissions:
env: env:
VERSION: ${{ inputs.version || github.ref_name }} VERSION: ${{ inputs.version || github.ref_name }}
IMAGE: ghcr.io/vvzvlad/gitmost IMAGE: ghcr.io/vvzvlad/gitmost
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
jobs: jobs:
# Run the reusable test suite first so a failing test blocks the image build. # Run the reusable test suite first so a failing test blocks the image build.
@@ -57,6 +58,7 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
build-args: | build-args: |
APP_VERSION=${{ env.VERSION }} APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.suffix }} cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
@@ -85,6 +87,7 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
build-args: | build-args: |
APP_VERSION=${{ env.VERSION }} APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
push: false push: false
tags: | tags: |
${{ env.IMAGE }}:latest ${{ env.IMAGE }}:latest

View File

@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
resolving the target space the same way the regular button does — created
directly when you can write to a single space, or via a space picker when
several. Each space overview screen gains two buttons — "New note" and "New
temporary note" — that create the page directly in that space and open it,
mirroring the existing space-sidebar actions and shown only to members who can
manage pages.
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat - **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The immediately sends that message, keeping the agent's partial output. The
@@ -19,6 +28,41 @@ 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 answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198) 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 configured via the
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
catalog's raw files; the image ships a per-branch default baked in CI, and it
can be overridden at runtime via the env var (see `.env.example`). (#222)
### Fixed
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
page's vanity slug previously inserted a second `share_aliases` row instead of
renaming the existing one, leaving the old `/l/<old>` link live forever and
making the share modal's lookup nondeterministic. Slug edits and confirmed
reassigns now rename/retarget the single row, and a new partial unique index on
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
orphaned duplicate alias rows the old bug created (keeping the newest per
page), so any previously-live duplicate `/l/<old>` link begins returning the
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
#227)
- **Typing a custom address already used by another page no longer looks like a
dead end.** The share modal previously flagged such a name with a red "This
address is already in use" error, hiding the fact that saving offers to MOVE
the address to the current page. The field now shows an informational hint —
"This address is in use. Saving will move it to this page." — and keeps Save
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED`
"Move custom address?") is discoverable instead of reading as terminal. (#227)
## [0.94.0] - 2026-06-26 ## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to This release makes AI chat durable and fast: assistant turns are persisted to
@@ -97,6 +141,13 @@ per-workspace rolling-day token budget.
applies it through the existing `/pages/update` route — reflecting it in the applies it through the existing `/pages/update` route — reflecting it in the
title field and broadcasting to other clients. Gated by the `settings.ai.generative` title field and broadcasting to other clients. Gated by the `settings.ai.generative`
flag and throttled per user. (#199) flag and throttled per user. (#199)
- **AI chat: header button auto-opens the chat bound to the current document.**
Clicking the AI-chat button in the header while viewing a page now reopens the
latest chat tied to that document instead of whatever chat was last active,
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
chat you created on the page wins; with no bound chat — or off a page, or if
the lookup fails — it falls soft to a fresh chat and keeps the current
selection otherwise. (#191)
### Changed ### Changed

View File

@@ -23,6 +23,11 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
# Agent-roles catalog base URL: per-branch default set at build time (CI);
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
ARG AI_AGENT_ROLES_CATALOG_URL=""
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
# Copy apps # Copy apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist COPY --from=builder /app/apps/client/dist /app/apps/client/dist

View File

@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). -**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. -**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. -**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress ### In progress

View File

@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). -**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. -**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. -**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе ### В процессе

View File

@@ -0,0 +1,193 @@
# 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)
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-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()`), an `http(s)://` base URL
to the catalog's raw files. The server fetches `<base>/index.json` for the
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
file (REMOTE only).
That base URL is provided as a per-branch default in the Docker image (set in
CI: a `develop` build points at the `develop` raw URL, a release build at the
`main` raw URL) and can be overridden at runtime via the
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
supported; if the value is unset the catalog is unavailable.
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`. Then run `node scripts/check.mjs --update-hashes`
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
now **fails if a role's content changed but its `version` was not bumped**, so
this step is mandatory — the lock can only be refreshed after the bump.
## 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.
### Content-hash guard
`check.mjs` also guards against changing a role's content without bumping its
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
`launchMessage`) across all of its language files, in a deterministic canonical
form. This lockfile is a **check artifact only** — the server fetches only
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
effect on the served catalog or its schema.
On a normal run, for every role the check recomputes the hash and compares it
against the lock:
- content unchanged and versions agree → OK;
- content changed but `version` not bumped above the lock → **error** asking you
to bump and refresh;
- content changed and `version` bumped → **error** asking you to record it by
refreshing the lock;
- role missing from the lock, or a lock entry for a role that no longer exists →
**error** asking you to refresh.
Refresh the lock with:
```sh
node scripts/check.mjs --update-hashes # alias: --fix
```
This recomputes the lock from the current catalog, prunes entries for removed
roles, and prints what changed — but it **refuses to write** (exit 1) if any
role's content changed while its `index.json` version was not bumped, so the
version bump is always enforced first. The check also requires every
`index.json` role to carry a finite numeric `version` (the server requires the
same).
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
role and run `--update-hashes`, then re-add it with changed content at the same
version) is **not** caught, because a brand-new slug has no lock baseline to
enforce a bump against.

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,31 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 2 },
{ "slug": "line-editor", "version": 2 },
{ "slug": "fact-checker", "version": 2 },
{ "slug": "proofreader", "version": 3 },
{ "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,353 @@
#!/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, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
// the current catalog instead of just validating against it.
const updateHashes =
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
// the server never fetches it, so it has zero impact on the served schema.
const lockPath = join(__dirname, "content-hashes.json");
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`);
}
// Each index role must carry a finite numeric "version". The server requires
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
// below relies on it for the bump comparison, so enforce it here too.
for (const r of bundle.roles || []) {
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
errors.push(
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
);
}
}
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);
}
}
}
}
// ---------------------------------------------------------------------------
// Content-hash guard: detect "content changed without a version bump".
//
// check.mjs cannot use git history, so we maintain a lockfile
// (scripts/content-hashes.json) mapping each role slug to its recorded
// { version, hash }. On every run we recompute each role's content hash and
// compare it against the lock; a content change is only allowed once the role's
// version in index.json has been bumped and the lock refreshed.
//
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
// the role and run --update-hashes, then re-add it with changed content at the
// same version) is NOT caught, because a brand-new slug has no lock baseline to
// enforce a bump against. We document this rather than building tombstones.
// ---------------------------------------------------------------------------
// Content fields hashed for each role, in a fixed canonical order. `slug` is
// identity (not content) and `version` lives in index.json, so neither is here.
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
// EXCLUDED: no shipped role uses it today, and being an object it would need a
// deterministic deep canonicalization (recursive key sort) before hashing —
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
// change to it is still caught by the bump guard.
const CONTENT_FIELDS = [
"emoji",
"autoStart",
"name",
"description",
"instructions",
"launchMessage",
];
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
// current catalog so we can compute hashes and read index versions.
function collectCatalogRoles() {
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) continue;
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
for (const r of bundle.roles || []) {
if (!r || !r.slug) continue;
if (!out.has(r.slug)) {
out.set(r.slug, { version: r.version, langRoles: new Map() });
} else {
// Same slug declared twice in index.json roles[]; already flagged above.
out.get(r.slug).version = r.version;
}
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) continue;
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
for (const role of roles) {
if (!role || !role.slug) continue;
const entry = out.get(role.slug);
if (!entry) continue; // role not declared in index.json; flagged above.
entry.langRoles.set(lang, role);
}
}
}
return out;
}
// Deterministic content hash for a role: languages sorted ascending, each
// language's content fields taken in CONTENT_FIELDS order (null when absent).
function contentHash(langRoles) {
const langs = [...langRoles.keys()].sort();
const canonical = langs.map((lang) => {
const role = langRoles.get(lang);
const fields = {};
for (const field of CONTENT_FIELDS) {
fields[field] = role && role[field] != null ? role[field] : null;
}
return [lang, fields];
});
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
// Compute current { version, hash } for every catalog role.
const catalogRoles = collectCatalogRoles();
const current = new Map(); // slug -> { version, hash }
for (const [slug, entry] of catalogRoles) {
current.set(slug, {
version: entry.version,
hash: contentHash(entry.langRoles),
});
}
// Load the existing lock (may be absent on first run).
let lock = {};
if (existsSync(lockPath)) {
const parsed = readJson(lockPath);
if (parsed && typeof parsed === "object") lock = parsed;
}
if (updateHashes) {
// Refresh the lock from the current catalog, but refuse to write if any role's
// content changed without its version being bumped above the existing lock.
const blockers = [];
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) continue; // new role; nothing to enforce a bump against.
if (cur.hash === prev.hash) continue; // content unchanged.
// Defense-in-depth: a non-numeric version must never pass the bump check via
// `undefined <= N` (which is false). The standard checks already flag a
// missing numeric version, but guard here too before comparing.
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
blockers.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
);
} else if (cur.version <= prev.version) {
blockers.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
);
}
}
// Still honor the standard checks before allowing a write.
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
if (blockers.length > 0) {
console.error("Refusing to update content-hash lock:");
for (const b of blockers) console.error(` - ${b}`);
process.exit(1);
}
// Compute the change summary relative to the old lock, pruning removed slugs.
const newLock = {};
const added = [];
const changed = [];
const removed = [];
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
newLock[slug] = { version: cur.version, hash: cur.hash };
const prev = lock[slug];
if (!prev) added.push(slug);
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
}
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) removed.push(slug);
}
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
console.log(`Wrote ${lockPath}`);
if (added.length) console.log(` added: ${added.join(", ")}`);
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
if (!added.length && !changed.length && !removed.length) {
console.log(" (no changes; lock already up to date)");
}
console.log("OK");
process.exit(0);
}
// Normal run: validate current content against the lock.
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) {
errors.push(
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
);
continue;
}
if (cur.hash === prev.hash) {
// Content unchanged; the lock version must still agree with index.json.
if (cur.version !== prev.version) {
errors.push(
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
);
}
continue;
}
// Content changed.
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
// comparison, so a missing version can never silently pass the bump check
// (and we avoid a misleading "version bumped to undefined" message).
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
errors.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
);
} else if (cur.version <= prev.version) {
errors.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
);
} else {
errors.push(
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
);
}
}
// Lock entries for slugs that no longer exist in the catalog.
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) {
errors.push(
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
);
}
}
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

@@ -0,0 +1,26 @@
{
"fact-checker": {
"version": 2,
"hash": "d7ad1dae07d6f4321e7d40c5b36259dbf930264d748834809c4fb77294bf72e3"
},
"line-editor": {
"version": 2,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
},
"narrator": {
"version": 1,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
},
"proofreader": {
"version": 3,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 2,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
}
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)", "AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten", "Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Upgrade your plan": "Upgrade Ihres Plans", "Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -687,9 +687,6 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)", "AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search", "Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.", "Upgrade your license tier.": "Upgrade your license tier.",
@@ -1336,6 +1333,7 @@
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.", "A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens", "Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use", "This address is already in use": "This address is already in use",
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
"Move custom address?": "Move custom address?", "Move custom address?": "Move custom address?",
"Move here": "Move here", "Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?", "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
@@ -1349,5 +1347,22 @@
"Could not generate a title": "Could not generate a title", "Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled", "AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured", "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

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)", "AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA", "Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Upgrade your plan": "Mejora tu plan", "Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago", "Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.", "Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)", "AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA", "Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Upgrade your plan": "Mettez à niveau votre forfait", "Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante", "Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)", "AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI", "Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano", "Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento", "Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)", "AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え", "Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Upgrade your plan": "プランをアップグレードする", "Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能", "Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)", "AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환", "Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Upgrade your plan": "요금제를 업그레이드하세요", "Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)", "AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit", "Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Upgrade your plan": "Upgrade je abonnement", "Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie", "Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.", "Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)", "AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA", "Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano", "Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga", "Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -749,9 +749,6 @@
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)", "AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ", "Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Upgrade your plan": "Обновите свой тарифный план", "Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией", "Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.", "Upgrade your license tier.": "Обновите уровень вашей лицензии.",
@@ -1193,6 +1190,7 @@
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.", "A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов", "Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят", "This address is already in use": "Этот адрес уже занят",
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
"Move custom address?": "Переместить пользовательский адрес?", "Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда", "Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
@@ -1206,5 +1204,23 @@
"Could not generate a title": "Не удалось придумать название", "Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена", "AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "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

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)", "AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ", "Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Upgrade your plan": "Оновіть свій тарифний план", "Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією", "Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)", "AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索", "Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Upgrade your plan": "升级您的方案", "Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用", "Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。", "Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo"; import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx"; import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAtom, useSetAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
@@ -38,7 +38,9 @@ export function AppHeader() {
const toggleDesktop = useToggleSidebar(desktopSidebarAtom); const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); // Opening from the header auto-opens the document's bound chat (last chat
// created on the current page); off a page it keeps the current selection.
const openAiChat = useOpenAiChatForCurrentPage();
// AI chat entry point: only shown when the workspace enables it (A7 gate). // AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true; const aiChatEnabled = workspace?.settings?.ai?.chat === true;
@@ -105,7 +107,7 @@ export function AppHeader() {
color="dark" color="dark"
size="sm" size="sm"
aria-label={t("AI chat")} aria-label={t("AI chat")}
onClick={() => setAiChatWindowOpen((v) => !v)} onClick={openAiChat}
> >
<IconMessage size={20} /> <IconMessage size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
// Mirrors the t-mock pattern used by the other component tests in this folder
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
// every OTHER named export of markdown.ts intact via `importActual`, and override
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
// factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
// whole point of this file (it closes the parent-side coverage gap left by the
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
// harness). Use the relative import for the component under test, mirroring how
// message-list.tsx itself imports `MessageItem from "./message-item"`.
import MessageList from "./message-list";
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
//
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
renderChatMarkdownSpy.mockClear();
const { queryByText } = render(
<MantineProvider>
<MessageList
messages={[msg([{ type: "text", text: "hello world" }])]}
isStreaming={false}
/>
</MantineProvider>,
);
// The assistant text renders, which proves MessageList mounted the real
// MessageItem and handed it a valid `signature` prop (computed from the real
// `messageSignature`) — the full parent -> child -> markdown path is live.
expect(queryByText("hello world")).not.toBeNull();
});
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
// delta that REUSES the same message object. The fix moved the content signature
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
// every render and forward it as the immutable `signature` prop, so MessageItem's
// memo (which compares that prop snapshot) sees it change and re-renders the row.
//
// This test exercises the PARENT half that the memo tests only simulate: if
// MessageList ever cached/memoized the signature keyed on the message object's
// identity (which stays stable across deltas while its `parts` mutate in place),
// the snapshot would never change, MessageItem's memo would skip every delta, and
// the row would freeze at its empty mount — exactly the regression class. That
// would make this test fail. See message-item.tsx (`signature` prop +
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
// snapshot at render time).
it("reflects in-place part mutation of a reused message object across renders", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does). The empty text
// part means MessageItem renders nothing visible initially.
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Nothing streamed yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place on the SAME message object...
(message.parts[0] as { text: string }).text = "streamed answer";
// ...then re-render with a NEW array literal that still holds the SAME mutated
// message object (this mirrors useChat handing back a fresh array of reused
// message objects on each delta).
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// The grown text now renders: MessageList re-snapshotted the signature, so the
// row re-rendered instead of freezing at its empty mount.
expect(queryByText("streamed answer")).not.toBeNull();
expect(
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
});

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import type { ReactNode } from "react";
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// useMatch is the only react-router-dom export the hook uses; drive its return
// per test to simulate "on a page" vs "off a page".
const useMatchMock = vi.fn();
vi.mock("react-router-dom", () => ({
useMatch: () => useMatchMock(),
}));
// The bound-chat resolver is the network boundary; stub it per test.
const getBoundChatMock = vi.fn();
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
}));
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
// tests override useMatch to go off-page.
function onPage(pageSlug = "doc-p1") {
useMatchMock.mockReturnValue({ params: { pageSlug } });
}
function offPage() {
useMatchMock.mockReturnValue(null);
}
// Render the hook inside an explicit jotai store so atom side effects are
// assertable; the store is returned for setup + assertions.
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
const store = createStore();
seed?.(store);
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
return { store, open: () => act(() => result.current()) };
}
describe("useOpenAiChatForCurrentPage", () => {
beforeEach(() => {
vi.clearAllMocks();
onPage();
});
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
getBoundChatMock.mockResolvedValue("bound-chat-1");
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
await open();
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
});
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
getBoundChatMock.mockResolvedValue(null);
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("off a page: keeps the current selection and does NOT resolve", async () => {
offPage();
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "keep-me");
s.set(aiChatDraftAtom, "untouched");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
getBoundChatMock.mockResolvedValue("would-switch");
const { store, open } = setup((s) => {
s.set(aiChatWindowOpenAtom, true);
s.set(activeAiChatIdAtom, "current");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("current");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
getBoundChatMock.mockResolvedValue("same");
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "same");
s.set(aiChatDraftAtom, "in-progress");
});
await open();
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
getBoundChatMock.mockRejectedValue(new Error("network"));
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("clears the picked role on a real switch", async () => {
getBoundChatMock.mockResolvedValue("bound");
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
await open();
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useMatch } from "react-router-dom";
import {
aiChatWindowOpenAtom,
activeAiChatIdAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { extractPageSlugId } from "@/lib";
/**
* The generic "open the AI chat" action, WITH document binding: when invoked
* while viewing a page, it resolves that page's bound chat and selects it before
* opening — so the last chat for this document re-opens by itself. With no bound
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
* by the app-header entry point; NOT by the provenance badge (which deep-links).
*/
export function useOpenAiChatForCurrentPage() {
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
// Same route-match trick the window uses: read :pageSlug from the pathname.
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
const pageId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT
// re-resolve and yank the user to another chat: resolve only on a genuine
// closed -> open transition. (`windowOpen` is already true here, so there
// is nothing to set — just bail.)
if (windowOpen) return;
// Open the window FIRST so the control feels instant: the bound-chat
// round-trip below must never gate the window appearing, or on a slow
// connection the first click reads as a hung control until the POST returns.
setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current
if (pageId) {
try {
resolved = await getBoundChat(pageId); // null => fresh chat
} catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback
}
}
// Clear the composer draft / picked role ONLY on an actual switch, so
// reopening the same chat does not wipe an in-progress draft. Applied after
// the resolve so the window is already visible while the switch settles.
if (resolved !== activeChatId) {
setActiveChatId(resolved);
setDraft("");
setSelectedRoleId(null);
}
}, [
windowOpen,
activeChatId,
pageId,
setWindowOpen,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
}

View File

@@ -13,21 +13,40 @@ import {
deleteAiRole, deleteAiRole,
getAiChatMessages, getAiChatMessages,
getAiChats, getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles, getAiRoles,
importAiRolesFromCatalog,
renameAiChat, renameAiChat,
updateAiRole, updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts"; } from "@/features/ai-chat/services/ai-chat-service.ts";
import { import {
IAiChat, IAiChat,
IAiChatMessageRow, IAiChatMessageRow,
IAiRole, IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate, IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate, IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"]; export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"]; 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) => [ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages", "ai-chat-messages",
chatId, 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, IAiChatMessageRow,
IAiChatMessagesParams, IAiChatMessagesParams,
IAiRole, IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate, IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate, IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } from "@/features/ai-chat/types/ai-chat.types.ts";
/** /**
@@ -37,6 +42,17 @@ export async function getAiChatMessages(
return req.data; return req.data;
} }
/**
* Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page.
*/
export async function getBoundChat(pageId: string): Promise<string | null> {
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId,
});
return req.data.chatId;
}
/** Rename a chat. */ /** Rename a chat. */
export async function renameAiChat(data: { export async function renameAiChat(data: {
chatId: string; chatId: string;
@@ -112,3 +128,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
}); });
return req.data; 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; autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent. // Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null; 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; createdAt?: string;
updatedAt?: 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. */ /** Admin create payload for a role. */
export interface IAiRoleCreate { export interface IAiRoleCreate {
name: string; 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

@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false); export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on // Current page's edit mode — initialized from the user's saved preference on

View File

@@ -9,11 +9,10 @@ import {
IconStrikethrough, IconStrikethrough,
IconUnderline, IconUnderline,
IconMessage, IconMessage,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./bubble-menu.module.css"; import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector"; import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector"; import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector"; import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { userAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props; const { templateMode = false } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const editorToolbarEnabled = const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false; user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup); const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom); const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu); const showLinkMenuRef = useRef(showLinkMenu);
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => { useEffect(() => {
showLinkMenuRef.current = showLinkMenu; showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]); }, [showLinkMenu]);
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty || empty ||
isNodeSelection(selection) || isNodeSelection(selection) ||
isCellSelection(selection) || isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current || showLinkMenuRef.current ||
showCommentPopupRef?.current showCommentPopupRef?.current
) { ) {
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown // Hide the bubble menu immediately when the link menu is shown
if (showAiMenu || showLinkMenu) return; if (showLinkMenu) return;
return ( return (
<BubbleMenu <BubbleMenu
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ zIndex: 199, position: "relative" }} style={{ zIndex: 199, position: "relative" }}
> >
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && ( {!editorToolbarEnabled && (
<> <>
<NodeSelector <NodeSelector

View File

@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group"; import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group"; import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group"; import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css"; import classes from "./fixed-toolbar.module.css";
type FixedToolbarProps = { type FixedToolbarProps = {
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const editorFromAtom = useAtomValue(pageEditorAtom); const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom; const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor); const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null; if (!editor || !state) return null;
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<div className={classes.inner}> <div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} /> <BlockTypeGroup editor={editor} />
<div className={classes.divider} /> <div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} /> <InlineMarksGroup editor={editor} state={state} />

View File

@@ -1,23 +0,0 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};

View File

@@ -13,7 +13,7 @@ interface Props {
/** /**
* AI "generate title" button (#199). Reads the live editor content and applies a * AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit * model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's generative AI flag is on. * mode and when the workspace's AI chat flag is on.
*/ */
export const GenerateTitleGroup: FC<Props> = ({ export const GenerateTitleGroup: FC<Props> = ({
pageId, pageId,

View File

@@ -77,9 +77,9 @@ export function FullEditor({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true; const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation reuses the generative AI flag (same gate as the on-page // AI title generation is gated by the general AI chat flag (the same toggle
// generative menu); the server enforces it too (#199). // that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true; const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled = const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false; user.settings?.preferences?.editorToolbar ?? false;
@@ -254,7 +254,7 @@ function PageByline({
{showDictation && editor && ( {showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} /> <DictationGroup editor={editor} color="gray" iconSize={20} />
)} )}
{/* Shown only in edit mode when the workspace's generative AI flag is on, {/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */} so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && ( {showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} /> <GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />

View File

@@ -1,5 +1,6 @@
import { Button, Menu, Text } from "@mantine/core"; import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts"; import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home // A single create-note action, parametrized by `temporary`. Self-contained: it
// screen has no active space, the target space is resolved from the user's // owns its own create mutation so the regular and temporary buttons show
// writable spaces: created directly when there is one, picked from a dropdown // independent loading state, while the list of writable spaces is resolved once
// when there are several. // by the parent and passed in. With exactly one writable space it creates
export default function NewNoteButton() { // directly; with several it shows a target-space picker.
function CreateNoteButton({
writableSpaces,
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 { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => { const createNote = async (space: ISpace) => {
try { try {
// `spaceId` is accepted by the create-page endpoint but is not part of // `spaceId`/`temporary` are accepted by the create-page endpoint but are
// the shared `IPageInput` type; cast to satisfy the mutation signature. // not part of the shared `IPageInput` type; cast to satisfy the mutation
// signature.
const createdPage = await createPageMutation.mutateAsync({ const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id, spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any); } as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title)); navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch { } catch {
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
} }
}; };
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending; const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed. // Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) { if (writableSpaces.length === 1) {
return ( return (
<Button <Button
fullWidth
size="md" size="md"
variant="light" variant="light"
color="gray" color={color}
leftSection={<IconPlus size={18} />} fullWidth
leftSection={icon}
loading={isPending} loading={isPending}
onClick={() => createNote(writableSpaces[0])} onClick={() => createNote(writableSpaces[0])}
> >
{t("New note")} {label}
</Button> </Button>
); );
} }
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
<Menu shadow="md" width="target" position="bottom-start"> <Menu shadow="md" width="target" position="bottom-start">
<Menu.Target> <Menu.Target>
<Button <Button
fullWidth
size="md" size="md"
variant="light" variant="light"
color="gray" color={color}
leftSection={<IconPlus size={18} />} fullWidth
leftSection={icon}
loading={isPending} loading={isPending}
> >
{t("New note")} {label}
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
</Menu> </Menu>
); );
} }
// 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 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 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
return (
<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"
/>
</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 { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { getDefaultStore } from "jotai";
import { import {
toggleTemplate, toggleTemplate,
toggleTemporary, toggleTemporary,
@@ -9,6 +10,9 @@ import type {
ToggleTemporaryResponse, ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types"; } from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx"; 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 * 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({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string), ["sidebar-pages"].includes(item.queryKey[0] as string),

View File

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

View File

@@ -32,7 +32,7 @@ import {
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx"; 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 { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
// Check if the page already exists in the tree (it shouldn't) // Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(currentTree, restoredPage.id)) { if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend // Create the tree node data with hasChildren from backend. Routed
const nodeData: SpaceTreeNode = { // through the canonical mapper so the field copy stays in lockstep with
id: restoredPage.id, // buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
slugId: restoredPage.slugId, // 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", name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false, hasChildren: restoredPage.hasChildren || false,
children: [], });
};
// Determine the parent and index // Determine the parent and index
const parentId = restoredPage.parentPageId || null; const parentId = restoredPage.parentPageId || null;
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
slugId: data.slugId, slugId: data.slugId,
spaceId: data.spaceId, spaceId: data.spaceId,
title: data.title, 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; let queryKey: QueryKey = null;

View File

@@ -37,6 +37,7 @@ import {
} from "@/features/page-embed/queries/page-embed-query"; } from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model"; 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 { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css"; 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 currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1; const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = { // Routed through the canonical mapper so the field copy stays in lockstep
id: duplicatedPage.id, // with buildTree. The server does NOT arm a death timer on duplicate (the
slugId: duplicatedPage.slugId, // copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
name: duplicatedPage.title, // carries that null through and the duplicated node correctly shows no
position: duplicatedPage.position, // clock marker — matching the server without a reload.
spaceId: duplicatedPage.spaceId, const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true, canEdit: true,
children: [], });
};
setData((prev) => setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex), 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 type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload"; import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; 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 { IPage } from "@/features/page/types/page.types.ts";
import { import {
useCreatePageMutation, useCreatePageMutation,
@@ -139,18 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
throw new Error("Failed to create page"); throw new Error("Failed to create page");
} }
const newNode: SpaceTreeNode = { // Route through the canonical mapper so the field copy (esp.
id: createdPage.id, // `temporaryExpiresAt`, which shows the temporary-note clock marker on
slugId: createdPage.slugId, // 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: "", name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false, 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 // Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling // 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 // optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first. // the ids match exactly regardless of which path runs first.
setData((prev) => { 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); 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 // 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 // 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 // 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[] { export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {}; const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = []; const tree: SpaceTreeNode[] = [];
pages.forEach((page) => { pages.forEach((page) => {
pageMap[page.id] = { pageMap[page.id] = pageToTreeNode(page);
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: [],
};
}); });
// Defense-in-depth: a duplicate id in `pages` would push two references to the // Defense-in-depth: a duplicate id in `pages` would push two references to the

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { IShareAlias } from "@/features/share/types/share.types";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// The mutation + query hooks reach react-query/network; the availability probe
// hits the API. Stub them so the section renders in isolation and we can drive
// the exact branches (taken name -> hint, 409 -> reassign modal).
const setMutateAsync = vi.fn();
let currentAlias: IShareAlias | null = null;
let availabilityResult: {
valid: boolean;
available: boolean;
currentPageId: string | null;
} = { valid: true, available: true, currentPageId: null };
vi.mock("@/features/share/queries/share-query.ts", () => ({
useShareAliasForPageQuery: () => ({ data: currentAlias }),
useSetShareAliasMutation: () => ({
mutateAsync: setMutateAsync,
isPending: false,
}),
useRemoveShareAliasMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/features/share/services/share-service.ts", () => ({
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
}));
import ShareAliasSection from "./share-alias-section";
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
id: `alias-${alias}`,
workspaceId: "ws-1",
alias,
pageId,
creatorId: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
function renderSection(pageId = "page-Y") {
return render(
<MantineProvider>
<ShareAliasSection pageId={pageId} readOnly={false} />
</MantineProvider>,
);
}
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
beforeEach(() => {
setMutateAsync.mockReset();
currentAlias = null;
availabilityResult = { valid: true, available: true, currentPageId: null };
});
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
// Page Y already owns "bee"; the user retypes a name owned by page X.
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
// The reassign hint replaces the old dead-end red error.
await waitFor(
() =>
expect(
screen.getByText(
"This address is in use. Saving will move it to this page.",
),
).toBeDefined(),
{ timeout: 2000 },
);
// The old terminal "already in use" error must NOT be shown.
expect(screen.queryByText("This address is already in use")).toBeNull();
// Save stays enabled so the confirm-reassign flow can run.
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
// The server rejects the un-confirmed save asking the client to confirm.
setMutateAsync.mockRejectedValueOnce({
status: 409,
response: {
status: 409,
data: {
code: "ALIAS_REASSIGN_REQUIRED",
currentPageId: "page-X",
currentPageTitle: "Alias Test Page X",
},
},
});
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
timeout: 2000,
});
fireEvent.click(saveBtn);
// First save sent WITHOUT confirmReassign.
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: false }),
),
);
// The "Move custom address?" confirm modal must appear (the path forward).
await waitFor(() =>
expect(screen.getByText("Move custom address?")).toBeDefined(),
);
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
// Confirming retries WITH confirmReassign: true.
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: true }),
),
);
});
});

View File

@@ -120,8 +120,13 @@ export default function ShareAliasSection({
}; };
const showInvalid = normalized.length > 0 && !isValid; const showInvalid = normalized.length > 0 && !isValid;
const showTaken = // The typed name is already in use by ANOTHER page. This is NOT a dead end:
isValid && !unchanged && availability && !availability.available; // hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
// the "Move custom address?" confirm modal that retargets the address here.
// So surface it as an informational hint (not a terminal red error) and keep
// Save enabled, instead of looking like the address is unusable.
const reassignable =
isValid && !unchanged && !!availability && !availability.available;
// The slug prefix (e.g. "docs.example.com/l/") is static for the session. // The slug prefix (e.g. "docs.example.com/l/") is static for the session.
const prefixLabel = aliasPrefixLabel(); const prefixLabel = aliasPrefixLabel();
@@ -185,7 +190,6 @@ export default function ShareAliasSection({
fontSize: "var(--mantine-font-size-xs)", fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)", color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)", backgroundColor: "var(--mantine-color-default-hover)",
borderRight: "1px solid var(--mantine-color-default-border)",
borderTopLeftRadius: "var(--input-radius)", borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)", borderBottomLeftRadius: "var(--input-radius)",
}} }}
@@ -199,9 +203,12 @@ export default function ShareAliasSection({
error={ error={
showInvalid showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens") ? t("Use 2-60 lowercase letters, digits and hyphens")
: showTaken : undefined
? t("This address is already in use") }
: undefined description={
reassignable
? t("This address is in use. Saving will move it to this page.")
: undefined
} }
/> />

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import { Button, Stack } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
// Space-overview quick actions: create a regular note or a temporary note
// (which auto-moves to Trash after the workspace lifetime) directly in the
// current space and open it. Mirrors the sidebar's create buttons but lives on
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
// page is optimistically inserted into the sidebar tree and navigated to.
export default function SpaceCreateNoteButtons() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// `handleCreate` is read unconditionally to keep hook order stable; it is
// only invoked after the permission guard below confirms a loaded space.
const { handleCreate } = useTreeMutation(space?.id ?? "");
// Which create action is in flight: drives the per-button spinner and the
// shared disabled state so a slow create round-trip cannot be double-fired.
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
// Render nothing until the space loads, or when the user cannot manage pages.
if (!space) return null;
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
return null;
}
const createNote = (temporary: boolean) => {
if (pending) return;
setPending(temporary ? "temporary" : "regular");
// handleCreate creates the page then navigates away (unmounting this
// component); the create mutation already shows a red notification on
// failure, so swallow the rejection and just clear the pending flag.
handleCreate(null, temporary ? { temporary: true } : undefined)
.catch(() => {})
.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 (
<Stack gap="sm">
<Button
size="md"
variant="light"
color="gray"
fullWidth
leftSection={<IconPlus size={18} />}
loading={pending === "regular"}
disabled={pending !== null}
onClick={() => createNote(false)}
>
{t("New note")}
</Button>
<Button
size="md"
variant="light"
color="orange"
fullWidth
leftSection={<IconHourglass size={18} />}
loading={pending === "temporary"}
disabled={pending !== null}
onClick={() => createNote(true)}
>
{t("New temporary note")}
</Button>
</Stack>
);
}

View File

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

View File

@@ -20,7 +20,6 @@ export interface IWorkspace {
plan?: string; plan?: string;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean; aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
mcpEnabled?: boolean; mcpEnabled?: boolean;
aiChat?: boolean; aiChat?: boolean;
@@ -61,7 +60,6 @@ export interface IWorkspaceApiSettings {
export interface IWorkspaceAiSettings { export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
generative?: boolean;
mcp?: boolean; mcp?: boolean;
chat?: boolean; chat?: boolean;
dictation?: boolean; dictation?: boolean;

View File

@@ -1,5 +1,6 @@
import {Container} from "@mantine/core"; import {Container, Space} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import SpaceCreateNoteButtons from "@/features/space/components/space-create-note-buttons.tsx";
import {useParams} from "react-router-dom"; import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts"; import {getAppName} from "@/lib/config.ts";
@@ -15,7 +16,13 @@ export default function SpaceHome() {
<title>{space?.name || 'Overview'} - {getAppName()}</title> <title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet> </Helmet>
<Container size={"900"} pt="xl"> <Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>} {space && (
<>
<SpaceCreateNoteButtons/>
<Space h="md"/>
<SpaceHomeTabs/>
</>
)}
</Container> </Container>
</> </>
); );

View File

@@ -0,0 +1,44 @@
import { AiChatController } from './ai-chat.controller';
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
* the requesting user + workspace + pageId to findLatestByPage and return the
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
* (null) — no extra page-access check is needed. Exercised with hand-rolled
* mocks, no Nest graph and no DB.
*/
describe('AiChatController.boundChat', () => {
const user = { id: 'u1' } as User;
const workspace = { id: 'ws1' } as Workspace;
function makeController(chat: unknown) {
const aiChatRepo = {
findLatestByPage: jest.fn().mockResolvedValue(chat),
};
const controller = new AiChatController(
{} as never,
aiChatRepo as never,
{} as never,
{} as never,
);
return { controller, aiChatRepo };
}
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
const { controller, aiChatRepo } = makeController({
id: 'c1',
creatorId: 'u1',
});
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
expect(res).toEqual({ chatId: 'c1' });
});
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
const { controller } = makeController(undefined);
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
expect(res).toEqual({ chatId: null });
});
});

View File

@@ -30,6 +30,7 @@ import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import { AiChatService, AiChatStreamBody } from './ai-chat.service'; import { AiChatService, AiChatStreamBody } from './ai-chat.service';
import { AiTranscriptionService } from './ai-transcription.service'; import { AiTranscriptionService } from './ai-transcription.service';
import { import {
BoundChatDto,
ChatIdDto, ChatIdDto,
ExportChatDto, ExportChatDto,
GeneratePageTitleDto, GeneratePageTitleDto,
@@ -67,6 +68,28 @@ export class AiChatController {
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination); return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
} }
/**
* Resolve the chat bound to a document for the requesting user: the most-recent
* non-deleted chat created on that page (ai_chats.page_id). Returns
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
* access check needed: only the caller's OWN chats are matched, so a foreign
* pageId reveals nothing.
*/
@HttpCode(HttpStatus.OK)
@Post('bound-chat')
async boundChat(
@Body() dto: BoundChatDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<{ chatId: string | null }> {
const chat = await this.aiChatRepo.findLatestByPage(
user.id,
workspace.id,
dto.pageId,
);
return { chatId: chat?.id ?? null };
}
/** Fetch the messages of a chat (oldest first, paginated). */ /** Fetch the messages of a chat (oldest first, paginated). */
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('messages') @Post('messages')
@@ -319,8 +342,8 @@ export class AiChatController {
/** /**
* Generate a page title from supplied note content (#199). One-shot, * Generate a page title from supplied note content (#199). One-shot,
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative, * non-streaming. Gated by the AI chat flag (settings.ai.chat, the same toggle
* the same flag that gates the on-page generative AI menu); returns { title }. * that enables the chat agent); returns { title }.
* The endpoint NEVER writes the page — the client applies the title via the * The endpoint NEVER writes the page — the client applies the title via the
* existing /pages/update route (which enforces edit permission), so access * existing /pages/update route (which enforces edit permission), so access
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER. * checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
@@ -334,9 +357,9 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
): Promise<{ title: string }> { ): Promise<{ title: string }> {
const settings = (workspace.settings ?? {}) as { const settings = (workspace.settings ?? {}) as {
ai?: { generative?: boolean }; ai?: { chat?: boolean };
}; };
if (settings.ai?.generative !== true) { if (settings.ai?.chat !== true) {
throw new ForbiddenException('AI title generation is disabled'); throw new ForbiddenException('AI title generation is disabled');
} }
try { try {

View File

@@ -42,7 +42,7 @@ describe('cleanGeneratedTitle', () => {
/** /**
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must: * Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
* gate on settings.ai.generative (403 when off), delegate to the service when on, * gate on settings.ai.chat (403 when off), delegate to the service when on,
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map * rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
* any other provider/transport fault to a 503. Exercised by instantiating the * any other provider/transport fault to a 503. Exercised by instantiating the
* controller with hand-rolled mocks — no Nest graph, no DB. * controller with hand-rolled mocks — no Nest graph, no DB.
@@ -50,7 +50,7 @@ describe('cleanGeneratedTitle', () => {
describe('AiChatController.generatePageTitle', () => { describe('AiChatController.generatePageTitle', () => {
const enabledWorkspace = { const enabledWorkspace = {
id: 'ws1', id: 'ws1',
settings: { ai: { generative: true } }, settings: { ai: { chat: true } },
} as unknown as Workspace; } as unknown as Workspace;
function makeController(generate: jest.Mock) { function makeController(generate: jest.Mock) {
@@ -64,7 +64,7 @@ describe('AiChatController.generatePageTitle', () => {
return { controller, aiChatService }; return { controller, aiChatService };
} }
it('forbids when the generative AI flag is off', async () => { it('forbids when the AI chat flag is off', async () => {
const generate = jest.fn(); const generate = jest.fn();
const { controller } = makeController(generate); const { controller } = makeController(generate);
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace; const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
@@ -74,12 +74,12 @@ describe('AiChatController.generatePageTitle', () => {
expect(generate).not.toHaveBeenCalled(); expect(generate).not.toHaveBeenCalled();
}); });
it('forbids when settings.ai.generative is anything but exactly true', async () => { it('forbids when settings.ai.chat is anything but exactly true', async () => {
const generate = jest.fn(); const generate = jest.fn();
const { controller } = makeController(generate); const { controller } = makeController(generate);
const ws = { const ws = {
id: 'ws1', id: 'ws1',
settings: { ai: { generative: 'yes' } }, settings: { ai: { chat: 'yes' } },
} as unknown as Workspace; } as unknown as Workspace;
await expect( await expect(
controller.generatePageTitle({ content: 'body' }, ws), controller.generatePageTitle({ content: 'body' }, ws),

View File

@@ -37,6 +37,12 @@ export class GetChatMessagesDto {
cursor?: string; cursor?: string;
} }
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
export class BoundChatDto {
@IsString()
pageId: string;
}
/** Export a chat to Markdown (#183). `lang` localizes the few fixed /** Export a chat to Markdown (#183). `lang` localizes the few fixed
* role/tool-action labels; defaults to English server-side. */ * role/tool-action labels; defaults to English server-side. */
export class ExportChatDto { export class ExportChatDto {

View File

@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
create: jest.fn().mockResolvedValue({ id: 'r1' }), create: jest.fn().mockResolvedValue({ id: 'r1' }),
update: jest.fn().mockResolvedValue({ id: 'r1' }), update: jest.fn().mockResolvedValue({ id: 'r1' }),
remove: jest.fn().mockResolvedValue({ success: true }), 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( const controller = new AiAgentRolesController(
rolesService as never, 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)', () => { describe('list (member-reachable)', () => {
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => { it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
const { controller, rolesService } = makeController(false); const { controller, rolesService } = makeController(false);

View File

@@ -22,6 +22,12 @@ import {
CreateAgentRoleDto, CreateAgentRoleDto,
UpdateAgentRoleDto, UpdateAgentRoleDto,
} from './dto/agent-role.dto'; } 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). */ /** Path/body param for the per-role routes (update/delete). */
class AgentRoleIdDto { class AgentRoleIdDto {
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
this.assertAdmin(user, workspace); this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id); 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 { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller'; import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service'; 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 * Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
* role picker. AiAgentRoleRepo (DatabaseModule, global) and * role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit * (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
* imports. The stream-time role resolution + model override live in * EnvironmentService (EnvironmentModule, global — used by the catalog provider)
* AiChatService / AiService; this module only hosts the management API. * are resolved without explicit imports. The stream-time role resolution +
* model override live in AiChatService / AiService; this module only hosts the
* management API.
*/ */
@Module({ @Module({
controllers: [AiAgentRolesController], controllers: [AiAgentRolesController],
providers: [AiAgentRolesService], providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
}) })
export class AiAgentRolesModule {} 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 { AiAgentRolesService } from './ai-agent-roles.service';
import type { AiAgentRole } from '@docmost/db/types/entity.types'; import type { AiAgentRole } from '@docmost/db/types/entity.types';
import type { import type {
@@ -27,12 +32,22 @@ describe('AiAgentRolesService guards', () => {
enabled: true, enabled: true,
autoStart: true, autoStart: true,
launchMessage: null, launchMessage: null,
source: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
...over, ...over,
} as AiAgentRole; } 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 } = {}) { function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
const repo = { const repo = {
findById: jest.fn().mockResolvedValue(opts.existing), findById: jest.fn().mockResolvedValue(opts.existing),
@@ -41,8 +56,9 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn().mockResolvedValue(undefined), softDelete: jest.fn().mockResolvedValue(undefined),
listByWorkspace: jest.fn().mockResolvedValue([]), listByWorkspace: jest.fn().mockResolvedValue([]),
}; };
const service = new AiAgentRolesService(repo as never); const catalog = makeCatalog();
return { service, repo }; const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
} }
describe('update', () => { describe('update', () => {
@@ -163,6 +179,7 @@ describe('AiAgentRolesService guards', () => {
enabled: false, enabled: false,
autoStart: true, autoStart: true,
launchMessage: null, launchMessage: null,
source: null,
createdAt, createdAt,
updatedAt, updatedAt,
}); });
@@ -397,7 +414,7 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn(), softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(rows), 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 }; return { service, repo };
} }
@@ -461,4 +478,630 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(ConflictException); ).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 { import {
BadGatewayException,
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import {
import { AiAgentRole } from '@docmost/db/types/entity.types'; 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 { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
import { RoleModelConfig } from './role-model-config'; 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 * Full (admin) view of an agent role. There are no secret columns on this table
@@ -24,6 +36,10 @@ export interface AgentRoleView {
enabled: boolean; enabled: boolean;
autoStart: boolean; autoStart: boolean;
launchMessage: string | null; 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; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -56,7 +72,12 @@ export interface AgentRolePickerView {
*/ */
@Injectable() @Injectable()
export class AiAgentRolesService { 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 * List the workspace's roles. Admins get the full view (the settings page needs
@@ -165,6 +186,316 @@ export class AiAgentRolesService {
return { success: true }; 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 { private toView(row: AiAgentRole): AgentRoleView {
return { return {
id: row.id, id: row.id,
@@ -176,6 +507,9 @@ export class AiAgentRolesService {
enabled: row.enabled, enabled: row.enabled,
autoStart: row.autoStart, autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null, 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, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@@ -205,11 +539,7 @@ export class AiAgentRolesService {
* failures keep surfacing as 500s. * failures keep surfacing as 500s.
*/ */
function rethrowDuplicateName(err: unknown, name: string): never { function rethrowDuplicateName(err: unknown, name: string): never {
if ( if (isUniqueViolation(err)) {
err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
) {
throw new ConflictException( throw new ConflictException(
`A role named "${name}" already exists in this workspace.`, `A role named "${name}" already exists in this workspace.`,
); );
@@ -217,13 +547,120 @@ function rethrowDuplicateName(err: unknown, name: string): never {
throw err; throw err;
} }
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */ /** Whether `err` is a Postgres unique-violation (SQLSTATE 23505). */
function emptyToNull(value: string | undefined): string | null { function isUniqueViolation(err: unknown): boolean {
if (value === undefined) return null; 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(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; 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 * 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 * there is no usable override (no driver and no chatModel). The DTO's @IsIn

View File

@@ -0,0 +1,307 @@
import { BadGatewayException, BadRequestException } from '@nestjs/common';
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
/**
* Provider tests against a mocked remote source (no network). They cover the
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection,
* rejection of non-http(s) sources (local sources are gone), and — most
* importantly — the `^[a-z0-9-]+$` path-traversal guard that runs BEFORE any
* path/URL is built.
*/
describe('AiAgentRolesCatalogProvider', () => {
function makeProvider(source: string) {
const env = {
getAiAgentRolesCatalogSource: () => source,
};
return new AiAgentRolesCatalogProvider(env as never);
}
it('non-http(s) source => BadGateway (local sources removed)', async () => {
for (const source of ['', '/var/lib/agent-roles-catalog', './agent-roles-catalog']) {
const provider = makeProvider(source);
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('fetchBundle remote happy path => parses + validates', async () => {
const json = JSON.stringify({
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
},
],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
const bundle = await provider.fetchBundle('general', 'en');
expect(bundle.roles[0].slug).toBe('researcher');
});
it('fetchBundle remote malformed (role missing instructions) => BadGateway', async () => {
const json = JSON.stringify({
schemaVersion: 1,
language: 'fr',
roles: [{ slug: 'researcher', name: 'Chercheur' }],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
BadGatewayException,
);
});
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('https://catalog.example.com');
await expect(
provider.fetchBundle(value, 'en'),
).rejects.toBeInstanceOf(BadRequestException);
});
it(`rejects language="${value}" with BadRequest`, async () => {
const provider = makeProvider('https://catalog.example.com');
await expect(
provider.fetchBundle('general', value),
).rejects.toBeInstanceOf(BadRequestException);
});
}
});
});

View File

@@ -0,0 +1,311 @@
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 (EnvironmentService.getAiAgentRolesCatalogSource()) is an http(s)://
* base URL — REMOTE only; local-filesystem sources are no longer supported. The
* value is baked into the Docker image at build time (set per-branch in CI).
*
* 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 remote source. */
private async readRelative(rel: string): Promise<string> {
const source = this.environmentService
.getAiAgentRolesCatalogSource()
.trim();
if (!/^https?:\/\//i.test(source)) {
this.logger.error(
'Agent roles catalog source is not configured (expected an http(s):// base URL)',
);
throw new BadGatewayException(
'Agent roles catalog is unavailable: source is not configured',
);
}
return this.fetchRemote(source, rel);
}
/**
* 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

@@ -1,4 +1,5 @@
import { BadRequestException, ConflictException } from '@nestjs/common'; import { BadRequestException, ConflictException } from '@nestjs/common';
import { NoResultError } from 'kysely';
import { ShareAliasService } from './share-alias.service'; import { ShareAliasService } from './share-alias.service';
/** /**
@@ -7,13 +8,18 @@ import { ShareAliasService } from './share-alias.service';
* request-time readable-target resolution (which re-runs the share boundary). * request-time readable-target resolution (which re-runs the share boundary).
*/ */
describe('ShareAliasService', () => { describe('ShareAliasService', () => {
// Sentinel handed to repo calls so tests can assert they ran inside the tx.
const trx = { __trx: true };
function makeService() { function makeService() {
const shareAliasRepo = { const shareAliasRepo = {
findByAliasAndWorkspace: jest.fn(), findByAliasAndWorkspace: jest.fn(),
findByPageId: jest.fn(), findByPageId: jest.fn(),
findById: jest.fn(), findById: jest.fn(),
insert: jest.fn(), insert: jest.fn(),
updateAlias: jest.fn(),
updatePageId: jest.fn(), updatePageId: jest.fn(),
deleteOthersForPage: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
}; };
const pageRepo = { findById: jest.fn() }; const pageRepo = { findById: jest.fn() };
@@ -21,12 +27,19 @@ describe('ShareAliasService', () => {
resolveReadableSharePage: jest.fn(), resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(), isSharingAllowed: jest.fn(),
}; };
// Fake kysely db: only .transaction().execute(cb) is used by setAlias.
const db = {
transaction: jest.fn(() => ({
execute: jest.fn(async (cb: any) => cb(trx)),
})),
};
const service = new ShareAliasService( const service = new ShareAliasService(
shareAliasRepo as any, shareAliasRepo as any,
pageRepo as any, pageRepo as any,
shareService as any, shareService as any,
db as any,
); );
return { service, shareAliasRepo, pageRepo, shareService }; return { service, shareAliasRepo, pageRepo, shareService, db };
} }
describe('setAlias', () => { describe('setAlias', () => {
@@ -43,9 +56,10 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled(); expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
}); });
it('normalizes then inserts a brand-new alias', async () => { it('normalizes then inserts a brand-new alias (page has none yet)', async () => {
const { service, shareAliasRepo } = makeService(); const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined); shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue(undefined);
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' }); shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
const res = await service.setAlias({ const res = await service.setAlias({
@@ -58,17 +72,70 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith( expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'my-page', 'my-page',
'ws-1', 'ws-1',
trx,
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith(
{
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
},
trx,
);
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
// self-heal still runs, keeping just the inserted row
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
); );
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toMatchObject({ id: 'a-1' }); expect(res).toMatchObject({ id: 'a-1' });
}); });
it('is a no-op when the alias already points at the same page', async () => { it('renames the existing row in place when editing to a free name (te -> ted)', async () => {
const { service, shareAliasRepo } = makeService();
// The new slug is free...
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// ...but the page already owns an alias named `te`.
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-1',
alias: 'te',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-1',
alias: 'ted',
pageId: 'p-1',
});
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'ted',
});
// RENAME, not INSERT a second row.
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).toHaveBeenCalledWith(
'a-1',
'ted',
'ws-1',
trx,
);
// ...and any other row for the page is reaped, so `te` cannot survive.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(res).toMatchObject({ id: 'a-1', alias: 'ted' });
});
it('is a no-op when the alias already points at the same page (and self-heals)', async () => {
const { service, shareAliasRepo } = makeService(); const { service, shareAliasRepo } = makeService();
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' }; const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing); shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
@@ -82,7 +149,45 @@ describe('ShareAliasService', () => {
expect(res).toBe(existing); expect(res).toBe(existing);
expect(shareAliasRepo.insert).not.toHaveBeenCalled(); expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled(); expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
// self-heal reaps any legacy duplicate rows for the page
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
});
it('self-heals a page with pre-existing duplicate rows down to one', async () => {
const { service, shareAliasRepo } = makeService();
// Name free; the page already has a (legacy) alias row we rename.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-keep',
alias: 'old',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-keep',
alias: 'new',
pageId: 'p-1',
});
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'new',
});
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-keep',
'ws-1',
trx,
);
}); });
it('throws 409 with current target when name is taken and not confirmed', async () => { it('throws 409 with current target when name is taken and not confirmed', async () => {
@@ -134,15 +239,190 @@ describe('ShareAliasService', () => {
'a-1', 'a-1',
'p-1', 'p-1',
'ws-1', 'ws-1',
trx,
); );
// ORDER MATTERS: the target page's existing alias row(s) are reaped BEFORE
// the retarget, so the non-deferrable (workspace_id, page_id) index never
// sees two rows for the page mid-statement. There is no trailing self-heal.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledTimes(1);
const deleteOrder =
shareAliasRepo.deleteOthersForPage.mock.invocationCallOrder[0];
const updateOrder =
shareAliasRepo.updatePageId.mock.invocationCallOrder[0];
expect(deleteOrder).toBeLessThan(updateOrder);
expect(res).toMatchObject({ pageId: 'p-1' }); expect(res).toMatchObject({ pageId: 'p-1' });
}); });
it('maps a unique-violation race to 409', async () => { it('maps a unique-violation race (no constraint info) to 409 "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService(); const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined); shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '23505' }); shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, alias) index violation to "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// postgres@3.x driver exposes the index name as `constraint_name`.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_alias_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, page_id) index violation to a DISTINCT page-race outcome', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
// NOT the misleading "Alias already taken" — a separate, page-scoped code.
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
expect((err as ConflictException).getResponse()).not.toMatchObject({
message: 'Alias already taken',
});
}
});
it('reads the index name from `.constraint` when `.constraint_name` is absent', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// Fallback path for non-postgres@3.x drivers.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a concurrent-delete race in the SWAP branch to a retryable 409 (not a 200-without-alias)', async () => {
const { service, shareAliasRepo } = makeService();
// Name points at another page; reassign confirmed -> swap branch.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
// A concurrent removeAlias deleted the row between read and UPDATE, so the
// repo's executeTakeFirstOrThrow finds 0 rows and throws NoResultError.
shareAliasRepo.updatePageId.mockRejectedValue(
new NoResultError({} as any),
);
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
confirmReassign: true,
});
fail('expected ConflictException');
} catch (err) {
// Crucially NOT a resolved 200 carrying `undefined` as the alias.
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a concurrent-delete race in the RENAME branch to a retryable 409 (not a generic 400)', async () => {
const { service, shareAliasRepo } = makeService();
// New slug is free, but the page already owns an alias we rename in place.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-1',
alias: 'te',
pageId: 'p-1',
});
// The row vanished before the UPDATE; repo throws NoResultError rather
// than returning undefined (which would dereference undefined.id -> 400).
shareAliasRepo.updateAlias.mockRejectedValue(new NoResultError({} as any));
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'ted',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect(err).not.toBeInstanceOf(BadRequestException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a non-unique-violation db error to BadRequest (Failed to set alias)', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '08006' }); // connection error
await expect( await expect(
service.setAlias({ service.setAlias({
workspaceId: 'ws-1', workspaceId: 'ws-1',
@@ -150,7 +430,7 @@ describe('ShareAliasService', () => {
creatorId: 'u-1', creatorId: 'u-1',
alias: 'foo', alias: 'foo',
}), }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });

View File

@@ -9,9 +9,24 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { ShareService } from './share.service'; import { ShareService } from './share.service';
import { Page, ShareAlias } from '@docmost/db/types/entity.types'; import { Page, ShareAlias } from '@docmost/db/types/entity.types';
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util'; import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
executeTx,
isUniqueViolation,
violatedConstraint,
} from '@docmost/db/utils';
import { NoResultError } from 'kysely';
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */ /**
const PG_UNIQUE_VIOLATION = '23505'; * Unique index name from the share_aliases migrations whose violation we map to
* a DISTINCT, non-misleading outcome:
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* -> a concurrent writer already gave THIS page an alias.
* The `(workspace_id, alias)` index (the vanity NAME being taken) needs no
* constant: it is the default "Alias already taken" mapping.
*/
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
export interface ResolvedAliasTarget { export interface ResolvedAliasTarget {
share: NonNullable< share: NonNullable<
@@ -28,16 +43,30 @@ export class ShareAliasService {
private readonly shareAliasRepo: ShareAliasRepo, private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly shareService: ShareService, private readonly shareService: ShareService,
@InjectKysely() private readonly db: KyselyDB,
) {} ) {}
/** /**
* Create or retarget a vanity alias. The alias is workspace-scoped: * Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
* - no row for this name -> INSERT a new pointer * EXACTLY ONE custom address. The alias name is workspace-scoped:
* - row already points at pageId -> no-op (idempotent) * - name free, page has no alias yet -> INSERT a new pointer
* - row points elsewhere -> the "swap". Without confirmReassign we * - name free, page already has one -> RENAME that row in place (the slug
* throw 409 carrying the current target so the client can confirm; with * edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
* it we UPDATE the single row's page_id (every /l/<alias> link follows the * `/l/<old>` link survives
* 302 to the new page instantly — no stale 301 cache). * - name already points at pageId -> no-op (idempotent)
* - name points at ANOTHER page -> the "swap". Without confirmReassign
* we throw 409 carrying the current target so the client can confirm;
* with it we UPDATE the single row's page_id (every /l/<alias> link
* follows the 302 to the new page instantly — no stale cache).
*
* To keep the invariant self-healing we DELETE every other alias row still
* pointing at this page (a legacy duplicate, or the target page's own former
* alias during a swap). The whole thing runs in one transaction. Because the
* `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end
* of each statement), the swap branch DELETEs the target page's existing row
* BEFORE retargeting, so the page is never transiently carried by two rows;
* the other branches self-heal AFTER their write. Either way the page never
* ends a statement with duplicate rows.
* *
* Caller is responsible for authorizing the page (edit rights + public * Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics. * readability); this method owns only the alias-name semantics.
@@ -57,48 +86,128 @@ export class ShareAliasService {
); );
} }
const existing = await this.shareAliasRepo.findByAliasAndWorkspace( try {
alias, return await executeTx(this.db, async (trx) => {
workspaceId, const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
);
if (!existing) {
try {
return await this.shareAliasRepo.insert({
workspaceId,
alias, alias,
pageId, workspaceId,
creatorId, trx,
}); );
} catch (err: any) {
// Lost a uniqueness race: another request claimed the name first. // The name is occupied by a DIFFERENT (or dangling) target page.
if (err?.code === PG_UNIQUE_VIOLATION) { if (byName && byName.pageId !== pageId) {
throw new ConflictException({ message: 'Alias already taken' }); if (!confirmReassign) {
const currentPage = byName.pageId
? await this.pageRepo.findById(byName.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: byName.pageId,
currentPageTitle: currentPage?.title ?? null,
});
}
// Confirmed swap. ORDER MATTERS: the partial unique index on
// `(workspace_id, page_id)` is NON-deferrable, so it is checked at the
// end of EVERY statement. If we retargeted `byName` onto `pageId`
// first while `pageId` still had its OWN alias row, there would
// momentarily be two rows with this page_id -> immediate 23505 and a
// rolled-back tx (a misleading "Alias already taken"). So we FIRST drop
// the target page's existing alias row(s), THEN retarget. `byName.id`
// still points at its old page here, so excluding it via `keepId` is
// harmless; after the retarget it is the page's only row, so no
// trailing self-heal is needed.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
byName.id,
workspaceId,
trx,
);
return await this.shareAliasRepo.updatePageId(
byName.id,
pageId,
workspaceId,
trx,
);
} }
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
}
// Already points at this page -> nothing to do. // The name is FREE, or already points at THIS page. Ensure the page has
if (existing.pageId === pageId) { // a single row carrying this name: rename its current one, or insert.
return existing; const current =
} byName ??
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
// Name occupied by a different (or dangling) target: require confirmation. let row: ShareAlias;
if (!confirmReassign) { if (current) {
const currentPage = existing.pageId row =
? await this.pageRepo.findById(existing.pageId) current.alias === alias
: null; ? current // same-name no-op
throw new ConflictException({ : await this.shareAliasRepo.updateAlias(
message: 'Alias already in use', current.id,
code: 'ALIAS_REASSIGN_REQUIRED', alias,
currentPageId: existing.pageId, workspaceId,
currentPageTitle: currentPage?.title ?? null, trx,
);
} else {
row = await this.shareAliasRepo.insert(
{ workspaceId, alias, pageId, creatorId },
trx,
);
}
// Self-heal: a page keeps EXACTLY ONE custom address.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
row.id,
workspaceId,
trx,
);
return row;
}); });
} catch (err: any) {
if (
err instanceof ConflictException ||
err instanceof BadRequestException
) {
throw err;
}
// The row we read was deleted (concurrent `removeAlias`) before our UPDATE
// matched it, so `executeTakeFirstOrThrow` found no row. Surface a
// retryable conflict instead of a 200-without-alias (swap branch) or a
// generic 400 from dereferencing `undefined.id` (rename branch).
if (err instanceof NoResultError) {
this.logger.warn(
'share alias update matched no row (concurrent-delete race)',
);
throw new ConflictException({
message: 'The address changed concurrently, please retry',
code: 'ALIAS_PAGE_RACE',
});
}
// A unique index fired. Which one decides the message — always log the
// constraint so the race is diagnosable.
if (isUniqueViolation(err)) {
const constraint = violatedConstraint(err);
this.logger.warn(
`share alias unique violation on ${constraint ?? '<unknown>'}`,
);
// `(workspace_id, page_id)`: a concurrent request already gave this page
// an alias. The page still has exactly one custom address (the racing
// writer's), so this is not a user-facing name clash — surface a
// distinct, non-misleading message instead of "Alias already taken".
if (constraint === UNIQUE_PAGE_ID_INDEX) {
throw new ConflictException({
message: 'This page is being given an address by another request',
code: 'ALIAS_PAGE_RACE',
});
}
// `(workspace_id, alias)` or any other/unknown unique index: treat as
// the vanity name being claimed first.
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
} }
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
} }
/** Free a vanity name (no history kept). */ /** Free a vanity name (no history kept). */

View File

@@ -31,10 +31,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean() @IsBoolean()
aiSearch: boolean; aiSearch: boolean;
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
disablePublicSharing: boolean; disablePublicSharing: boolean;

View File

@@ -145,7 +145,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active; status = WorkspaceStatus.Active;
plan = 'standard'; plan = 'standard';
billingEmail = user.email; billingEmail = user.email;
settings = { ai: { generative: true, chat: true } }; settings = { ai: { chat: true } };
} }
// create workspace // create workspace
@@ -439,20 +439,6 @@ export class WorkspaceService {
); );
} }
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
const prev = settingsBefore?.ai?.generative ?? false;
if (prev !== updateWorkspaceDto.generativeAi) {
before.generativeAi = prev;
after.generativeAi = updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
trx,
);
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') { if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false; const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateWorkspaceDto.disablePublicSharing) { if (prev !== updateWorkspaceDto.disablePublicSharing) {
@@ -587,7 +573,6 @@ export class WorkspaceService {
delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing; delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled; delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates; delete updateWorkspaceDto.allowMemberTemplates;

View File

@@ -21,6 +21,41 @@ export interface TreeNodeSnapshot {
position: string; position: string;
spaceId: string; spaceId: string;
parentPageId: string | null; 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 { 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

@@ -0,0 +1,48 @@
import { type Kysely, sql } from 'kysely';
/**
* Enforce "a page has EXACTLY ONE custom address" at the DB level. The original
* `share_aliases` table only had a unique index on `(workspace_id, alias)`, so a
* page could accumulate several alias rows (every slug edit used to INSERT a new
* one), leaving orphan `/l/<old>` links live forever and making the share
* modal's `findByPageId` lookup nondeterministic.
*
* We first dedup any pre-existing rows (keeping the NEWEST per page — the same
* "current" choice the read path now makes), then add a PARTIAL unique index on
* `(workspace_id, page_id)`. It is partial (`WHERE page_id IS NOT NULL`) so that
* multiple DANGLING aliases (target page deleted -> `page_id` SET NULL) can
* still coexist without colliding.
*
* ⚠️ IRREVERSIBLE DATA LOSS (intended): the dedup DELETE below permanently drops
* every alias row but the newest per page. Those duplicates were live `/l/<old>`
* pointers (resolved by name via `findByAliasAndWorkspace`, not by page), so
* after this upgrade any such OLD vanity link starts returning the SPA 404. This
* is the point — it kills the orphan rows the pre-invariant bug accumulated —
* but `down()` only drops the unique index; it CANNOT restore the deleted rows.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Reap legacy duplicates: for each (workspace_id, page_id) keep only the row
// with the greatest (created_at, id) — matches ShareAliasRepo.findByPageId.
await sql`
DELETE FROM share_aliases sa
USING share_aliases keep
WHERE sa.page_id IS NOT NULL
AND sa.workspace_id = keep.workspace_id
AND sa.page_id = keep.page_id
AND (keep.created_at, keep.id) > (sa.created_at, sa.id)
`.execute(db);
await db.schema
.createIndex('share_aliases_workspace_id_page_id_unique')
.on('share_aliases')
.columns(['workspace_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('share_aliases_workspace_id_page_id_unique')
.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'; 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(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false); 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 { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils'; 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). */ /** The jsonb shape persisted in `model_config` (loosely typed for the column). */
type ModelConfigValue = Record<string, unknown> | null; type ModelConfigValue = Record<string, unknown> | null;
@@ -81,6 +81,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean; autoStart?: boolean;
// null/'' => stored as null (client default launch message). // null/'' => stored as null (client default launch message).
launchMessage?: string | null; launchMessage?: string | null;
// Catalog origin { slug, language, version } | null. null => manual role.
source?: Record<string, unknown> | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<AiAgentRole> { ): Promise<AiAgentRole> {
@@ -103,6 +105,9 @@ export class AiAgentRoleRepo {
autoStart: values.autoStart ?? true, autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null. // Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || 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() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
@@ -124,6 +129,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean; autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set. // undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null; launchMessage?: string | null;
// undefined => unchanged; null => clear; object => set.
source?: Record<string, unknown> | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<void> { ): Promise<void> {
@@ -142,6 +149,9 @@ export class AiAgentRoleRepo {
// Empty string clears to null (client default launch message). // Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage || null; set.launchMessage = patch.launchMessage || null;
} }
if (patch.source !== undefined) {
set.source = jsonbBind(patch.source);
}
await db await db
.updateTable('aiAgentRoles') .updateTable('aiAgentRoles')
.set(set) .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 * THE single form validator for the `source` jsonb column: parse the value read
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */ * 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 { function normalizeRow(row: AiAgentRole): AiAgentRole {
return { return {
...row, ...row,
modelConfig: parseModelConfig( modelConfig: parseModelConfig(
row.modelConfig, row.modelConfig,
) as AiAgentRole['modelConfig'], ) as AiAgentRole['modelConfig'],
source: parseSource(row.source) as unknown as AiAgentRole['source'],
}; };
} }

View File

@@ -0,0 +1,85 @@
import { AiChatRepo } from './ai-chat.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
* Unit test for AiChatRepo.findLatestByPage — the "bound chat" resolver behind
* #191 (auto-open the last chat created on a document). It builds the scoping
* query, so we assert the EXACT predicates/ordering the spec mandates over a
* chainable builder mock (no live DB): user + workspace + page scope, the
* deletedAt filter, newest-by-createdAt with an id tiebreaker, limit 1. A
* live-Postgres ordering test is out of scope for this pure unit test.
*/
describe('AiChatRepo.findLatestByPage', () => {
type Recorded = {
table?: string;
wheres: Array<[string, string, unknown]>;
orderBys: Array<[string, string]>;
limit?: number;
};
function makeDb(result: unknown): { db: KyselyDB; rec: Recorded } {
const rec: Recorded = { wheres: [], orderBys: [] };
const builder: Record<string, unknown> = {};
const chain = () => builder;
builder.selectAll = chain;
builder.where = (col: string, op: string, val: unknown) => {
rec.wheres.push([col, op, val]);
return builder;
};
builder.orderBy = (col: string, dir: string) => {
rec.orderBys.push([col, dir]);
return builder;
};
builder.limit = (n: number) => {
rec.limit = n;
return builder;
};
builder.executeTakeFirst = () => Promise.resolve(result);
const db = {
selectFrom: (table: string) => {
rec.table = table;
return builder;
},
} as unknown as KyselyDB;
return { db, rec };
}
it('returns the matched chat and scopes by user + workspace + page (deletedAt null)', async () => {
const chat = { id: 'c1', creatorId: 'u1', workspaceId: 'ws1', pageId: 'p1' };
const { db, rec } = makeDb(chat);
const repo = new AiChatRepo(db);
const res = await repo.findLatestByPage('u1', 'ws1', 'p1');
expect(res).toBe(chat);
expect(rec.table).toBe('aiChats');
expect(rec.wheres).toEqual(
expect.arrayContaining([
['creatorId', '=', 'u1'],
['workspaceId', '=', 'ws1'],
['pageId', '=', 'p1'],
['deletedAt', 'is', null],
]),
);
});
it('orders newest-first by createdAt then id, limit 1', async () => {
const { db, rec } = makeDb(undefined);
const repo = new AiChatRepo(db);
await repo.findLatestByPage('u1', 'ws1', 'p1');
expect(rec.orderBys).toEqual([
['createdAt', 'desc'],
['id', 'desc'],
]);
expect(rec.limit).toBe(1);
});
it('returns undefined when the page has no owned chat', async () => {
const { db } = makeDb(undefined);
const repo = new AiChatRepo(db);
await expect(repo.findLatestByPage('u1', 'ws1', 'p1')).resolves.toBeUndefined();
});
});

View File

@@ -80,6 +80,32 @@ export class AiChatRepo {
}); });
} }
/**
* The "bound chat" for a document: the requesting user's most recently
* created, non-deleted chat whose origin page is `pageId`. Auto-opened when
* the AI chat window is opened on that page. Newest-by-createdAt wins, so a
* chat created later on the same page supersedes earlier ones — exactly how
* "new chat -> becomes the bound one" falls out for free. Scoped to the user +
* workspace, so a foreign pageId can only ever match the caller's own chats.
*/
async findLatestByPage(
creatorId: string,
workspaceId: string,
pageId: string,
): Promise<AiChat | undefined> {
return this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('pageId', '=', pageId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc') // stable tiebreaker, mirrors findByCreator's cursor
.limit(1)
.executeTakeFirst();
}
async insert( async insert(
insertable: InsertableAiChat, insertable: InsertableAiChat,
trx?: KyselyTransaction, trx?: KyselyTransaction,

View File

@@ -7,7 +7,7 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely'; import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx, isUniqueViolation } from '@docmost/db/utils';
export const FavoriteType = { export const FavoriteType = {
PAGE: 'page', PAGE: 'page',
@@ -29,7 +29,8 @@ export class FavoriteRepo {
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
} catch (err: any) { } catch (err: any) {
if (err?.code === '23505') return undefined; // Idempotent favorite: a duplicate (already-favorited) is not an error.
if (isUniqueViolation(err)) return undefined;
throw err; throw err;
} }
} }

View File

@@ -16,7 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants'; 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 * 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, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id], pageIds: [result.id],
workspaceId: result.workspaceId, workspaceId: result.workspaceId,
pages: [ // Built via the shared snapshot helper so the field copy (and the
{ // death-timer deadline that shows the sidebar clock marker without a
id: result.id, // reload) can't drift from the `addTreeNode` broadcast literal.
slugId: result.slugId, pages: [toTreeNodeSnapshot(result)],
title: result.title,
icon: result.icon,
position: result.position,
spaceId: result.spaceId,
parentPageId: result.parentPageId,
},
],
}); });
return result; return result;

View File

@@ -10,16 +10,21 @@ import type { KyselyDB } from '../../types/kysely.types';
describe('ShareAliasRepo', () => { describe('ShareAliasRepo', () => {
function makeSelectRepo(result: unknown) { function makeSelectRepo(result: unknown) {
const where = jest.fn(); const where = jest.fn();
const orderBy = jest.fn();
const builder: any = { const builder: any = {
select: jest.fn(() => builder), select: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => { where: jest.fn((...args: unknown[]) => {
where(...args); where(...args);
return builder; return builder;
}), }),
orderBy: jest.fn((...args: unknown[]) => {
orderBy(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result), executeTakeFirst: jest.fn().mockResolvedValue(result),
}; };
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB; const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
return { repo: new ShareAliasRepo(db), db, where, builder }; return { repo: new ShareAliasRepo(db), db, where, orderBy, builder };
} }
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => { it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
@@ -34,11 +39,15 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1'); expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
}); });
it('findByPageId scopes by page AND workspace', async () => { it('findByPageId scopes by page AND workspace, deterministically ordered', async () => {
const { repo, where } = makeSelectRepo(undefined); const { repo, where, orderBy } = makeSelectRepo(undefined);
await repo.findByPageId('p-1', 'ws-1'); await repo.findByPageId('p-1', 'ws-1');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1'); expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1'); expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
// Explicit ORDER BY removes the nondeterministic heap order for any legacy
// duplicate rows (newest createdAt wins, id as a stable tiebreak).
expect(orderBy).toHaveBeenCalledWith('createdAt', 'desc');
expect(orderBy).toHaveBeenCalledWith('id', 'desc');
}); });
it('insert writes the provided columns and returns the row', async () => { it('insert writes the provided columns and returns the row', async () => {
@@ -85,7 +94,9 @@ describe('ShareAliasRepo', () => {
return builder; return builder;
}), }),
returning: jest.fn(() => builder), returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }), // Retarget uses executeTakeFirstOrThrow so a row reaped by a concurrent
// delete (0 rows matched) raises NoResultError instead of returning undefined.
executeTakeFirstOrThrow: jest.fn().mockResolvedValue({ id: 'a-1' }),
}; };
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB; const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db); const repo = new ShareAliasRepo(db);
@@ -99,6 +110,60 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1'); expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
}); });
it('updateAlias renames a single row scoped by id + workspace', async () => {
const set = jest.fn();
const where = jest.fn();
const builder: any = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
returning: jest.fn(() => builder),
// Rename uses executeTakeFirstOrThrow so a row reaped by a concurrent
// delete (0 rows matched) raises NoResultError instead of returning undefined.
executeTakeFirstOrThrow: jest
.fn()
.mockResolvedValue({ id: 'a-1', alias: 'ted' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
const res = await repo.updateAlias('a-1', 'ted', 'ws-1');
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
expect(set.mock.calls[0][0].alias).toBe('ted');
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
// a rename must NOT touch page_id (the page's pointer is preserved)
expect(set.mock.calls[0][0]).not.toHaveProperty('pageId');
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(res).toMatchObject({ alias: 'ted' });
});
it('deleteOthersForPage reaps every row for the page except keepId', async () => {
const where = jest.fn();
const builder: any = {
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.deleteOthersForPage('p-1', 'a-keep', 'ws-1');
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(where).toHaveBeenCalledWith('id', '!=', 'a-keep');
});
it('delete scopes by id + workspace', async () => { it('delete scopes by id + workspace', async () => {
const where = jest.fn(); const where = jest.fn();
const builder: any = { const builder: any = {

View File

@@ -41,7 +41,14 @@ export class ShareAliasRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
/** The alias currently pointing at a page (for the share modal). */ /**
* The alias currently pointing at a page (for the share modal). The service
* enforces a single alias row per page, but legacy rows (pre-invariant) may
* still exist until self-healed; the explicit ORDER BY makes the "current"
* choice DETERMINISTIC (newest wins — i.e. the most recently created address,
* which is the one the user last asked for) instead of an arbitrary Postgres
* heap order.
*/
async findByPageId( async findByPageId(
pageId: string, pageId: string,
workspaceId: string, workspaceId: string,
@@ -52,6 +59,8 @@ export class ShareAliasRepo {
.select(this.baseFields) .select(this.baseFields)
.where('pageId', '=', pageId) .where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -79,7 +88,60 @@ export class ShareAliasRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
/** Retarget an existing alias to a new page (the "swap" operation). */ /**
* Rename an existing alias row in place (the vanity-slug edit, e.g.
* `te` -> `ted`). Keeps the row's id/page_id/creator so the page's single
* alias pointer is preserved — only the human-readable name changes.
*
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
* between the service's read and this UPDATE (READ COMMITTED), the UPDATE
* matches 0 rows and kysely throws `NoResultError` rather than returning
* `undefined` for a `Promise<ShareAlias>`. The service maps that to a
* retryable conflict instead of dereferencing `undefined.id`.
*/
async updateAlias(
id: string,
alias: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.updateTable('shareAliases')
.set({ alias, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirstOrThrow();
}
/**
* Self-heal helper: drop every OTHER alias row still pointing at a page,
* keeping only `keepId`. Enforces the "exactly one custom address per page"
* invariant after a rename/retarget and reaps any legacy duplicates.
*/
async deleteOthersForPage(
pageId: string,
keepId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await dbOrTx(this.db, trx)
.deleteFrom('shareAliases')
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', keepId)
.execute();
}
/**
* Retarget an existing alias to a new page (the "swap" operation).
*
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
* between the service's read and this UPDATE, the UPDATE matches 0 rows and
* kysely throws `NoResultError` instead of returning `undefined` into the 200
* response (a "success" with no alias). The service maps that to a retryable
* conflict.
*/
async updatePageId( async updatePageId(
id: string, id: string,
pageId: string, pageId: string,
@@ -92,7 +154,7 @@ export class ShareAliasRepo {
.where('id', '=', id) .where('id', '=', id)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.returning(this.baseFields) .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirstOrThrow();
} }
async delete( async delete(

View File

@@ -246,7 +246,7 @@ export class WorkspaceRepo {
* otherwise re-serialize a `JSON.stringify`'d string, yielding a jsonb string * otherwise re-serialize a `JSON.stringify`'d string, yielding a jsonb string
* that `||` turns into an array). A `jsonb_typeof = 'object'` CASE self-heals * that `||` turns into an array). A `jsonb_typeof = 'object'` CASE self-heals
* workspaces whose `settings.ai.provider` was previously corrupted into an * workspaces whose `settings.ai.provider` was previously corrupted into an
* array/string. Sibling `settings.ai.*` keys (search / generative / chat / mcp * array/string. Sibling `settings.ai.*` keys (search / chat / mcp
* / systemPrompt) and provider fields absent from the partial are preserved via * / systemPrompt) and provider fields absent from the partial are preserved via
* jsonb `||` merge. * jsonb `||` merge.
*/ */

View File

@@ -618,6 +618,8 @@ export interface AiAgentRoles {
autoStart: Generated<boolean>; autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message. // Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null; launchMessage: string | null;
// Catalog origin of an imported role: { slug, language, version } | null. null => manually created.
source: Json | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null; 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 // A role replaces the persona layer of the system prompt (instructions) and may
// optionally override the chat model (`modelConfig`). Soft-deletable. // optionally override the chat model (`modelConfig`). Soft-deletable.
export type AiAgentRole = Selectable<AiAgentRoles>; 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 InsertableAiAgentRole = Insertable<AiAgentRoles>;
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>; export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>;

View File

@@ -0,0 +1,51 @@
import { isUniqueViolation, violatedConstraint } from './utils';
/**
* Unit tests for the driver-bound Postgres unique-violation helpers extracted
* from the share-alias service (and now shared with favorite.repo). They encode
* two `kysely-postgres-js` / `postgres@3.x` quirks: the SQLSTATE is the string
* `'23505'`, and the violated index name arrives as `constraint_name` (with
* `constraint` only a fallback for other drivers).
*/
describe('isUniqueViolation', () => {
it('is true for a 23505 error', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true);
});
it('is false for any other code', () => {
expect(isUniqueViolation({ code: '08006' })).toBe(false);
});
it('is false when there is no code / not an object', () => {
expect(isUniqueViolation({})).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
expect(isUniqueViolation(new Error('boom'))).toBe(false);
});
});
describe('violatedConstraint', () => {
it('reads the postgres@3.x `constraint_name` field', () => {
expect(
violatedConstraint({ code: '23505', constraint_name: 'idx_a' }),
).toBe('idx_a');
});
it('falls back to `constraint` when `constraint_name` is absent', () => {
expect(violatedConstraint({ code: '23505', constraint: 'idx_b' })).toBe(
'idx_b',
);
});
it('prefers `constraint_name` over `constraint` when both are present', () => {
expect(
violatedConstraint({ constraint_name: 'idx_a', constraint: 'idx_b' }),
).toBe('idx_a');
});
it('is undefined when neither field is present', () => {
expect(violatedConstraint({ code: '23505' })).toBeUndefined();
expect(violatedConstraint(null)).toBeUndefined();
expect(violatedConstraint(undefined)).toBeUndefined();
});
});

View File

@@ -33,6 +33,35 @@ export function dbOrTx(
} }
} }
/** Postgres `unique_violation` SQLSTATE — raised when a write hits a UNIQUE index. */
const PG_UNIQUE_VIOLATION = '23505';
/**
* Whether `err` is a Postgres unique-violation (SQLSTATE `23505`). THE single
* check so repos/services stop re-hardcoding the magic code.
*
* NOTE (#222): `core/ai-chat/roles/ai-agent-roles.service.ts` still carries its
* own inline `23505` check on a separate, unmerged branch; it should adopt this
* helper (and {@link violatedConstraint}) after #227 lands.
*/
export function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null | undefined)?.code === PG_UNIQUE_VIOLATION;
}
/**
* The name of the UNIQUE index/constraint a `23505` error violated, or
* undefined. The `kysely-postgres-js` / `postgres@3.x` driver surfaces it as
* `err.constraint_name` (NOT `.constraint`); `.constraint` is kept only as a
* defensive fallback for other drivers.
*/
export function violatedConstraint(err: unknown): string | undefined {
const e = err as
| { constraint_name?: string; constraint?: string }
| null
| undefined;
return e?.constraint_name ?? e?.constraint;
}
/** /**
* Bind a JS array/object as a `jsonb` column value, working around a postgres * Bind a JS array/object as a `jsonb` column value, working around a postgres
* driver double-encoding quirk. THE single implementation — repos that persist * driver double-encoding quirk. THE single implementation — repos that persist

View File

@@ -289,6 +289,18 @@ export class EnvironmentService {
// provider/model/key config now lives solely in workspace settings + // provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret). // ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getAiAgentRolesCatalogSource(): string {
// Catalog location: an http(s):// base URL the catalog is fetched from.
// The image ships a per-branch default for this baked in at build time
// (Dockerfile ARG AI_AGENT_ROLES_CATALOG_URL, set per-branch in CI), but it
// is overridable at runtime via the env var (this getter returns that
// runtime value). Local-filesystem sources are no longer supported.
// Empty/unset => the catalog is unavailable (the provider returns 502).
// This is INFRA config (where the catalog lives), not provider/model
// config, so an env var is appropriate.
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
}
getEventStoreDriver(): string { getEventStoreDriver(): string {
return this.configService return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres') .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 () => { it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => {
await service.broadcastPageDeleted({ await service.broadcastPageDeleted({
...snapshot, ...snapshot,

View File

@@ -5,6 +5,7 @@ import {
PageMovedEvent, PageMovedEvent,
TreeNodeSnapshot, TreeNodeSnapshot,
TreeUpdateSnapshot, TreeUpdateSnapshot,
toTreeNodeSnapshot,
} from '../database/listeners/page.listener'; } from '../database/listeners/page.listener';
@Injectable() @Injectable()
@@ -28,15 +29,16 @@ export class WsTreeService {
// Receivers place by `position` among already-loaded siblings, not by // Receivers place by `position` among already-loaded siblings, not by
// this absolute index (sender's loaded set differs from receivers'). // this absolute index (sender's loaded set differs from receivers').
index: 0, 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: { data: {
id: page.id, ...toTreeNodeSnapshot(page),
slugId: page.slugId,
name: page.title ?? '', name: page.title ?? '',
title: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
hasChildren: false, hasChildren: false,
children: [], children: [],
}, },

View File

@@ -0,0 +1,344 @@
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { ShareAliasService } from 'src/core/share/share-alias.service';
import * as onePerPageMigration from 'src/database/migrations/20260627T120000-share-aliases-one-per-page';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* Issue #226 (regression of #205): "a page has EXACTLY ONE custom address".
* Exercises against real Postgres:
* - the partial unique index `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* (migration 20260627T120000) — one alias per page, but dangling aliases
* (page_id NULL) may coexist;
* - the migration's dedup DELETE keeps the NEWEST row per page;
* - ShareAliasService.setAlias renames in place (te -> ted) instead of
* spawning a second row, and self-heals the page down to one alias.
*/
describe('share_aliases one-per-page invariant [integration]', () => {
let db: Kysely<any>;
let repo: ShareAliasRepo;
let service: ShareAliasService;
let wsId: string;
let spaceId: string;
// setAlias only consults pageRepo on the unconfirmed-reassign (409) path.
const pageRepo = {
findById: async (id: string) => ({ id, title: `title-${id}` }),
};
beforeAll(async () => {
db = getTestDb();
repo = new ShareAliasRepo(db as any);
service = new ShareAliasService(
repo as any,
pageRepo as any,
{} as any, // shareService — unused by setAlias
db as any,
);
wsId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, wsId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const newPage = async (): Promise<string> =>
(await createPage(db, { workspaceId: wsId, spaceId })).id;
const aliasRowsForWs = (pageId: string, workspaceId: string) =>
db
.selectFrom('shareAliases')
.select(['id', 'alias'])
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.orderBy('alias')
.execute();
const aliasRowsFor = (pageId: string) => aliasRowsForWs(pageId, wsId);
it('partial unique index rejects a second alias for the same page (23505)', async () => {
const pageId = await newPage();
await repo.insert({ workspaceId: wsId, alias: 'first', pageId });
let code: string | undefined;
try {
await repo.insert({ workspaceId: wsId, alias: 'second', pageId });
} catch (err: any) {
code = err?.code ?? err?.cause?.code;
}
expect(code).toBe('23505');
});
it('allows multiple DANGLING aliases (page_id NULL) — partial index excludes them', async () => {
const a = await repo.insert({
workspaceId: wsId,
alias: `dangling-${randomUUID().slice(0, 8)}`,
pageId: null as any,
});
const b = await repo.insert({
workspaceId: wsId,
alias: `dangling-${randomUUID().slice(0, 8)}`,
pageId: null as any,
});
expect(a.id).toBeDefined();
expect(b.id).toBeDefined();
expect(a.id).not.toBe(b.id);
});
it("migration dedup DELETE keeps the page's NEWEST alias row", async () => {
const pageId = await newPage();
// Temporarily drop the guard so we can seed the legacy duplicate shape.
await sql`DROP INDEX share_aliases_workspace_id_page_id_unique`.execute(db);
try {
const mk = async (alias: string, createdAt: string): Promise<string> => {
const id = randomUUID();
await db
.insertInto('shareAliases')
.values({ id, workspaceId: wsId, alias, pageId, createdAt })
.execute();
return id;
};
await mk('oldest', '2026-01-01T00:00:00Z');
await mk('middle', '2026-02-01T00:00:00Z');
const newest = await mk('newest', '2026-03-01T00:00:00Z');
// Exact dedup statement from the migration.
await sql`
DELETE FROM share_aliases sa
USING share_aliases keep
WHERE sa.page_id IS NOT NULL
AND sa.workspace_id = keep.workspace_id
AND sa.page_id = keep.page_id
AND (keep.created_at, keep.id) > (sa.created_at, sa.id)
`.execute(db);
const rows = await aliasRowsFor(pageId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: newest, alias: 'newest' });
} finally {
await sql`
CREATE UNIQUE INDEX share_aliases_workspace_id_page_id_unique
ON share_aliases (workspace_id, page_id)
WHERE page_id IS NOT NULL
`.execute(db);
}
});
it('migration up() dedups per page and leaves OTHER pages and workspaces untouched', async () => {
// Seed legacy duplicates for two pages in this workspace AND a page in a
// SECOND workspace, then run the real migration up() (not an inlined copy of
// its SQL) and assert it scopes the DELETE to (workspace_id, page_id).
const ws2 = (await createWorkspace(db)).id;
const space2 = (await createSpace(db, ws2)).id;
const pageA = await newPage();
const pageB = await newPage();
const pageC = (await createPage(db, { workspaceId: ws2, spaceId: space2 }))
.id;
// Drop the guard so we can seed the pre-invariant duplicate shape.
await sql`DROP INDEX share_aliases_workspace_id_page_id_unique`.execute(db);
const seed = async (
workspaceId: string,
pageId: string,
alias: string,
createdAt: string,
): Promise<string> => {
const id = randomUUID();
await db
.insertInto('shareAliases')
.values({ id, workspaceId, alias, pageId, createdAt })
.execute();
return id;
};
await seed(wsId, pageA, 'a-old', '2026-01-01T00:00:00Z');
const aNew = await seed(wsId, pageA, 'a-new', '2026-03-01T00:00:00Z');
await seed(wsId, pageB, 'b-old', '2026-01-01T00:00:00Z');
const bNew = await seed(wsId, pageB, 'b-new', '2026-03-01T00:00:00Z');
await seed(ws2, pageC, 'c-old', '2026-01-01T00:00:00Z');
const cNew = await seed(ws2, pageC, 'c-new', '2026-03-01T00:00:00Z');
// Run the migration. It dedups AND recreates the unique index.
await onePerPageMigration.up(db as any);
const aliasesOf = async (pageId: string) =>
(await aliasRowsForWs(pageId, wsId)).map((r) => r.alias);
const aRows = await aliasRowsForWs(pageA, wsId);
expect(aRows).toEqual([{ id: aNew, alias: 'a-new' }]);
const bRows = await aliasRowsForWs(pageB, wsId);
expect(bRows).toEqual([{ id: bNew, alias: 'b-new' }]);
// The other workspace's page keeps only ITS newest row, untouched by wsId.
const cRows = await aliasRowsForWs(pageC, ws2);
expect(cRows).toEqual([{ id: cNew, alias: 'c-new' }]);
expect(await aliasesOf(pageA)).toEqual(['a-new']);
});
it('setAlias renames te -> ted in place: page ends with ONE row named ted', async () => {
const pageId = await newPage();
const creatorId = null as any;
const first = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId,
alias: 'te',
});
expect(first.alias).toBe('te');
const renamed = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId,
alias: 'ted',
});
// Same row id — a RENAME, not a new insert.
expect(renamed.id).toBe(first.id);
expect(renamed.alias).toBe('ted');
const rows = await aliasRowsFor(pageId);
expect(rows).toHaveLength(1);
expect(rows[0].alias).toBe('ted'); // the stale `te` row is gone
// The modal read resolves the current (only) row deterministically.
const shown = await service.getAliasForPage(pageId, wsId);
expect(shown?.alias).toBe('ted');
});
it('setAlias inserts the first alias, then is a no-op for the same name', async () => {
const pageId = await newPage();
const inserted = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'hello',
});
const again = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'hello',
});
expect(again.id).toBe(inserted.id);
expect(await aliasRowsFor(pageId)).toHaveLength(1);
});
it('a mid-transaction error becomes BadRequestException and rolls back cleanly', async () => {
// A non-23505 failure inside the tx must surface as BadRequest AND leave NO
// partial alias state behind (the whole executeTx unit rolls back).
const pageId = await newPage();
const boom = new Error('disk on fire'); // not a unique-violation
// Wrap the real repo so the INSERT succeeds but the trailing self-heal
// throws — the row inserted earlier in the tx must not survive.
const flakyRepo = Object.create(repo);
flakyRepo.deleteOthersForPage = async () => {
throw boom;
};
const flakyService = new ShareAliasService(
flakyRepo as any,
pageRepo as any,
{} as any,
db as any,
);
await expect(
flakyService.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'rollback-me',
}),
).rejects.toBeInstanceOf(BadRequestException);
// Rolled back: neither the page nor the name has any row.
expect(await aliasRowsFor(pageId)).toHaveLength(0);
expect(
await db
.selectFrom('shareAliases')
.select('id')
.where('alias', '=', 'rollback-me')
.where('workspaceId', '=', wsId)
.execute(),
).toHaveLength(0);
});
it('cross-page collision throws 409, and confirmReassign moves the single row', async () => {
const pageA = await newPage();
const pageB = await newPage();
await service.setAlias({
workspaceId: wsId,
pageId: pageA,
creatorId: null as any,
alias: 'shared',
});
await expect(
service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared',
}),
).rejects.toBeInstanceOf(ConflictException);
const moved = await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared',
confirmReassign: true,
});
expect(moved.alias).toBe('shared');
// The name now belongs to pageB only; pageA has no alias.
expect(await aliasRowsFor(pageA)).toHaveLength(0);
const bRows = await aliasRowsFor(pageB);
expect(bRows).toHaveLength(1);
expect(bRows[0].alias).toBe('shared');
});
it('confirmReassign onto a page that ALREADY has its own alias: target ends with ONE row', async () => {
// Regression guard for the operation-order bug: A has `shared`, B has its
// OWN alias `bee`. Moving `shared` onto B must FIRST drop B's `bee` row,
// THEN retarget, or the NON-deferrable (workspace_id, page_id) index fires a
// 23505 mid-transaction (two rows momentarily carry page_id = B) and the tx
// rolls back into a misleading "Alias already taken".
// Distinct names: the workspace is shared across tests, so reuse of an
// earlier test's `shared` would trip the 409 guard before we get here.
const pageA = await newPage();
const pageB = await newPage();
await service.setAlias({
workspaceId: wsId,
pageId: pageA,
creatorId: null as any,
alias: 'shared-target',
});
await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'bee',
});
const moved = await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared-target',
confirmReassign: true,
});
expect(moved.alias).toBe('shared-target');
// B now carries exactly `shared-target` (its old `bee` is gone); A has none.
const bRows = await aliasRowsFor(pageB);
expect(bRows).toHaveLength(1);
expect(bRows[0].alias).toBe('shared-target');
expect(await aliasRowsFor(pageA)).toHaveLength(0);
});
});