@agent-native/dispatch 0.16.1 → 0.16.3

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 (38) hide show
  1. package/dist/actions/get-agent-thread-debug.d.ts +9 -0
  2. package/dist/actions/index.d.ts.map +1 -1
  3. package/dist/actions/index.js +2 -0
  4. package/dist/actions/index.js.map +1 -1
  5. package/dist/actions/list-agent-run-failures.d.ts +61 -0
  6. package/dist/actions/list-agent-run-failures.d.ts.map +1 -0
  7. package/dist/actions/list-agent-run-failures.js +38 -0
  8. package/dist/actions/list-agent-run-failures.js.map +1 -0
  9. package/dist/actions/provider-api-register.d.ts +12 -12
  10. package/dist/actions/upsert-destination.d.ts +12 -12
  11. package/dist/actions/view-screen.js +32 -5
  12. package/dist/actions/view-screen.js.map +1 -1
  13. package/dist/hooks/use-navigation-state.d.ts +6 -0
  14. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  15. package/dist/hooks/use-navigation-state.js +30 -0
  16. package/dist/hooks/use-navigation-state.js.map +1 -1
  17. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  18. package/dist/routes/pages/thread-debug.js +291 -77
  19. package/dist/routes/pages/thread-debug.js.map +1 -1
  20. package/dist/server/lib/thread-debug-store.d.ts +72 -0
  21. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  22. package/dist/server/lib/thread-debug-store.js +215 -19
  23. package/dist/server/lib/thread-debug-store.js.map +1 -1
  24. package/dist/server/plugins/agent-chat.js +1 -0
  25. package/dist/server/plugins/agent-chat.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/actions/index.ts +2 -0
  28. package/src/actions/list-agent-run-failures.ts +44 -0
  29. package/src/actions/view-screen.spec.ts +91 -0
  30. package/src/actions/view-screen.ts +34 -4
  31. package/src/hooks/use-navigation-state.spec.ts +45 -0
  32. package/src/hooks/use-navigation-state.ts +28 -0
  33. package/src/routes/pages/thread-debug.spec.tsx +272 -0
  34. package/src/routes/pages/thread-debug.tsx +843 -224
  35. package/src/server/lib/thread-debug-store.spec.ts +292 -16
  36. package/src/server/lib/thread-debug-store.ts +318 -22
  37. package/src/server/plugins/agent-chat.spec.ts +30 -0
  38. package/src/server/plugins/agent-chat.ts +1 -0
@@ -65,10 +65,45 @@ interface AgentRunRow {
65
65
  started_at: number | string;
66
66
  completed_at?: number | string | null;
67
67
  heartbeat_at?: number | string | null;
68
+ turn_id?: string | null;
69
+ last_progress_at?: number | string | null;
70
+ error_code?: string | null;
71
+ error_detail?: string | null;
72
+ terminal_reason?: string | null;
73
+ dispatch_mode?: string | null;
74
+ worker_stage?: string | null;
75
+ diag_stage?: string | null;
76
+ peak_rss_mb?: number | string | null;
68
77
  }
69
78
 
70
79
  const execCache = new Map<string, Promise<DbExec>>();
71
80
 
81
+ const UNSUCCESSFUL_RUN_STATUSES = ["errored", "aborted", "truncated"] as const;
82
+
83
+ export type AgentRunFailureStatus = (typeof UNSUCCESSFUL_RUN_STATUSES)[number];
84
+
85
+ type ThreadDebugSourceHealthStatus =
86
+ | "ok"
87
+ | "disconnected"
88
+ | "unsupported"
89
+ | "unavailable";
90
+
91
+ interface AgentRunFailureRow extends AgentRunRow {
92
+ debug_owner_email: string;
93
+ debug_thread_title?: string | null;
94
+ debug_thread_preview?: string | null;
95
+ debug_terminal_event_data?: string | null;
96
+ }
97
+
98
+ class UnsupportedThreadDebugSchemaError extends Error {
99
+ readonly code = "thread_debug_schema_unsupported";
100
+
101
+ constructor(readonly table: string) {
102
+ super(`This database does not have the required "${table}" table.`);
103
+ this.name = "UnsupportedThreadDebugSchemaError";
104
+ }
105
+ }
106
+
72
107
  function envEmails(name: string): string[] {
73
108
  return (process.env[name] ?? "")
74
109
  .split(",")
@@ -89,14 +124,24 @@ function isEnvAdmin(email: string): boolean {
89
124
  ].includes(normalized);
90
125
  }
91
126
 
127
+ function missingTableName(error: unknown): string | null {
128
+ const message = String((error as Error)?.message ?? error);
129
+ const patterns = [
130
+ /no such table:\s*(?:(?:main|public)\.)?["'`]?([a-zA-Z_][\w$]*)/i,
131
+ /relation\s+["'`](?:(?:public)\.)?([a-zA-Z_][\w$]*)["'`]\s+does not exist/i,
132
+ /table\s+["'`](?:[^"'`.]+\.)?([a-zA-Z_][\w$]*)["'`]\s+does(?:n't| not)\s+exist/i,
133
+ /unknown table\s+["'`]?(?:[^"'`.\s]+\.)?([a-zA-Z_][\w$]*)/i,
134
+ /undefined table[^a-zA-Z_]+(?:[^.\s]+\.)?([a-zA-Z_][\w$]*)/i,
135
+ ];
136
+ for (const pattern of patterns) {
137
+ const match = message.match(pattern);
138
+ if (match?.[1]) return match[1].toLowerCase();
139
+ }
140
+ return null;
141
+ }
142
+
92
143
  function isMissingTableError(error: unknown): boolean {
93
- const message = String((error as Error)?.message ?? error).toLowerCase();
94
- return (
95
- message.includes("no such table") ||
96
- message.includes("does not exist") ||
97
- message.includes("unknown table") ||
98
- message.includes("undefined table")
99
- );
144
+ return missingTableName(error) !== null;
100
145
  }
101
146
 
102
147
  async function optionalRows<T = Record<string, unknown>>(
@@ -120,10 +165,9 @@ async function queryRows<T = Record<string, unknown>>(
120
165
  try {
121
166
  return (await exec.execute({ sql, args })).rows as T[];
122
167
  } catch (error) {
123
- if (isMissingTableError(error)) {
124
- throw new Error(
125
- "This database does not have agent chat thread tables yet.",
126
- );
168
+ const missingTable = missingTableName(error);
169
+ if (missingTable) {
170
+ throw new UnsupportedThreadDebugSchemaError(missingTable);
127
171
  }
128
172
  throw error;
129
173
  }
@@ -276,7 +320,6 @@ function parseConfiguredSources(): ThreadDebugSourceConfig[] {
276
320
  : databaseUrlEnv
277
321
  ? process.env[databaseUrlEnv]
278
322
  : undefined;
279
- if (!databaseUrl) return null;
280
323
  return {
281
324
  id,
282
325
  label:
@@ -349,7 +392,14 @@ function sourceConfigs(): ThreadDebugSourceConfig[] {
349
392
  function resolveSourceConfig(sourceId = "current"): ThreadDebugSourceConfig {
350
393
  const normalized = sourceId.trim() || "current";
351
394
  const direct = sourceConfigs().find((source) => source.id === normalized);
352
- if (direct) return direct;
395
+ if (direct) {
396
+ if (direct.kind !== "current" && !direct.databaseUrl) {
397
+ throw new Error(
398
+ `Thread debug source "${normalized}" is configured but disconnected.`,
399
+ );
400
+ }
401
+ return direct;
402
+ }
353
403
 
354
404
  const prefix = envPrefixForSourceId(normalized);
355
405
  const databaseUrlEnv = `${prefix}_DATABASE_URL`;
@@ -371,6 +421,11 @@ function resolveSourceConfig(sourceId = "current"): ThreadDebugSourceConfig {
371
421
 
372
422
  async function execForSource(source: ThreadDebugSourceConfig): Promise<DbExec> {
373
423
  if (source.kind === "current") return getDbExec();
424
+ if (!source.databaseUrl) {
425
+ throw new Error(
426
+ `Thread debug source "${source.id}" is configured but disconnected.`,
427
+ );
428
+ }
374
429
  const cacheKey = `${source.databaseUrl ?? ""}\n${source.databaseAuthToken ?? ""}`;
375
430
  if (!execCache.has(cacheKey)) {
376
431
  execCache.set(
@@ -443,19 +498,33 @@ function assertSourceAccess(
443
498
  }
444
499
  }
445
500
 
446
- function ownerScope(access: DebugAccess, ownerEmail?: string): OwnerScope {
501
+ function ownerScope(
502
+ access: DebugAccess,
503
+ ownerEmail?: string,
504
+ column = "owner_email",
505
+ ): OwnerScope {
447
506
  const requested = ownerEmail?.trim();
448
507
  if (!access.canInspectAll) {
449
508
  return {
450
- sql: "owner_email = ?",
509
+ sql: `${column} = ?`,
451
510
  args: [access.viewerEmail],
452
511
  label: access.viewerEmail,
453
512
  };
454
513
  }
455
514
 
456
515
  if (requested && requested !== "*") {
516
+ if (
517
+ access.orgId &&
518
+ !access.memberEmails.some(
519
+ (email) => email.toLowerCase() === requested.toLowerCase(),
520
+ )
521
+ ) {
522
+ throw new Error(
523
+ "The requested owner is not a member of the current organization.",
524
+ );
525
+ }
457
526
  return {
458
- sql: "owner_email = ?",
527
+ sql: `${column} = ?`,
459
528
  args: [requested],
460
529
  label: requested,
461
530
  };
@@ -468,14 +537,14 @@ function ownerScope(access: DebugAccess, ownerEmail?: string): OwnerScope {
468
537
  const emails = access.memberEmails;
469
538
  if (emails.length === 0) {
470
539
  return {
471
- sql: "owner_email = ?",
540
+ sql: `${column} = ?`,
472
541
  args: [access.viewerEmail],
473
542
  label: access.viewerEmail,
474
543
  };
475
544
  }
476
545
  const placeholders = emails.map(() => "?").join(", ");
477
546
  return {
478
- sql: `owner_email IN (${placeholders})`,
547
+ sql: `${column} IN (${placeholders})`,
479
548
  args: emails,
480
549
  label: access.orgId ? "current organization" : "all users",
481
550
  };
@@ -498,11 +567,20 @@ function serializeRun(row: AgentRunRow, events: any[] = []) {
498
567
  return {
499
568
  id: String(row.id),
500
569
  threadId: String(row.thread_id),
570
+ turnId: row.turn_id ? String(row.turn_id) : null,
501
571
  status: String(row.status),
502
572
  abortReason: row.abort_reason ? String(row.abort_reason) : null,
573
+ errorCode: row.error_code ? String(row.error_code) : null,
574
+ errorDetail: row.error_detail ? String(row.error_detail) : null,
575
+ terminalReason: row.terminal_reason ? String(row.terminal_reason) : null,
503
576
  startedAt: numberField(row.started_at),
504
577
  completedAt: nullableNumberField(row.completed_at),
505
578
  heartbeatAt: nullableNumberField(row.heartbeat_at),
579
+ lastProgressAt: nullableNumberField(row.last_progress_at),
580
+ dispatchMode: row.dispatch_mode ? String(row.dispatch_mode) : null,
581
+ workerStage: row.worker_stage ? String(row.worker_stage) : null,
582
+ diagStage: row.diag_stage ? String(row.diag_stage) : null,
583
+ peakRssMb: nullableNumberField(row.peak_rss_mb),
506
584
  events,
507
585
  };
508
586
  }
@@ -547,6 +625,224 @@ export async function listThreadDebugSources(): Promise<{
547
625
  };
548
626
  }
549
627
 
628
+ function publicSource(source: ThreadDebugSourceConfig) {
629
+ return {
630
+ id: source.id,
631
+ label: source.label,
632
+ kind: source.kind,
633
+ databaseUrlEnv: source.databaseUrlEnv ?? null,
634
+ };
635
+ }
636
+
637
+ function parseTerminalEvent(value: unknown): Record<string, unknown> | null {
638
+ if (value == null || value === "") return null;
639
+ try {
640
+ const parsed = JSON.parse(String(value));
641
+ return parsed && typeof parsed === "object"
642
+ ? (parsed as Record<string, unknown>)
643
+ : { type: "unparseable" };
644
+ } catch {
645
+ return { type: "unparseable" };
646
+ }
647
+ }
648
+
649
+ function serializeRunFailure(
650
+ row: AgentRunFailureRow,
651
+ source: ThreadDebugSourceConfig,
652
+ ) {
653
+ const startedAt = numberField(row.started_at);
654
+ const completedAt = nullableNumberField(row.completed_at);
655
+ return {
656
+ source: publicSource(source),
657
+ id: String(row.id),
658
+ threadId: String(row.thread_id),
659
+ turnId: row.turn_id ? String(row.turn_id) : null,
660
+ ownerEmail: String(row.debug_owner_email ?? ""),
661
+ threadTitle: String(row.debug_thread_title ?? ""),
662
+ threadPreview: String(row.debug_thread_preview ?? ""),
663
+ status: String(row.status) as AgentRunFailureStatus,
664
+ abortReason: row.abort_reason ? String(row.abort_reason) : null,
665
+ errorCode: row.error_code ? String(row.error_code) : null,
666
+ errorDetail: row.error_detail ? String(row.error_detail) : null,
667
+ terminalReason: row.terminal_reason ? String(row.terminal_reason) : null,
668
+ startedAt,
669
+ completedAt,
670
+ heartbeatAt: nullableNumberField(row.heartbeat_at),
671
+ lastProgressAt: nullableNumberField(row.last_progress_at),
672
+ durationMs: completedAt == null ? null : completedAt - startedAt,
673
+ dispatchMode: row.dispatch_mode ? String(row.dispatch_mode) : null,
674
+ workerStage: row.worker_stage ? String(row.worker_stage) : null,
675
+ diagStage: row.diag_stage ? String(row.diag_stage) : null,
676
+ peakRssMb: nullableNumberField(row.peak_rss_mb),
677
+ terminalEvent: parseTerminalEvent(row.debug_terminal_event_data),
678
+ };
679
+ }
680
+
681
+ function sourceHealth(
682
+ source: ThreadDebugSourceConfig,
683
+ status: ThreadDebugSourceHealthStatus,
684
+ failureCount: number,
685
+ errorCode:
686
+ | null
687
+ | "thread_debug_source_disconnected"
688
+ | "thread_debug_schema_unsupported"
689
+ | "thread_debug_source_unavailable",
690
+ ) {
691
+ return {
692
+ source: publicSource(source),
693
+ status,
694
+ failureCount,
695
+ errorCode,
696
+ };
697
+ }
698
+
699
+ async function failuresForSource(
700
+ source: ThreadDebugSourceConfig,
701
+ scope: OwnerScope,
702
+ input: {
703
+ status: AgentRunFailureStatus | "all";
704
+ cutoff: number;
705
+ limit: number;
706
+ },
707
+ ) {
708
+ const exec = await execForSource(source);
709
+ const statuses =
710
+ input.status === "all" ? [...UNSUCCESSFUL_RUN_STATUSES] : [input.status];
711
+ const statusPlaceholders = statuses.map(() => "?").join(", ");
712
+ const rows = await queryRows<AgentRunFailureRow>(
713
+ exec,
714
+ `SELECT r.*,
715
+ t.owner_email AS debug_owner_email,
716
+ t.title AS debug_thread_title,
717
+ t.preview AS debug_thread_preview,
718
+ (
719
+ SELECT e.event_data
720
+ FROM agent_run_events e
721
+ WHERE e.run_id = r.id
722
+ ORDER BY e.seq DESC
723
+ LIMIT 1
724
+ ) AS debug_terminal_event_data
725
+ FROM agent_runs r
726
+ JOIN chat_threads t ON t.id = r.thread_id
727
+ WHERE r.status IN (${statusPlaceholders})
728
+ AND ${scope.sql}
729
+ AND COALESCE(r.completed_at, r.started_at) >= ?
730
+ ORDER BY COALESCE(r.completed_at, r.started_at) DESC, r.id DESC
731
+ LIMIT ?`,
732
+ [...statuses, ...scope.args, input.cutoff, input.limit],
733
+ );
734
+ return rows.map((row) => serializeRunFailure(row, source));
735
+ }
736
+
737
+ export async function listAgentRunFailures(input: {
738
+ sourceId?: string;
739
+ ownerEmail?: string;
740
+ status?: AgentRunFailureStatus | "all";
741
+ lookbackHours?: number;
742
+ limit?: number;
743
+ }) {
744
+ const access = await resolveDebugAccess();
745
+ const requestedSourceId = input.sourceId?.trim() || "all";
746
+ const status = input.status ?? "all";
747
+ const lookbackHours = Math.max(1, Math.min(720, input.lookbackHours ?? 168));
748
+ const limit = Math.max(1, Math.min(100, input.limit ?? DEFAULT_SEARCH_LIMIT));
749
+ const scope = ownerScope(access, input.ownerEmail, "t.owner_email");
750
+ const cutoff = Date.now() - lookbackHours * 60 * 60 * 1000;
751
+
752
+ let sources: ThreadDebugSourceConfig[];
753
+ if (requestedSourceId === "all") {
754
+ sources = sourceConfigs().filter(
755
+ (source) => source.kind === "current" || access.canInspectAll,
756
+ );
757
+ } else {
758
+ const configured = sourceConfigs().find(
759
+ (source) => source.id === requestedSourceId,
760
+ );
761
+ const source = configured ?? resolveSourceConfig(requestedSourceId);
762
+ assertSourceAccess(source, access);
763
+ if (source.kind !== "current" && !source.databaseUrl) {
764
+ throw new Error(
765
+ `Thread debug source "${requestedSourceId}" is configured but disconnected.`,
766
+ );
767
+ }
768
+ sources = [source];
769
+ }
770
+
771
+ const results = await Promise.all(
772
+ sources.map(async (source) => {
773
+ if (source.kind !== "current" && !source.databaseUrl) {
774
+ return {
775
+ failures: [] as ReturnType<typeof serializeRunFailure>[],
776
+ health: sourceHealth(
777
+ source,
778
+ "disconnected",
779
+ 0,
780
+ "thread_debug_source_disconnected",
781
+ ),
782
+ };
783
+ }
784
+ try {
785
+ const failures = await failuresForSource(source, scope, {
786
+ status,
787
+ cutoff,
788
+ limit,
789
+ });
790
+ return {
791
+ failures,
792
+ health: sourceHealth(source, "ok", failures.length, null),
793
+ };
794
+ } catch (error) {
795
+ if (error instanceof UnsupportedThreadDebugSchemaError) {
796
+ return {
797
+ failures: [] as ReturnType<typeof serializeRunFailure>[],
798
+ health: sourceHealth(
799
+ source,
800
+ "unsupported",
801
+ 0,
802
+ "thread_debug_schema_unsupported",
803
+ ),
804
+ };
805
+ }
806
+ return {
807
+ failures: [] as ReturnType<typeof serializeRunFailure>[],
808
+ health: sourceHealth(
809
+ source,
810
+ "unavailable",
811
+ 0,
812
+ "thread_debug_source_unavailable",
813
+ ),
814
+ };
815
+ }
816
+ }),
817
+ );
818
+
819
+ const failures = results
820
+ .flatMap((result) => result.failures)
821
+ .sort(
822
+ (a, b) =>
823
+ (b.completedAt ?? b.startedAt) - (a.completedAt ?? a.startedAt) ||
824
+ b.id.localeCompare(a.id) ||
825
+ b.source.id.localeCompare(a.source.id),
826
+ )
827
+ .slice(0, limit);
828
+
829
+ return {
830
+ sourceId: requestedSourceId,
831
+ status,
832
+ lookbackHours,
833
+ limit,
834
+ count: failures.length,
835
+ partial: results.some((result) => result.health.status !== "ok"),
836
+ access: {
837
+ viewerEmail: access.viewerEmail,
838
+ scope: scope.label,
839
+ canInspectAll: access.canInspectAll,
840
+ },
841
+ sources: results.map((result) => result.health),
842
+ failures,
843
+ };
844
+ }
845
+
550
846
  export async function searchAgentThreads(input: {
551
847
  sourceId?: string;
552
848
  query?: string;
@@ -694,10 +990,10 @@ export async function getAgentThreadDebug(input: {
694
990
 
695
991
  const runRows = await optionalRows<AgentRunRow>(
696
992
  exec,
697
- `SELECT id, thread_id, status, abort_reason, started_at, heartbeat_at, completed_at
698
- FROM agent_runs
699
- WHERE thread_id = ?
700
- ORDER BY started_at DESC
993
+ `SELECT r.*
994
+ FROM agent_runs r
995
+ WHERE r.thread_id = ?
996
+ ORDER BY r.started_at DESC
701
997
  LIMIT ?`,
702
998
  [row.id, maxRuns],
703
999
  );
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ createAgentChatPlugin: vi.fn((options: Record<string, unknown>) => options),
5
+ }));
6
+
7
+ vi.mock("@agent-native/core/server", () => ({
8
+ createAgentChatPlugin: mocks.createAgentChatPlugin,
9
+ }));
10
+
11
+ vi.mock("@agent-native/core/org", () => ({
12
+ getOrgContext: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("../../actions/index.js", () => ({
16
+ dispatchActions: {},
17
+ }));
18
+
19
+ describe("Dispatch agent chat plugin", () => {
20
+ it("opts delegated work into the durable background run contract", async () => {
21
+ await import("./agent-chat.js");
22
+
23
+ expect(mocks.createAgentChatPlugin).toHaveBeenCalledWith(
24
+ expect.objectContaining({
25
+ appId: "dispatch",
26
+ durableBackgroundRuns: true,
27
+ }),
28
+ );
29
+ });
30
+ });
@@ -29,6 +29,7 @@ const INITIAL_TOOL_NAMES = [
29
29
 
30
30
  export default createAgentChatPlugin({
31
31
  appId: "dispatch",
32
+ durableBackgroundRuns: true,
32
33
  initialToolNames: INITIAL_TOOL_NAMES,
33
34
  connectorCatalog: ["resolve-integration-source-context"],
34
35
  // Without this, AGENT_ORG_ID is never set on agent action calls and every