@agent-native/dispatch 0.16.4 → 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.
- package/README.md +2 -2
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-agent-run-failures.d.ts +4 -0
- package/dist/actions/list-agent-run-failures.js +5 -1
- package/dist/actions/list-agent-run-failures.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/actions/read-slack-thread-context.d.ts +29 -0
- package/dist/actions/read-slack-thread-context.d.ts.map +1 -0
- package/dist/actions/read-slack-thread-context.js +138 -0
- package/dist/actions/read-slack-thread-context.js.map +1 -0
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +1 -1
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/server/lib/thread-debug-store.d.ts +5 -0
- package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
- package/dist/server/lib/thread-debug-store.js +21 -1
- package/dist/server/lib/thread-debug-store.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/index.spec.ts +1 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/list-agent-run-failures.ts +7 -1
- package/src/actions/read-slack-thread-context.spec.ts +134 -0
- package/src/actions/read-slack-thread-context.ts +171 -0
- package/src/components/layout/Layout.tsx +6 -2
- package/src/server/lib/thread-debug-store.spec.ts +60 -0
- package/src/server/lib/thread-debug-store.ts +27 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|