@promptbook/cli 0.112.0-109 → 0.112.0-110

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 (30) hide show
  1. package/apps/agents-server/src/app/agents/[agentName]/api/user-chats/[chatId]/stream/route.ts +86 -4
  2. package/apps/agents-server/src/database/loadAgentsServerEnvFile.ts +29 -0
  3. package/apps/agents-server/src/database/migrate.ts +2 -25
  4. package/apps/agents-server/src/database/seedDefaultAgents.ts +2 -26
  5. package/apps/agents-server/src/utils/agentRouting/resolveAgentRouteTarget.ts +56 -0
  6. package/apps/agents-server/src/utils/importAgent.ts +57 -1
  7. package/apps/agents-server/src/utils/importAgentWithFallback.ts +10 -0
  8. package/apps/agents-server/src/utils/userChat/getUserChatRevision.ts +145 -0
  9. package/apps/agents-server/src/utils/userChat.ts +1 -0
  10. package/esm/index.es.js +226 -105
  11. package/esm/index.es.js.map +1 -1
  12. package/esm/src/cli/cli-commands/agents-server/buildAgentsServer.d.ts +17 -1
  13. package/esm/src/cli/cli-commands/agents-server/run.d.ts +6 -0
  14. package/esm/src/cli/cli-commands/agents-server/startAgentsServer.d.ts +7 -0
  15. package/esm/src/version.d.ts +1 -1
  16. package/package.json +1 -1
  17. package/src/book-components/Chat/Chat/Chat.module.css +5 -0
  18. package/src/cli/cli-commands/agents-server/buildAgentsServer.ts +48 -14
  19. package/src/cli/cli-commands/agents-server/run.ts +103 -31
  20. package/src/cli/cli-commands/agents-server/startAgentsServer.ts +111 -35
  21. package/src/cli/cli-commands/agents-server.ts +7 -1
  22. package/src/other/templates/getTemplatesPipelineCollection.ts +1081 -516
  23. package/src/version.ts +2 -2
  24. package/src/versions.txt +1 -0
  25. package/umd/index.umd.js +226 -105
  26. package/umd/index.umd.js.map +1 -1
  27. package/umd/src/cli/cli-commands/agents-server/buildAgentsServer.d.ts +17 -1
  28. package/umd/src/cli/cli-commands/agents-server/run.d.ts +6 -0
  29. package/umd/src/cli/cli-commands/agents-server/startAgentsServer.d.ts +7 -0
  30. package/umd/src/version.d.ts +1 -1
@@ -1,6 +1,11 @@
1
1
  import { CHAT_STREAM_KEEP_ALIVE_INTERVAL_MS } from '@/src/constants/streaming';
2
2
  import { isPrivateModeEnabledFromRequest } from '@/src/utils/privateMode';
3
- import { createUserChatDetailPayload, getUserChat, isFrozenUserChatSource } from '@/src/utils/userChat';
3
+ import {
4
+ createUserChatDetailPayload,
5
+ getUserChat,
6
+ getUserChatRevision,
7
+ isFrozenUserChatSource,
8
+ } from '@/src/utils/userChat';
4
9
  import type { ChatMessage } from '@promptbook-local/types';
5
10
  import { NextResponse } from 'next/server';
6
11
  import { resolveUserChatScope } from '../../resolveUserChatScope';
@@ -32,6 +37,11 @@ type UserChatStreamFrame =
32
37
  type: 'keepalive';
33
38
  };
34
39
 
40
+ /**
41
+ * Lightweight revision shape returned by `getUserChatRevision`.
42
+ */
43
+ type UserChatRevision = Awaited<ReturnType<typeof getUserChatRevision>>;
44
+
35
45
  /**
36
46
  * Streams canonical chat snapshots for one scoped user chat so multiple viewers can observe the same background turn.
37
47
  */
@@ -73,6 +83,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
73
83
  async start(controller) {
74
84
  let isStreamClosed = false;
75
85
  let lastSnapshotSignature: string | null = null;
86
+ let lastRevisionSignature: string | null = null;
76
87
  let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
77
88
 
78
89
  /**
@@ -138,6 +149,24 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
138
149
 
139
150
  const payload = await createUserChatDetailPayload(currentChat);
140
151
  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
+ });
141
170
 
142
171
  if (nextSignature !== lastSnapshotSignature) {
143
172
  lastSnapshotSignature = nextSignature;
@@ -149,6 +178,32 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
149
178
  return !isFrozenUserChatSource(payload.chat.source) && payload.activeJobs.length > 0;
150
179
  };
151
180
 
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
+
152
207
  /**
153
208
  * Tracks client disconnects so the polling loop can exit promptly.
154
209
  *
@@ -165,11 +220,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
165
220
  keepAliveInterval.unref?.();
166
221
 
167
222
  try {
168
- let hasActiveJobs = await emitLatestSnapshot();
223
+ let shouldUseFullPolling = await emitLatestSnapshot();
169
224
 
170
225
  while (!isStreamClosed && !request.signal.aborted) {
171
226
  await waitForNextUserChatStreamPoll(
172
- hasActiveJobs
227
+ shouldUseFullPolling
173
228
  ? ACTIVE_USER_CHAT_STREAM_POLL_INTERVAL_MS
174
229
  : IDLE_USER_CHAT_STREAM_POLL_INTERVAL_MS,
175
230
  request.signal,
@@ -179,7 +234,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
179
234
  break;
180
235
  }
181
236
 
182
- hasActiveJobs = await emitLatestSnapshot();
237
+ shouldUseFullPolling = shouldUseFullPolling
238
+ ? await emitLatestSnapshot()
239
+ : await emitLatestSnapshotWhenRevisionChanged();
183
240
  }
184
241
  } catch (error) {
185
242
  if (!isStreamClosed && !request.signal.aborted) {
@@ -210,6 +267,30 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
210
267
  });
211
268
  }
212
269
 
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
+
213
294
  /**
214
295
  * Builds a stable signature for the user-visible parts of a canonical chat snapshot.
215
296
  */
@@ -227,6 +308,7 @@ function createUserChatDetailSignature(payload: Awaited<ReturnType<typeof create
227
308
  activeTimeouts: payload.activeTimeouts.map((timeout) => ({
228
309
  id: timeout.id,
229
310
  status: timeout.status,
311
+ message: timeout.message || '',
230
312
  dueAt: timeout.dueAt,
231
313
  cancelRequestedAt: timeout.cancelRequestedAt,
232
314
  })),
@@ -0,0 +1,29 @@
1
+ import * as dotenv from 'dotenv';
2
+
3
+ /**
4
+ * Environment variable pointing to the installed Agents Server `.env` file.
5
+ *
6
+ * @private utility of standalone database scripts
7
+ */
8
+ export const AGENTS_SERVER_ENV_FILE_ENV_NAME = 'PTBK_AGENTS_SERVER_ENV_FILE';
9
+
10
+ /**
11
+ * Loads Agents Server environment variables for standalone database scripts.
12
+ *
13
+ * The installer passes an explicit `.env` file from the installation directory. That file must override ambient
14
+ * `PTBK_*` values because these scripts often run from the repository checkout while the server runs from the
15
+ * installation directory, so a stale relative SQLite path would point at a different database.
16
+ *
17
+ * @private utility of standalone database scripts
18
+ */
19
+ export function loadAgentsServerEnvFile(): void {
20
+ const explicitEnvFilePath = process.env[AGENTS_SERVER_ENV_FILE_ENV_NAME]?.trim();
21
+ if (explicitEnvFilePath) {
22
+ const explicitLoadResult = dotenv.config({ path: explicitEnvFilePath, override: true });
23
+ if (!explicitLoadResult.error) {
24
+ return;
25
+ }
26
+ }
27
+
28
+ dotenv.config();
29
+ }
@@ -1,18 +1,13 @@
1
1
  import colors from 'colors';
2
- import * as dotenv from 'dotenv';
3
2
  import {
4
3
  DATABASE_MIGRATION_APPLIED_BY,
5
4
  resolveDatabaseMigrationRuntimeConfiguration,
6
5
  runDatabaseMigrations,
7
6
  } from './runDatabaseMigrations';
8
7
  import { isAgentsServerSqliteMode } from './agentsServerDatabaseMode';
8
+ import { loadAgentsServerEnvFile } from './loadAgentsServerEnvFile';
9
9
 
10
- /**
11
- * Environment variable pointing to the installed Agents Server `.env` file.
12
- */
13
- const AGENTS_SERVER_ENV_FILE_ENV_NAME = 'PTBK_AGENTS_SERVER_ENV_FILE';
14
-
15
- loadDatabaseMigrationEnvironment();
10
+ loadAgentsServerEnvFile();
16
11
 
17
12
  /**
18
13
  * Runs manual migration command from CLI arguments.
@@ -57,24 +52,6 @@ async function migrate(): Promise<void> {
57
52
  }
58
53
  }
59
54
 
60
- /**
61
- * Loads database migration environment variables.
62
- *
63
- * Self-update runs the migration command from the repository checkout, while
64
- * the deployed Agents Server `.env` lives in the installation directory.
65
- */
66
- function loadDatabaseMigrationEnvironment(): void {
67
- const explicitEnvFilePath = process.env[AGENTS_SERVER_ENV_FILE_ENV_NAME]?.trim();
68
- if (explicitEnvFilePath) {
69
- const explicitLoadResult = dotenv.config({ path: explicitEnvFilePath });
70
- if (!explicitLoadResult.error) {
71
- return;
72
- }
73
- }
74
-
75
- dotenv.config();
76
- }
77
-
78
55
  /**
79
56
  * Parses optional `--only` flag from CLI arguments.
80
57
  *
@@ -1,17 +1,10 @@
1
- import * as dotenv from 'dotenv';
2
1
  import type { SupabaseClient } from '@supabase/supabase-js';
3
2
  import { AgentCollectionInSupabase } from '../../../../src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase';
4
3
  import type { AgentsDatabaseSchema } from '../../../../src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentsDatabaseSchema';
5
4
  import { $provideSupabaseForServer } from './$provideSupabaseForServer';
6
5
  import { DEFAULT_AGENT_VISIBILITY } from '../utils/agentVisibility';
7
6
  import { loadDefaultAgentBooks } from '../utils/defaultAgents/loadDefaultAgentBooks';
8
-
9
- /**
10
- * Environment variable pointing to the installed Agents Server `.env` file.
11
- *
12
- * @private utility of standalone default-agent seeding
13
- */
14
- const AGENTS_SERVER_ENV_FILE_ENV_NAME = 'PTBK_AGENTS_SERVER_ENV_FILE';
7
+ import { loadAgentsServerEnvFile } from './loadAgentsServerEnvFile';
15
8
 
16
9
  /**
17
10
  * Environment variable with an explicit default-agent source directory.
@@ -178,30 +171,13 @@ function resolveAgentsDatabaseSupabaseClient(): SupabaseClient<AgentsDatabaseSch
178
171
  return $provideSupabaseForServer() as unknown as SupabaseClient<AgentsDatabaseSchema>;
179
172
  }
180
173
 
181
- /**
182
- * Loads the installed Agents Server environment before seeding.
183
- *
184
- * @private utility of standalone default-agent seeding
185
- */
186
- function loadSeedDefaultAgentsEnvironment(): void {
187
- const explicitEnvFilePath = process.env[AGENTS_SERVER_ENV_FILE_ENV_NAME]?.trim();
188
- if (explicitEnvFilePath) {
189
- const explicitLoadResult = dotenv.config({ path: explicitEnvFilePath });
190
- if (!explicitLoadResult.error) {
191
- return;
192
- }
193
- }
194
-
195
- dotenv.config();
196
- }
197
-
198
174
  /**
199
175
  * Runs the standalone default-agent seed command.
200
176
  *
201
177
  * @private utility of standalone default-agent seeding
202
178
  */
203
179
  async function runSeedDefaultAgentsCommand(): Promise<void> {
204
- loadSeedDefaultAgentsEnvironment();
180
+ loadAgentsServerEnvFile();
205
181
  const result = await seedDefaultAgents();
206
182
 
207
183
  if (result.createdCount > 0) {
@@ -1,11 +1,15 @@
1
1
  import { $provideServer } from '../../tools/$provideServer';
2
2
  import { $provideAgentCollectionForServer } from '../../tools/$provideAgentCollectionForServer';
3
+ import { $getTableName } from '../../database/$getTableName';
4
+ import { $provideSupabaseForServer } from '../../database/$provideSupabaseForServer';
3
5
  import { $provideAgentReferenceResolver } from '../agentReferenceResolver/$provideAgentReferenceResolver';
4
6
  import { consumeAgentReferenceResolutionIssues } from '../agentReferenceResolver/AgentReferenceResolutionIssue';
5
7
  import { parseBookScopedAgentIdentifier } from '../agentReferenceResolver/bookScopedAgentReferences';
8
+ import { buildAgentNameOrIdFilter } from '../agentIdentifier';
6
9
  import { resolvePseudoAgentDescriptor } from '../pseudoAgents';
7
10
  import { normalizeAgentName } from '../../../../../src/_packages/core.index';
8
11
  import type { PseudoAgentKind } from '../../../../../src/book-2.0/agent-source/pseudoAgentReferences';
12
+ import type { AgentsServerDatabase } from '../../database/schema';
9
13
  import { cache } from 'react';
10
14
 
11
15
  /**
@@ -50,6 +54,14 @@ type PseudoAgentRouteTarget = {
50
54
  canonicalUrl: string;
51
55
  };
52
56
 
57
+ /**
58
+ * Minimal database row needed to resolve one local route target.
59
+ */
60
+ type LocalAgentRouteRow = Pick<
61
+ AgentsServerDatabase['public']['Tables']['Agent']['Row'],
62
+ 'agentName' | 'permanentId'
63
+ >;
64
+
53
65
  /**
54
66
  * Target returned for an incoming `/agents/:agentId` route value.
55
67
  */
@@ -102,6 +114,11 @@ async function resolveAgentRouteTargetUncached(
102
114
  };
103
115
  }
104
116
 
117
+ const directLocalRouteTarget = await resolveLocalAgentRouteTargetFromDatabase(normalizedReference, localServerUrl);
118
+ if (directLocalRouteTarget) {
119
+ return directLocalRouteTarget;
120
+ }
121
+
105
122
  const resolver = await $provideAgentReferenceResolver({ forceRefresh: options?.forceRefresh });
106
123
  let resolvedUrlValue: string;
107
124
 
@@ -299,3 +316,42 @@ async function resolveLocalAgentRouteTarget(
299
316
  canonicalUrl: `${localServerUrl}${AGENT_PATH_PREFIX}${encodeURIComponent(canonicalAgentId)}`,
300
317
  };
301
318
  }
319
+
320
+ /**
321
+ * Resolves the common local route case with a targeted indexed database lookup.
322
+ *
323
+ * The reference resolver has to initialize the full local/federated lookup map.
324
+ * Most page requests already carry a permanent id or exact local name, so checking
325
+ * the Agent table first keeps normal navigation from loading every agent profile.
326
+ *
327
+ * @param reference - Normalized route reference.
328
+ * @param localServerUrl - Normalized URL of the current Agents Server instance.
329
+ * @returns Local route target or `null` when the reference is not a local agent.
330
+ */
331
+ async function resolveLocalAgentRouteTargetFromDatabase(
332
+ reference: string,
333
+ localServerUrl: string,
334
+ ): Promise<AgentRouteTarget | null> {
335
+ const supabase = $provideSupabaseForServer();
336
+ const agentTable = await $getTableName('Agent');
337
+ const result = await supabase
338
+ .from(agentTable)
339
+ .select('agentName, permanentId')
340
+ .or(buildAgentNameOrIdFilter(reference))
341
+ .is('deletedAt', null)
342
+ .order('createdAt', { ascending: true })
343
+ .limit(1);
344
+
345
+ if (result.error || !result.data || result.data.length === 0) {
346
+ return null;
347
+ }
348
+
349
+ const agent = result.data[0] as LocalAgentRouteRow;
350
+ const canonicalAgentId = agent.permanentId || agent.agentName;
351
+
352
+ return {
353
+ kind: 'local',
354
+ canonicalAgentId,
355
+ canonicalUrl: `${localServerUrl}${AGENT_PATH_PREFIX}${encodeURIComponent(canonicalAgentId)}`,
356
+ };
357
+ }
@@ -39,6 +39,16 @@ type ImportedAgentCacheRecord = {
39
39
  */
40
40
  readonly source: string_book;
41
41
 
42
+ /**
43
+ * Fetch implementation that produced this cached record.
44
+ */
45
+ readonly fetchImplementation: typeof fetch;
46
+
47
+ /**
48
+ * Timestamp when the source was last fetched successfully.
49
+ */
50
+ readonly cachedAt: number;
51
+
42
52
  /**
43
53
  * Last observed ETag returned by the remote `/api/book` endpoint.
44
54
  */
@@ -55,6 +65,22 @@ type ImportedAgentCacheRecord = {
55
65
  */
56
66
  const IMPORTED_AGENT_CACHE = new Map<string, ImportedAgentCacheRecord>();
57
67
 
68
+ /**
69
+ * Freshness window for successful imported agent books.
70
+ *
71
+ * This avoids a remote revalidation round trip on every server render while
72
+ * still allowing federated/default parent updates to appear shortly after.
73
+ */
74
+ const IMPORTED_AGENT_CACHE_FRESH_TTL_MS = 60_000;
75
+
76
+ /**
77
+ * Upper bound for fetching one imported agent book.
78
+ *
79
+ * Imported agents are used while rendering agent profiles and lists, so one slow
80
+ * federated/default parent must not block the entire Agents Server page render.
81
+ */
82
+ const IMPORTED_AGENT_BOOK_FETCH_TIMEOUT_MS = 3_000;
83
+
58
84
  /**
59
85
  * In-flight remote imports deduplicated by canonical agent identifier.
60
86
  */
@@ -101,6 +127,26 @@ function createImportCacheKey(
101
127
  return agentIdentification.replace(/\/+$/g, '');
102
128
  }
103
129
 
130
+ /**
131
+ * Returns `true` when a cached imported agent can be reused without revalidation.
132
+ *
133
+ * @param cachedImport - Cached successful import record.
134
+ * @returns Whether the cached import is still fresh enough for immediate reuse.
135
+ */
136
+ function isImportedAgentCacheFresh(cachedImport: ImportedAgentCacheRecord): boolean {
137
+ return Date.now() - cachedImport.cachedAt < IMPORTED_AGENT_CACHE_FRESH_TTL_MS;
138
+ }
139
+
140
+ /**
141
+ * Returns `true` when the current fetch implementation can safely reuse the cached import.
142
+ *
143
+ * @param cachedImport - Cached successful import record.
144
+ * @returns Whether the cache was produced by the active fetch implementation.
145
+ */
146
+ function isImportedAgentCacheCompatible(cachedImport: ImportedAgentCacheRecord): boolean {
147
+ return cachedImport.fetchImplementation === globalThis.fetch;
148
+ }
149
+
104
150
  /**
105
151
  * Extracts one text/book payload from a successful HTTP response.
106
152
  *
@@ -153,7 +199,14 @@ export async function importAgent(
153
199
  inheritancePath: options?.inheritancePath,
154
200
  } satisfies ImportAgentOptions;
155
201
  const cacheKey = createImportCacheKey(agentIdentification);
156
- const cachedImport = IMPORTED_AGENT_CACHE.get(cacheKey);
202
+ const storedCachedImport = IMPORTED_AGENT_CACHE.get(cacheKey);
203
+ const cachedImport =
204
+ storedCachedImport && isImportedAgentCacheCompatible(storedCachedImport) ? storedCachedImport : undefined;
205
+
206
+ if (cachedImport && isImportedAgentCacheFresh(cachedImport)) {
207
+ return cachedImport.source;
208
+ }
209
+
157
210
  const existingRequest = PENDING_IMPORTED_AGENT_REQUESTS.get(cacheKey);
158
211
  if (existingRequest) {
159
212
  return existingRequest;
@@ -175,6 +228,7 @@ export async function importAgent(
175
228
  const response: Response = await fetch(agentBookUrl, {
176
229
  cache: 'no-store',
177
230
  headers,
231
+ signal: AbortSignal.timeout(IMPORTED_AGENT_BOOK_FETCH_TIMEOUT_MS),
178
232
  });
179
233
 
180
234
  if (response.status === 304 && cachedImport) {
@@ -202,6 +256,8 @@ export async function importAgent(
202
256
  const source = await readImportedAgentSource(agentIdentification, response);
203
257
  IMPORTED_AGENT_CACHE.set(cacheKey, {
204
258
  source,
259
+ fetchImplementation: globalThis.fetch,
260
+ cachedAt: Date.now(),
205
261
  etag: response.headers.get('etag'),
206
262
  lastModified: response.headers.get('last-modified'),
207
263
  });
@@ -19,6 +19,10 @@ type FailedImportedAgentFallbackCacheRecord = {
19
19
  * Generated fallback book returned for the failed import.
20
20
  */
21
21
  readonly fallbackSource: string_book;
22
+ /**
23
+ * Fetch implementation that produced this failed-import fallback.
24
+ */
25
+ readonly fetchImplementation: typeof fetch;
22
26
  /**
23
27
  * Cache expiration timestamp in epoch milliseconds.
24
28
  */
@@ -122,6 +126,11 @@ function readCachedFailedImportedAgentFallback(cacheKey: string): string_book |
122
126
  return null;
123
127
  }
124
128
 
129
+ if (cachedFallback.fetchImplementation !== globalThis.fetch) {
130
+ cachedFailedImportedAgentFallbackByKey.delete(cacheKey);
131
+ return null;
132
+ }
133
+
125
134
  return cachedFallback.fallbackSource;
126
135
  }
127
136
 
@@ -134,6 +143,7 @@ function readCachedFailedImportedAgentFallback(cacheKey: string): string_book |
134
143
  function writeCachedFailedImportedAgentFallback(cacheKey: string, fallbackSource: string_book): void {
135
144
  cachedFailedImportedAgentFallbackByKey.set(cacheKey, {
136
145
  fallbackSource,
146
+ fetchImplementation: globalThis.fetch,
137
147
  expiresAt: Date.now() + FAILED_IMPORTED_AGENT_FALLBACK_CACHE_TTL_MS,
138
148
  });
139
149
  }
@@ -0,0 +1,145 @@
1
+ import type { UserChatRow } from './UserChatRow';
2
+ import { provideUserChatTable } from './provideUserChatTable';
3
+ import type { UserChatJobRow } from './UserChatJobRow';
4
+ import { provideUserChatJobTable } from './provideUserChatJobTable';
5
+ import { ACTIVE_USER_CHAT_TIMEOUT_STATUSES } from '../userChatTimeout/userChatTimeoutStore/ACTIVE_USER_CHAT_TIMEOUT_STATUSES';
6
+ import { isMissingUserChatTimeoutRelationError } from '../userChatTimeout/userChatTimeoutStore/isMissingUserChatTimeoutRelationError';
7
+ import { provideUserChatTimeoutTable } from '../userChatTimeout/userChatTimeoutStore/provideUserChatTimeoutTable';
8
+ import type { UserChatTimeoutRow } from '../userChatTimeout/UserChatTimeoutRecord';
9
+ import { USER_CHAT_SOURCES } from './UserChatSource';
10
+
11
+ /**
12
+ * Active user-chat job statuses that keep the live chat stream in a faster polling mode.
13
+ */
14
+ const ACTIVE_USER_CHAT_JOB_STATUSES = ['QUEUED', 'RUNNING'] as const;
15
+
16
+ /**
17
+ * Compact active-job shape used only for revision comparison.
18
+ */
19
+ export type UserChatRevisionJob = Pick<UserChatJobRow, 'id' | 'status' | 'cancelRequestedAt'>;
20
+
21
+ /**
22
+ * Compact active-timeout shape used only for revision comparison.
23
+ */
24
+ export type UserChatRevisionTimeout = Pick<
25
+ UserChatTimeoutRow,
26
+ 'id' | 'status' | 'message' | 'dueAt' | 'cancelRequestedAt'
27
+ >;
28
+
29
+ /**
30
+ * Lightweight revision metadata for detecting whether a chat detail payload changed.
31
+ */
32
+ export type UserChatRevision = {
33
+ id: string;
34
+ updatedAt: string;
35
+ draftMessage: string | null;
36
+ source: UserChatRow['source'];
37
+ activeJobs: Array<UserChatRevisionJob>;
38
+ activeTimeouts: Array<UserChatRevisionTimeout>;
39
+ };
40
+
41
+ /**
42
+ * Loads one scoped chat revision without hydrating the full `messages` JSON payload.
43
+ */
44
+ export async function getUserChatRevision(options: {
45
+ userId: number;
46
+ viewerIsAdmin?: boolean;
47
+ agentPermanentId: string;
48
+ chatId: string;
49
+ }): Promise<UserChatRevision | null> {
50
+ const { userId, viewerIsAdmin = false, agentPermanentId, chatId } = options;
51
+ const userChatTable = await provideUserChatTable();
52
+
53
+ const { data, error } = await userChatTable
54
+ .select('id, userId, updatedAt, draftMessage, source')
55
+ .eq('id', chatId)
56
+ .eq('agentPermanentId', agentPermanentId)
57
+ .maybeSingle();
58
+
59
+ if (error) {
60
+ throw new Error(`Failed to load user chat revision "${chatId}": ${error.message}`);
61
+ }
62
+
63
+ if (!data) {
64
+ return null;
65
+ }
66
+
67
+ const row = data as Pick<UserChatRow, 'id' | 'userId' | 'updatedAt' | 'draftMessage' | 'source'>;
68
+ if (row.source === USER_CHAT_SOURCES.WEB_UI) {
69
+ if (row.userId !== userId) {
70
+ return null;
71
+ }
72
+ } else if (!viewerIsAdmin) {
73
+ return null;
74
+ }
75
+
76
+ const [activeJobs, activeTimeouts] = await Promise.all([
77
+ listUserChatRevisionActiveJobs({ userId: row.userId, agentPermanentId, chatId }),
78
+ listUserChatRevisionActiveTimeouts({ userId: row.userId, agentPermanentId, chatId }),
79
+ ]);
80
+
81
+ return {
82
+ id: row.id,
83
+ updatedAt: row.updatedAt,
84
+ draftMessage: row.draftMessage,
85
+ source: row.source,
86
+ activeJobs,
87
+ activeTimeouts,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Lists only active job fields that affect the canonical chat surface.
93
+ */
94
+ async function listUserChatRevisionActiveJobs(options: {
95
+ userId: number;
96
+ agentPermanentId: string;
97
+ chatId: string;
98
+ }): Promise<Array<UserChatRevisionJob>> {
99
+ const userChatJobTable = await provideUserChatJobTable();
100
+ const { data, error } = await userChatJobTable
101
+ .select('id, status, cancelRequestedAt')
102
+ .eq('chatId', options.chatId)
103
+ .eq('userId', options.userId)
104
+ .eq('agentPermanentId', options.agentPermanentId)
105
+ .in('status', ACTIVE_USER_CHAT_JOB_STATUSES)
106
+ .order('createdAt', { ascending: true });
107
+
108
+ if (error) {
109
+ throw new Error(`Failed to load active user chat job revisions for chat "${options.chatId}": ${error.message}`);
110
+ }
111
+
112
+ return (data || []) as Array<UserChatRevisionJob>;
113
+ }
114
+
115
+ /**
116
+ * Lists only active timeout fields that affect the canonical chat surface.
117
+ */
118
+ async function listUserChatRevisionActiveTimeouts(options: {
119
+ userId: number;
120
+ agentPermanentId: string;
121
+ chatId: string;
122
+ }): Promise<Array<UserChatRevisionTimeout>> {
123
+ const userChatTimeoutTable = await provideUserChatTimeoutTable();
124
+ const { data, error } = await userChatTimeoutTable
125
+ .select('id, status, message, dueAt, cancelRequestedAt')
126
+ .eq('chatId', options.chatId)
127
+ .eq('userId', options.userId)
128
+ .eq('agentPermanentId', options.agentPermanentId)
129
+ .in('status', ACTIVE_USER_CHAT_TIMEOUT_STATUSES)
130
+ .is('pausedAt', null)
131
+ .order('dueAt', { ascending: true })
132
+ .order('createdAt', { ascending: true });
133
+
134
+ if (error) {
135
+ if (isMissingUserChatTimeoutRelationError(error)) {
136
+ return [];
137
+ }
138
+
139
+ throw new Error(
140
+ `Failed to load active user chat timeout revisions for chat "${options.chatId}": ${error.message}`,
141
+ );
142
+ }
143
+
144
+ return (data || []) as Array<UserChatRevisionTimeout>;
145
+ }
@@ -13,6 +13,7 @@ export { getUserChat } from './userChat/getUserChat';
13
13
  export { getUserChatJob } from './userChat/getUserChatJob';
14
14
  export { getUserChatJobById } from './userChat/getUserChatJobById';
15
15
  export { getUserChatJobByClientMessageId } from './userChat/getUserChatJobByClientMessageId';
16
+ export { getUserChatRevision } from './userChat/getUserChatRevision';
16
17
  export { heartbeatUserChatJob } from './userChat/heartbeatUserChatJob';
17
18
  export { listExpiredRunningUserChatJobs } from './userChat/listExpiredRunningUserChatJobs';
18
19
  export { listUserChats, listUserChatSummarySeeds } from './userChat/listUserChats';