@promptbook/cli 0.112.0-110 → 0.112.0-112

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 (44) hide show
  1. package/apps/agents-server/src/app/admin/servers/CreateServerDialog.tsx +16 -0
  2. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +31 -5
  3. package/apps/agents-server/src/app/agents/[agentName]/_utils.ts +7 -5
  4. package/apps/agents-server/src/app/agents/[agentName]/api/user-chats/[chatId]/stream/route.ts +4 -86
  5. package/apps/agents-server/src/app/api/admin/servers/route.ts +5 -0
  6. package/apps/agents-server/src/app/api/metadata/route.ts +4 -0
  7. package/apps/agents-server/src/components/ApplicationErrorPage/ApplicationErrorPage.tsx +118 -12
  8. package/apps/agents-server/src/database/customJavascript.ts +62 -1
  9. package/apps/agents-server/src/database/customStylesheet.ts +60 -1
  10. package/apps/agents-server/src/database/getMetadata.ts +84 -3
  11. package/apps/agents-server/src/instrumentation.ts +3 -0
  12. package/apps/agents-server/src/utils/agentRouting/resolveAgentRouteTarget.ts +27 -62
  13. package/apps/agents-server/src/utils/errorReporting/applicationErrorHandling.ts +45 -0
  14. package/apps/agents-server/src/utils/errorReporting/refreshApplicationDocument.ts +10 -0
  15. package/apps/agents-server/src/utils/errorReporting/registerServerErrorSentryLogging.ts +331 -0
  16. package/apps/agents-server/src/utils/errorReporting/sendApplicationErrorReportToSentry.ts +8 -153
  17. package/apps/agents-server/src/utils/errorReporting/sentryStore.ts +177 -0
  18. package/apps/agents-server/src/utils/importAgent.ts +1 -57
  19. package/apps/agents-server/src/utils/importAgentWithFallback.ts +0 -10
  20. package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +3 -1
  21. package/apps/agents-server/src/utils/serverManagement/createManagedServer/normalizeCreateServerInput.ts +6 -0
  22. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerDefaultAgents.ts +7 -3
  23. package/apps/agents-server/src/utils/serverManagement/createManagedServer.ts +5 -0
  24. package/apps/agents-server/src/utils/userChat/listUserChats.ts +109 -0
  25. package/apps/agents-server/src/utils/userChat.ts +0 -1
  26. package/esm/index.es.js +39 -13
  27. package/esm/index.es.js.map +1 -1
  28. package/esm/src/cli/cli-commands/agents-server/startAgentsServer.d.ts +2 -1
  29. package/esm/src/cli/cli-commands/agents-server/startAgentsServer.test.d.ts +1 -0
  30. package/esm/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase.d.ts +22 -0
  31. package/esm/src/version.d.ts +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/cli-commands/agents-server/startAgentsServer.ts +23 -2
  34. package/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase.ts +91 -14
  35. package/src/other/templates/getTemplatesPipelineCollection.ts +801 -652
  36. package/src/version.ts +2 -2
  37. package/src/versions.txt +2 -0
  38. package/umd/index.umd.js +39 -13
  39. package/umd/index.umd.js.map +1 -1
  40. package/umd/src/cli/cli-commands/agents-server/startAgentsServer.d.ts +2 -1
  41. package/umd/src/cli/cli-commands/agents-server/startAgentsServer.test.d.ts +1 -0
  42. package/umd/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase.d.ts +22 -0
  43. package/umd/src/version.d.ts +1 -1
  44. package/apps/agents-server/src/utils/userChat/getUserChatRevision.ts +0 -145
@@ -203,6 +203,22 @@ function CreateServerForm(props: {
203
203
  ) : null}
204
204
  </div>
205
205
 
206
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
207
+ <label htmlFor="create-server-default-agents" className="flex items-start gap-3">
208
+ <input
209
+ id="create-server-default-agents"
210
+ type="checkbox"
211
+ checked={wizardState.isDefaultAgentsInstalled}
212
+ onChange={(event) => updateWizardField('isDefaultAgentsInstalled', event.target.checked)}
213
+ className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
214
+ />
215
+ <span>
216
+ <span className="block font-semibold text-gray-900">Install default agents</span>
217
+ <span className="mt-1 block">Create bundled starter agents from agents/default.</span>
218
+ </span>
219
+ </label>
220
+ </div>
221
+
206
222
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
207
223
  <p className="font-semibold text-gray-900">Admin user exists</p>
208
224
  <p className="mt-1">
@@ -121,6 +121,11 @@ export type CreateServerWizardState = {
121
121
  */
122
122
  additionalUsers: WizardUser[];
123
123
 
124
+ /**
125
+ * Whether bundled default agents should be installed during server creation.
126
+ */
127
+ isDefaultAgentsInstalled: boolean;
128
+
124
129
  /**
125
130
  * Initial metadata values.
126
131
  */
@@ -155,7 +160,10 @@ export type CreateServerWizardError = {
155
160
  * @private function of <ServersClient/>
156
161
  */
157
162
  export type UpdateCreateServerWizardField = <
158
- TFieldName extends keyof Pick<CreateServerWizardState, 'name' | 'domain' | 'iconUrl'>,
163
+ TFieldName extends keyof Pick<
164
+ CreateServerWizardState,
165
+ 'name' | 'domain' | 'iconUrl' | 'isDefaultAgentsInstalled'
166
+ >,
159
167
  >(
160
168
  fieldName: TFieldName,
161
169
  value: CreateServerWizardState[TFieldName],
@@ -262,6 +270,7 @@ function createInitialWizardState(): CreateServerWizardState {
262
270
  password: '',
263
271
  },
264
272
  additionalUsers: [],
273
+ isDefaultAgentsInstalled: true,
265
274
  initialSettings: {
266
275
  language: 'en',
267
276
  homepageMessage: '',
@@ -345,7 +354,8 @@ function hasCreateServerWizardChanges(wizardState: CreateServerWizardState): boo
345
354
  wizardState.name !== initialWizardState.name ||
346
355
  wizardState.identifier !== initialWizardState.identifier ||
347
356
  wizardState.domain !== initialWizardState.domain ||
348
- wizardState.iconUrl !== initialWizardState.iconUrl
357
+ wizardState.iconUrl !== initialWizardState.iconUrl ||
358
+ wizardState.isDefaultAgentsInstalled !== initialWizardState.isDefaultAgentsInstalled
349
359
  );
350
360
  }
351
361
 
@@ -402,16 +412,32 @@ export function useCreateServerWizard(options: UseCreateServerWizardOptions): Us
402
412
  const updateWizardField = useCallback<UpdateCreateServerWizardField>((fieldName, value) => {
403
413
  setWizardState((previous) => {
404
414
  if (fieldName === 'name') {
415
+ const name = value as CreateServerWizardState['name'];
416
+
417
+ return {
418
+ ...previous,
419
+ name,
420
+ identifier: createServerIdentifierFromName(name),
421
+ };
422
+ }
423
+
424
+ if (fieldName === 'domain') {
425
+ return {
426
+ ...previous,
427
+ domain: value as CreateServerWizardState['domain'],
428
+ };
429
+ }
430
+
431
+ if (fieldName === 'iconUrl') {
405
432
  return {
406
433
  ...previous,
407
- name: value,
408
- identifier: createServerIdentifierFromName(value),
434
+ iconUrl: value as CreateServerWizardState['iconUrl'],
409
435
  };
410
436
  }
411
437
 
412
438
  return {
413
439
  ...previous,
414
- [fieldName]: value,
440
+ isDefaultAgentsInstalled: value as CreateServerWizardState['isDefaultAgentsInstalled'],
415
441
  };
416
442
  });
417
443
  }, []);
@@ -5,7 +5,7 @@ import { $provideAgentReferenceResolver } from '@/src/utils/agentReferenceResolv
5
5
  import {
6
6
  parseBookScopedAgentIdentifier,
7
7
  } from '@/src/utils/agentReferenceResolver/bookScopedAgentReferences';
8
- import { resolveServerAgentContext } from '@/src/utils/resolveServerAgentContext';
8
+ import { resolveCachedServerAgentContext } from '@/src/utils/cachedServerAgentRuntime';
9
9
  import { cache } from 'react';
10
10
  import type { AgentsServerDatabase } from '../../../database/schema';
11
11
  import { $provideSupabaseForServer } from '../../../database/$provideSupabaseForServer';
@@ -91,10 +91,12 @@ export async function enforceCanonicalLocalAgentId(
91
91
  * @returns Resolved agent profile with visibility.
92
92
  */
93
93
  const getCachedAgentProfile = cache(async (agentName: string) => {
94
- const collection = await $provideAgentCollectionForServer();
95
- const { publicUrl } = await $provideServer();
96
- const baseAgentReferenceResolver = await $provideAgentReferenceResolver();
97
- const resolvedAgentContext = await resolveServerAgentContext({
94
+ const [collection, { publicUrl }, baseAgentReferenceResolver] = await Promise.all([
95
+ $provideAgentCollectionForServer(),
96
+ $provideServer(),
97
+ $provideAgentReferenceResolver(),
98
+ ]);
99
+ const resolvedAgentContext = await resolveCachedServerAgentContext({
98
100
  collection,
99
101
  agentIdentifier: agentName,
100
102
  localServerUrl: publicUrl.href,
@@ -1,11 +1,6 @@
1
1
  import { CHAT_STREAM_KEEP_ALIVE_INTERVAL_MS } from '@/src/constants/streaming';
2
2
  import { isPrivateModeEnabledFromRequest } from '@/src/utils/privateMode';
3
- import {
4
- createUserChatDetailPayload,
5
- getUserChat,
6
- getUserChatRevision,
7
- isFrozenUserChatSource,
8
- } from '@/src/utils/userChat';
3
+ import { createUserChatDetailPayload, getUserChat, isFrozenUserChatSource } from '@/src/utils/userChat';
9
4
  import type { ChatMessage } from '@promptbook-local/types';
10
5
  import { NextResponse } from 'next/server';
11
6
  import { resolveUserChatScope } from '../../resolveUserChatScope';
@@ -37,11 +32,6 @@ type UserChatStreamFrame =
37
32
  type: 'keepalive';
38
33
  };
39
34
 
40
- /**
41
- * Lightweight revision shape returned by `getUserChatRevision`.
42
- */
43
- type UserChatRevision = Awaited<ReturnType<typeof getUserChatRevision>>;
44
-
45
35
  /**
46
36
  * Streams canonical chat snapshots for one scoped user chat so multiple viewers can observe the same background turn.
47
37
  */
@@ -83,7 +73,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
83
73
  async start(controller) {
84
74
  let isStreamClosed = false;
85
75
  let lastSnapshotSignature: string | null = null;
86
- let lastRevisionSignature: string | null = null;
87
76
  let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
88
77
 
89
78
  /**
@@ -149,24 +138,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
149
138
 
150
139
  const payload = await createUserChatDetailPayload(currentChat);
151
140
  const nextSignature = createUserChatDetailSignature(payload);
152
- lastRevisionSignature = createUserChatRevisionSignature({
153
- id: payload.chat.id,
154
- updatedAt: payload.chat.updatedAt,
155
- draftMessage: payload.draftMessage,
156
- source: payload.chat.source,
157
- activeJobs: payload.activeJobs.map((job) => ({
158
- id: job.id,
159
- status: job.status,
160
- cancelRequestedAt: job.cancelRequestedAt,
161
- })),
162
- activeTimeouts: payload.activeTimeouts.map((timeout) => ({
163
- id: timeout.id,
164
- status: timeout.status,
165
- message: timeout.message,
166
- dueAt: timeout.dueAt,
167
- cancelRequestedAt: timeout.cancelRequestedAt,
168
- })),
169
- });
170
141
 
171
142
  if (nextSignature !== lastSnapshotSignature) {
172
143
  lastSnapshotSignature = nextSignature;
@@ -178,32 +149,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
178
149
  return !isFrozenUserChatSource(payload.chat.source) && payload.activeJobs.length > 0;
179
150
  };
180
151
 
181
- /**
182
- * Emits a full snapshot only when an idle chat revision changed.
183
- *
184
- * @private route helper
185
- */
186
- const emitLatestSnapshotWhenRevisionChanged = async (): Promise<boolean> => {
187
- const currentRevision = await getUserChatRevision({
188
- userId: scopeResult.scope.userId,
189
- viewerIsAdmin: scopeResult.scope.viewerIsAdmin,
190
- agentPermanentId: scopeResult.scope.agentPermanentId,
191
- chatId,
192
- });
193
-
194
- if (!currentRevision) {
195
- closeStream();
196
- return false;
197
- }
198
-
199
- const nextRevisionSignature = createUserChatRevisionSignature(currentRevision);
200
- if (nextRevisionSignature === lastRevisionSignature) {
201
- return false;
202
- }
203
-
204
- return emitLatestSnapshot();
205
- };
206
-
207
152
  /**
208
153
  * Tracks client disconnects so the polling loop can exit promptly.
209
154
  *
@@ -220,11 +165,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
220
165
  keepAliveInterval.unref?.();
221
166
 
222
167
  try {
223
- let shouldUseFullPolling = await emitLatestSnapshot();
168
+ let hasActiveJobs = await emitLatestSnapshot();
224
169
 
225
170
  while (!isStreamClosed && !request.signal.aborted) {
226
171
  await waitForNextUserChatStreamPoll(
227
- shouldUseFullPolling
172
+ hasActiveJobs
228
173
  ? ACTIVE_USER_CHAT_STREAM_POLL_INTERVAL_MS
229
174
  : IDLE_USER_CHAT_STREAM_POLL_INTERVAL_MS,
230
175
  request.signal,
@@ -234,9 +179,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
234
179
  break;
235
180
  }
236
181
 
237
- shouldUseFullPolling = shouldUseFullPolling
238
- ? await emitLatestSnapshot()
239
- : await emitLatestSnapshotWhenRevisionChanged();
182
+ hasActiveJobs = await emitLatestSnapshot();
240
183
  }
241
184
  } catch (error) {
242
185
  if (!isStreamClosed && !request.signal.aborted) {
@@ -267,30 +210,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
267
210
  });
268
211
  }
269
212
 
270
- /**
271
- * Builds a compact signature for idle stream revision checks.
272
- */
273
- function createUserChatRevisionSignature(revision: NonNullable<UserChatRevision>): string {
274
- return JSON.stringify({
275
- id: revision.id,
276
- updatedAt: revision.updatedAt,
277
- draftMessage: revision.draftMessage || '',
278
- source: revision.source,
279
- activeJobs: revision.activeJobs.map((job) => ({
280
- id: job.id,
281
- status: job.status,
282
- cancelRequestedAt: job.cancelRequestedAt,
283
- })),
284
- activeTimeouts: revision.activeTimeouts.map((timeout) => ({
285
- id: timeout.id,
286
- status: timeout.status,
287
- message: timeout.message || '',
288
- dueAt: timeout.dueAt,
289
- cancelRequestedAt: timeout.cancelRequestedAt,
290
- })),
291
- });
292
- }
293
-
294
213
  /**
295
214
  * Builds a stable signature for the user-visible parts of a canonical chat snapshot.
296
215
  */
@@ -308,7 +227,6 @@ function createUserChatDetailSignature(payload: Awaited<ReturnType<typeof create
308
227
  activeTimeouts: payload.activeTimeouts.map((timeout) => ({
309
228
  id: timeout.id,
310
229
  status: timeout.status,
311
- message: timeout.message || '',
312
230
  dueAt: timeout.dueAt,
313
231
  cancelRequestedAt: timeout.cancelRequestedAt,
314
232
  })),
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
2
2
  import { spaceTrim } from 'spacetrim';
3
3
  import { DatabaseError } from '../../../../../../../src/errors/DatabaseError';
4
4
  import { isAgentsServerSqliteMode } from '../../../../database/agentsServerDatabaseMode';
5
+ import { seedDefaultAgents } from '../../../../database/seedDefaultAgents';
5
6
  import { resolveCurrentServerRegistryContext } from '../../../../utils/currentServerRegistryContext';
6
7
  import { isUserAdmin } from '../../../../utils/isUserAdmin';
7
8
  import { isUserGlobalAdmin } from '../../../../utils/isUserGlobalAdmin';
@@ -97,6 +98,7 @@ export async function POST(request: Request) {
97
98
 
98
99
  const body = withEnvironmentAdminUser((await request.json()) as CreateServerInput);
99
100
  if (isAgentsServerSqliteMode()) {
101
+ const isDefaultAgentsInstalled = body.isDefaultAgentsInstalled !== false;
100
102
  const normalizedDomain = normalizeServerDomain(body.domain);
101
103
  if (!normalizedDomain) {
102
104
  return NextResponse.json({ error: 'A valid domain is required.' }, { status: 400 });
@@ -114,6 +116,9 @@ export async function POST(request: Request) {
114
116
  iconUrl: body.iconUrl,
115
117
  });
116
118
  }
119
+ if (isDefaultAgentsInstalled) {
120
+ await seedDefaultAgents({ tablePrefix });
121
+ }
117
122
 
118
123
  return NextResponse.json(
119
124
  {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { keepUnused } from '../../../../../../src/utils/organization/keepUnused';
3
3
  import { $getTableName } from '../../../database/$getTableName';
4
4
  import { $provideSupabase } from '../../../database/$provideSupabase';
5
+ import { invalidateMetadataCache } from '../../../database/getMetadata';
5
6
  import { validateMetadataValue } from '../../../database/metadataDefaults';
6
7
  import { isUserAdmin } from '../../../utils/isUserAdmin';
7
8
 
@@ -121,6 +122,7 @@ export async function POST(request: NextRequest) {
121
122
  return NextResponse.json({ error: error.message }, { status: 500 });
122
123
  }
123
124
 
125
+ invalidateMetadataCache();
124
126
  return NextResponse.json(data);
125
127
  }
126
128
 
@@ -152,6 +154,7 @@ export async function PUT(request: NextRequest) {
152
154
  return NextResponse.json({ error: error.message }, { status: 500 });
153
155
  }
154
156
 
157
+ invalidateMetadataCache();
155
158
  return NextResponse.json(data);
156
159
  }
157
160
 
@@ -179,5 +182,6 @@ export async function DELETE(request: NextRequest) {
179
182
  return NextResponse.json({ error: error.message }, { status: 500 });
180
183
  }
181
184
 
185
+ invalidateMetadataCache();
182
186
  return NextResponse.json({ success: true });
183
187
  }
@@ -14,8 +14,10 @@ import {
14
14
  createApplicationErrorHeadline,
15
15
  createApplicationErrorReportPayload,
16
16
  describeApplicationError,
17
+ isApplicationErrorRecoverableByHardRefresh,
17
18
  resolveApplicationErrorVariant,
18
19
  } from '../../utils/errorReporting/applicationErrorHandling';
20
+ import { refreshApplicationDocument } from '../../utils/errorReporting/refreshApplicationDocument';
19
21
  import { ErrorPageButtonAction, ErrorPageLinkAction } from '../ErrorPage/ErrorPage';
20
22
  import { InternalServerErrorPage } from '../InternalServerErrorPage/InternalServerErrorPage';
21
23
 
@@ -27,7 +29,7 @@ import { InternalServerErrorPage } from '../InternalServerErrorPage/InternalServ
27
29
  const troubleshootingSteps = [
28
30
  {
29
31
  title: 'Refresh the route',
30
- detail: 'Try "Try again" so the last navigation runs with fresh cookies, network state, and session data.',
32
+ detail: 'Use the primary action so the page can retry with fresh cookies, network state, and build assets.',
31
33
  },
32
34
  {
33
35
  title: 'Share the digest',
@@ -46,6 +48,20 @@ const troubleshootingSteps = [
46
48
  */
47
49
  const REPORT_ACTION_FEEDBACK_DURATION_MS = 2500;
48
50
 
51
+ /**
52
+ * Delay before one recoverable stale-asset error triggers an automatic full-document refresh.
53
+ *
54
+ * @private
55
+ */
56
+ const APPLICATION_ERROR_AUTO_REFRESH_DELAY_MS = 1500;
57
+
58
+ /**
59
+ * Prefix used for session-scoped stale-asset refresh guards.
60
+ *
61
+ * @private
62
+ */
63
+ const APPLICATION_ERROR_AUTO_REFRESH_STORAGE_KEY_PREFIX = 'promptbook.application-error-hard-refresh';
64
+
49
65
  /**
50
66
  * Writes plain text to the user clipboard.
51
67
  *
@@ -82,17 +98,25 @@ function downloadMarkdownReport(reportMarkdown: string, filename: string): void
82
98
  URL.revokeObjectURL(reportUrl);
83
99
  }
84
100
 
101
+ /**
102
+ * Builds the session-storage key used to ensure one stale-asset error only auto-refreshes once per URL and digest.
103
+ *
104
+ * @param pageUrl - Browser URL where the error happened.
105
+ * @param digest - Deterministic digest rendered for the error.
106
+ * @returns Stable session-storage key for the current recoverable error.
107
+ *
108
+ * @private
109
+ */
110
+ function createApplicationErrorAutoRefreshStorageKey(pageUrl: string, digest: string): string {
111
+ return `${APPLICATION_ERROR_AUTO_REFRESH_STORAGE_KEY_PREFIX}:${pageUrl}:${digest}`;
112
+ }
113
+
85
114
  /**
86
115
  * Props accepted by shared action controls used across error variants.
87
116
  *
88
117
  * @private
89
118
  */
90
119
  type ApplicationErrorActionsProps = {
91
- /**
92
- * Callback that retries the failed route transition.
93
- */
94
- reset: () => void;
95
-
96
120
  /**
97
121
  * Digest value displayed for operator correlation.
98
122
  */
@@ -107,6 +131,16 @@ type ApplicationErrorActionsProps = {
107
131
  * Default filename used by the markdown save action.
108
132
  */
109
133
  reportFilename: string;
134
+
135
+ /**
136
+ * Primary action label rendered on the left-most action button.
137
+ */
138
+ primaryActionLabel: string;
139
+
140
+ /**
141
+ * Callback used by the left-most primary action button.
142
+ */
143
+ onPrimaryAction: () => void;
110
144
  };
111
145
 
112
146
  /**
@@ -117,10 +151,11 @@ type ApplicationErrorActionsProps = {
117
151
  * @private
118
152
  */
119
153
  function ApplicationErrorActions({
120
- reset,
121
154
  digest,
122
155
  reportMarkdown,
123
156
  reportFilename,
157
+ primaryActionLabel,
158
+ onPrimaryAction,
124
159
  }: ApplicationErrorActionsProps) {
125
160
  const [reportFeedback, setReportFeedback] = useState<string | null>(null);
126
161
  const reportFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -179,9 +214,7 @@ function ApplicationErrorActions({
179
214
  return (
180
215
  <div className="flex flex-col items-center gap-3">
181
216
  <div className="flex flex-wrap justify-center gap-3">
182
- <ErrorPageButtonAction onClick={() => reset()}>
183
- Try again
184
- </ErrorPageButtonAction>
217
+ <ErrorPageButtonAction onClick={onPrimaryAction}>{primaryActionLabel}</ErrorPageButtonAction>
185
218
  <ErrorPageLinkAction href="/" tone="secondary">
186
219
  Go to homepage
187
220
  </ErrorPageLinkAction>
@@ -245,10 +278,25 @@ type ApplicationErrorViewProps = {
245
278
  */
246
279
  size?: 'default' | 'wide';
247
280
 
281
+ /**
282
+ * Primary action label rendered on the left-most action button.
283
+ */
284
+ primaryActionLabel: string;
285
+
286
+ /**
287
+ * Callback used by the left-most primary action button.
288
+ */
289
+ onPrimaryAction: () => void;
290
+
248
291
  /**
249
292
  * Optional supplementary content rendered beneath shared actions.
250
293
  */
251
294
  supplementaryContent?: ReactNode;
295
+
296
+ /**
297
+ * Optional recovery note rendered between the actions and supplementary content.
298
+ */
299
+ recoveryNotice?: ReactNode;
252
300
  };
253
301
 
254
302
  /**
@@ -264,18 +312,22 @@ function ApplicationErrorView({
264
312
  digest,
265
313
  reportMarkdown,
266
314
  reportFilename,
267
- reset,
268
315
  size = 'default',
316
+ primaryActionLabel,
317
+ onPrimaryAction,
318
+ recoveryNotice,
269
319
  supplementaryContent,
270
320
  }: ApplicationErrorViewProps) {
271
321
  return (
272
322
  <InternalServerErrorPage headline={headline} description={description} size={size}>
273
323
  <ApplicationErrorActions
274
- reset={reset}
275
324
  digest={digest}
276
325
  reportMarkdown={reportMarkdown}
277
326
  reportFilename={reportFilename}
327
+ primaryActionLabel={primaryActionLabel}
328
+ onPrimaryAction={onPrimaryAction}
278
329
  />
330
+ {recoveryNotice}
279
331
  {supplementaryContent}
280
332
  </InternalServerErrorPage>
281
333
  );
@@ -374,9 +426,11 @@ export function ApplicationErrorPage({ error, reset }: ApplicationErrorPageProps
374
426
  );
375
427
  const digest = createApplicationErrorDigest(error);
376
428
  const serverName = process.env.NEXT_PUBLIC_SERVER_NAME ?? DEFAULT_APPLICATION_ERROR_SERVER_NAME;
429
+ const isRecoverableByHardRefresh = isApplicationErrorRecoverableByHardRefresh(error);
377
430
  const headline = createApplicationErrorHeadline(serverName);
378
431
  const description = describeApplicationError(error, serverName);
379
432
  const [pageUrl, setPageUrl] = useState<string | undefined>(undefined);
433
+ const [hasAutomaticRefreshAlreadyBeenAttempted, setHasAutomaticRefreshAlreadyBeenAttempted] = useState(false);
380
434
  const reportPayload = useMemo(
381
435
  () => createApplicationErrorReportPayload(error, digest, serverName, variant, pageUrl),
382
436
  [digest, error, pageUrl, serverName, variant],
@@ -392,6 +446,35 @@ export function ApplicationErrorPage({ error, reset }: ApplicationErrorPageProps
392
446
  setPageUrl(window.location.href);
393
447
  }, []);
394
448
 
449
+ useEffect(() => {
450
+ if (!pageUrl || !isRecoverableByHardRefresh) {
451
+ setHasAutomaticRefreshAlreadyBeenAttempted(false);
452
+ return undefined;
453
+ }
454
+
455
+ const storageKey = createApplicationErrorAutoRefreshStorageKey(pageUrl, digest);
456
+
457
+ try {
458
+ const hasStoredRefreshAttempt = window.sessionStorage.getItem(storageKey) === 'done';
459
+ if (hasStoredRefreshAttempt) {
460
+ setHasAutomaticRefreshAlreadyBeenAttempted(true);
461
+ return undefined;
462
+ }
463
+
464
+ window.sessionStorage.setItem(storageKey, 'done');
465
+ } catch {
466
+ // Some browser privacy modes may block session storage; the one-shot reload remains best-effort.
467
+ }
468
+
469
+ const timeout = window.setTimeout(() => {
470
+ refreshApplicationDocument();
471
+ }, APPLICATION_ERROR_AUTO_REFRESH_DELAY_MS);
472
+
473
+ return () => {
474
+ window.clearTimeout(timeout);
475
+ };
476
+ }, [digest, isRecoverableByHardRefresh, pageUrl]);
477
+
395
478
  useEffect(() => {
396
479
  if (!pageUrl) {
397
480
  return;
@@ -408,6 +491,23 @@ export function ApplicationErrorPage({ error, reset }: ApplicationErrorPageProps
408
491
  });
409
492
  }, [error, pageUrl, reportPayload]);
410
493
 
494
+ const primaryActionLabel = isRecoverableByHardRefresh ? 'Refresh now' : 'Try again';
495
+ const handlePrimaryAction = () => {
496
+ if (isRecoverableByHardRefresh) {
497
+ refreshApplicationDocument();
498
+ return;
499
+ }
500
+
501
+ reset();
502
+ };
503
+ const recoveryNotice = isRecoverableByHardRefresh ? (
504
+ <p className="mt-6 text-center text-sm text-gray-600">
505
+ {hasAutomaticRefreshAlreadyBeenAttempted
506
+ ? 'Automatic refresh already ran once. If the newest assets still do not load, use "Refresh now" or share the digest with your administrator.'
507
+ : 'Refreshing this page automatically once so the latest application files can be loaded.'}
508
+ </p>
509
+ ) : undefined;
510
+
411
511
  if (variant === 'simple') {
412
512
  return (
413
513
  <SimpleApplicationErrorView
@@ -417,6 +517,9 @@ export function ApplicationErrorPage({ error, reset }: ApplicationErrorPageProps
417
517
  reportMarkdown={reportMarkdown}
418
518
  reportFilename={reportFilename}
419
519
  reset={reset}
520
+ recoveryNotice={recoveryNotice}
521
+ primaryActionLabel={primaryActionLabel}
522
+ onPrimaryAction={handlePrimaryAction}
420
523
  />
421
524
  );
422
525
  }
@@ -429,6 +532,9 @@ export function ApplicationErrorPage({ error, reset }: ApplicationErrorPageProps
429
532
  reportMarkdown={reportMarkdown}
430
533
  reportFilename={reportFilename}
431
534
  reset={reset}
535
+ recoveryNotice={recoveryNotice}
536
+ primaryActionLabel={primaryActionLabel}
537
+ onPrimaryAction={handlePrimaryAction}
432
538
  />
433
539
  );
434
540
  }
@@ -14,6 +14,13 @@ export const MAX_CUSTOM_JAVASCRIPT_LENGTH = 100_000;
14
14
  */
15
15
  const CUSTOM_JAVASCRIPT_TABLE_BASENAME = 'CustomJavascript';
16
16
 
17
+ /**
18
+ * Process-level cache lifetime for custom JavaScript rows.
19
+ *
20
+ * @private
21
+ */
22
+ const CUSTOM_JAVASCRIPT_CACHE_TTL_MS = 30_000;
23
+
17
24
  /**
18
25
  * Stored `CustomJavascript` row shape.
19
26
  *
@@ -90,6 +97,28 @@ type DynamicSupabaseClient = {
90
97
  from: (tableName: string) => DynamicCustomJavascriptTableQuery;
91
98
  };
92
99
 
100
+ /**
101
+ * Cached custom JavaScript rows keyed by the resolved table name.
102
+ *
103
+ * @private
104
+ */
105
+ const cachedCustomJavascriptByTableName = new Map<
106
+ string,
107
+ {
108
+ readonly loadedAt: number;
109
+ readonly rowsPromise: Promise<CustomJavascriptRow[]>;
110
+ }
111
+ >();
112
+
113
+ /**
114
+ * Clears process-level custom JavaScript cache after admin writes.
115
+ *
116
+ * @private
117
+ */
118
+ export function invalidateCustomJavascriptCache(): void {
119
+ cachedCustomJavascriptByTableName.clear();
120
+ }
121
+
93
122
  /**
94
123
  * Resolves the prefixed table name for `CustomJavascript`.
95
124
  *
@@ -137,8 +166,37 @@ function getCustomJavascriptClient() {
137
166
  */
138
167
  export async function getCustomJavascriptFiles(): Promise<CustomJavascriptRow[]> {
139
168
  const table = await getCustomJavascriptTableName();
140
- const supabase = getCustomJavascriptClient();
169
+ const cachedJavascript = cachedCustomJavascriptByTableName.get(table);
170
+ if (cachedJavascript && Date.now() - cachedJavascript.loadedAt < CUSTOM_JAVASCRIPT_CACHE_TTL_MS) {
171
+ return cachedJavascript.rowsPromise;
172
+ }
173
+
174
+ const rowsPromise = loadCustomJavascriptFilesFromDatabase(table);
175
+ cachedCustomJavascriptByTableName.set(table, {
176
+ loadedAt: Date.now(),
177
+ rowsPromise,
178
+ });
179
+
180
+ try {
181
+ return await rowsPromise;
182
+ } catch (error) {
183
+ if (cachedCustomJavascriptByTableName.get(table)?.rowsPromise === rowsPromise) {
184
+ cachedCustomJavascriptByTableName.delete(table);
185
+ }
186
+ throw error;
187
+ }
188
+ }
141
189
 
190
+ /**
191
+ * Reads custom JavaScript rows from the database.
192
+ *
193
+ * @param table - Resolved CustomJavascript table name.
194
+ * @returns Stored JavaScript rows.
195
+ *
196
+ * @private
197
+ */
198
+ async function loadCustomJavascriptFilesFromDatabase(table: string): Promise<CustomJavascriptRow[]> {
199
+ const supabase = getCustomJavascriptClient();
142
200
  const { data, error } = await supabase.from(table).select('*').order('scope', { ascending: true });
143
201
 
144
202
  if (error) {
@@ -234,6 +292,7 @@ export async function saveCustomJavascriptFile({
234
292
  throw new Error(`Failed to save custom JavaScript: ${error.message || String(error)}`);
235
293
  }
236
294
 
295
+ invalidateCustomJavascriptCache();
237
296
  return data as CustomJavascriptRow;
238
297
  }
239
298
 
@@ -255,4 +314,6 @@ export async function deleteCustomJavascriptFile(id: number): Promise<void> {
255
314
 
256
315
  throw new Error(`Failed to delete custom JavaScript: ${error.message || String(error)}`);
257
316
  }
317
+
318
+ invalidateCustomJavascriptCache();
258
319
  }