@agent-native/dispatch 0.32.0 → 0.33.0-nightly-20260831223557

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/ask_app.js +0 -1
  2. package/dist/actions/ask_app.js.map +1 -1
  3. package/dist/actions/provider-api-register.d.ts +8 -8
  4. package/dist/components/automation-create-dialog.d.ts +16 -0
  5. package/dist/components/automation-create-dialog.d.ts.map +1 -0
  6. package/dist/components/automation-create-dialog.js +213 -0
  7. package/dist/components/automation-create-dialog.js.map +1 -0
  8. package/dist/components/automation-details-panel.d.ts.map +1 -1
  9. package/dist/components/automation-details-panel.js +35 -4
  10. package/dist/components/automation-details-panel.js.map +1 -1
  11. package/dist/hooks/use-navigation-state.d.ts +1 -0
  12. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  13. package/dist/hooks/use-navigation-state.js +5 -0
  14. package/dist/hooks/use-navigation-state.js.map +1 -1
  15. package/dist/lib/automation-display.d.ts.map +1 -1
  16. package/dist/lib/automation-display.js +4 -0
  17. package/dist/lib/automation-display.js.map +1 -1
  18. package/dist/lib/automations.d.ts +2 -1
  19. package/dist/lib/automations.d.ts.map +1 -1
  20. package/dist/lib/automations.js.map +1 -1
  21. package/dist/routes/pages/automations.d.ts.map +1 -1
  22. package/dist/routes/pages/automations.js +16 -24
  23. package/dist/routes/pages/automations.js.map +1 -1
  24. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  25. package/dist/server/lib/mcp-gateway.js +27 -3
  26. package/dist/server/lib/mcp-gateway.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/actions/ask_app.ts +0 -1
  29. package/src/components/automation-create-dialog.tsx +759 -0
  30. package/src/components/automation-details-panel.tsx +80 -12
  31. package/src/hooks/use-navigation-state.spec.ts +13 -0
  32. package/src/hooks/use-navigation-state.ts +6 -0
  33. package/src/lib/automation-display.ts +2 -0
  34. package/src/lib/automations.ts +2 -1
  35. package/src/routes/pages/automations.spec.tsx +11 -0
  36. package/src/routes/pages/automations.tsx +27 -57
  37. package/src/server/lib/mcp-gateway.spec.ts +37 -2
  38. package/src/server/lib/mcp-gateway.ts +40 -3
@@ -1,11 +1,17 @@
1
+ import { appPath } from "@agent-native/core/client/api-path";
2
+ import { writeClipboardText } from "@agent-native/core/client/clipboard";
1
3
  import { useActionQuery } from "@agent-native/core/client/hooks";
4
+ import { useT } from "@agent-native/core/client/i18n";
2
5
  import {
3
6
  IconAlertTriangle,
7
+ IconCheck,
8
+ IconCopy,
4
9
  IconClock,
5
10
  IconExternalLink,
6
11
  IconPlayerPause,
7
12
  IconPlayerPlay,
8
13
  } from "@tabler/icons-react";
14
+ import { useState } from "react";
9
15
  import { Link } from "react-router";
10
16
 
11
17
  import {
@@ -107,6 +113,7 @@ export function AutomationDetailsPanel({
107
113
  isToggling = false,
108
114
  onToggle,
109
115
  }: AutomationDetailsPanelProps) {
116
+ const t = useT();
110
117
  const runsQuery = useActionQuery<AutomationRun[]>(
111
118
  "list-automation-runs",
112
119
  {
@@ -118,7 +125,18 @@ export function AutomationDetailsPanel({
118
125
  );
119
126
  const runs = runsQuery.data ?? [];
120
127
  const status = automationStatus(automation);
121
- const isScheduled = automation.triggerType !== "event";
128
+ const isScheduled = automation.triggerType === "schedule";
129
+ const [copied, setCopied] = useState(false);
130
+ const webhookUrl =
131
+ automation.webhookPath && typeof window !== "undefined"
132
+ ? window.location.origin + appPath(automation.webhookPath)
133
+ : null;
134
+ const triggerLabel =
135
+ automation.triggerType === "webhook"
136
+ ? t("jobs.webhook", { defaultValue: "Webhook" })
137
+ : isScheduled
138
+ ? "Schedule"
139
+ : "Event";
122
140
  const prompt = automation.body?.trim();
123
141
  const hasExecutionTarget = Boolean(
124
142
  automation.executionHostId ||
@@ -156,8 +174,7 @@ export function AutomationDetailsPanel({
156
174
  {automation.name}
157
175
  </h2>
158
176
  <p className="mt-1 text-xs text-muted-foreground">
159
- {isScheduled ? "Schedule" : "Event"} ·{" "}
160
- {automationScopeLabel(automation)}
177
+ {triggerLabel} · {automationScopeLabel(automation)}
161
178
  </p>
162
179
  </div>
163
180
  </header>
@@ -183,7 +200,15 @@ export function AutomationDetailsPanel({
183
200
  <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-4">
184
201
  <DetailField
185
202
  label="Trigger"
186
- value={isScheduled ? "Scheduled" : "Event-triggered"}
203
+ value={
204
+ automation.triggerType === "webhook"
205
+ ? t("jobs.webhookTrigger", {
206
+ defaultValue: "Webhook-triggered",
207
+ })
208
+ : isScheduled
209
+ ? "Scheduled"
210
+ : "Event-triggered"
211
+ }
187
212
  />
188
213
  <DetailField
189
214
  label={isScheduled ? "Schedule" : "Event"}
@@ -204,6 +229,47 @@ export function AutomationDetailsPanel({
204
229
  />
205
230
  </>
206
231
  ) : null}
232
+ {webhookUrl ? (
233
+ <div className="col-span-2 rounded-md border border-primary/20 bg-primary/5 p-3">
234
+ <div className="flex items-center justify-between gap-3">
235
+ <span className="text-xs font-medium text-foreground">
236
+ {t("jobs.webhookUrl", { defaultValue: "Webhook URL" })}
237
+ </span>
238
+ <Button
239
+ type="button"
240
+ variant="outline"
241
+ size="sm"
242
+ className="h-7 shrink-0 gap-1.5 text-xs"
243
+ onClick={() => {
244
+ void writeClipboardText(webhookUrl).then((didCopy) => {
245
+ setCopied(didCopy);
246
+ if (didCopy) {
247
+ window.setTimeout(() => setCopied(false), 1800);
248
+ }
249
+ });
250
+ }}
251
+ >
252
+ {copied ? (
253
+ <IconCheck className="size-3.5" />
254
+ ) : (
255
+ <IconCopy className="size-3.5" />
256
+ )}
257
+ {copied
258
+ ? t("common.copied", { defaultValue: "Copied" })
259
+ : t("common.copy", { defaultValue: "Copy URL" })}
260
+ </Button>
261
+ </div>
262
+ <code className="mt-2 block break-all text-[11px] leading-5 text-muted-foreground">
263
+ {webhookUrl}
264
+ </code>
265
+ <p className="mt-2 text-xs text-muted-foreground">
266
+ {t("jobs.webhookUrlHint", {
267
+ defaultValue:
268
+ "Paste this URL into a service that sends HTTP POST webhooks.",
269
+ })}
270
+ </p>
271
+ </div>
272
+ ) : null}
207
273
  <DetailField
208
274
  label="Condition"
209
275
  value={automation.condition || "Always"}
@@ -218,14 +284,16 @@ export function AutomationDetailsPanel({
218
284
  />
219
285
  <DetailField label="Mode" value={automation.mode || "Agentic"} />
220
286
  <DetailField label="Runs as" value={runAsLabel(automation.runAs)} />
221
- <DetailField
222
- label="Next run"
223
- value={
224
- automation.enabled
225
- ? formatTimestamp(automation.nextRun)
226
- : "Paused"
227
- }
228
- />
287
+ {isScheduled ? (
288
+ <DetailField
289
+ label="Next run"
290
+ value={
291
+ automation.enabled
292
+ ? formatTimestamp(automation.nextRun)
293
+ : "Paused"
294
+ }
295
+ />
296
+ ) : null}
229
297
  <DetailField
230
298
  label="Last run"
231
299
  value={formatTimestamp(automation.lastRun)}
@@ -97,6 +97,19 @@ describe("buildDispatchNavigationState", () => {
97
97
  });
98
98
  });
99
99
 
100
+ it("keeps the selected automation in application state", () => {
101
+ expect(
102
+ buildDispatchNavigationState(
103
+ "/automations",
104
+ "?automationId=personal%3Amorning-digest",
105
+ ),
106
+ ).toEqual({
107
+ view: "automations",
108
+ path: "/automations",
109
+ automationId: "personal:morning-digest",
110
+ });
111
+ });
112
+
100
113
  it("recognizes Admin routes without losing the underlying view", () => {
101
114
  expect(buildDispatchNavigationState("/admin/metrics")).toEqual({
102
115
  view: "metrics",
@@ -42,6 +42,7 @@ export interface NavigationState {
42
42
  usageScope?: "me" | "workspace" | "app";
43
43
  usageUserEmail?: string;
44
44
  usageAppId?: string;
45
+ automationId?: string;
45
46
  }
46
47
 
47
48
  export function useNavigationState(extensions?: DispatchExtensionConfig) {
@@ -214,6 +215,11 @@ export function buildDispatchNavigationState(
214
215
  if (usageUserEmail) state.usageUserEmail = usageUserEmail;
215
216
  }
216
217
 
218
+ if (state.view === "automations") {
219
+ const automationId = new URLSearchParams(search).get("automationId");
220
+ if (automationId) state.automationId = automationId;
221
+ }
222
+
217
223
  return state;
218
224
  }
219
225
 
@@ -33,6 +33,7 @@ export function automationTroubleshootPath(
33
33
 
34
34
  export function automationTarget(item: DispatchAutomationItem): string {
35
35
  if (item.triggerType === "event" && item.event) return item.event;
36
+ if (item.triggerType === "webhook") return "On webhook";
36
37
  if (item.scheduleDescription) return item.scheduleDescription;
37
38
  if (item.schedule) return item.schedule;
38
39
  return item.triggerType || "schedule";
@@ -72,6 +73,7 @@ export function automationLastCheck(item: DispatchAutomationItem): string {
72
73
  export function automationNextRun(item: DispatchAutomationItem): string {
73
74
  if (!item.enabled) return "paused";
74
75
  if (item.triggerType === "event") return "on event";
76
+ if (item.triggerType === "webhook") return "on webhook";
75
77
  return item.nextRun ? relativeRunTime(item.nextRun) : "not scheduled";
76
78
  }
77
79
 
@@ -9,8 +9,9 @@ export interface DispatchAutomationItem {
9
9
  orgId?: string;
10
10
  scope?: "personal" | "organization";
11
11
  canUpdate?: boolean;
12
- triggerType?: "schedule" | "event" | (string & {});
12
+ triggerType?: "schedule" | "event" | "webhook" | (string & {});
13
13
  event?: string;
14
+ webhookPath?: string;
14
15
  schedule?: string;
15
16
  scheduleDescription?: string;
16
17
  condition?: string;
@@ -64,7 +64,18 @@ vi.mock("@tanstack/react-query", () => ({
64
64
  }));
65
65
 
66
66
  vi.mock("@agent-native/core/client/hooks", () => ({
67
+ useActionMutation: () => ({
68
+ isPending: false,
69
+ mutate: vi.fn(),
70
+ reset: vi.fn(),
71
+ error: null,
72
+ }),
67
73
  useChangeVersions: () => 0,
74
+ useActionQuery: () => ({
75
+ data: [],
76
+ isLoading: false,
77
+ error: null,
78
+ }),
68
79
  }));
69
80
 
70
81
  vi.mock("@agent-native/core/client/agent-chat", () => ({
@@ -1,5 +1,3 @@
1
- import { sendToAgentChat } from "@agent-native/core/client/agent-chat";
2
- import { PromptComposer } from "@agent-native/core/client/composer";
3
1
  import { useChangeVersions } from "@agent-native/core/client/hooks";
4
2
  import {
5
3
  IconFileSearch,
@@ -13,16 +11,12 @@ import { useMemo, useState } from "react";
13
11
  import { Link, useSearchParams } from "react-router";
14
12
  import { toast } from "sonner";
15
13
 
14
+ import { AutomationCreateDialog } from "../../components/automation-create-dialog";
16
15
  import { AutomationDetailsPanel } from "../../components/automation-details-panel";
17
16
  import { DispatchShell } from "../../components/dispatch-shell";
18
17
  import { Badge } from "../../components/ui/badge";
19
18
  import { Button } from "../../components/ui/button";
20
19
  import { Input } from "../../components/ui/input";
21
- import {
22
- Popover,
23
- PopoverContent,
24
- PopoverTrigger,
25
- } from "../../components/ui/popover";
26
20
  import {
27
21
  Select,
28
22
  SelectContent,
@@ -134,57 +128,24 @@ function useToggleAutomation() {
134
128
  });
135
129
  }
136
130
 
137
- function CreateAutomationButton() {
131
+ function CreateAutomationButton({ onCreated }: { onCreated: () => void }) {
138
132
  const [open, setOpen] = useState(false);
139
- const [scope, setScope] = useState<"personal" | "organization">("personal");
140
-
141
- function handleSubmit(text: string) {
142
- const trimmed = text.trim();
143
- if (!trimmed) return;
144
- window.dispatchEvent(
145
- new CustomEvent("agent-panel:set-mode", {
146
- detail: { mode: "chat" },
147
- }),
148
- );
149
- sendToAgentChat({
150
- message: trimmed,
151
- context: `The user wants to create a new automation. Scope: ${scope}. Use manage-automations with action=define to create it. Ask clarifying questions if needed about what event to trigger on, conditions, and what actions to take.`,
152
- submit: true,
153
- newTab: true,
154
- });
155
- setOpen(false);
156
- }
157
133
 
158
134
  return (
159
- <Popover open={open} onOpenChange={setOpen}>
160
- <PopoverTrigger asChild>
161
- <Button size="sm">
162
- <IconPlus size={14} />
163
- New automation
164
- </Button>
165
- </PopoverTrigger>
166
- <PopoverContent align="end" className="w-[min(100vw-2rem,24rem)] p-3">
167
- <p className="pb-2 text-sm font-semibold text-foreground">
168
- New automation
169
- </p>
170
- <PromptComposer
171
- autoFocus
172
- placeholder="Describe what you want to automate..."
173
- draftScope="dispatch-automations:create"
174
- onSubmit={handleSubmit}
175
- />
176
- <select
177
- value={scope}
178
- onChange={(event) =>
179
- setScope(event.target.value as "personal" | "organization")
180
- }
181
- className="mt-2 w-full cursor-pointer rounded-md border border-input bg-background px-3 py-1.5 text-xs text-foreground"
182
- >
183
- <option value="personal">Personal</option>
184
- <option value="organization">Organization</option>
185
- </select>
186
- </PopoverContent>
187
- </Popover>
135
+ <>
136
+ <Button size="sm" onClick={() => setOpen(true)}>
137
+ <IconPlus size={14} />
138
+ New automation
139
+ </Button>
140
+ <AutomationCreateDialog
141
+ open={open}
142
+ onOpenChange={setOpen}
143
+ onCreated={() => {
144
+ setOpen(false);
145
+ onCreated();
146
+ }}
147
+ />
148
+ </>
188
149
  );
189
150
  }
190
151
 
@@ -194,6 +155,7 @@ export default function AutomationsRoute() {
194
155
  const [query, setQuery] = useState("");
195
156
  const automationsQuery = useAutomations();
196
157
  const toggleAutomation = useToggleAutomation();
158
+ const queryClient = useQueryClient();
197
159
  const automations = automationsQuery.data ?? [];
198
160
  const visibleAutomations = useMemo(
199
161
  () =>
@@ -215,6 +177,7 @@ export default function AutomationsRoute() {
215
177
  item.event,
216
178
  item.schedule,
217
179
  item.scheduleDescription,
180
+ item.webhookPath,
218
181
  item.body,
219
182
  item.model,
220
183
  item.domain,
@@ -259,7 +222,7 @@ export default function AutomationsRoute() {
259
222
  return (
260
223
  <DispatchShell
261
224
  title="Automations"
262
- description="See scheduled and event-triggered jobs, inspect their checks and past runs, pause them, or ask the agent to create one."
225
+ description="Create schedule, webhook, or app-event automations, then inspect their checks and past runs."
263
226
  >
264
227
  <section className="flex flex-col gap-4">
265
228
  <div className="flex flex-wrap items-center justify-between gap-3">
@@ -304,7 +267,14 @@ export default function AutomationsRoute() {
304
267
  <SelectItem value="all">All apps</SelectItem>
305
268
  </SelectContent>
306
269
  </Select>
307
- <CreateAutomationButton />
270
+ <CreateAutomationButton
271
+ onCreated={() => {
272
+ toast.success("Automation created");
273
+ void queryClient.invalidateQueries({
274
+ queryKey: AUTOMATIONS_QUERY_KEY,
275
+ });
276
+ }}
277
+ />
308
278
  </div>
309
279
  </div>
310
280
 
@@ -415,14 +415,16 @@ describe("askGrantedDispatchMcpApp", () => {
415
415
  { type: "text", text: "Build a weekly active users dashboard." },
416
416
  ],
417
417
  },
418
- {
418
+ expect.objectContaining({
419
419
  async: true,
420
+ deadlineMs: expect.any(Number),
421
+ idempotencyKey: expect.stringMatching(/^ask-app:/),
420
422
  metadata: {
421
423
  userEmail: "owner@example.test",
422
424
  orgDomain: "builder.io",
423
425
  requestOrigin: "http://localhost:8092",
424
426
  },
425
- },
427
+ }),
426
428
  );
427
429
  expect(result).toMatchObject({
428
430
  app: "analytics",
@@ -433,6 +435,31 @@ describe("askGrantedDispatchMcpApp", () => {
433
435
  });
434
436
  });
435
437
 
438
+ it("reuses the MCP request identity for transport retries", async () => {
439
+ const requestContext = {
440
+ userEmail: "owner@example.test",
441
+ orgId: "org-1",
442
+ requestOrigin: "http://localhost:8092",
443
+ mcpRequestId: "session-1:request-42",
444
+ };
445
+
446
+ await runWithRequestContext(requestContext, () =>
447
+ askGrantedDispatchMcpApp("analytics", "Retry this request.", {
448
+ async: true,
449
+ }),
450
+ );
451
+ await runWithRequestContext(requestContext, () =>
452
+ askGrantedDispatchMcpApp("analytics", "Retry this request.", {
453
+ async: true,
454
+ }),
455
+ );
456
+
457
+ const firstKey = mocks.a2aSend.mock.calls[0]?.[1].idempotencyKey;
458
+ const secondKey = mocks.a2aSend.mock.calls[1]?.[1].idempotencyKey;
459
+ expect(firstKey).toMatch(/^ask-app:v1:[0-9a-f]{64}$/);
460
+ expect(secondKey).toBe(firstKey);
461
+ });
462
+
436
463
  it("preserves authenticated structured mutation receipts from the target app", async () => {
437
464
  const orgSecret = "org-receipt-secret";
438
465
  mocks.getOrgA2ASecret.mockResolvedValueOnce(orgSecret);
@@ -742,6 +769,14 @@ describe("askGrantedDispatchMcpApp", () => {
742
769
  message:
743
770
  'ask_app is still working. Call ask_app_status with taskId "task-working" to retrieve the final response.',
744
771
  });
772
+ expect(mocks.a2aSend).toHaveBeenCalledWith(
773
+ expect.anything(),
774
+ expect.objectContaining({
775
+ async: true,
776
+ deadlineMs: expect.any(Number),
777
+ idempotencyKey: expect.stringMatching(/^ask-app:/),
778
+ }),
779
+ );
745
780
  });
746
781
 
747
782
  it("counts submission and every poll against one inline deadline", async () => {
@@ -53,7 +53,8 @@ const TARGET_EMBED_SESSION_RETRY_BASE_MS = 250;
53
53
  const TARGET_EMBED_SESSION_CONNECT_TIMEOUT_MS = 90_000;
54
54
  const TARGET_EMBED_SESSION_BUDGET_MS = 95_000;
55
55
  const DISPATCH_ASK_APP_DEFAULT_INLINE_WAIT_MS = 20_000;
56
- const DISPATCH_ASK_APP_MAX_INLINE_WAIT_MS = 25_000;
56
+ // Leave response headroom for the hosted MCP transport after the inline wait.
57
+ const DISPATCH_ASK_APP_MAX_INLINE_WAIT_MS = 20_000;
57
58
  const DISPATCH_ASK_APP_POLL_INTERVAL_MS = 1_500;
58
59
  const DISPATCH_A2A_REQUEST_TIMEOUT_MS = 10_000;
59
60
  const DISPATCH_ASK_APP_STATUS_RETRY_DELAYS_MS = [250, 750, 1_500] as const;
@@ -98,6 +99,31 @@ function boundedDispatchAskAppWaitMs(raw: unknown): number {
98
99
  );
99
100
  }
100
101
 
102
+ async function dispatchAskAppIdempotencyKey(
103
+ target: DispatchMcpAccessibleApp,
104
+ message: string,
105
+ ): Promise<string> {
106
+ const requestId = getRequestContext()?.mcpRequestId;
107
+ if (!requestId) return `ask-app:${globalThis.crypto.randomUUID()}`;
108
+
109
+ const digest = await globalThis.crypto.subtle.digest(
110
+ "SHA-256",
111
+ new TextEncoder().encode(
112
+ JSON.stringify({
113
+ requestId,
114
+ app: target.id,
115
+ targetUrl: target.url,
116
+ message,
117
+ }),
118
+ ),
119
+ );
120
+ let hex = "";
121
+ for (const byte of new Uint8Array(digest)) {
122
+ hex += byte.toString(16).padStart(2, "0");
123
+ }
124
+ return `ask-app:v1:${hex}`;
125
+ }
126
+
101
127
  function isTerminalDispatchTask(task: Task): boolean {
102
128
  return DISPATCH_ASK_APP_TERMINAL_STATES.has(String(task.status.state));
103
129
  }
@@ -801,20 +827,31 @@ export async function askGrantedDispatchMcpApp(
801
827
  ? 0
802
828
  : boundedDispatchAskAppWaitMs(options?.maxWaitMs);
803
829
  const deadline = inlineWaitMs > 0 ? Date.now() + inlineWaitMs : undefined;
830
+ const submissionDeadline =
831
+ deadline ?? Date.now() + DISPATCH_A2A_REQUEST_TIMEOUT_MS;
804
832
 
805
833
  const { client, metadata } = await createDispatchA2AClient({
806
834
  targetUrl: target.url,
807
835
  userEmail,
808
836
  orgDomain: orgDomain ?? undefined,
809
837
  orgSecret: orgSecret ?? undefined,
810
- deadline,
838
+ deadline: submissionDeadline,
811
839
  });
840
+ const idempotencyKey = await dispatchAskAppIdempotencyKey(
841
+ target,
842
+ trimmedMessage,
843
+ );
812
844
  const task = await client.send(
813
845
  {
814
846
  role: "user",
815
847
  parts: [{ type: "text", text: trimmedMessage }],
816
848
  },
817
- { async: true, metadata },
849
+ {
850
+ async: true,
851
+ metadata,
852
+ idempotencyKey,
853
+ deadlineMs: submissionDeadline,
854
+ },
818
855
  );
819
856
  const finalOrRunning = await waitForDispatchA2ATask(client, task, deadline);
820
857
  return dispatchAskAppTaskResult(target.id, finalOrRunning, {