@agent-native/dispatch 0.2.20 → 0.4.0

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 (79) hide show
  1. package/dist/actions/archive-workspace-app.d.ts +3 -0
  2. package/dist/actions/archive-workspace-app.d.ts.map +1 -0
  3. package/dist/actions/archive-workspace-app.js +15 -0
  4. package/dist/actions/archive-workspace-app.js.map +1 -0
  5. package/dist/actions/get-agent-thread-debug.d.ts +3 -0
  6. package/dist/actions/get-agent-thread-debug.d.ts.map +1 -0
  7. package/dist/actions/get-agent-thread-debug.js +24 -0
  8. package/dist/actions/get-agent-thread-debug.js.map +1 -0
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +8 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/list-agent-thread-sources.d.ts +3 -0
  13. package/dist/actions/list-agent-thread-sources.d.ts.map +1 -0
  14. package/dist/actions/list-agent-thread-sources.js +11 -0
  15. package/dist/actions/list-agent-thread-sources.js.map +1 -0
  16. package/dist/actions/list-available-workspace-templates.d.ts +3 -0
  17. package/dist/actions/list-available-workspace-templates.d.ts.map +1 -0
  18. package/dist/actions/list-available-workspace-templates.js +10 -0
  19. package/dist/actions/list-available-workspace-templates.js.map +1 -0
  20. package/dist/actions/navigate.js +1 -1
  21. package/dist/actions/navigate.js.map +1 -1
  22. package/dist/actions/remove-pending-workspace-app.d.ts +3 -0
  23. package/dist/actions/remove-pending-workspace-app.d.ts.map +1 -0
  24. package/dist/actions/remove-pending-workspace-app.js +15 -0
  25. package/dist/actions/remove-pending-workspace-app.js.map +1 -0
  26. package/dist/actions/scaffold-workspace-app.d.ts +3 -0
  27. package/dist/actions/scaffold-workspace-app.d.ts.map +1 -0
  28. package/dist/actions/scaffold-workspace-app.js +27 -0
  29. package/dist/actions/scaffold-workspace-app.js.map +1 -0
  30. package/dist/actions/search-agent-threads.d.ts +3 -0
  31. package/dist/actions/search-agent-threads.d.ts.map +1 -0
  32. package/dist/actions/search-agent-threads.js +25 -0
  33. package/dist/actions/search-agent-threads.js.map +1 -0
  34. package/dist/actions/unarchive-workspace-app.d.ts +3 -0
  35. package/dist/actions/unarchive-workspace-app.d.ts.map +1 -0
  36. package/dist/actions/unarchive-workspace-app.js +15 -0
  37. package/dist/actions/unarchive-workspace-app.js.map +1 -0
  38. package/dist/actions/view-screen.d.ts.map +1 -1
  39. package/dist/actions/view-screen.js +38 -0
  40. package/dist/actions/view-screen.js.map +1 -1
  41. package/dist/components/layout/Layout.d.ts.map +1 -1
  42. package/dist/components/layout/Layout.js +8 -1
  43. package/dist/components/layout/Layout.js.map +1 -1
  44. package/dist/components/ui/command.d.ts +7 -7
  45. package/dist/hooks/use-navigation-state.js +5 -0
  46. package/dist/hooks/use-navigation-state.js.map +1 -1
  47. package/dist/routes/index.d.ts.map +1 -1
  48. package/dist/routes/index.js +1 -0
  49. package/dist/routes/index.js.map +1 -1
  50. package/dist/routes/pages/thread-debug.d.ts +5 -0
  51. package/dist/routes/pages/thread-debug.d.ts.map +1 -0
  52. package/dist/routes/pages/thread-debug.js +160 -0
  53. package/dist/routes/pages/thread-debug.js.map +1 -0
  54. package/dist/server/lib/app-creation-store.d.ts +40 -0
  55. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  56. package/dist/server/lib/app-creation-store.js +245 -6
  57. package/dist/server/lib/app-creation-store.js.map +1 -1
  58. package/dist/server/lib/thread-debug-store.d.ts +101 -0
  59. package/dist/server/lib/thread-debug-store.d.ts.map +1 -0
  60. package/dist/server/lib/thread-debug-store.js +587 -0
  61. package/dist/server/lib/thread-debug-store.js.map +1 -0
  62. package/package.json +2 -2
  63. package/src/actions/archive-workspace-app.ts +16 -0
  64. package/src/actions/get-agent-thread-debug.ts +25 -0
  65. package/src/actions/index.ts +12 -0
  66. package/src/actions/list-agent-thread-sources.ts +12 -0
  67. package/src/actions/list-available-workspace-templates.ts +11 -0
  68. package/src/actions/navigate.ts +1 -1
  69. package/src/actions/remove-pending-workspace-app.ts +16 -0
  70. package/src/actions/scaffold-workspace-app.ts +34 -0
  71. package/src/actions/search-agent-threads.ts +30 -0
  72. package/src/actions/unarchive-workspace-app.ts +16 -0
  73. package/src/actions/view-screen.ts +41 -0
  74. package/src/components/layout/Layout.tsx +8 -0
  75. package/src/hooks/use-navigation-state.ts +4 -0
  76. package/src/routes/index.ts +1 -0
  77. package/src/routes/pages/thread-debug.tsx +683 -0
  78. package/src/server/lib/app-creation-store.ts +312 -15
  79. package/src/server/lib/thread-debug-store.ts +779 -0
@@ -0,0 +1,779 @@
1
+ import { createDbExec, getDbExec, type DbExec } from "@agent-native/core/db";
2
+ import { currentOrgId, currentOwnerEmail } from "./dispatch-store.js";
3
+
4
+ const CONFIG_ENV_KEY = "AGENT_NATIVE_THREAD_DEBUG_DATABASES";
5
+ const MAX_RAW_PREVIEW_CHARS = 1_500;
6
+ const DEFAULT_SEARCH_LIMIT = 25;
7
+ const MAX_SEARCH_LIMIT = 100;
8
+ const DEFAULT_RUN_LIMIT = 20;
9
+ const DEFAULT_EVENT_LIMIT = 600;
10
+ const DEFAULT_TRACE_SPAN_LIMIT = 500;
11
+
12
+ interface ThreadDebugSourceConfig {
13
+ id: string;
14
+ label: string;
15
+ kind: "current" | "env" | "configured";
16
+ databaseUrl?: string;
17
+ databaseAuthToken?: string;
18
+ databaseUrlEnv?: string | null;
19
+ databaseAuthTokenEnv?: string | null;
20
+ }
21
+
22
+ export interface ThreadDebugSource {
23
+ id: string;
24
+ label: string;
25
+ kind: "current" | "env" | "configured";
26
+ current: boolean;
27
+ connected: boolean;
28
+ databaseUrlEnv: string | null;
29
+ databaseAuthTokenEnv: string | null;
30
+ canInspectAll: boolean;
31
+ }
32
+
33
+ interface DebugAccess {
34
+ viewerEmail: string;
35
+ orgId: string | null;
36
+ role: string | null;
37
+ envAdmin: boolean;
38
+ canInspectAll: boolean;
39
+ memberEmails: string[];
40
+ }
41
+
42
+ interface OwnerScope {
43
+ sql: string;
44
+ args: unknown[];
45
+ label: string;
46
+ }
47
+
48
+ interface ChatThreadRow {
49
+ id: string;
50
+ owner_email: string;
51
+ title: string | null;
52
+ preview: string | null;
53
+ thread_data: string | null;
54
+ message_count: number | string | null;
55
+ created_at: number | string;
56
+ updated_at: number | string;
57
+ }
58
+
59
+ interface AgentRunRow {
60
+ id: string;
61
+ thread_id: string;
62
+ status: string;
63
+ abort_reason?: string | null;
64
+ started_at: number | string;
65
+ completed_at?: number | string | null;
66
+ heartbeat_at?: number | string | null;
67
+ }
68
+
69
+ const execCache = new Map<string, Promise<DbExec>>();
70
+
71
+ function envEmails(name: string): string[] {
72
+ return (process.env[name] ?? "")
73
+ .split(",")
74
+ .map((value) => value.trim().toLowerCase())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function isEnvAdmin(email: string): boolean {
79
+ const normalized = email.trim().toLowerCase();
80
+ return [
81
+ ...envEmails("DISPATCH_ADMIN_EMAILS"),
82
+ ...envEmails("WORKSPACE_OWNER_EMAIL"),
83
+ ...envEmails("DISPATCH_DEFAULT_OWNER_EMAIL"),
84
+ ].includes(normalized);
85
+ }
86
+
87
+ function isMissingTableError(error: unknown): boolean {
88
+ const message = String((error as Error)?.message ?? error).toLowerCase();
89
+ return (
90
+ message.includes("no such table") ||
91
+ message.includes("does not exist") ||
92
+ message.includes("unknown table") ||
93
+ message.includes("undefined table")
94
+ );
95
+ }
96
+
97
+ async function optionalRows<T = Record<string, unknown>>(
98
+ exec: DbExec,
99
+ sql: string,
100
+ args: unknown[] = [],
101
+ ): Promise<T[]> {
102
+ try {
103
+ return (await exec.execute({ sql, args })).rows as T[];
104
+ } catch (error) {
105
+ if (isMissingTableError(error)) return [];
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function queryRows<T = Record<string, unknown>>(
111
+ exec: DbExec,
112
+ sql: string,
113
+ args: unknown[] = [],
114
+ ): Promise<T[]> {
115
+ try {
116
+ return (await exec.execute({ sql, args })).rows as T[];
117
+ } catch (error) {
118
+ if (isMissingTableError(error)) {
119
+ throw new Error(
120
+ "This database does not have agent chat thread tables yet.",
121
+ );
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ function numberField(value: unknown): number {
128
+ const parsed = Number(value ?? 0);
129
+ return Number.isFinite(parsed) ? parsed : 0;
130
+ }
131
+
132
+ function nullableNumberField(value: unknown): number | null {
133
+ if (value == null) return null;
134
+ const parsed = Number(value);
135
+ return Number.isFinite(parsed) ? parsed : null;
136
+ }
137
+
138
+ function safeJsonParse<T>(value: unknown, fallback: T): T {
139
+ if (value == null || value === "") return fallback;
140
+ try {
141
+ return JSON.parse(String(value)) as T;
142
+ } catch {
143
+ return fallback;
144
+ }
145
+ }
146
+
147
+ function safeJsonStringify(value: unknown): string {
148
+ try {
149
+ return JSON.stringify(value, null, 2);
150
+ } catch {
151
+ return String(value);
152
+ }
153
+ }
154
+
155
+ function textFromContent(content: unknown): string {
156
+ if (typeof content === "string") return content;
157
+ if (!Array.isArray(content)) return "";
158
+ return content
159
+ .map((part: any) => {
160
+ if (part?.type === "text" && typeof part.text === "string") {
161
+ return part.text;
162
+ }
163
+ if (part?.type === "tool-call") {
164
+ const name = part.toolName ?? part.name ?? "tool";
165
+ return `[tool:${name}] ${safeJsonStringify(part.args ?? part.input ?? {})}`;
166
+ }
167
+ if (part?.type === "tool-result") {
168
+ return `[tool-result:${part.toolName ?? part.toolCallId ?? "tool"}] ${
169
+ typeof part.content === "string"
170
+ ? part.content
171
+ : safeJsonStringify(part.content)
172
+ }`;
173
+ }
174
+ return "";
175
+ })
176
+ .filter(Boolean)
177
+ .join("\n");
178
+ }
179
+
180
+ function normalizeMessages(threadData: any) {
181
+ const messages = Array.isArray(threadData?.messages)
182
+ ? threadData.messages
183
+ : [];
184
+ return messages.map((entry: any, index: number) => {
185
+ const message = entry?.message ?? entry;
186
+ const content = message?.content;
187
+ const contentParts = Array.isArray(content) ? content : [];
188
+ return {
189
+ index,
190
+ id: typeof message?.id === "string" ? message.id : null,
191
+ role: typeof message?.role === "string" ? message.role : "unknown",
192
+ createdAt: message?.createdAt ?? null,
193
+ status: message?.status ?? null,
194
+ text: textFromContent(content),
195
+ contentParts,
196
+ attachments: Array.isArray(message?.attachments)
197
+ ? message.attachments
198
+ : [],
199
+ metadata: message?.metadata ?? null,
200
+ parentId: entry?.parentId ?? null,
201
+ };
202
+ });
203
+ }
204
+
205
+ function snippetFor(row: ChatThreadRow, query: string): string {
206
+ const raw = `${row.title ?? ""}\n${row.preview ?? ""}\n${row.thread_data ?? ""}`;
207
+ const compact = raw.replace(/\s+/g, " ").trim();
208
+ if (!query.trim()) {
209
+ return compact.slice(0, MAX_RAW_PREVIEW_CHARS);
210
+ }
211
+ const lower = compact.toLowerCase();
212
+ const needle = query.trim().toLowerCase();
213
+ const match = lower.indexOf(needle);
214
+ if (match === -1) return compact.slice(0, MAX_RAW_PREVIEW_CHARS);
215
+ const start = Math.max(0, match - 160);
216
+ const end = Math.min(compact.length, match + needle.length + 320);
217
+ const prefix = start > 0 ? "..." : "";
218
+ const suffix = end < compact.length ? "..." : "";
219
+ return `${prefix}${compact.slice(start, end)}${suffix}`;
220
+ }
221
+
222
+ function envPrefixForSourceId(sourceId: string): string {
223
+ return sourceId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
224
+ }
225
+
226
+ function sourceIdFromEnvPrefix(prefix: string): string {
227
+ return prefix.toLowerCase().replace(/_/g, "-");
228
+ }
229
+
230
+ function labelFromSourceId(sourceId: string): string {
231
+ return sourceId
232
+ .split(/[-_\s]+/)
233
+ .filter(Boolean)
234
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
235
+ .join(" ");
236
+ }
237
+
238
+ function currentDatabaseUrlEnv(): string | null {
239
+ const appName = process.env.APP_NAME?.toUpperCase().replace(/-/g, "_");
240
+ if (appName && process.env[`${appName}_DATABASE_URL`]) {
241
+ return `${appName}_DATABASE_URL`;
242
+ }
243
+ return process.env.DATABASE_URL ? "DATABASE_URL" : null;
244
+ }
245
+
246
+ function parseConfiguredSources(): ThreadDebugSourceConfig[] {
247
+ const raw = process.env[CONFIG_ENV_KEY];
248
+ if (!raw) return [];
249
+ const parsed = safeJsonParse<any>(raw, null);
250
+ const entries = Array.isArray(parsed)
251
+ ? parsed
252
+ : parsed && typeof parsed === "object"
253
+ ? Object.entries(parsed).map(([id, value]) => ({ id, ...(value as any) }))
254
+ : [];
255
+
256
+ return entries
257
+ .map((entry: any): ThreadDebugSourceConfig | null => {
258
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
259
+ if (!id) return null;
260
+ const databaseUrlEnv =
261
+ typeof entry.databaseUrlEnv === "string"
262
+ ? entry.databaseUrlEnv.trim()
263
+ : null;
264
+ const databaseAuthTokenEnv =
265
+ typeof entry.databaseAuthTokenEnv === "string"
266
+ ? entry.databaseAuthTokenEnv.trim()
267
+ : null;
268
+ const databaseUrl =
269
+ typeof entry.databaseUrl === "string" && entry.databaseUrl.trim()
270
+ ? entry.databaseUrl.trim()
271
+ : databaseUrlEnv
272
+ ? process.env[databaseUrlEnv]
273
+ : undefined;
274
+ if (!databaseUrl) return null;
275
+ return {
276
+ id,
277
+ label:
278
+ typeof entry.label === "string" && entry.label.trim()
279
+ ? entry.label.trim()
280
+ : labelFromSourceId(id),
281
+ kind: "configured",
282
+ databaseUrl,
283
+ databaseAuthToken:
284
+ typeof entry.databaseAuthToken === "string"
285
+ ? entry.databaseAuthToken
286
+ : databaseAuthTokenEnv
287
+ ? process.env[databaseAuthTokenEnv]
288
+ : undefined,
289
+ databaseUrlEnv,
290
+ databaseAuthTokenEnv,
291
+ };
292
+ })
293
+ .filter((source): source is ThreadDebugSourceConfig => Boolean(source));
294
+ }
295
+
296
+ function discoverEnvSources(): ThreadDebugSourceConfig[] {
297
+ const ignored = new Set([
298
+ "DATABASE_URL",
299
+ "NETLIFY_DATABASE_URL",
300
+ "NETLIFY_DATABASE_URL_UNPOOLED",
301
+ ]);
302
+ const currentAppPrefix = process.env.APP_NAME?.toUpperCase().replace(
303
+ /-/g,
304
+ "_",
305
+ );
306
+ const sources: ThreadDebugSourceConfig[] = [];
307
+ for (const [key, value] of Object.entries(process.env)) {
308
+ if (!value || !key.endsWith("_DATABASE_URL")) continue;
309
+ if (ignored.has(key) || key.endsWith("_UNPOOLED_DATABASE_URL")) continue;
310
+ const prefix = key.slice(0, -"_DATABASE_URL".length);
311
+ if (!prefix || prefix === "NETLIFY") continue;
312
+ if (currentAppPrefix && prefix === currentAppPrefix) continue;
313
+ const id = sourceIdFromEnvPrefix(prefix);
314
+ const tokenEnv = `${prefix}_DATABASE_AUTH_TOKEN`;
315
+ sources.push({
316
+ id,
317
+ label: labelFromSourceId(id),
318
+ kind: "env",
319
+ databaseUrl: value,
320
+ databaseAuthToken: process.env[tokenEnv],
321
+ databaseUrlEnv: key,
322
+ databaseAuthTokenEnv: process.env[tokenEnv] ? tokenEnv : null,
323
+ });
324
+ }
325
+ return sources;
326
+ }
327
+
328
+ function sourceConfigs(): ThreadDebugSourceConfig[] {
329
+ const byId = new Map<string, ThreadDebugSourceConfig>();
330
+ byId.set("current", {
331
+ id: "current",
332
+ label: "Current Dispatch DB",
333
+ kind: "current",
334
+ databaseUrlEnv: currentDatabaseUrlEnv(),
335
+ databaseAuthTokenEnv: process.env.DATABASE_AUTH_TOKEN
336
+ ? "DATABASE_AUTH_TOKEN"
337
+ : null,
338
+ });
339
+ for (const source of discoverEnvSources()) byId.set(source.id, source);
340
+ for (const source of parseConfiguredSources()) byId.set(source.id, source);
341
+ return [...byId.values()];
342
+ }
343
+
344
+ function resolveSourceConfig(sourceId = "current"): ThreadDebugSourceConfig {
345
+ const normalized = sourceId.trim() || "current";
346
+ const direct = sourceConfigs().find((source) => source.id === normalized);
347
+ if (direct) return direct;
348
+
349
+ const prefix = envPrefixForSourceId(normalized);
350
+ const databaseUrlEnv = `${prefix}_DATABASE_URL`;
351
+ const databaseUrl = process.env[databaseUrlEnv];
352
+ if (!databaseUrl) {
353
+ throw new Error(`Thread debug source "${normalized}" is not configured.`);
354
+ }
355
+ const tokenEnv = `${prefix}_DATABASE_AUTH_TOKEN`;
356
+ return {
357
+ id: normalized,
358
+ label: labelFromSourceId(normalized),
359
+ kind: "env",
360
+ databaseUrl,
361
+ databaseAuthToken: process.env[tokenEnv],
362
+ databaseUrlEnv,
363
+ databaseAuthTokenEnv: process.env[tokenEnv] ? tokenEnv : null,
364
+ };
365
+ }
366
+
367
+ async function execForSource(source: ThreadDebugSourceConfig): Promise<DbExec> {
368
+ if (source.kind === "current") return getDbExec();
369
+ const cacheKey = `${source.databaseUrl ?? ""}\n${source.databaseAuthToken ?? ""}`;
370
+ if (!execCache.has(cacheKey)) {
371
+ execCache.set(
372
+ cacheKey,
373
+ createDbExec({
374
+ url: source.databaseUrl,
375
+ authToken: source.databaseAuthToken,
376
+ }),
377
+ );
378
+ }
379
+ return execCache.get(cacheKey)!;
380
+ }
381
+
382
+ async function currentDbRows<T = Record<string, unknown>>(
383
+ sql: string,
384
+ args: unknown[] = [],
385
+ ): Promise<T[]> {
386
+ return optionalRows<T>(getDbExec(), sql, args);
387
+ }
388
+
389
+ async function viewerOrgRole(
390
+ orgId: string | null,
391
+ viewerEmail: string,
392
+ ): Promise<string | null> {
393
+ if (!orgId) return null;
394
+ const rows = await currentDbRows<{ role?: string }>(
395
+ `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
396
+ [orgId, viewerEmail.toLowerCase()],
397
+ );
398
+ return typeof rows[0]?.role === "string" ? rows[0].role : null;
399
+ }
400
+
401
+ async function currentOrgMembers(orgId: string | null): Promise<string[]> {
402
+ if (!orgId) return [];
403
+ const rows = await currentDbRows<{ email?: string }>(
404
+ `SELECT email FROM org_members WHERE org_id = ?`,
405
+ [orgId],
406
+ );
407
+ return rows.map((row) => String(row.email ?? "").trim()).filter(Boolean);
408
+ }
409
+
410
+ async function resolveDebugAccess(): Promise<DebugAccess> {
411
+ const viewerEmail = currentOwnerEmail();
412
+ const orgId = currentOrgId();
413
+ const role = await viewerOrgRole(orgId, viewerEmail);
414
+ const envAdmin = isEnvAdmin(viewerEmail);
415
+ const canInspectAll = envAdmin || role === "owner" || role === "admin";
416
+ const memberEmails = canInspectAll
417
+ ? await currentOrgMembers(orgId)
418
+ : [viewerEmail];
419
+ return {
420
+ viewerEmail,
421
+ orgId,
422
+ role,
423
+ envAdmin,
424
+ canInspectAll,
425
+ memberEmails: memberEmails.length > 0 ? memberEmails : [viewerEmail],
426
+ };
427
+ }
428
+
429
+ function assertSourceAccess(
430
+ source: ThreadDebugSourceConfig,
431
+ access: DebugAccess,
432
+ ) {
433
+ if (source.kind === "current") return;
434
+ if (!access.canInspectAll) {
435
+ throw new Error(
436
+ "Only Dispatch admins can inspect thread databases from other apps.",
437
+ );
438
+ }
439
+ }
440
+
441
+ function ownerScope(access: DebugAccess, ownerEmail?: string): OwnerScope {
442
+ const requested = ownerEmail?.trim();
443
+ if (!access.canInspectAll) {
444
+ return {
445
+ sql: "owner_email = ?",
446
+ args: [access.viewerEmail],
447
+ label: access.viewerEmail,
448
+ };
449
+ }
450
+
451
+ if (requested && requested !== "*") {
452
+ return {
453
+ sql: "owner_email = ?",
454
+ args: [requested],
455
+ label: requested,
456
+ };
457
+ }
458
+
459
+ if (access.envAdmin && !access.orgId) {
460
+ return { sql: "1 = 1", args: [], label: "all users" };
461
+ }
462
+
463
+ const emails = access.memberEmails;
464
+ if (emails.length === 0) {
465
+ return {
466
+ sql: "owner_email = ?",
467
+ args: [access.viewerEmail],
468
+ label: access.viewerEmail,
469
+ };
470
+ }
471
+ const placeholders = emails.map(() => "?").join(", ");
472
+ return {
473
+ sql: `owner_email IN (${placeholders})`,
474
+ args: emails,
475
+ label: access.orgId ? "current organization" : "all users",
476
+ };
477
+ }
478
+
479
+ function serializeThreadSummary(row: ChatThreadRow, query = "") {
480
+ return {
481
+ id: String(row.id),
482
+ ownerEmail: String(row.owner_email ?? ""),
483
+ title: String(row.title ?? ""),
484
+ preview: String(row.preview ?? ""),
485
+ messageCount: numberField(row.message_count),
486
+ createdAt: numberField(row.created_at),
487
+ updatedAt: numberField(row.updated_at),
488
+ snippet: snippetFor(row, query),
489
+ };
490
+ }
491
+
492
+ function serializeRun(row: AgentRunRow, events: any[] = []) {
493
+ return {
494
+ id: String(row.id),
495
+ threadId: String(row.thread_id),
496
+ status: String(row.status),
497
+ abortReason: row.abort_reason ? String(row.abort_reason) : null,
498
+ startedAt: numberField(row.started_at),
499
+ completedAt: nullableNumberField(row.completed_at),
500
+ heartbeatAt: nullableNumberField(row.heartbeat_at),
501
+ events,
502
+ };
503
+ }
504
+
505
+ function parseRunEvent(row: Record<string, unknown>) {
506
+ const raw = String(row.event_data ?? "");
507
+ return {
508
+ runId: String(row.run_id ?? ""),
509
+ seq: numberField(row.seq),
510
+ event: safeJsonParse(raw, { type: "unparseable", raw }),
511
+ rawEventData: raw,
512
+ };
513
+ }
514
+
515
+ export async function listThreadDebugSources(): Promise<{
516
+ access: Omit<DebugAccess, "memberEmails" | "envAdmin"> & {
517
+ envAdmin: boolean;
518
+ memberCount: number;
519
+ };
520
+ sources: ThreadDebugSource[];
521
+ }> {
522
+ const access = await resolveDebugAccess();
523
+ return {
524
+ access: {
525
+ viewerEmail: access.viewerEmail,
526
+ orgId: access.orgId,
527
+ role: access.role,
528
+ envAdmin: access.envAdmin,
529
+ canInspectAll: access.canInspectAll,
530
+ memberCount: access.memberEmails.length,
531
+ },
532
+ sources: sourceConfigs().map((source) => ({
533
+ id: source.id,
534
+ label: source.label,
535
+ kind: source.kind,
536
+ current: source.kind === "current",
537
+ connected: source.kind === "current" || Boolean(source.databaseUrl),
538
+ databaseUrlEnv: source.databaseUrlEnv ?? null,
539
+ databaseAuthTokenEnv: source.databaseAuthTokenEnv ?? null,
540
+ canInspectAll: access.canInspectAll,
541
+ })),
542
+ };
543
+ }
544
+
545
+ export async function searchAgentThreads(input: {
546
+ sourceId?: string;
547
+ query?: string;
548
+ ownerEmail?: string;
549
+ limit?: number;
550
+ }) {
551
+ const source = resolveSourceConfig(input.sourceId ?? "current");
552
+ const access = await resolveDebugAccess();
553
+ assertSourceAccess(source, access);
554
+ const exec = await execForSource(source);
555
+ const limit = Math.max(
556
+ 1,
557
+ Math.min(MAX_SEARCH_LIMIT, input.limit ?? DEFAULT_SEARCH_LIMIT),
558
+ );
559
+ const q = input.query?.trim() ?? "";
560
+ const scope = ownerScope(access, input.ownerEmail);
561
+ const where = [scope.sql];
562
+ const args: unknown[] = [...scope.args];
563
+ if (q) {
564
+ const pattern = `%${q.toLowerCase()}%`;
565
+ where.push(
566
+ `(LOWER(title) LIKE ? OR LOWER(preview) LIKE ? OR LOWER(thread_data) LIKE ?)`,
567
+ );
568
+ args.push(pattern, pattern, pattern);
569
+ }
570
+ args.push(limit);
571
+
572
+ const rows = await optionalRows<ChatThreadRow>(
573
+ exec,
574
+ `SELECT id, owner_email, title, preview, thread_data, message_count, created_at, updated_at
575
+ FROM chat_threads
576
+ WHERE ${where.join(" AND ")}
577
+ ORDER BY updated_at DESC
578
+ LIMIT ?`,
579
+ args,
580
+ );
581
+ return {
582
+ source: {
583
+ id: source.id,
584
+ label: source.label,
585
+ kind: source.kind,
586
+ databaseUrlEnv: source.databaseUrlEnv ?? null,
587
+ },
588
+ access: {
589
+ viewerEmail: access.viewerEmail,
590
+ scope: scope.label,
591
+ canInspectAll: access.canInspectAll,
592
+ },
593
+ query: q,
594
+ count: rows.length,
595
+ threads: rows.map((row) => serializeThreadSummary(row, q)),
596
+ };
597
+ }
598
+
599
+ export async function getAgentThreadDebug(input: {
600
+ sourceId?: string;
601
+ threadId: string;
602
+ ownerEmail?: string;
603
+ maxRuns?: number;
604
+ maxEvents?: number;
605
+ maxTraceSpans?: number;
606
+ }) {
607
+ const source = resolveSourceConfig(input.sourceId ?? "current");
608
+ const access = await resolveDebugAccess();
609
+ assertSourceAccess(source, access);
610
+ const exec = await execForSource(source);
611
+ const scope = ownerScope(access, input.ownerEmail);
612
+ const rows = await queryRows<ChatThreadRow>(
613
+ exec,
614
+ `SELECT id, owner_email, title, preview, thread_data, message_count, created_at, updated_at
615
+ FROM chat_threads
616
+ WHERE id = ? AND ${scope.sql}
617
+ LIMIT 1`,
618
+ [input.threadId, ...scope.args],
619
+ );
620
+ const row = rows[0];
621
+ if (!row) {
622
+ throw new Error(`Thread "${input.threadId}" was not found.`);
623
+ }
624
+
625
+ const threadData = safeJsonParse<Record<string, unknown>>(
626
+ row.thread_data,
627
+ {},
628
+ );
629
+ const maxRuns = Math.max(1, Math.min(50, input.maxRuns ?? DEFAULT_RUN_LIMIT));
630
+ const maxEvents = Math.max(
631
+ 1,
632
+ Math.min(2_000, input.maxEvents ?? DEFAULT_EVENT_LIMIT),
633
+ );
634
+ const maxTraceSpans = Math.max(
635
+ 1,
636
+ Math.min(2_000, input.maxTraceSpans ?? DEFAULT_TRACE_SPAN_LIMIT),
637
+ );
638
+
639
+ const runRows = await optionalRows<AgentRunRow>(
640
+ exec,
641
+ `SELECT id, thread_id, status, abort_reason, started_at, heartbeat_at, completed_at
642
+ FROM agent_runs
643
+ WHERE thread_id = ?
644
+ ORDER BY started_at DESC
645
+ LIMIT ?`,
646
+ [row.id, maxRuns],
647
+ );
648
+
649
+ const runs = [];
650
+ for (const run of runRows) {
651
+ const eventRows = await optionalRows<Record<string, unknown>>(
652
+ exec,
653
+ `SELECT run_id, seq, event_data
654
+ FROM agent_run_events
655
+ WHERE run_id = ?
656
+ ORDER BY seq ASC
657
+ LIMIT ?`,
658
+ [run.id, maxEvents],
659
+ );
660
+ runs.push(serializeRun(run, eventRows.map(parseRunEvent)));
661
+ }
662
+
663
+ const runIds = runRows.map((run) => run.id);
664
+ const runPlaceholders = runIds.map(() => "?").join(", ");
665
+ const traceSummaryRows = await optionalRows<Record<string, unknown>>(
666
+ exec,
667
+ runIds.length > 0
668
+ ? `SELECT *
669
+ FROM agent_trace_summaries
670
+ WHERE thread_id = ? OR run_id IN (${runPlaceholders})
671
+ ORDER BY created_at DESC
672
+ LIMIT 50`
673
+ : `SELECT *
674
+ FROM agent_trace_summaries
675
+ WHERE thread_id = ?
676
+ ORDER BY created_at DESC
677
+ LIMIT 50`,
678
+ runIds.length > 0 ? [row.id, ...runIds] : [row.id],
679
+ );
680
+ const traceSpanRows = await optionalRows<Record<string, unknown>>(
681
+ exec,
682
+ runIds.length > 0
683
+ ? `SELECT *
684
+ FROM agent_trace_spans
685
+ WHERE thread_id = ? OR run_id IN (${runPlaceholders})
686
+ ORDER BY created_at ASC
687
+ LIMIT ?`
688
+ : `SELECT *
689
+ FROM agent_trace_spans
690
+ WHERE thread_id = ?
691
+ ORDER BY created_at ASC
692
+ LIMIT ?`,
693
+ runIds.length > 0
694
+ ? [row.id, ...runIds, maxTraceSpans]
695
+ : [row.id, maxTraceSpans],
696
+ );
697
+ const feedbackRows = await optionalRows<Record<string, unknown>>(
698
+ exec,
699
+ `SELECT *
700
+ FROM agent_feedback
701
+ WHERE thread_id = ?
702
+ ORDER BY created_at DESC
703
+ LIMIT 50`,
704
+ [row.id],
705
+ );
706
+ const satisfactionRows = await optionalRows<Record<string, unknown>>(
707
+ exec,
708
+ `SELECT *
709
+ FROM agent_satisfaction_scores
710
+ WHERE thread_id = ?
711
+ ORDER BY computed_at DESC
712
+ LIMIT 10`,
713
+ [row.id],
714
+ );
715
+ const evalRows = await optionalRows<Record<string, unknown>>(
716
+ exec,
717
+ runIds.length > 0
718
+ ? `SELECT *
719
+ FROM agent_evals
720
+ WHERE thread_id = ? OR run_id IN (${runPlaceholders})
721
+ ORDER BY created_at DESC
722
+ LIMIT 50`
723
+ : `SELECT *
724
+ FROM agent_evals
725
+ WHERE thread_id = ?
726
+ ORDER BY created_at DESC
727
+ LIMIT 50`,
728
+ runIds.length > 0 ? [row.id, ...runIds] : [row.id],
729
+ );
730
+ const checkpointRows = await optionalRows<Record<string, unknown>>(
731
+ exec,
732
+ `SELECT id, thread_id, run_id, commit_sha, message, created_at
733
+ FROM agent_checkpoints
734
+ WHERE thread_id = ?
735
+ ORDER BY created_at DESC
736
+ LIMIT 50`,
737
+ [row.id],
738
+ );
739
+
740
+ return {
741
+ source: {
742
+ id: source.id,
743
+ label: source.label,
744
+ kind: source.kind,
745
+ databaseUrlEnv: source.databaseUrlEnv ?? null,
746
+ },
747
+ access: {
748
+ viewerEmail: access.viewerEmail,
749
+ scope: scope.label,
750
+ canInspectAll: access.canInspectAll,
751
+ },
752
+ thread: {
753
+ id: String(row.id),
754
+ ownerEmail: String(row.owner_email ?? ""),
755
+ title: String(row.title ?? ""),
756
+ preview: String(row.preview ?? ""),
757
+ messageCount: numberField(row.message_count),
758
+ createdAt: numberField(row.created_at),
759
+ updatedAt: numberField(row.updated_at),
760
+ },
761
+ messages: normalizeMessages(threadData),
762
+ debug: (threadData as any)?._debug ?? null,
763
+ debugRuns: Array.isArray((threadData as any)?._debugRuns)
764
+ ? (threadData as any)._debugRuns
765
+ : [],
766
+ queuedMessages: (threadData as any)?.queuedMessages ?? [],
767
+ threadData,
768
+ rawThreadData: row.thread_data ?? "",
769
+ runs,
770
+ traces: {
771
+ summaries: traceSummaryRows,
772
+ spans: traceSpanRows,
773
+ },
774
+ feedback: feedbackRows,
775
+ satisfaction: satisfactionRows,
776
+ evals: evalRows,
777
+ checkpoints: checkpointRows,
778
+ };
779
+ }