@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/de-DE/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/es-ES/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +55 -0
- package/corpus/core/docs/content/tracking.mdx +2 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +199 -38
- package/corpus/core/src/agent/run-manager.ts +79 -0
- package/corpus/core/src/agent/run-store.ts +117 -5
- package/corpus/core/src/agent/types.ts +13 -0
- package/corpus/core/src/client/RunStuckBanner.tsx +14 -0
- package/corpus/core/src/client/agent-chat-adapter.ts +475 -0
- package/corpus/core/src/client/analytics.ts +2 -0
- package/corpus/core/src/client/error-format.ts +4 -0
- package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +1 -1
- package/corpus/core/src/client/session-replay.ts +774 -2
- package/corpus/core/src/client/sse-event-processor.ts +65 -9
- package/corpus/core/src/client/use-run-stuck-detection.ts +31 -7
- package/corpus/core/src/db/runtime-diagnostics.ts +331 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +115 -2
- package/corpus/core/src/server/core-routes-plugin.ts +91 -4
- package/corpus/core/src/server/http-response-telemetry.ts +189 -0
- package/corpus/core/src/server/self-dispatch.ts +29 -0
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +53 -1
- package/corpus/templates/analytics/AGENTS.md +13 -0
- package/corpus/templates/analytics/actions/view-screen.ts +25 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +20 -0
- package/corpus/templates/analytics/app/global.css +55 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +100 -1
- package/corpus/templates/analytics/app/i18n-data.ts +1226 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +3 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +182 -144
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +11 -18
- package/corpus/templates/analytics/app/pages/analyses/AnalysisDetail.tsx +11 -18
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +49 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +557 -0
- package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +274 -0
- package/corpus/templates/analytics/app/pages/settings/AlertRulesSettingsCard.tsx +974 -0
- package/corpus/templates/analytics/changelog/2026-07-02-alert-rules-can-now-be-viewed-and-managed-from-settings.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-recent-ask-chats-show-a-loading-placeholder-while-your-chat-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-session-replays-now-capture-console-logs-and-network-request.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-sharing-status-labels-use-neutral-icons-instead-of-colored-d.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-the-session-replay-viewer-has-a-dev-tools-panel-with-console.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +3 -0
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +142 -1
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +369 -8
- package/corpus/templates/analytics/server/lib/session-replay.ts +76 -2
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +101 -0
- package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +78 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +24 -12
- package/corpus/templates/design/changelog/2026-07-02-the-temporary-code-tab-is-hidden-from-the-design-editor-side.md +6 -0
- package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
- package/corpus/templates/plan/actions/update-local-plan-folder.ts +3 -1
- package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
- package/corpus/templates/plan/changelog/2026-07-02-trying-to-edit-a-recap-or-plan-you-can-only-view-now-explain.md +6 -0
- package/corpus/templates/plan/server/plans.ts +26 -9
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +155 -34
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +29 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +73 -0
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +41 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +100 -4
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +13 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/RunStuckBanner.d.ts.map +1 -1
- package/dist/client/RunStuckBanner.js +13 -0
- package/dist/client/RunStuckBanner.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +410 -0
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/analytics.d.ts +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +2 -0
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
- package/dist/client/session-replay.d.ts +36 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +579 -1
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +20 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +42 -11
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-run-stuck-detection.d.ts +14 -4
- package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
- package/dist/client/use-run-stuck-detection.js +18 -4
- package/dist/client/use-run-stuck-detection.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/db/runtime-diagnostics.d.ts +52 -0
- package/dist/db/runtime-diagnostics.d.ts.map +1 -0
- package/dist/db/runtime-diagnostics.js +250 -0
- package/dist/db/runtime-diagnostics.js.map +1 -0
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +101 -2
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +17 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +65 -5
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/http-response-telemetry.d.ts +4 -0
- package/dist/server/http-response-telemetry.d.ts.map +1 -0
- package/dist/server/http-response-telemetry.js +174 -0
- package/dist/server/http-response-telemetry.js.map +1 -0
- package/dist/server/self-dispatch.d.ts +17 -0
- package/dist/server/self-dispatch.d.ts.map +1 -1
- package/dist/server/self-dispatch.js +11 -0
- package/dist/server/self-dispatch.js.map +1 -1
- package/docs/content/locales/ar-SA/tracking.mdx +55 -0
- package/docs/content/locales/de-DE/tracking.mdx +55 -0
- package/docs/content/locales/es-ES/tracking.mdx +55 -0
- package/docs/content/locales/fr-FR/tracking.mdx +55 -0
- package/docs/content/locales/hi-IN/tracking.mdx +55 -0
- package/docs/content/locales/ja-JP/tracking.mdx +55 -0
- package/docs/content/locales/ko-KR/tracking.mdx +55 -0
- package/docs/content/locales/pt-BR/tracking.mdx +55 -0
- package/docs/content/locales/zh-CN/tracking.mdx +55 -0
- package/docs/content/locales/zh-TW/tracking.mdx +55 -0
- package/docs/content/tracking.mdx +2 -0
- 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(
|
|
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,
|
|
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 (
|
|
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 >=
|
|
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 (
|
|
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 >=
|
|
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.
|
|
38
|
-
* sits comfortably above the adapter's 75s no-progress
|
|
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 >
|
|
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
|
-
}, [
|
|
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
|
+
}
|