diff --git a/agent-roles-catalog/README.md b/agent-roles-catalog/README.md
index b3b253bb..b35d184b 100644
--- a/agent-roles-catalog/README.md
+++ b/agent-roles-catalog/README.md
@@ -26,6 +26,25 @@ Currently shipped bundles:
copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
+## How it's served
+
+The server does not bundle this data; it reads it at request time from a single
+configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
+(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of
+three sources:
+
+- **`http(s)://…`** — a REMOTE base URL. The server fetches `/index.json`
+ for the manifest and `/bundles//.json` for each opened
+ bundle file (e.g. the raw GitHub base of the catalog repo in production).
+- **any other non-empty value** — a LOCAL filesystem directory; the same
+ `index.json` / `bundles//.json` paths are read from disk.
+- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder
+ (this directory), i.e. local dev reads these files directly.
+
+In every case the layout below is what the server expects, and the fetched JSON
+is re-validated server-side (the catalog is treated as untrusted input). See
+`.env.example` for the variable and the CHANGELOG for the rollout.
+
## `index.json` schema
```jsonc
diff --git a/agent-roles-catalog/scripts/check.mjs b/agent-roles-catalog/scripts/check.mjs
index 664a0146..32c4b698 100644
--- a/agent-roles-catalog/scripts/check.mjs
+++ b/agent-roles-catalog/scripts/check.mjs
@@ -106,9 +106,8 @@ for (const bundle of bundles) {
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.
- const key = slug;
- if (slugSeen.has(key)) {
- const prev = slugSeen.get(key);
+ if (slugSeen.has(slug)) {
+ const prev = slugSeen.get(slug);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
@@ -116,7 +115,7 @@ for (const bundle of bundles) {
);
}
} else {
- slugSeen.set(key, where);
+ slugSeen.set(slug, where);
}
}
}
diff --git a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts
index 5f6162f5..37cf70f8 100644
--- a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts
+++ b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts
@@ -319,15 +319,19 @@ export function useUpdateAiRoleFromCatalogMutation() {
// 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 (result.updated) {
+ 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" and any unexpected reason.
+ // "up-to-date" (the only remaining reason).
message = t("Already up to date");
}
notifications.show({ message });
diff --git a/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx b/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx
new file mode 100644
index 00000000..85aab673
--- /dev/null
+++ b/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx
@@ -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 (
+ {children}
+ );
+ };
+}
+
+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",
+ });
+ });
+});
diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts
index a4ac23be..78b049c0 100644
--- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts
+++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts
@@ -116,14 +116,19 @@ export interface IAiRoleImportResult {
errors: { slug: string; message: string }[];
}
-/** Update-from-catalog result (mirrors `updateFromCatalog()`). */
-export interface IAiRoleUpdateFromCatalogResult {
- updated: boolean;
- fromVersion?: number;
- toVersion?: number;
- reason?: string;
- role?: IAiRole;
-}
+/**
+ * Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
+ * discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
+ * to a specific message; a successful update carries the version bump + new role.
+ * Keeping the union (not a widened `reason?: string`) lets the consumer's literal
+ * comparisons be compiler-checked.
+ */
+export type IAiRoleUpdateFromCatalogResult =
+ | {
+ updated: false;
+ reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
+ }
+ | { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
/** Admin create payload for a role. */
export interface IAiRoleCreate {
diff --git a/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts
new file mode 100644
index 00000000..5aa05cca
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts
@@ -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 {
+ 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" });
+ });
+});
diff --git a/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts
new file mode 100644
index 00000000..e28229c3
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts
@@ -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,
+ 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,
+ };
+}
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 3d381b56..9095396d 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
@@ -27,6 +27,7 @@ import {
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;
@@ -230,19 +231,14 @@ function BundlePanel({
const updateMutation = useUpdateAiRoleFromCatalogMutation();
// Compute each catalog role's install state against the current workspace
- // roles: an importable role matched by source.slug + source.language.
+ // 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) => {
- const installed = roles.find(
- (r) => r.source?.slug === role.slug && r.source?.language === language,
- );
- if (!installed) return { role, state: "import" as const };
- if ((installed.source?.version ?? 0) >= role.version) {
- return { role, state: "installed" as const, installed };
- }
- return { role, state: "update" as const, installed };
- });
+ return list.map((role) => ({
+ role,
+ ...catalogRoleInstallState(role, roles, language),
+ }));
}, [bundleQuery.data, roles, language]);
// Default-check every importable role once the bundle content arrives (unless
@@ -300,17 +296,23 @@ function BundlePanel({
{bundleQuery.data && (
- {computed.map(({ role, state, installed }) => (
+ {computed.map((entry) => (
onToggleSlug(role.slug, checked)}
- fromVersion={installed?.source?.version}
+ 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={
- state === "update" && installed
- ? () => updateMutation.mutate(installed.id)
+ entry.state === "update"
+ ? () => updateMutation.mutate(entry.installed.id)
: undefined
}
updating={updateMutation.isPending}
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 04d5698e..f278a783 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
@@ -664,9 +664,11 @@ describe('AiAgentRolesService guards', () => {
{ 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: 'ai_agent_roles_workspace_source_unique',
+ constraint_name: 'ai_agent_roles_workspace_source_unique',
});
repo.insert
.mockRejectedValueOnce(sourceRace)
@@ -714,6 +716,17 @@ describe('AiAgentRolesService guards', () => {
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', () => {
@@ -847,6 +860,22 @@ describe('AiAgentRolesService guards', () => {
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' });
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 4c1ae20f..2438e9e6 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
@@ -14,7 +14,11 @@ import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
import { RoleModelConfig } from './role-model-config';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
-import { CatalogBundleMeta } from './catalog/catalog-types';
+import {
+ CatalogBundleFile,
+ CatalogBundleMeta,
+ CatalogRole,
+} from './catalog/catalog-types';
/**
* Full (admin) view of an agent role. There are no secret columns on this table
@@ -217,6 +221,30 @@ export class AiAgentRolesService {
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;
+ }> {
+ 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
@@ -239,13 +267,7 @@ export class AiAgentRolesService {
version: 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);
- const versions = versionMap(meta);
+ const { file, versions } = await this.loadBundleById(bundleId, language);
return {
bundleId,
language,
@@ -284,13 +306,10 @@ export class AiAgentRolesService {
renamed: number;
errors: { slug: string; message: string }[];
}> {
- const index = await this.catalog.fetchIndex();
- const meta = index.bundles.find((b) => b.id === dto.bundleId);
- if (!meta) {
- throw new BadGatewayException('Catalog bundle not found');
- }
- const file = await this.catalog.fetchBundle(dto.bundleId, dto.language);
- const versions = versionMap(meta);
+ const { file, versions } = await this.loadBundleById(
+ dto.bundleId,
+ dto.language,
+ );
const errors: { slug: string; message: string }[] = [];
@@ -355,15 +374,8 @@ export class AiAgentRolesService {
workspaceId,
creatorId,
name,
- emoji: emptyToNull(role.emoji),
- description: emptyToNull(role.description),
- instructions: role.instructions,
- modelConfig: normalizeModelConfig(role.modelConfig) as
- | Record
- | null,
+ ...catalogRoleContentFields(role),
enabled: true,
- autoStart: role.autoStart ?? true,
- launchMessage: emptyToNull(role.launchMessage ?? undefined),
source: { slug: role.slug, language: dto.language, version },
});
created++;
@@ -465,14 +477,7 @@ export class AiAgentRolesService {
await this.repo.update(dto.id, workspaceId, {
name,
- emoji: emptyToNull(fresh.emoji),
- description: emptyToNull(fresh.description),
- instructions: fresh.instructions,
- modelConfig: normalizeModelConfig(fresh.modelConfig) as
- | Record
- | null,
- autoStart: fresh.autoStart ?? true,
- launchMessage: emptyToNull(fresh.launchMessage ?? undefined),
+ ...catalogRoleContentFields(fresh),
// enabled is deliberately NOT changed.
source: {
slug: source.slug,
@@ -562,19 +567,49 @@ 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). Postgres sets `constraint` to the violated
- * index name on a unique violation; we key off that so 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.
+ * (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 (
- isUniqueViolation(err) &&
- (err as { constraint?: unknown }).constraint === SOURCE_UNIQUE_CONSTRAINT
+ 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 | null;
+ autoStart: boolean;
+ launchMessage: string | null;
+} {
+ return {
+ emoji: emptyToNull(role.emoji),
+ description: emptyToNull(role.description),
+ instructions: role.instructions,
+ modelConfig: normalizeModelConfig(role.modelConfig) as
+ | Record
+ | 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;