diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ec2b39..0c74d97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 answer was cut off and builds on it instead of restarting; the rest of the queue still flushes normally afterward. (#198) +- **Importable multilingual agent-roles catalog.** Admins can browse a curated + catalog of agent roles, grouped into bundles and offered in several languages, + and import the ones they want into the workspace (with skip-or-rename handling + for name collisions); the same role in a different language imports as a + separate install. An imported role remembers its catalog origin and offers a + one-click update when the catalog ships a newer revision. Backed by four new + admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles), + `/catalog/bundle` (read one bundle's roles), `/import`, and + `/update-from-catalog` — and a new `source` column linking a role to its + catalog slug/language/version. The catalog source is configurable via the new + `AI_AGENT_ROLES_CATALOG_URL` env var (an `http(s)://` base URL fetches it + remotely; otherwise a local directory; empty defaults to the in-repo + `agent-roles-catalog/` folder — see `.env.example`). (#222) + ## [0.94.0] - 2026-06-26 This release makes AI chat durable and fast: assistant turns are persisted to diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx index 8eac9d6a..3d381b56 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx @@ -55,14 +55,16 @@ export default function AiAgentRolesCatalogModal({ }: 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 i18n base subtag (e.g. "ru-RU" => "ru") 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( - () => (i18n.language || "en").split("-")[0].toLowerCase(), - ); + // 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(() => baseLang); const catalogQuery = useAiRoleCatalogQuery(language || "en", opened); // On name conflict: Skip (default) or Rename to a free " (N)" name. @@ -82,9 +84,8 @@ export default function AiAgentRolesCatalogModal({ useEffect(() => { if (!languages || languages.length === 0) return; if (language && languages.includes(language)) return; - const base = (i18n.language || "en").split("-")[0].toLowerCase(); - const preferred = languages.includes(base) - ? base + const preferred = languages.includes(baseLang) + ? baseLang : languages.includes("en") ? "en" : languages[0]; diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts index c4c0468c..e676c383 100644 --- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts +++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts @@ -2,6 +2,7 @@ import { BadGatewayException, BadRequestException, ConflictException, + Logger, } from '@nestjs/common'; import { AiAgentRolesService } from './ai-agent-roles.service'; import type { AiAgentRole } from '@docmost/db/types/entity.types'; @@ -645,6 +646,42 @@ describe('AiAgentRolesService guards', () => { { slug: 'a', message: 'A role with this name already exists' }, ]); }); + + 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(); + } + }); }); describe('updateFromCatalog', () => { diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts index c33200f5..9e1a72ff 100644 --- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts +++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, Injectable, + Logger, } from '@nestjs/common'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import { AiAgentRole } from '@docmost/db/types/entity.types'; @@ -71,6 +72,8 @@ export interface AgentRolePickerView { */ @Injectable() export class AiAgentRolesService { + private readonly logger = new Logger(AiAgentRolesService.name); + constructor( private readonly repo: AiAgentRoleRepo, private readonly catalog: AiAgentRolesCatalogProvider, @@ -264,12 +267,16 @@ export class AiAgentRolesService { } /** - * Import a bundle's roles into the workspace. Roles whose `source.slug` is - * already installed are skipped (updates are a separate action). 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. + * 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, @@ -368,6 +375,15 @@ export class AiAgentRolesService { takenNames.add(name.toLowerCase()); installedKeys.add(installKey); } catch (err) { + // A unique-name race 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) }); } } diff --git a/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.spec.ts b/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.spec.ts index 23d53a78..759fe03f 100644 --- a/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.spec.ts +++ b/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.spec.ts @@ -131,18 +131,29 @@ describe('AiAgentRolesCatalogProvider (local fixtures)', () => { }); } + /** A ReadableStream whose first read rejects (e.g. a mid-body AbortError). */ + function errorStream(err: Error): ReadableStream { + return new ReadableStream({ + pull() { + throw err; + }, + cancel() {}, + }); + } + function mockResponse(opts: { ok?: boolean; status?: number; headers?: Record; body: ReadableStream | 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 () => 'unused', + text: async () => opts.text ?? 'unused', } as unknown as Response; } @@ -212,6 +223,80 @@ describe('AiAgentRolesCatalogProvider (local fixtures)', () => { 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-]+$)', () => { diff --git a/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.ts b/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.ts index 83895cc8..b19dbb38 100644 --- a/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.ts +++ b/apps/server/src/core/ai-chat/roles/catalog/ai-agent-roles-catalog.provider.ts @@ -156,15 +156,31 @@ export class AiAgentRolesCatalogProvider { throw new BadGatewayException('Agent roles catalog file is too large'); } // Bound the actual read: a missing/lying Content-Length is caught here. - if (response.body) { - return await readStreamCapped(response.body, MAX_BYTES); + // 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}`, + ); } - // 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; } finally { clearTimeout(timer); }