@promptbook/cli 0.112.0-138 → 0.112.0-140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/apps/agents-server/README.md +9 -0
  3. package/apps/agents-server/src/app/actions.ts +18 -7
  4. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +7 -17
  5. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +395 -30
  6. package/apps/agents-server/src/app/api/agents/export/route.ts +28 -2
  7. package/apps/agents-server/src/app/api/auth/change-password/route.ts +53 -6
  8. package/apps/agents-server/src/app/api/auth/login/route.ts +17 -8
  9. package/apps/agents-server/src/app/api/chat/export/pdf/route.ts +167 -2
  10. package/apps/agents-server/src/app/api/emails/incoming/sendgrid/route.ts +24 -2
  11. package/apps/agents-server/src/app/api/metadata/import/route.ts +2 -1
  12. package/apps/agents-server/src/app/api/users/[username]/route.ts +3 -2
  13. package/apps/agents-server/src/app/api/users/route.ts +5 -4
  14. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +1 -13
  15. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +3 -19
  16. package/apps/agents-server/src/components/Homepage/AgentsListListView.tsx +6 -0
  17. package/apps/agents-server/src/components/Homepage/AgentsListViewContent.tsx +6 -0
  18. package/apps/agents-server/src/components/Homepage/SortableFolderCard.tsx +7 -1
  19. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +45 -43
  20. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +1 -3
  21. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +2 -10
  22. package/apps/agents-server/src/database/seedCoreAgents.ts +8 -7
  23. package/apps/agents-server/src/message-providers/email/sendgrid/verifySendgridInboundParseWebhook.ts +345 -0
  24. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +15 -2
  25. package/apps/agents-server/src/utils/authenticateUser.ts +110 -3
  26. package/apps/agents-server/src/utils/authenticationAttemptRateLimit.ts +504 -0
  27. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +76 -5
  28. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +32 -0
  29. package/apps/agents-server/src/utils/chatExport/sanitizeChatPdfExportHtml.ts +193 -0
  30. package/apps/agents-server/src/utils/currentUserIdentity.ts +22 -9
  31. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +5 -0
  32. package/apps/agents-server/src/utils/getUserById.ts +19 -5
  33. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +5 -0
  34. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +32 -16
  35. package/apps/agents-server/src/utils/publicUser.ts +59 -0
  36. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +17 -9
  37. package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +17 -11
  38. package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +99 -0
  39. package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +63 -0
  40. package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +0 -3
  41. package/apps/agents-server/src/utils/vpsConfiguration.ts +3 -0
  42. package/esm/index.es.js +14 -5
  43. package/esm/index.es.js.map +1 -1
  44. package/esm/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  45. package/esm/src/version.d.ts +1 -1
  46. package/package.json +1 -1
  47. package/src/book-components/Chat/Chat/createChatMessageToolCallRenderModel.ts +2 -9
  48. package/src/book-components/Chat/utils/isVisibleChatToolCall.ts +23 -0
  49. package/src/other/templates/getTemplatesPipelineCollection.ts +805 -736
  50. package/src/version.ts +2 -2
  51. package/src/versions.txt +2 -1
  52. package/umd/index.umd.js +14 -5
  53. package/umd/index.umd.js.map +1 -1
  54. package/umd/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  55. package/umd/src/version.d.ts +1 -1
  56. package/apps/agents-server/src/app/api/long-streaming/route.ts +0 -23
@@ -1,7 +1,9 @@
1
+ import { ParseError } from '@promptbook-local/core';
1
2
  import JSZip from 'jszip';
2
3
  import { $getTableName } from '../../database/$getTableName';
3
4
  import { $provideSupabaseForServer } from '../../database/$provideSupabaseForServer';
4
5
  import type { AgentsServerDatabase } from '../../database/schema';
6
+ import { buildFolderTree, collectDescendantFolderIds } from '../agentOrganization/folderTree';
5
7
  import { sanitizeBackupPathSegment } from './sanitizeBackupPathSegment';
6
8
 
7
9
  /**
@@ -96,6 +98,18 @@ export type BooksBackupZipStream = {
96
98
  readonly stream: NodeJS.ReadableStream;
97
99
  };
98
100
 
101
+ /**
102
+ * Options for appending book entries into an existing ZIP archive.
103
+ */
104
+ export type AppendBooksBackupEntriesToZipOptions = {
105
+ /**
106
+ * Optional folder id used as the export root.
107
+ *
108
+ * When provided, only agents from this folder and its descendants are exported, with paths relative to this folder.
109
+ */
110
+ readonly rootFolderId?: number;
111
+ };
112
+
99
113
  /**
100
114
  * Appends all exported books into a target ZIP folder while preserving the Agents Server folder tree.
101
115
  *
@@ -104,8 +118,13 @@ export type BooksBackupZipStream = {
104
118
  *
105
119
  * @param zip - ZIP archive being assembled.
106
120
  * @param archiveRootPath - ZIP folder path where books should be written.
121
+ * @param options - Optional folder scope for the exported books.
107
122
  */
108
- export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath: string): Promise<void> {
123
+ export async function appendBooksBackupEntriesToZip(
124
+ zip: JSZip,
125
+ archiveRootPath: string,
126
+ options: AppendBooksBackupEntriesToZipOptions = {},
127
+ ): Promise<void> {
109
128
  const supabase = $provideSupabaseForServer();
110
129
  const agentTable = await $getTableName('Agent');
111
130
  const folderTable = await $getTableName('AgentFolder');
@@ -125,8 +144,11 @@ export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath:
125
144
 
126
145
  const agents = (agentsResult.data || []) as BackupAgentRow[];
127
146
  const folders = (foldersResult.data || []) as BackupFolderRow[];
147
+ const agentsForExport =
148
+ options.rootFolderId === undefined ? agents : filterAgentsByRootFolder(agents, folders, options.rootFolderId);
128
149
  const folderSegmentById = buildFolderSegmentById(folders);
129
150
  const resolveFolderPath = createFolderPathResolver(folders, folderSegmentById);
151
+ const resolveArchiveFolderPath = createArchiveFolderPathResolver(resolveFolderPath, options.rootFolderId);
130
152
 
131
153
  // Keep the root directory explicit even when there are no agents.
132
154
  if (archiveRootPath) {
@@ -136,12 +158,12 @@ export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath:
136
158
  const relativeFilePaths = new Set<string>();
137
159
  const folderPathByAgentId = new Map<number, string>();
138
160
 
139
- for (const agent of agents) {
140
- const folderPath = resolveFolderPath(agent.folderId ?? null).join('/');
161
+ for (const agent of agentsForExport) {
162
+ const folderPath = resolveArchiveFolderPath(agent.folderId ?? null).join('/');
141
163
  folderPathByAgentId.set(agent.id, folderPath);
142
164
  }
143
165
 
144
- const sortedAgents = [...agents].sort((left, right) =>
166
+ const sortedAgents = [...agentsForExport].sort((left, right) =>
145
167
  compareAgents(
146
168
  left,
147
169
  right,
@@ -151,7 +173,7 @@ export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath:
151
173
  );
152
174
 
153
175
  for (const agent of sortedAgents) {
154
- const folderSegments = resolveFolderPath(agent.folderId ?? null);
176
+ const folderSegments = resolveArchiveFolderPath(agent.folderId ?? null);
155
177
  const initialBookFilename = createInitialBookFilename(agent);
156
178
  const relativePath = createUniqueBookRelativePath(relativeFilePaths, folderSegments, initialBookFilename, agent.id);
157
179
  zip.file(createArchivePath(archiveRootPath, relativePath), agent.agentSource || '');
@@ -169,6 +191,55 @@ function createArchivePath(archiveRootPath: string, relativePath: string): strin
169
191
  return archiveRootPath ? `${archiveRootPath}/${relativePath}` : relativePath;
170
192
  }
171
193
 
194
+ /**
195
+ * Filters agents to a selected folder subtree.
196
+ *
197
+ * @param agents - All loaded agent rows.
198
+ * @param folders - All loaded folder rows.
199
+ * @param rootFolderId - Folder id selected as the export root.
200
+ * @returns Agents assigned to the root folder or its descendant folders.
201
+ */
202
+ function filterAgentsByRootFolder(
203
+ agents: ReadonlyArray<BackupAgentRow>,
204
+ folders: ReadonlyArray<BackupFolderRow>,
205
+ rootFolderId: number,
206
+ ): BackupAgentRow[] {
207
+ const { folderById, childrenByParentId } = buildFolderTree([...folders]);
208
+ if (!folderById.has(rootFolderId)) {
209
+ throw new ParseError(`Folder \`${rootFolderId}\` was not found.`);
210
+ }
211
+
212
+ const scopedFolderIds = new Set(collectDescendantFolderIds(rootFolderId, childrenByParentId));
213
+
214
+ return agents.filter((agent) => agent.folderId !== null && scopedFolderIds.has(agent.folderId));
215
+ }
216
+
217
+ /**
218
+ * Creates a path resolver for full exports or folder-relative exports.
219
+ *
220
+ * @param resolveFolderPath - Full folder-path resolver.
221
+ * @param rootFolderId - Optional folder id selected as the export root.
222
+ * @returns Folder-path resolver for archive entry paths.
223
+ */
224
+ function createArchiveFolderPathResolver(
225
+ resolveFolderPath: (folderId: number | null) => string[],
226
+ rootFolderId: number | undefined,
227
+ ): (folderId: number | null) => string[] {
228
+ if (rootFolderId === undefined) {
229
+ return resolveFolderPath;
230
+ }
231
+
232
+ const rootFolderPath = resolveFolderPath(rootFolderId);
233
+
234
+ return (folderId: number | null): string[] => {
235
+ if (folderId === null) {
236
+ return [];
237
+ }
238
+
239
+ return resolveFolderPath(folderId).slice(rootFolderPath.length);
240
+ };
241
+ }
242
+
172
243
  /**
173
244
  * Builds a download-ready stream with all books organized by the Agents Server folder tree.
174
245
  *
@@ -1,6 +1,12 @@
1
1
  import { createServerChromiumLaunchOptions } from '@/src/tools/createServerChromiumLaunchOptions';
2
2
  import { chromium } from 'playwright';
3
3
 
4
+ /**
5
+ * URL protocols that can be allowed through the PDF renderer request
6
+ * interception because they cannot reach a server-side network destination.
7
+ */
8
+ const CHAT_EXPORT_PDF_ALLOWED_REQUEST_PROTOCOLS = new Set(['about:', 'data:']);
9
+
4
10
  /**
5
11
  * Browser viewport used while rendering standalone HTML exports to PDF.
6
12
  */
@@ -32,6 +38,16 @@ export async function renderHtmlToPdfOnServer(html: string): Promise<Buffer> {
32
38
  const page = await browser.newPage();
33
39
 
34
40
  try {
41
+ await page.route('**/*', async (route) => {
42
+ const requestUrl = route.request().url();
43
+
44
+ if (isAllowedPdfRendererRequestUrl(requestUrl)) {
45
+ await route.continue();
46
+ return;
47
+ }
48
+
49
+ await route.abort('blockedbyclient');
50
+ });
35
51
  await page.setViewportSize(CHAT_EXPORT_PDF_VIEWPORT);
36
52
  await page.emulateMedia({ media: 'print' });
37
53
  await page.setContent(html, { waitUntil: 'load' });
@@ -52,3 +68,19 @@ export async function renderHtmlToPdfOnServer(html: string): Promise<Buffer> {
52
68
  await browser.close();
53
69
  }
54
70
  }
71
+
72
+ /**
73
+ * Returns whether a Playwright request can proceed while rendering a chat export PDF.
74
+ *
75
+ * @param requestUrl - URL reported by Playwright for one intercepted request.
76
+ * @returns Whether the request cannot reach an external or internal network host.
77
+ *
78
+ * @private internal helper of `renderHtmlToPdfOnServer`
79
+ */
80
+ function isAllowedPdfRendererRequestUrl(requestUrl: string): boolean {
81
+ try {
82
+ return CHAT_EXPORT_PDF_ALLOWED_REQUEST_PROTOCOLS.has(new URL(requestUrl).protocol);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
@@ -0,0 +1,193 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import { assertSafeUrl } from '../assertSafeUrl';
3
+
4
+ /**
5
+ * URL-bearing HTML attributes that can appear in standalone chat export markup.
6
+ *
7
+ * @private internal constant of chat PDF export sanitization
8
+ */
9
+ const CHAT_PDF_EXPORT_URL_ATTRIBUTE_NAMES = new Set(['href', 'poster', 'src', 'xlink:href']);
10
+
11
+ /**
12
+ * Protocols that cannot make the PDF renderer issue server-side network requests.
13
+ *
14
+ * @private internal constant of chat PDF export sanitization
15
+ */
16
+ const CHAT_PDF_EXPORT_NON_NETWORK_PROTOCOLS = new Set(['about:', 'data:']);
17
+
18
+ /**
19
+ * Link-only protocols that are safe to keep on anchors because Playwright does not fetch them while rendering.
20
+ *
21
+ * @private internal constant of chat PDF export sanitization
22
+ */
23
+ const CHAT_PDF_EXPORT_LINK_ONLY_PROTOCOLS = new Set(['mailto:', 'tel:']);
24
+
25
+ /**
26
+ * Matches CSS `url(...)` values inside inline styles and style blocks.
27
+ *
28
+ * @private internal constant of chat PDF export sanitization
29
+ */
30
+ const CHAT_PDF_EXPORT_CSS_URL_FUNCTION_REGEX = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
31
+
32
+ /**
33
+ * Parses the generated standalone chat export HTML and removes URL attributes
34
+ * that would let Chromium reach private or unsupported destinations while
35
+ * rendering the PDF.
36
+ *
37
+ * Markdown message bodies are already sanitized by the shared `renderMarkdown`
38
+ * DOMPurify pipeline; this pass covers structured export URLs such as avatars,
39
+ * citations, attachments, and markdown image `src` attributes after HTML
40
+ * generation.
41
+ *
42
+ * @param html - Standalone chat export HTML generated by `buildChatHtml`.
43
+ * @returns HTML safe to pass into the server-side PDF renderer.
44
+ *
45
+ * @private internal utility of the Agents Server chat PDF export route
46
+ */
47
+ export function sanitizeChatPdfExportHtml(html: string): string {
48
+ const dom = new JSDOM(html);
49
+ const { document } = dom.window;
50
+
51
+ for (const element of Array.from(document.querySelectorAll('*'))) {
52
+ sanitizeChatPdfExportElementUrlAttributes(element);
53
+ sanitizeChatPdfExportStyleAttribute(element);
54
+ }
55
+
56
+ for (const styleElement of Array.from(document.querySelectorAll('style'))) {
57
+ styleElement.textContent = sanitizeChatPdfExportCssUrls(styleElement.textContent || '');
58
+ }
59
+
60
+ return dom.serialize();
61
+ }
62
+
63
+ /**
64
+ * Removes unsafe URL attributes from one element.
65
+ *
66
+ * @param element - HTML element to sanitize.
67
+ *
68
+ * @private internal helper of `sanitizeChatPdfExportHtml`
69
+ */
70
+ function sanitizeChatPdfExportElementUrlAttributes(element: Element): void {
71
+ for (const attributeName of element.getAttributeNames()) {
72
+ const normalizedAttributeName = attributeName.toLowerCase();
73
+
74
+ if (!CHAT_PDF_EXPORT_URL_ATTRIBUTE_NAMES.has(normalizedAttributeName)) {
75
+ continue;
76
+ }
77
+
78
+ const attributeValue = element.getAttribute(attributeName);
79
+
80
+ if (
81
+ attributeValue !== null &&
82
+ !isSafeChatPdfExportUrl(attributeValue, isChatPdfExportNetworkRequestAttribute(element, attributeName))
83
+ ) {
84
+ element.removeAttribute(attributeName);
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Removes unsafe CSS `url(...)` values from one inline style attribute.
91
+ *
92
+ * @param element - HTML element whose style attribute should be sanitized.
93
+ *
94
+ * @private internal helper of `sanitizeChatPdfExportHtml`
95
+ */
96
+ function sanitizeChatPdfExportStyleAttribute(element: Element): void {
97
+ const styleValue = element.getAttribute('style');
98
+
99
+ if (styleValue === null) {
100
+ return;
101
+ }
102
+
103
+ element.setAttribute('style', sanitizeChatPdfExportCssUrls(styleValue));
104
+ }
105
+
106
+ /**
107
+ * Replaces unsafe CSS `url(...)` functions with `none` so Chromium has no
108
+ * private/internal resource to request while keeping unrelated inline styles.
109
+ *
110
+ * @param cssValue - CSS text from a style attribute or style element.
111
+ * @returns CSS text without unsafe URL functions.
112
+ *
113
+ * @private internal helper of `sanitizeChatPdfExportHtml`
114
+ */
115
+ function sanitizeChatPdfExportCssUrls(cssValue: string): string {
116
+ return cssValue.replace(CHAT_PDF_EXPORT_CSS_URL_FUNCTION_REGEX, (rawMatch: string, _quote: string, url: string) => {
117
+ if (isSafeChatPdfExportUrl(url, true)) {
118
+ return rawMatch;
119
+ }
120
+
121
+ return 'none';
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Returns whether an attribute value can be kept in the PDF-rendered HTML.
127
+ *
128
+ * Absolute HTTP(S) URLs are checked through the Agents Server SSRF guard.
129
+ * Relative resource URLs are removed because server-side rendering has no
130
+ * trusted origin for them, while relative anchor links remain useful inside the
131
+ * PDF table of sources.
132
+ *
133
+ * @param rawUrl - Raw attribute or CSS URL value.
134
+ * @param isNetworkRequestAttribute - Whether Chromium could fetch this value during rendering.
135
+ * @returns Whether the URL value is safe to keep.
136
+ *
137
+ * @private internal helper of `sanitizeChatPdfExportHtml`
138
+ */
139
+ function isSafeChatPdfExportUrl(rawUrl: string, isNetworkRequestAttribute: boolean): boolean {
140
+ const trimmedUrl = rawUrl.trim();
141
+
142
+ if (trimmedUrl === '' || trimmedUrl.startsWith('#')) {
143
+ return true;
144
+ }
145
+
146
+ const absoluteUrl = trimmedUrl.startsWith('//') ? `https:${trimmedUrl}` : trimmedUrl;
147
+ let parsedUrl: URL;
148
+ try {
149
+ parsedUrl = new URL(absoluteUrl);
150
+ } catch {
151
+ return !isNetworkRequestAttribute;
152
+ }
153
+
154
+ if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
155
+ try {
156
+ assertSafeUrl(absoluteUrl);
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ if (CHAT_PDF_EXPORT_NON_NETWORK_PROTOCOLS.has(parsedUrl.protocol)) {
164
+ return true;
165
+ }
166
+
167
+ return !isNetworkRequestAttribute && CHAT_PDF_EXPORT_LINK_ONLY_PROTOCOLS.has(parsedUrl.protocol);
168
+ }
169
+
170
+ /**
171
+ * Returns whether one URL attribute can trigger a network request during PDF rendering.
172
+ *
173
+ * @param element - Element that owns the attribute.
174
+ * @param attributeName - URL-bearing attribute name.
175
+ * @returns Whether Chromium may fetch the attribute while rendering.
176
+ *
177
+ * @private internal helper of `sanitizeChatPdfExportHtml`
178
+ */
179
+ function isChatPdfExportNetworkRequestAttribute(element: Element, attributeName: string): boolean {
180
+ const normalizedAttributeName = attributeName.toLowerCase();
181
+
182
+ if (normalizedAttributeName === 'src' || normalizedAttributeName === 'poster') {
183
+ return true;
184
+ }
185
+
186
+ if (normalizedAttributeName === 'xlink:href') {
187
+ return true;
188
+ }
189
+
190
+ return normalizedAttributeName === 'href' && element.tagName.toLowerCase() !== 'a';
191
+ }
192
+
193
+ // Note: [🟢] Code for Agents Server chat PDF export sanitization should never be published into packages that could be imported into browser environment
@@ -16,15 +16,20 @@ const GENERATED_USER_PASSWORD_HASH_PLACEHOLDER = 'auto-generated-user';
16
16
  const ENV_ADMIN_PASSWORD_HASH_PLACEHOLDER = 'env-admin-managed';
17
17
 
18
18
  /**
19
- * Database row shape for the `User` table.
19
+ * User row shape needed for request identity resolution.
20
20
  */
21
- type UserRow = AgentsServerDatabase['public']['Tables']['User']['Row'];
21
+ type UserIdentityRow = Pick<AgentsServerDatabase['public']['Tables']['User']['Row'], 'id' | 'username' | 'isAdmin'>;
22
22
 
23
23
  /**
24
24
  * Database insert shape for the `User` table.
25
25
  */
26
26
  type UserInsert = AgentsServerDatabase['public']['Tables']['User']['Insert'];
27
27
 
28
+ /**
29
+ * Supabase projection for user fields needed by request identity resolution.
30
+ */
31
+ const USER_IDENTITY_SELECT_COLUMNS = 'id, username, isAdmin';
32
+
28
33
  /**
29
34
  * Identity resolved for the current request, including database user id.
30
35
  */
@@ -109,7 +114,11 @@ export async function ensureChatHistoryIdentity(): Promise<boolean> {
109
114
  /**
110
115
  * Attempts to find an existing user row by username and creates one when missing.
111
116
  */
112
- async function findOrCreateUserRow(username: string, isAdmin: boolean, passwordHash: string): Promise<UserRow> {
117
+ async function findOrCreateUserRow(
118
+ username: string,
119
+ isAdmin: boolean,
120
+ passwordHash: string,
121
+ ): Promise<UserIdentityRow> {
113
122
  const existing = await findUserRowByUsername(username);
114
123
  if (existing) {
115
124
  return existing;
@@ -154,22 +163,26 @@ function isDuplicateUsernameCreateError(error: unknown): boolean {
154
163
  /**
155
164
  * Finds one user row by its username.
156
165
  */
157
- async function findUserRowByUsername(username: string): Promise<UserRow | null> {
166
+ async function findUserRowByUsername(username: string): Promise<UserIdentityRow | null> {
158
167
  const supabase = $provideSupabaseForServer();
159
168
  const tableName = await $getTableName('User');
160
- const { data, error } = await supabase.from(tableName).select('*').eq('username', username).maybeSingle();
169
+ const { data, error } = await supabase
170
+ .from(tableName)
171
+ .select(USER_IDENTITY_SELECT_COLUMNS)
172
+ .eq('username', username)
173
+ .maybeSingle();
161
174
 
162
175
  if (error) {
163
176
  throw new Error(`Failed to resolve user "${username}": ${error.message}`);
164
177
  }
165
178
 
166
- return (data as UserRow | null) || null;
179
+ return (data as UserIdentityRow | null) || null;
167
180
  }
168
181
 
169
182
  /**
170
183
  * Inserts a new user row for the provided username.
171
184
  */
172
- async function insertUserRow(payload: UserInsert): Promise<UserRow> {
185
+ async function insertUserRow(payload: UserInsert): Promise<UserIdentityRow> {
173
186
  const supabase = $provideSupabaseForServer();
174
187
  const tableName = await $getTableName('User');
175
188
  const now = new Date().toISOString();
@@ -180,7 +193,7 @@ async function insertUserRow(payload: UserInsert): Promise<UserRow> {
180
193
  createdAt: payload.createdAt ?? now,
181
194
  updatedAt: payload.updatedAt ?? now,
182
195
  })
183
- .select('*')
196
+ .select(USER_IDENTITY_SELECT_COLUMNS)
184
197
  .maybeSingle();
185
198
 
186
199
  if (error) {
@@ -195,5 +208,5 @@ async function insertUserRow(payload: UserInsert): Promise<UserRow> {
195
208
  throw new Error(`Failed to insert user "${payload.username}".`);
196
209
  }
197
210
 
198
- return data as UserRow;
211
+ return data as UserIdentityRow;
199
212
  }
@@ -7,6 +7,7 @@ import { getUserChat } from '../userChat/getUserChat';
7
7
  import { getUserChatJobById } from '../userChat/getUserChatJobById';
8
8
  import { persistUserChatJobTerminalState } from '../userChat/persistUserChatJobTerminalState';
9
9
  import { provideUserChatJobTable } from '../userChat/provideUserChatJobTable';
10
+ import { createUserChatRunnerProgressCard } from '../userChat/createUserChatRunnerProgressCard';
10
11
  import { updateUserChatAssistantMessage } from '../userChat/updateUserChatAssistantMessage';
11
12
  import type { UserChatJobRecord } from '../userChat/UserChatJobRecord';
12
13
  import type { UserChatJobRow } from '../userChat/UserChatJobRow';
@@ -196,6 +197,10 @@ async function enqueueExternalUserChatJob(job: UserChatJobRecord): Promise<Proce
196
197
  isComplete: false,
197
198
  lifecycleState: 'running',
198
199
  lifecycleError: undefined,
200
+ progressCard: createUserChatRunnerProgressCard({
201
+ runnerKind: 'external',
202
+ phase: 'queued_for_runner',
203
+ }),
199
204
  }),
200
205
  });
201
206
 
@@ -3,21 +3,35 @@ import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForSer
3
3
  import type { AgentsServerDatabase } from '@/src/database/schema';
4
4
 
5
5
  /**
6
- * Database row shape for `User`.
6
+ * User row shape needed by durable chat execution.
7
7
  */
8
- type UserRow = AgentsServerDatabase['public']['Tables']['User']['Row'];
8
+ type UserByIdRow = Pick<
9
+ AgentsServerDatabase['public']['Tables']['User']['Row'],
10
+ 'id' | 'username' | 'isAdmin' | 'profileImageUrl'
11
+ >;
12
+
13
+ /**
14
+ * Supabase projection for user fields needed by durable chat execution.
15
+ */
16
+ const USER_BY_ID_SELECT_COLUMNS = 'id, username, isAdmin, profileImageUrl';
9
17
 
10
18
  /**
11
19
  * Loads one user row by database id.
20
+ *
21
+ * @private internal Agents Server user lookup helper
12
22
  */
13
- export async function getUserById(userId: number): Promise<UserRow | null> {
23
+ export async function getUserById(userId: number): Promise<UserByIdRow | null> {
14
24
  const supabase = $provideSupabaseForServer();
15
25
  const tableName = await $getTableName('User');
16
- const { data, error } = await supabase.from(tableName).select('*').eq('id', userId).maybeSingle();
26
+ const { data, error } = await supabase
27
+ .from(tableName)
28
+ .select(USER_BY_ID_SELECT_COLUMNS)
29
+ .eq('id', userId)
30
+ .maybeSingle();
17
31
 
18
32
  if (error) {
19
33
  throw new Error(`Failed to resolve user "${userId}": ${error.message}`);
20
34
  }
21
35
 
22
- return (data as UserRow | null) || null;
36
+ return (data as UserByIdRow | null) || null;
23
37
  }
@@ -9,6 +9,7 @@ import { getUserChat } from '../userChat/getUserChat';
9
9
  import { getUserChatJobById } from '../userChat/getUserChatJobById';
10
10
  import { persistUserChatJobTerminalState } from '../userChat/persistUserChatJobTerminalState';
11
11
  import { provideUserChatJobTable } from '../userChat/provideUserChatJobTable';
12
+ import { createUserChatRunnerProgressCard } from '../userChat/createUserChatRunnerProgressCard';
12
13
  import { updateUserChatAssistantMessage } from '../userChat/updateUserChatAssistantMessage';
13
14
  import type { UserChatJobRecord } from '../userChat/UserChatJobRecord';
14
15
  import type { UserChatJobRow } from '../userChat/UserChatJobRow';
@@ -188,6 +189,10 @@ async function enqueueLocalUserChatJob(job: UserChatJobRecord): Promise<ProcessL
188
189
  isComplete: false,
189
190
  lifecycleState: 'running',
190
191
  lifecycleError: undefined,
192
+ progressCard: createUserChatRunnerProgressCard({
193
+ runnerKind: 'local',
194
+ phase: 'queued_for_runner',
195
+ }),
191
196
  }),
192
197
  });
193
198
 
@@ -103,6 +103,16 @@ export type MetadataConfigurationImportPlan = {
103
103
  readonly defaultKeysToReset: ReadonlyArray<string>;
104
104
  };
105
105
 
106
+ /**
107
+ * Options controlling standalone metadata import behavior.
108
+ */
109
+ export type MetadataConfigurationImportPlanOptions = {
110
+ /**
111
+ * When true, built-in metadata keys missing from the import payload keep their current persisted value.
112
+ */
113
+ readonly isDefaultResetSkipped?: boolean;
114
+ };
115
+
106
116
  /**
107
117
  * Summary returned after importing metadata.
108
118
  */
@@ -152,18 +162,23 @@ export async function createMetadataConfigurationExport(): Promise<MetadataConfi
152
162
  * Imports standalone metadata JSON into the current server database.
153
163
  *
154
164
  * @param payload - Parsed JSON import payload.
165
+ * @param options - Import behavior options.
155
166
  * @returns Import summary for the UI.
156
167
  */
157
168
  export async function importMetadataConfigurationPayload(
158
169
  payload: unknown,
170
+ options: MetadataConfigurationImportPlanOptions = {},
159
171
  ): Promise<MetadataConfigurationImportSummary> {
160
- const importPlan = createMetadataConfigurationImportPlan(payload);
172
+ const importPlan = createMetadataConfigurationImportPlan(payload, options);
161
173
  const supabase = $provideSupabaseForServer();
162
174
  const tableName = await $getTableName('Metadata');
163
175
  const nowIso = new Date().toISOString();
164
176
 
165
177
  if (importPlan.defaultKeysToReset.length > 0) {
166
- const { error } = await supabase.from(tableName).delete().in('key', [...importPlan.defaultKeysToReset]);
178
+ const { error } = await supabase
179
+ .from(tableName)
180
+ .delete()
181
+ .in('key', [...importPlan.defaultKeysToReset]);
167
182
 
168
183
  if (error) {
169
184
  throw new DatabaseError(
@@ -247,9 +262,13 @@ export function createMetadataConfigurationExportFilename(serverName: string | n
247
262
  * Validates and normalizes a standalone metadata import payload.
248
263
  *
249
264
  * @param payload - Parsed JSON import payload.
265
+ * @param options - Import behavior options.
250
266
  * @returns Import plan ready for persistence.
251
267
  */
252
- export function createMetadataConfigurationImportPlan(payload: unknown): MetadataConfigurationImportPlan {
268
+ export function createMetadataConfigurationImportPlan(
269
+ payload: unknown,
270
+ options: MetadataConfigurationImportPlanOptions = {},
271
+ ): MetadataConfigurationImportPlan {
253
272
  if (!payload || typeof payload !== 'object') {
254
273
  throw new ParseError('Metadata import must be a JSON object.');
255
274
  }
@@ -268,10 +287,12 @@ export function createMetadataConfigurationImportPlan(payload: unknown): Metadat
268
287
  const rowsToUpsert = payloadRecord.metadata.map((rawMetadataEntry, index) =>
269
288
  normalizeMetadataConfigurationImportEntry(rawMetadataEntry, index, importedMetadataKeys),
270
289
  );
271
- const defaultKeysToReset = metadataDefaults
272
- .map((metadataDefinition) => metadataDefinition.key)
273
- .filter((metadataKey) => !importedMetadataKeys.has(metadataKey))
274
- .sort((leftKey, rightKey) => leftKey.localeCompare(rightKey));
290
+ const defaultKeysToReset = options.isDefaultResetSkipped
291
+ ? []
292
+ : metadataDefaults
293
+ .map((metadataDefinition) => metadataDefinition.key)
294
+ .filter((metadataKey) => !importedMetadataKeys.has(metadataKey))
295
+ .sort((leftKey, rightKey) => leftKey.localeCompare(rightKey));
275
296
 
276
297
  return {
277
298
  rowsToUpsert: rowsToUpsert.sort((leftRow, rightRow) => leftRow.key.localeCompare(rightRow.key)),
@@ -377,11 +398,7 @@ function normalizeMetadataConfigurationImportEntry(
377
398
  throw new ParseError(`Metadata entry \`${key}\` has invalid \`value\`; expected a string.`);
378
399
  }
379
400
 
380
- if (
381
- isNoteProvided &&
382
- typeof rawMetadataEntryRecord.note !== 'string' &&
383
- rawMetadataEntryRecord.note !== null
384
- ) {
401
+ if (isNoteProvided && typeof rawMetadataEntryRecord.note !== 'string' && rawMetadataEntryRecord.note !== null) {
385
402
  throw new ParseError(`Metadata entry \`${key}\` has invalid \`note\`; expected a string or null.`);
386
403
  }
387
404
 
@@ -396,9 +413,7 @@ function normalizeMetadataConfigurationImportEntry(
396
413
  }
397
414
 
398
415
  const value = isValueProvided ? (rawMetadataEntryRecord.value as string) : metadataDefinition!.value;
399
- const note = isNoteProvided
400
- ? (rawMetadataEntryRecord.note as string | null)
401
- : metadataDefinition?.note ?? null;
416
+ const note = isNoteProvided ? (rawMetadataEntryRecord.note as string | null) : metadataDefinition?.note ?? null;
402
417
  const validationError = validateMetadataValue(key, value);
403
418
 
404
419
  if (validationError) {
@@ -420,7 +435,8 @@ function normalizeMetadataConfigurationImportEntry(
420
435
  */
421
436
  function resolveServerNameFromMetadataRows(metadataRows: ReadonlyArray<MetadataConfigurationRow>): string {
422
437
  const metadataRow = metadataRows.find((candidateRow) => candidateRow.key === SERVER_NAME_METADATA_KEY);
423
- const defaultServerName = getMetadataDefinition(SERVER_NAME_METADATA_KEY)?.value ?? DEFAULT_METADATA_EXPORT_FILENAME_STEM;
438
+ const defaultServerName =
439
+ getMetadataDefinition(SERVER_NAME_METADATA_KEY)?.value ?? DEFAULT_METADATA_EXPORT_FILENAME_STEM;
424
440
 
425
441
  return metadataRow?.value.trim() || defaultServerName;
426
442
  }