@agent-native/core 0.84.67 → 0.85.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 (145) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +55 -0
  4. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +55 -0
  5. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +55 -0
  6. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +55 -0
  7. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +55 -0
  8. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +55 -0
  9. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +55 -0
  10. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +55 -0
  11. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +55 -0
  12. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +55 -0
  13. package/corpus/core/docs/content/tracking.mdx +2 -0
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/agent/production-agent.ts +199 -38
  16. package/corpus/core/src/agent/run-manager.ts +79 -0
  17. package/corpus/core/src/agent/run-store.ts +117 -5
  18. package/corpus/core/src/agent/types.ts +13 -0
  19. package/corpus/core/src/client/RunStuckBanner.tsx +14 -0
  20. package/corpus/core/src/client/agent-chat-adapter.ts +475 -0
  21. package/corpus/core/src/client/analytics.ts +2 -0
  22. package/corpus/core/src/client/error-format.ts +4 -0
  23. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +1 -1
  24. package/corpus/core/src/client/session-replay.ts +774 -2
  25. package/corpus/core/src/client/sse-event-processor.ts +65 -9
  26. package/corpus/core/src/client/use-run-stuck-detection.ts +31 -7
  27. package/corpus/core/src/db/runtime-diagnostics.ts +331 -0
  28. package/corpus/core/src/server/agent-chat-plugin.ts +115 -2
  29. package/corpus/core/src/server/core-routes-plugin.ts +91 -4
  30. package/corpus/core/src/server/http-response-telemetry.ts +189 -0
  31. package/corpus/core/src/server/self-dispatch.ts +29 -0
  32. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +53 -1
  33. package/corpus/templates/analytics/AGENTS.md +13 -0
  34. package/corpus/templates/analytics/actions/view-screen.ts +25 -0
  35. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +20 -0
  36. package/corpus/templates/analytics/app/global.css +55 -0
  37. package/corpus/templates/analytics/app/i18n/zh-TW.ts +100 -1
  38. package/corpus/templates/analytics/app/i18n-data.ts +1226 -0
  39. package/corpus/templates/analytics/app/pages/Settings.tsx +3 -0
  40. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +182 -144
  41. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +11 -18
  42. package/corpus/templates/analytics/app/pages/analyses/AnalysisDetail.tsx +11 -18
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +49 -1
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +557 -0
  45. package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +274 -0
  46. package/corpus/templates/analytics/app/pages/settings/AlertRulesSettingsCard.tsx +974 -0
  47. package/corpus/templates/analytics/changelog/2026-07-02-alert-rules-can-now-be-viewed-and-managed-from-settings.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-02-recent-ask-chats-show-a-loading-placeholder-while-your-chat-.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-02-session-replays-now-capture-console-logs-and-network-request.md +6 -0
  50. package/corpus/templates/analytics/changelog/2026-07-02-sharing-status-labels-use-neutral-icons-instead-of-colored-d.md +6 -0
  51. package/corpus/templates/analytics/changelog/2026-07-02-the-session-replay-viewer-has-a-dev-tools-panel-with-console.md +6 -0
  52. package/corpus/templates/analytics/server/db/schema.ts +3 -0
  53. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -0
  54. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +142 -1
  55. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +369 -8
  56. package/corpus/templates/analytics/server/lib/session-replay.ts +76 -2
  57. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +101 -0
  58. package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +78 -0
  59. package/corpus/templates/design/app/pages/DesignEditor.tsx +24 -12
  60. package/corpus/templates/design/changelog/2026-07-02-the-temporary-code-tab-is-hidden-from-the-design-editor-side.md +6 -0
  61. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  62. package/corpus/templates/plan/actions/update-local-plan-folder.ts +3 -1
  63. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  64. package/corpus/templates/plan/changelog/2026-07-02-trying-to-edit-a-recap-or-plan-you-can-only-view-now-explain.md +6 -0
  65. package/corpus/templates/plan/server/plans.ts +26 -9
  66. package/dist/agent/production-agent.d.ts.map +1 -1
  67. package/dist/agent/production-agent.js +155 -34
  68. package/dist/agent/production-agent.js.map +1 -1
  69. package/dist/agent/run-manager.d.ts +29 -0
  70. package/dist/agent/run-manager.d.ts.map +1 -1
  71. package/dist/agent/run-manager.js +73 -0
  72. package/dist/agent/run-manager.js.map +1 -1
  73. package/dist/agent/run-store.d.ts +41 -0
  74. package/dist/agent/run-store.d.ts.map +1 -1
  75. package/dist/agent/run-store.js +100 -4
  76. package/dist/agent/run-store.js.map +1 -1
  77. package/dist/agent/types.d.ts +13 -0
  78. package/dist/agent/types.d.ts.map +1 -1
  79. package/dist/agent/types.js.map +1 -1
  80. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  81. package/dist/client/RunStuckBanner.js +13 -0
  82. package/dist/client/RunStuckBanner.js.map +1 -1
  83. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  84. package/dist/client/agent-chat-adapter.js +410 -0
  85. package/dist/client/agent-chat-adapter.js.map +1 -1
  86. package/dist/client/analytics.d.ts +1 -1
  87. package/dist/client/analytics.d.ts.map +1 -1
  88. package/dist/client/analytics.js.map +1 -1
  89. package/dist/client/error-format.d.ts.map +1 -1
  90. package/dist/client/error-format.js +2 -0
  91. package/dist/client/error-format.js.map +1 -1
  92. package/dist/client/extensions/ExtensionsSidebarSection.js +1 -1
  93. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  94. package/dist/client/session-replay.d.ts +36 -0
  95. package/dist/client/session-replay.d.ts.map +1 -1
  96. package/dist/client/session-replay.js +579 -1
  97. package/dist/client/session-replay.js.map +1 -1
  98. package/dist/client/sse-event-processor.d.ts +20 -1
  99. package/dist/client/sse-event-processor.d.ts.map +1 -1
  100. package/dist/client/sse-event-processor.js +42 -11
  101. package/dist/client/sse-event-processor.js.map +1 -1
  102. package/dist/client/use-run-stuck-detection.d.ts +14 -4
  103. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  104. package/dist/client/use-run-stuck-detection.js +18 -4
  105. package/dist/client/use-run-stuck-detection.js.map +1 -1
  106. package/dist/collab/awareness.d.ts +2 -2
  107. package/dist/collab/awareness.d.ts.map +1 -1
  108. package/dist/collab/routes.d.ts +2 -2
  109. package/dist/db/runtime-diagnostics.d.ts +52 -0
  110. package/dist/db/runtime-diagnostics.d.ts.map +1 -0
  111. package/dist/db/runtime-diagnostics.js +250 -0
  112. package/dist/db/runtime-diagnostics.js.map +1 -0
  113. package/dist/notifications/routes.d.ts +1 -1
  114. package/dist/observability/routes.d.ts +3 -3
  115. package/dist/progress/routes.d.ts +1 -1
  116. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  117. package/dist/resources/handlers.d.ts +1 -1
  118. package/dist/secrets/routes.d.ts +3 -3
  119. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  120. package/dist/server/agent-chat-plugin.js +101 -2
  121. package/dist/server/agent-chat-plugin.js.map +1 -1
  122. package/dist/server/core-routes-plugin.d.ts +17 -0
  123. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  124. package/dist/server/core-routes-plugin.js +65 -5
  125. package/dist/server/core-routes-plugin.js.map +1 -1
  126. package/dist/server/http-response-telemetry.d.ts +4 -0
  127. package/dist/server/http-response-telemetry.d.ts.map +1 -0
  128. package/dist/server/http-response-telemetry.js +174 -0
  129. package/dist/server/http-response-telemetry.js.map +1 -0
  130. package/dist/server/self-dispatch.d.ts +17 -0
  131. package/dist/server/self-dispatch.d.ts.map +1 -1
  132. package/dist/server/self-dispatch.js +11 -0
  133. package/dist/server/self-dispatch.js.map +1 -1
  134. package/docs/content/locales/ar-SA/tracking.mdx +55 -0
  135. package/docs/content/locales/de-DE/tracking.mdx +55 -0
  136. package/docs/content/locales/es-ES/tracking.mdx +55 -0
  137. package/docs/content/locales/fr-FR/tracking.mdx +55 -0
  138. package/docs/content/locales/hi-IN/tracking.mdx +55 -0
  139. package/docs/content/locales/ja-JP/tracking.mdx +55 -0
  140. package/docs/content/locales/ko-KR/tracking.mdx +55 -0
  141. package/docs/content/locales/pt-BR/tracking.mdx +55 -0
  142. package/docs/content/locales/zh-CN/tracking.mdx +55 -0
  143. package/docs/content/locales/zh-TW/tracking.mdx +55 -0
  144. package/docs/content/tracking.mdx +2 -0
  145. package/package.json +1 -1
@@ -148,6 +148,36 @@ export class AgentAutoContinueSignal extends Error {
148
148
 
149
149
  export const SSE_NO_PROGRESS_TIMEOUT_MS = 75_000;
150
150
  export const SSE_ACTION_PREPARATION_STALL_TIMEOUT_MS = 90_000;
151
+ /**
152
+ * Widened client watchdog windows for durable background runs. The SERVER is
153
+ * the recovery brain for these runs: its run-manager no-progress backstop
154
+ * (150s, `RUN_NO_PROGRESS_HARD_TIMEOUT_MS`) emits `auto_continue` over the
155
+ * same stream the client is already reading, and its unclaimed-run sweep reaps
156
+ * dead workers into loud terminal errors. The client watchdogs therefore sit
157
+ * ABOVE the server's 150s backstop so a healthy background run never trips
158
+ * them — the server's own recovery event arrives first over the wire. When one
159
+ * does fire, the thrown signal only means "reattach the read" (the adapter's
160
+ * background follow loop re-polls /runs/active); it never escalates to a
161
+ * client-declared error or a synthetic continuation POST. Progress ACCOUNTING
162
+ * (what counts as a meaningful event) is unchanged — only the client-initiated
163
+ * recovery timing is relaxed.
164
+ */
165
+ export const SSE_DURABLE_NO_PROGRESS_TIMEOUT_MS = 180_000;
166
+ export const SSE_DURABLE_ACTION_PREPARATION_STALL_TIMEOUT_MS = 180_000;
167
+
168
+ export function sseNoProgressTimeoutMs(options?: SSEStreamOptions): number {
169
+ return options?.durableBackgroundRun === true
170
+ ? SSE_DURABLE_NO_PROGRESS_TIMEOUT_MS
171
+ : SSE_NO_PROGRESS_TIMEOUT_MS;
172
+ }
173
+
174
+ function sseActionPreparationStallTimeoutMs(
175
+ options?: SSEStreamOptions,
176
+ ): number {
177
+ return options?.durableBackgroundRun === true
178
+ ? SSE_DURABLE_ACTION_PREPARATION_STALL_TIMEOUT_MS
179
+ : SSE_ACTION_PREPARATION_STALL_TIMEOUT_MS;
180
+ }
151
181
 
152
182
  export interface SSEStreamOptions {
153
183
  /**
@@ -155,7 +185,9 @@ export interface SSEStreamOptions {
155
185
  * heartbeat. While one is active, generic keepalive-only periods keep the
156
186
  * client attached. Tool-input preparation is stricter: real byte progress
157
187
  * keeps long payloads alive, but zero-byte/silent preparation still recovers
158
- * so one stuck action cannot pin the chat forever.
188
+ * so one stuck action cannot pin the chat forever — just on the wider
189
+ * durable windows above (behind the server's own 150s backstop) instead of
190
+ * the tight foreground 75s/90s windows.
159
191
  */
160
192
  durableBackgroundRun?: boolean;
161
193
  /**
@@ -435,20 +467,25 @@ function updatePreparingActionState(
435
467
  return undefined;
436
468
  }
437
469
 
438
- function hasStalledPreparingAction(state: PreparingActionState, now: number) {
470
+ function hasStalledPreparingAction(
471
+ state: PreparingActionState,
472
+ now: number,
473
+ stallTimeoutMs: number,
474
+ ) {
439
475
  // Fire only when a tool input has gone SILENT — no further streaming deltas
440
476
  // for the whole window — never merely because a large input has been
441
477
  // streaming for a long time. `lastProgressAt` advances on every delta
442
478
  // heartbeat, so an actively-streaming large output keeps resetting this and
443
479
  // survives; a genuinely stuck prep (keepalive-only, no deltas) trips it.
480
+ // Durable background reads pass the wider durable window so the server's
481
+ // own 150s no-progress backstop gets first chance to recover the stall.
444
482
  for (const entry of [
445
483
  ...(state.toolEntries?.values() ?? []),
446
484
  ...(state.entries?.values() ?? []),
447
485
  ]) {
448
486
  if (
449
487
  entry.startedAt !== undefined &&
450
- now - (entry.lastProgressAt ?? entry.startedAt) >=
451
- SSE_ACTION_PREPARATION_STALL_TIMEOUT_MS
488
+ now - (entry.lastProgressAt ?? entry.startedAt) >= stallTimeoutMs
452
489
  ) {
453
490
  return true;
454
491
  }
@@ -459,9 +496,10 @@ function hasStalledPreparingAction(state: PreparingActionState, now: number) {
459
496
  async function readChunkWithProgressTimeout(
460
497
  reader: ReadableStreamDefaultReader<Uint8Array>,
461
498
  lastMeaningfulEventAt: number,
499
+ noProgressTimeoutMs: number,
462
500
  ): Promise<ReadableStreamReadResult<Uint8Array>> {
463
501
  const elapsed = Date.now() - lastMeaningfulEventAt;
464
- const timeoutMs = Math.max(0, SSE_NO_PROGRESS_TIMEOUT_MS - elapsed);
502
+ const timeoutMs = Math.max(0, noProgressTimeoutMs - elapsed);
465
503
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
466
504
  const readPromise = reader.read();
467
505
  // If the timeout wins and cancellation causes the pending read to reject,
@@ -1305,6 +1343,8 @@ export async function* readSSEStream(
1305
1343
  const decoder = new TextDecoder();
1306
1344
  let buf = "";
1307
1345
  let lastMeaningfulEventAt = Date.now();
1346
+ const noProgressTimeoutMs = sseNoProgressTimeoutMs(options);
1347
+ const preparationStallTimeoutMs = sseActionPreparationStallTimeoutMs(options);
1308
1348
  const activityTrail: ActivityTrailEntry[] = [];
1309
1349
  const preparingActionState: PreparingActionState =
1310
1350
  options?.preparingActionState ?? {};
@@ -1349,6 +1389,7 @@ export async function* readSSEStream(
1349
1389
  readResult = await readChunkWithProgressTimeout(
1350
1390
  reader,
1351
1391
  lastMeaningfulEventAt,
1392
+ noProgressTimeoutMs,
1352
1393
  );
1353
1394
  } catch (err) {
1354
1395
  if (err instanceof AgentAutoContinueSignal) {
@@ -1428,7 +1469,13 @@ export async function* readSSEStream(
1428
1469
  );
1429
1470
 
1430
1471
  if (result) yield withStreamMetadata(result);
1431
- if (hasStalledPreparingAction(preparingActionState, Date.now())) {
1472
+ if (
1473
+ hasStalledPreparingAction(
1474
+ preparingActionState,
1475
+ Date.now(),
1476
+ preparationStallTimeoutMs,
1477
+ )
1478
+ ) {
1432
1479
  throw new AgentAutoContinueSignal({
1433
1480
  reason: "no_progress",
1434
1481
  activityTrail: [...activityTrail],
@@ -1452,7 +1499,7 @@ export async function* readSSEStream(
1452
1499
 
1453
1500
  if (
1454
1501
  !sawProgressEvent &&
1455
- Date.now() - lastMeaningfulEventAt >= SSE_NO_PROGRESS_TIMEOUT_MS
1502
+ Date.now() - lastMeaningfulEventAt >= noProgressTimeoutMs
1456
1503
  ) {
1457
1504
  throw new AgentAutoContinueSignal({
1458
1505
  reason: "no_progress",
@@ -1498,6 +1545,8 @@ export async function readSSEStreamRaw(
1498
1545
  const decoder = new TextDecoder();
1499
1546
  let buf = "";
1500
1547
  let lastMeaningfulEventAt = Date.now();
1548
+ const noProgressTimeoutMs = sseNoProgressTimeoutMs(options);
1549
+ const preparationStallTimeoutMs = sseActionPreparationStallTimeoutMs(options);
1501
1550
  const activityTrail: ActivityTrailEntry[] = [];
1502
1551
  const preparingActionState: PreparingActionState =
1503
1552
  options?.preparingActionState ?? {};
@@ -1517,6 +1566,7 @@ export async function readSSEStreamRaw(
1517
1566
  readResult = await readChunkWithProgressTimeout(
1518
1567
  reader,
1519
1568
  lastMeaningfulEventAt,
1569
+ noProgressTimeoutMs,
1520
1570
  );
1521
1571
  } catch (err) {
1522
1572
  if (err instanceof AgentAutoContinueSignal) {
@@ -1612,7 +1662,13 @@ export async function readSSEStreamRaw(
1612
1662
  : { reason: "stream_ended", activityTrail: [...activityTrail] },
1613
1663
  );
1614
1664
  }
1615
- if (hasStalledPreparingAction(preparingActionState, Date.now())) {
1665
+ if (
1666
+ hasStalledPreparingAction(
1667
+ preparingActionState,
1668
+ Date.now(),
1669
+ preparationStallTimeoutMs,
1670
+ )
1671
+ ) {
1616
1672
  onUpdate(contentSnapshot(content));
1617
1673
  throw new AgentAutoContinueSignal({
1618
1674
  reason: "no_progress",
@@ -1630,7 +1686,7 @@ export async function readSSEStreamRaw(
1630
1686
 
1631
1687
  if (
1632
1688
  !sawProgressEvent &&
1633
- Date.now() - lastMeaningfulEventAt >= SSE_NO_PROGRESS_TIMEOUT_MS
1689
+ Date.now() - lastMeaningfulEventAt >= noProgressTimeoutMs
1634
1690
  ) {
1635
1691
  throw new AgentAutoContinueSignal({
1636
1692
  reason: "no_progress",
@@ -34,11 +34,20 @@ export interface UseRunStuckDetectionOptions {
34
34
  /** The thread to monitor. Pass null/undefined to disable polling. */
35
35
  threadId: string | null | undefined;
36
36
  /**
37
- * Threshold above which an in-flight run is considered stuck. The default
38
- * sits comfortably above the adapter's 75s no-progress reconnect — by then
39
- * automatic recovery has already had its chance.
37
+ * Threshold above which an in-flight FOREGROUND run is considered stuck.
38
+ * The default sits comfortably above the adapter's 75s no-progress
39
+ * reconnect — by then automatic recovery has already had its chance.
40
40
  */
41
41
  stuckThresholdMs?: number;
42
+ /**
43
+ * Threshold for BACKGROUND-dispatched runs (dispatchMode starts with
44
+ * "background"). The server owns recovery for these — its run-manager
45
+ * no-progress backstop (150s) and unclaimed-run sweep act first — so the
46
+ * user-facing "stuck" affordance is a late fallback, not a race against
47
+ * them. Selected inside the hook because the dispatch mode is only known
48
+ * from the same poll response that computes the elapsed time.
49
+ */
50
+ backgroundStuckThresholdMs?: number;
42
51
  /** Poll interval. Default 5_000ms. */
43
52
  pollIntervalMs?: number;
44
53
  /** API base path. Default `/_agent-native/agent-chat`. */
@@ -46,6 +55,7 @@ export interface UseRunStuckDetectionOptions {
46
55
  }
47
56
 
48
57
  const DEFAULT_STUCK_THRESHOLD_MS = 90_000;
58
+ export const DEFAULT_BACKGROUND_STUCK_THRESHOLD_MS = 180_000;
49
59
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
50
60
  const IDLE_BACKOFF_INTERVAL_MS = 15_000;
51
61
 
@@ -74,6 +84,7 @@ const EMPTY_STATE: RunStuckState = {
74
84
  export function useRunStuckDetection({
75
85
  threadId,
76
86
  stuckThresholdMs = DEFAULT_STUCK_THRESHOLD_MS,
87
+ backgroundStuckThresholdMs = DEFAULT_BACKGROUND_STUCK_THRESHOLD_MS,
77
88
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
78
89
  apiUrl,
79
90
  }: UseRunStuckDetectionOptions): RunStuckState {
@@ -112,11 +123,19 @@ export function useRunStuckDetection({
112
123
  const heartbeatAt = data.heartbeatAt ?? null;
113
124
  const heartbeatSinceMs =
114
125
  heartbeatAt != null ? nowMs - heartbeatAt : null;
126
+ const dispatchMode =
127
+ typeof data.dispatchMode === "string" ? data.dispatchMode : null;
128
+ // Background-dispatched runs get the wider threshold: the server's
129
+ // own recovery (150s no-progress backstop + chained continuations)
130
+ // must get its chance before the user sees a "stuck" affordance.
131
+ const effectiveThresholdMs = dispatchMode?.startsWith("background")
132
+ ? backgroundStuckThresholdMs
133
+ : stuckThresholdMs;
115
134
  const isStuck = Boolean(
116
135
  data.active &&
117
136
  data.status === "running" &&
118
137
  stuckSinceMs != null &&
119
- stuckSinceMs > stuckThresholdMs,
138
+ stuckSinceMs > effectiveThresholdMs,
120
139
  );
121
140
  setState({
122
141
  isStuck,
@@ -126,8 +145,7 @@ export function useRunStuckDetection({
126
145
  stuckSinceMs,
127
146
  heartbeatAt,
128
147
  heartbeatSinceMs,
129
- dispatchMode:
130
- typeof data.dispatchMode === "string" ? data.dispatchMode : null,
148
+ dispatchMode,
131
149
  });
132
150
  // Back off polling when nothing is in flight — there's no point
133
151
  // hammering the endpoint while the chat is idle. We still poll
@@ -153,7 +171,13 @@ export function useRunStuckDetection({
153
171
  cancelled = true;
154
172
  if (timer) clearTimeout(timer);
155
173
  };
156
- }, [threadId, stuckThresholdMs, pollIntervalMs, apiUrl]);
174
+ }, [
175
+ threadId,
176
+ stuckThresholdMs,
177
+ backgroundStuckThresholdMs,
178
+ pollIntervalMs,
179
+ apiUrl,
180
+ ]);
157
181
 
158
182
  return state;
159
183
  }
@@ -0,0 +1,331 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import {
4
+ getDatabaseUrl,
5
+ getDialect,
6
+ getDbExec,
7
+ type DbExec,
8
+ type Dialect,
9
+ } from "./client.js";
10
+
11
+ export interface DatabaseRuntimeFingerprint {
12
+ configured: boolean;
13
+ source: string;
14
+ dialect: Dialect;
15
+ urlHash?: string;
16
+ protocol?: string;
17
+ host?: string;
18
+ database?: string;
19
+ appName?: string;
20
+ authTokenConfigured: boolean;
21
+ netlifyDatabaseUrlConfigured: boolean;
22
+ neon?: {
23
+ endpointId?: string;
24
+ pooled: boolean;
25
+ projectHost?: string;
26
+ };
27
+ }
28
+
29
+ export interface RuntimeDebugFingerprint {
30
+ app: string;
31
+ environment: string;
32
+ deployContext?: string;
33
+ deployId?: string;
34
+ commitRef?: string;
35
+ branch?: string;
36
+ siteName?: string;
37
+ database: DatabaseRuntimeFingerprint;
38
+ }
39
+
40
+ export interface RequiredSchemaTable {
41
+ table: string;
42
+ columns: string[];
43
+ }
44
+
45
+ export interface DatabaseSchemaHealthResult {
46
+ ok: boolean;
47
+ checked: boolean;
48
+ dialect: Dialect;
49
+ missingTables: string[];
50
+ missingColumns: Array<{ table: string; column: string }>;
51
+ error?: string;
52
+ }
53
+
54
+ const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
55
+ {
56
+ table: "agent_runs",
57
+ columns: [
58
+ "id",
59
+ "thread_id",
60
+ "status",
61
+ "started_at",
62
+ "completed_at",
63
+ "heartbeat_at",
64
+ "last_progress_at",
65
+ "turn_id",
66
+ "error_code",
67
+ "error_detail",
68
+ "terminal_reason",
69
+ "dispatch_mode",
70
+ "diag_stage",
71
+ "worker_stage",
72
+ ],
73
+ },
74
+ {
75
+ table: "agent_run_events",
76
+ columns: ["run_id", "seq", "event_at", "event_data"],
77
+ },
78
+ {
79
+ table: "agent_tool_ledger",
80
+ columns: ["thread_id", "tool_key", "result_summary", "completed_at"],
81
+ },
82
+ {
83
+ table: "chat_threads",
84
+ columns: [
85
+ "id",
86
+ "owner_email",
87
+ "title",
88
+ "preview",
89
+ "thread_data",
90
+ "message_count",
91
+ "created_at",
92
+ "updated_at",
93
+ "scope_type",
94
+ "scope_id",
95
+ "scope_label",
96
+ "pinned_at",
97
+ "archived_at",
98
+ ],
99
+ },
100
+ {
101
+ table: "settings",
102
+ columns: ["key", "value", "updated_at"],
103
+ },
104
+ {
105
+ table: "application_state",
106
+ columns: ["session_id", "key", "value", "updated_at"],
107
+ },
108
+ ];
109
+
110
+ function envValue(key: string): string | undefined {
111
+ const value = process.env[key]?.trim();
112
+ return value || undefined;
113
+ }
114
+
115
+ function appEnvPrefix(): string | undefined {
116
+ return envValue("APP_NAME")?.toUpperCase().replace(/-/g, "_");
117
+ }
118
+
119
+ function databaseUrlSource(): string {
120
+ const appName = appEnvPrefix();
121
+ if (appName && envValue(`${appName}_DATABASE_URL`)) {
122
+ return `${appName}_DATABASE_URL`;
123
+ }
124
+ if (envValue("DATABASE_URL")) return "DATABASE_URL";
125
+ if (envValue("NETLIFY_DATABASE_URL")) return "NETLIFY_DATABASE_URL";
126
+ return "default";
127
+ }
128
+
129
+ function databaseAuthTokenConfigured(): boolean {
130
+ const appName = appEnvPrefix();
131
+ return Boolean(
132
+ (appName && envValue(`${appName}_DATABASE_AUTH_TOKEN`)) ||
133
+ envValue("DATABASE_AUTH_TOKEN") ||
134
+ envValue("NETLIFY_DATABASE_AUTH_TOKEN"),
135
+ );
136
+ }
137
+
138
+ function shortHash(value: string): string | undefined {
139
+ if (!value) return undefined;
140
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
141
+ }
142
+
143
+ function parseDatabaseUrl(url: string): Partial<DatabaseRuntimeFingerprint> {
144
+ if (!url) return {};
145
+ if (url.startsWith("pglite:")) {
146
+ return { protocol: "pglite", database: url.slice("pglite:".length) };
147
+ }
148
+ if (url.startsWith("file:")) {
149
+ return { protocol: "file", database: url.slice("file:".length) };
150
+ }
151
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
152
+ return { protocol: "sqlite", database: url };
153
+ }
154
+
155
+ try {
156
+ const parsed = new URL(url);
157
+ const host = parsed.hostname || undefined;
158
+ const neonMatch = host?.match(/^(ep-[a-z0-9-]+?)(-pooler)?\.(.+)$/i);
159
+ const isNeon = Boolean(host?.endsWith(".neon.tech") && neonMatch);
160
+ return {
161
+ protocol: parsed.protocol.replace(/:$/, ""),
162
+ host,
163
+ database: parsed.pathname.replace(/^\//, "") || undefined,
164
+ ...(isNeon
165
+ ? {
166
+ neon: {
167
+ endpointId: neonMatch?.[1],
168
+ pooled: Boolean(neonMatch?.[2]),
169
+ projectHost: neonMatch?.[3],
170
+ },
171
+ }
172
+ : {}),
173
+ };
174
+ } catch {
175
+ return {};
176
+ }
177
+ }
178
+
179
+ export function getDatabaseRuntimeFingerprint(): DatabaseRuntimeFingerprint {
180
+ const url = getDatabaseUrl();
181
+ const parsed = parseDatabaseUrl(url);
182
+ return {
183
+ configured: Boolean(url),
184
+ source: databaseUrlSource(),
185
+ dialect: getDialect(),
186
+ urlHash: shortHash(url),
187
+ appName: envValue("APP_NAME"),
188
+ authTokenConfigured: databaseAuthTokenConfigured(),
189
+ netlifyDatabaseUrlConfigured: Boolean(envValue("NETLIFY_DATABASE_URL")),
190
+ ...parsed,
191
+ };
192
+ }
193
+
194
+ export function getRuntimeDebugFingerprint(): RuntimeDebugFingerprint {
195
+ return {
196
+ app: envValue("APP_NAME") ?? "unknown",
197
+ environment: envValue("NODE_ENV") ?? "unknown",
198
+ deployContext: envValue("CONTEXT") ?? envValue("VERCEL_ENV"),
199
+ deployId: envValue("DEPLOY_ID") ?? envValue("VERCEL_DEPLOYMENT_ID"),
200
+ commitRef:
201
+ envValue("COMMIT_REF") ??
202
+ envValue("NETLIFY_COMMIT_REF") ??
203
+ envValue("VERCEL_GIT_COMMIT_SHA") ??
204
+ envValue("GIT_COMMIT_SHA"),
205
+ branch:
206
+ envValue("BRANCH") ??
207
+ envValue("HEAD") ??
208
+ envValue("VERCEL_GIT_COMMIT_REF"),
209
+ siteName: envValue("SITE_NAME") ?? envValue("NETLIFY_SITE_NAME"),
210
+ database: getDatabaseRuntimeFingerprint(),
211
+ };
212
+ }
213
+
214
+ function assertSafeIdentifier(value: string): void {
215
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
216
+ throw new Error(`Unsafe schema identifier: ${value}`);
217
+ }
218
+ }
219
+
220
+ async function postgresTableColumns(
221
+ exec: DbExec,
222
+ table: string,
223
+ ): Promise<Set<string> | null> {
224
+ const result = await exec.execute({
225
+ sql: `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?`,
226
+ args: [table],
227
+ });
228
+ if (!result.rows.length) return null;
229
+ return new Set(result.rows.map((row) => String(row.column_name)));
230
+ }
231
+
232
+ async function sqliteTableColumns(
233
+ exec: DbExec,
234
+ table: string,
235
+ ): Promise<Set<string> | null> {
236
+ assertSafeIdentifier(table);
237
+ const exists = await exec.execute({
238
+ sql: `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
239
+ args: [table],
240
+ });
241
+ if (!exists.rows.length) return null;
242
+ const result = await exec.execute(`PRAGMA table_info(${table})`);
243
+ return new Set(result.rows.map((row) => String(row.name)));
244
+ }
245
+
246
+ async function tableColumns(
247
+ exec: DbExec,
248
+ dialect: Dialect,
249
+ table: string,
250
+ ): Promise<Set<string> | null> {
251
+ return dialect === "postgres"
252
+ ? postgresTableColumns(exec, table)
253
+ : sqliteTableColumns(exec, table);
254
+ }
255
+
256
+ export async function runDatabaseSchemaHealthCheck(
257
+ options: {
258
+ exec?: DbExec;
259
+ dialect?: Dialect;
260
+ required?: RequiredSchemaTable[];
261
+ } = {},
262
+ ): Promise<DatabaseSchemaHealthResult> {
263
+ const dialect = options.dialect ?? getDialect();
264
+ const exec = options.exec ?? getDbExec();
265
+ const required = options.required ?? DEFAULT_REQUIRED_SCHEMA;
266
+ const missingTables: string[] = [];
267
+ const missingColumns: Array<{ table: string; column: string }> = [];
268
+
269
+ try {
270
+ for (const requirement of required) {
271
+ const columns = await tableColumns(exec, dialect, requirement.table);
272
+ if (!columns) {
273
+ missingTables.push(requirement.table);
274
+ continue;
275
+ }
276
+ for (const column of requirement.columns) {
277
+ if (!columns.has(column)) {
278
+ missingColumns.push({ table: requirement.table, column });
279
+ }
280
+ }
281
+ }
282
+ } catch (err) {
283
+ return {
284
+ ok: false,
285
+ checked: false,
286
+ dialect,
287
+ missingTables,
288
+ missingColumns,
289
+ error: err instanceof Error ? err.message : String(err),
290
+ };
291
+ }
292
+
293
+ return {
294
+ ok: missingTables.length === 0 && missingColumns.length === 0,
295
+ checked: true,
296
+ dialect,
297
+ missingTables,
298
+ missingColumns,
299
+ };
300
+ }
301
+
302
+ export function formatRuntimeDebugFingerprint(
303
+ fingerprint: RuntimeDebugFingerprint = getRuntimeDebugFingerprint(),
304
+ ): string {
305
+ const db = fingerprint.database;
306
+ return [
307
+ `app: ${fingerprint.app}`,
308
+ `environment: ${fingerprint.environment}`,
309
+ fingerprint.deployContext
310
+ ? `deploy_context: ${fingerprint.deployContext}`
311
+ : "",
312
+ fingerprint.deployId ? `deploy_id: ${fingerprint.deployId}` : "",
313
+ fingerprint.commitRef ? `commit_ref: ${fingerprint.commitRef}` : "",
314
+ fingerprint.branch ? `branch: ${fingerprint.branch}` : "",
315
+ fingerprint.siteName ? `site_name: ${fingerprint.siteName}` : "",
316
+ `db_configured: ${db.configured}`,
317
+ `db_source: ${db.source}`,
318
+ `db_dialect: ${db.dialect}`,
319
+ db.protocol ? `db_protocol: ${db.protocol}` : "",
320
+ db.host ? `db_host: ${db.host}` : "",
321
+ db.database ? `db_database: ${db.database}` : "",
322
+ db.urlHash ? `db_url_hash: ${db.urlHash}` : "",
323
+ db.neon?.endpointId ? `db_neon_endpoint: ${db.neon.endpointId}` : "",
324
+ db.neon ? `db_neon_pooled: ${db.neon.pooled}` : "",
325
+ db.neon?.projectHost ? `db_neon_project_host: ${db.neon.projectHost}` : "",
326
+ `db_auth_token_configured: ${db.authTokenConfigured}`,
327
+ `netlify_database_url_configured: ${db.netlifyDatabaseUrlConfigured}`,
328
+ ]
329
+ .filter(Boolean)
330
+ .join("\n");
331
+ }