@agent-native/dispatch 0.16.5 → 0.16.6

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.
@@ -0,0 +1,171 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+
4
+ import { executeProviderApiRequest } from "../server/lib/provider-api.js";
5
+
6
+ const SlackPermalinkSchema = z
7
+ .string()
8
+ .url()
9
+ .refine((value) => {
10
+ const url = new URL(value);
11
+ return (
12
+ url.protocol === "https:" &&
13
+ url.hostname.endsWith(".slack.com") &&
14
+ url.pathname.startsWith("/archives/")
15
+ );
16
+ }, "Expected an https Slack archive permalink.")
17
+ .describe("Slack message permalink from the issue or feedback report.");
18
+
19
+ type SlackMessage = {
20
+ ts?: string;
21
+ thread_ts?: string;
22
+ user?: string;
23
+ username?: string;
24
+ bot_id?: string;
25
+ text?: string;
26
+ blocks?: unknown;
27
+ attachments?: unknown;
28
+ files?: unknown;
29
+ reactions?: unknown;
30
+ };
31
+
32
+ function parseSlackPermalink(permalink: string) {
33
+ const url = new URL(permalink);
34
+ const match = url.pathname.match(/^\/archives\/([^/]+)\/p(\d{16})$/);
35
+ if (!match) {
36
+ throw new Error(
37
+ "Slack permalink must include a channel id and 16-digit message timestamp.",
38
+ );
39
+ }
40
+
41
+ const [, channelId, compactTimestamp] = match;
42
+ const linkedMessageTs = `${compactTimestamp.slice(0, 10)}.${compactTimestamp.slice(10)}`;
43
+ const threadTs = url.searchParams.get("thread_ts") || linkedMessageTs;
44
+
45
+ return { channelId, linkedMessageTs, threadTs };
46
+ }
47
+
48
+ function getResponseJson(response: unknown): Record<string, unknown> {
49
+ if (!response || typeof response !== "object") {
50
+ throw new Error("Slack thread read returned no response metadata.");
51
+ }
52
+
53
+ const value = response as { json?: unknown; status?: number; ok?: boolean };
54
+ if (value.ok !== true) {
55
+ throw new Error(
56
+ `Slack thread read failed with HTTP ${value.status ?? "unknown"}.`,
57
+ );
58
+ }
59
+ if (!value.json || typeof value.json !== "object") {
60
+ throw new Error("Slack thread read returned no JSON body.");
61
+ }
62
+
63
+ const body = value.json as Record<string, unknown>;
64
+ if (body.ok !== true) {
65
+ const error = typeof body.error === "string" ? body.error : "unknown_error";
66
+ throw new Error(`Slack thread read failed: ${error}.`);
67
+ }
68
+ return body;
69
+ }
70
+
71
+ function collectLinks(value: unknown, links: Set<string>): void {
72
+ if (typeof value === "string") {
73
+ for (const match of value.matchAll(/https?:\/\/[^\s<>|]+/g)) {
74
+ links.add(match[0].replace(/[),.;]+$/, ""));
75
+ }
76
+ return;
77
+ }
78
+ if (Array.isArray(value)) {
79
+ for (const item of value) collectLinks(item, links);
80
+ return;
81
+ }
82
+ if (!value || typeof value !== "object") return;
83
+
84
+ for (const [key, child] of Object.entries(value)) {
85
+ if (key === "url" && typeof child === "string") links.add(child);
86
+ else collectLinks(child, links);
87
+ }
88
+ }
89
+
90
+ function projectMessage(message: SlackMessage) {
91
+ return {
92
+ ts: message.ts ?? null,
93
+ threadTs: message.thread_ts ?? null,
94
+ user: message.user ?? null,
95
+ username: message.username ?? null,
96
+ botId: message.bot_id ?? null,
97
+ text: message.text ?? "",
98
+ ...(message.blocks ? { blocks: message.blocks } : {}),
99
+ ...(message.attachments ? { attachments: message.attachments } : {}),
100
+ ...(message.files ? { files: message.files } : {}),
101
+ ...(message.reactions ? { reactions: message.reactions } : {}),
102
+ };
103
+ }
104
+
105
+ export default defineAction({
106
+ description:
107
+ "Read the complete Slack thread behind an issue permalink before diagnosing or fixing it. Resolves a child permalink to its parent, returns messages plus attachments and related links, and reports pagination completeness. Read-only; never joins a channel or sends a message.",
108
+ schema: z.object({
109
+ permalink: SlackPermalinkSchema,
110
+ limit: z.coerce
111
+ .number()
112
+ .int()
113
+ .min(1)
114
+ .max(1000)
115
+ .default(100)
116
+ .describe("Maximum Slack messages to return in this page."),
117
+ cursor: z
118
+ .string()
119
+ .optional()
120
+ .describe("Slack response_metadata.next_cursor from a previous page."),
121
+ connectionId: z
122
+ .string()
123
+ .optional()
124
+ .describe(
125
+ "Optional connected Slack workspace id when several are granted.",
126
+ ),
127
+ }),
128
+ http: false,
129
+ readOnly: true,
130
+ run: async ({ permalink, limit, cursor, connectionId }) => {
131
+ const parsed = parseSlackPermalink(permalink);
132
+ const result = await executeProviderApiRequest({
133
+ provider: "slack",
134
+ method: "GET",
135
+ path: "/conversations.replies",
136
+ query: {
137
+ channel: parsed.channelId,
138
+ ts: parsed.threadTs,
139
+ limit,
140
+ ...(cursor ? { cursor } : {}),
141
+ },
142
+ connectionId,
143
+ maxBytes: 2 * 1024 * 1024,
144
+ });
145
+
146
+ const response = (result as { response?: unknown }).response;
147
+ const body = getResponseJson(response);
148
+ const messages = Array.isArray(body.messages)
149
+ ? (body.messages as SlackMessage[])
150
+ : [];
151
+ const nextCursor =
152
+ body.response_metadata && typeof body.response_metadata === "object"
153
+ ? (body.response_metadata as { next_cursor?: unknown }).next_cursor
154
+ : null;
155
+ const relatedLinks = new Set<string>();
156
+ collectLinks(messages, relatedLinks);
157
+
158
+ return {
159
+ permalink,
160
+ channelId: parsed.channelId,
161
+ linkedMessageTs: parsed.linkedMessageTs,
162
+ threadTs: parsed.threadTs,
163
+ messages: messages.map(projectMessage),
164
+ messageCount: messages.length,
165
+ completeness: nextCursor ? "partial" : "complete",
166
+ nextCursor:
167
+ typeof nextCursor === "string" && nextCursor ? nextCursor : null,
168
+ relatedLinks: [...relatedLinks],
169
+ };
170
+ },
171
+ });
@@ -654,13 +654,17 @@ export function NavContent({
654
654
  src={appPath("/agent-native-icon-light.svg")}
655
655
  alt=""
656
656
  aria-hidden="true"
657
- className="block h-5 w-auto shrink-0 dark:hidden"
657
+ width={35}
658
+ height={20}
659
+ className="block h-5 w-[35px] shrink-0 object-contain object-center dark:hidden"
658
660
  />
659
661
  <img
660
662
  src={appPath("/agent-native-icon-dark.svg")}
661
663
  alt=""
662
664
  aria-hidden="true"
663
- className="hidden h-5 w-auto shrink-0 dark:block"
665
+ width={35}
666
+ height={20}
667
+ className="hidden h-5 w-[35px] shrink-0 object-contain object-center dark:block"
664
668
  />
665
669
  <div className="min-w-0 flex-1">
666
670
  <div className="truncate text-lg font-bold tracking-tight text-foreground">
@@ -182,6 +182,66 @@ describe("thread-debug-store", () => {
182
182
  });
183
183
  });
184
184
 
185
+ it("separates interactive and scheduled populations and attaches the measured taxonomy", async () => {
186
+ mocks.currentExecute.mockImplementation(async ({ sql }) => {
187
+ if (!sql.includes("JOIN chat_threads")) return { rows: [] };
188
+ if (sql.includes("r.id NOT LIKE 'job-%'")) {
189
+ return {
190
+ rows: [
191
+ failureRow("run-interactive", Date.now(), {
192
+ error_code: null,
193
+ error_detail: "Missing Authentication header",
194
+ terminal_reason: null,
195
+ }),
196
+ ],
197
+ };
198
+ }
199
+ if (sql.includes("r.id LIKE 'job-%'")) {
200
+ return {
201
+ rows: [
202
+ failureRow("job-analytics-1", Date.now(), {
203
+ error_code: null,
204
+ error_detail:
205
+ '{"error":{"type":"overloaded_error","message":"Overloaded"}}',
206
+ terminal_reason: null,
207
+ }),
208
+ ],
209
+ };
210
+ }
211
+ return [];
212
+ });
213
+
214
+ const interactive = await listAgentRunFailures({
215
+ sourceId: "current",
216
+ regime: "interactive",
217
+ });
218
+ const scheduled = await listAgentRunFailures({
219
+ sourceId: "current",
220
+ regime: "scheduled",
221
+ });
222
+
223
+ expect(interactive).toMatchObject({
224
+ regime: "interactive",
225
+ failures: [
226
+ {
227
+ id: "run-interactive",
228
+ regime: "interactive",
229
+ failureTaxonomy: { code: "authentication_error" },
230
+ },
231
+ ],
232
+ });
233
+ expect(scheduled).toMatchObject({
234
+ regime: "scheduled",
235
+ failures: [
236
+ {
237
+ id: "job-analytics-1",
238
+ regime: "scheduled",
239
+ failureTaxonomy: { code: "overloaded_error" },
240
+ },
241
+ ],
242
+ });
243
+ });
244
+
185
245
  it("merges all admin-visible sources, sorts globally, limits, and preserves partial health", async () => {
186
246
  vi.stubEnv("DISPATCH_ADMIN_EMAILS", "owner@example.com");
187
247
  vi.stubEnv("REMOTE_A_DATABASE_URL", "libsql://remote-a");
@@ -1,3 +1,7 @@
1
+ import {
2
+ classifyAgentFailure,
3
+ type AgentFailureRegime,
4
+ } from "@agent-native/core/agent/engine";
1
5
  import { createDbExec, getDbExec, type DbExec } from "@agent-native/core/db";
2
6
 
3
7
  import { currentOrgId, currentOwnerEmail } from "./dispatch-store.js";
@@ -652,6 +656,14 @@ function serializeRunFailure(
652
656
  ) {
653
657
  const startedAt = numberField(row.started_at);
654
658
  const completedAt = nullableNumberField(row.completed_at);
659
+ const terminalEvent = parseTerminalEvent(row.debug_terminal_event_data);
660
+ const failureTaxonomy = classifyAgentFailure({
661
+ runId: row.id,
662
+ errorCode: row.error_code,
663
+ errorDetail: row.error_detail,
664
+ terminalReason: row.terminal_reason,
665
+ terminalEvent,
666
+ });
655
667
  return {
656
668
  source: publicSource(source),
657
669
  id: String(row.id),
@@ -674,7 +686,9 @@ function serializeRunFailure(
674
686
  workerStage: row.worker_stage ? String(row.worker_stage) : null,
675
687
  diagStage: row.diag_stage ? String(row.diag_stage) : null,
676
688
  peakRssMb: nullableNumberField(row.peak_rss_mb),
677
- terminalEvent: parseTerminalEvent(row.debug_terminal_event_data),
689
+ terminalEvent,
690
+ regime: failureTaxonomy.regime,
691
+ failureTaxonomy,
678
692
  };
679
693
  }
680
694
 
@@ -701,6 +715,7 @@ async function failuresForSource(
701
715
  scope: OwnerScope,
702
716
  input: {
703
717
  status: AgentRunFailureStatus | "all";
718
+ regime: AgentFailureRegime | "all";
704
719
  cutoff: number;
705
720
  limit: number;
706
721
  },
@@ -709,6 +724,12 @@ async function failuresForSource(
709
724
  const statuses =
710
725
  input.status === "all" ? [...UNSUCCESSFUL_RUN_STATUSES] : [input.status];
711
726
  const statusPlaceholders = statuses.map(() => "?").join(", ");
727
+ const regimeClause =
728
+ input.regime === "scheduled"
729
+ ? "AND r.id LIKE 'job-%'"
730
+ : input.regime === "interactive"
731
+ ? "AND r.id NOT LIKE 'job-%'"
732
+ : "";
712
733
  const rows = await queryRows<AgentRunFailureRow>(
713
734
  exec,
714
735
  `SELECT r.*,
@@ -726,6 +747,7 @@ async function failuresForSource(
726
747
  JOIN chat_threads t ON t.id = r.thread_id
727
748
  WHERE r.status IN (${statusPlaceholders})
728
749
  AND ${scope.sql}
750
+ ${regimeClause}
729
751
  AND COALESCE(r.completed_at, r.started_at) >= ?
730
752
  ORDER BY COALESCE(r.completed_at, r.started_at) DESC, r.id DESC
731
753
  LIMIT ?`,
@@ -738,12 +760,14 @@ export async function listAgentRunFailures(input: {
738
760
  sourceId?: string;
739
761
  ownerEmail?: string;
740
762
  status?: AgentRunFailureStatus | "all";
763
+ regime?: AgentFailureRegime | "all";
741
764
  lookbackHours?: number;
742
765
  limit?: number;
743
766
  }) {
744
767
  const access = await resolveDebugAccess();
745
768
  const requestedSourceId = input.sourceId?.trim() || "all";
746
769
  const status = input.status ?? "all";
770
+ const regime = input.regime ?? "all";
747
771
  const lookbackHours = Math.max(1, Math.min(720, input.lookbackHours ?? 168));
748
772
  const limit = Math.max(1, Math.min(100, input.limit ?? DEFAULT_SEARCH_LIMIT));
749
773
  const scope = ownerScope(access, input.ownerEmail, "t.owner_email");
@@ -784,6 +808,7 @@ export async function listAgentRunFailures(input: {
784
808
  try {
785
809
  const failures = await failuresForSource(source, scope, {
786
810
  status,
811
+ regime,
787
812
  cutoff,
788
813
  limit,
789
814
  });
@@ -829,6 +854,7 @@ export async function listAgentRunFailures(input: {
829
854
  return {
830
855
  sourceId: requestedSourceId,
831
856
  status,
857
+ regime,
832
858
  lookbackHours,
833
859
  limit,
834
860
  count: failures.length,