@agent-native/core 0.136.3 → 0.136.5

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 (49) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
  5. package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
  6. package/corpus/templates/clips/desktop/src/app.tsx +81 -30
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
  8. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
  9. package/corpus/templates/clips/server/plugins/auth.ts +9 -0
  10. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
  11. package/dist/client/agent-page/AgentJobsTab.js +19 -4
  12. package/dist/client/agent-page/use-jobs.d.ts +10 -0
  13. package/dist/client/agent-page/use-jobs.js +21 -0
  14. package/dist/client/settings/SecretsSection.js +32 -8
  15. package/dist/collab/awareness.d.ts +2 -2
  16. package/dist/collab/struct-routes.d.ts +1 -1
  17. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  18. package/dist/jobs/actions/run-automation-now.d.ts +6 -0
  19. package/dist/jobs/actions/run-automation-now.js +22 -0
  20. package/dist/jobs/background-automation-runner.d.ts +2 -0
  21. package/dist/jobs/background-automation-runner.js +22 -16
  22. package/dist/jobs/run-history.d.ts +17 -3
  23. package/dist/jobs/run-history.js +73 -7
  24. package/dist/jobs/run-now.d.ts +20 -0
  25. package/dist/jobs/run-now.js +99 -0
  26. package/dist/jobs/scheduler.d.ts +15 -0
  27. package/dist/jobs/scheduler.js +76 -12
  28. package/dist/localization/default-messages.d.ts +8 -0
  29. package/dist/localization/default-messages.js +8 -0
  30. package/dist/notifications/routes.d.ts +4 -4
  31. package/dist/observability/routes.d.ts +3 -3
  32. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  33. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  34. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  35. package/dist/secrets/routes.d.ts +5 -5
  36. package/dist/secrets/routes.js +41 -17
  37. package/dist/server/action-discovery.js +4 -0
  38. package/dist/server/agent-chat-plugin.js +135 -68
  39. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  40. package/dist/server/email-actions.d.ts +6 -3
  41. package/dist/server/email-actions.js +17 -83
  42. package/dist/server/email-markdown.d.ts +4 -0
  43. package/dist/server/email-markdown.js +174 -0
  44. package/dist/server/transcribe-voice.d.ts +1 -1
  45. package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
  46. package/dist/triggers/actions.d.ts +1 -1
  47. package/dist/triggers/actions.js +27 -5
  48. package/package.json +1 -1
  49. package/src/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
@@ -78,6 +78,12 @@ export async function dispatchPostFinalizeJob(args: {
78
78
  }
79
79
  : {}),
80
80
  });
81
+ console.log("[post-finalize] dispatching", {
82
+ recordingId: args.recordingId,
83
+ kind: args.kind,
84
+ workerUrl,
85
+ usesDurableBackground,
86
+ });
81
87
  const post = (url: string) =>
82
88
  fetch(url, {
83
89
  method: "POST",
@@ -89,7 +95,14 @@ export async function dispatchPostFinalizeJob(args: {
89
95
  usesDurableBackground && !initialResponse.ok
90
96
  ? await post(resolveWorkerUrl(processorRoute))
91
97
  : initialResponse;
92
- if (response.ok) return;
98
+ if (response.ok) {
99
+ console.log("[post-finalize] dispatch accepted", {
100
+ recordingId: args.recordingId,
101
+ kind: args.kind,
102
+ status: response.status,
103
+ });
104
+ return;
105
+ }
93
106
  const detail = (await response.text().catch(() => "")).trim().slice(0, 300);
94
107
  throw new Error(
95
108
  `Post-finalize ${args.kind} worker returned HTTP ${response.status}${
@@ -45,6 +45,15 @@ export default createAuthPlugin({
45
45
  "/api/video",
46
46
  "/api/thumbnail",
47
47
  "/api/auth/google-calendar",
48
+ // Internal post-finalize worker (media verification, seekable remux,
49
+ // transcript, brain-export, loom-import retries). It's a server-to-server
50
+ // self-dispatch with no session cookie — its own scoped, short-lived
51
+ // signed token (verifyScopedAgentAccessToken) is the real auth check, so
52
+ // it must bypass the session gate to ever reach that check. Exact path
53
+ // only, not the whole `_agent-native-background` namespace — a future
54
+ // route added under that prefix without its own auth check must not
55
+ // become silently public by inheriting this bypass.
56
+ "/api/_agent-native-background/post-finalize-worker",
48
57
  "/_agent-native/google/auth-url",
49
58
  "/_agent-native/google/callback",
50
59
  ],
@@ -51,11 +51,17 @@ export default defineEventHandler(async (event: H3Event) => {
51
51
 
52
52
  const { recordingId, kind, token, delayMs, retryAttempt, regenerate } =
53
53
  parsed.data;
54
+ console.log("[post-finalize-worker] received job", { recordingId, kind });
54
55
  const verified = verifyScopedAgentAccessToken(token, {
55
56
  resourceKind: POST_FINALIZE_JOB_TOKEN_KIND,
56
57
  resourceId: postFinalizeJobResourceId(recordingId, kind),
57
58
  });
58
59
  if (!verified.ok) {
60
+ console.warn("[post-finalize-worker] token verification failed", {
61
+ recordingId,
62
+ kind,
63
+ reason: verified.reason,
64
+ });
59
65
  setResponseStatus(event, 401);
60
66
  return { ok: false, error: "Invalid or expired post-finalize job token" };
61
67
  }
@@ -153,6 +159,9 @@ export default defineEventHandler(async (event: H3Event) => {
153
159
  )
154
160
  .returning({ id: schema.recordings.id });
155
161
  if (!claimed) {
162
+ console.log("[post-finalize-worker] loom-import already running", {
163
+ recordingId,
164
+ });
156
165
  return {
157
166
  ok: true,
158
167
  recordingId,
@@ -161,6 +170,10 @@ export default defineEventHandler(async (event: H3Event) => {
161
170
  reason: "loom-import-already-running",
162
171
  };
163
172
  }
173
+ console.log("[post-finalize-worker] loom-import claimed", {
174
+ recordingId,
175
+ claimId,
176
+ });
164
177
  const result = await runLoomImportJob({
165
178
  recordingId,
166
179
  ownerEmail: recording.ownerEmail,
@@ -10,7 +10,7 @@ import { AgentEmptyState } from "./AgentEmptyState.js";
10
10
  import { AgentTabFrame } from "./AgentTabFrame.js";
11
11
  import { AutomationDetailsDialog, } from "./AutomationDetailsDialog.js";
12
12
  import { AutomationScheduleDialog } from "./AutomationScheduleDialog.js";
13
- import { useAutomations, useManageAutomation, useManageRecurringJob, useRecurringJobs, } from "./use-jobs.js";
13
+ import { useAutomations, useManageAutomation, useManageRecurringJob, useRunAutomationNow, useRecurringJobs, } from "./use-jobs.js";
14
14
  function listRecurringJobs(jobs) {
15
15
  return jobs.map((resource) => ({
16
16
  kind: "recurring",
@@ -107,9 +107,11 @@ export function AgentJobsTab({ canManageOrg = false }) {
107
107
  const personalAutomationsMutation = useManageAutomation("user");
108
108
  const organizationJobsMutation = useManageRecurringJob("org");
109
109
  const organizationAutomationsMutation = useManageAutomation("org");
110
+ const runAutomationMutation = useRunAutomationNow();
110
111
  const [deleteTarget, setDeleteTarget] = useState(null);
111
112
  const [detailsTarget, setDetailsTarget] = useState(null);
112
113
  const [scheduleTarget, setScheduleTarget] = useState(null);
114
+ const [runTarget, setRunTarget] = useState(null);
113
115
  const formatDateTime = (value) => {
114
116
  if (!value || Number.isNaN(new Date(value).getTime()))
115
117
  return null;
@@ -210,7 +212,7 @@ export function AgentJobsTab({ canManageOrg = false }) {
210
212
  : t("jobs.paused", { defaultValue: "Paused" }) }), resource.lastStatus ? (_jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground", children: resource.lastStatus })) : null] }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: triggerDescription }), _jsx("p", { className: "mt-1 line-clamp-2 text-xs text-muted-foreground/80", children: instructions }), lastRun || nextRun || lastCheck ? (_jsxs("div", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground", children: [nextRun ? (_jsxs("span", { children: [t("jobs.nextRun", { defaultValue: "Next run" }), ":", " ", nextRun] })) : null, _jsxs("span", { children: [t("jobs.lastRun", { defaultValue: "Last run" }), ":", " ", lastRun ??
211
213
  t("jobs.neverRan", { defaultValue: "Never" })] }), !lastRun && lastCheck ? (_jsxs("span", { children: [t("jobs.lastChecked", {
212
214
  defaultValue: "Last checked",
213
- }), ": ", lastCheck] })) : null] })) : null, resource.lastError ? (_jsxs("p", { className: "mt-2 flex items-start gap-1.5 text-[11px] text-destructive", children: [_jsx(IconAlertTriangle, { className: "mt-px size-3 shrink-0" }), _jsx("span", { className: "min-w-0 break-words", children: resource.lastError })] })) : null] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", onClick: () => setDetailsTarget(entry), children: [_jsx(IconEye, { className: "size-3.5" }), t("jobs.details", { defaultValue: "Details" })] }), resource.canUpdate ? (_jsxs(_Fragment, { children: [entry.triggerType === "schedule" ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", disabled: mutationPending, onClick: () => setScheduleTarget(entry), children: [_jsx(IconPencil, { className: "size-3.5" }), t("jobs.edit", { defaultValue: "Edit" })] })) : null, _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", disabled: mutationPending, onClick: () => mutateEntry(entry, "update", {
215
+ }), ": ", lastCheck] })) : null] })) : null, resource.lastError ? (_jsxs("p", { className: "mt-2 flex items-start gap-1.5 text-[11px] text-destructive", children: [_jsx(IconAlertTriangle, { className: "mt-px size-3 shrink-0" }), _jsx("span", { className: "min-w-0 break-words", children: resource.lastError })] })) : null] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", onClick: () => setDetailsTarget(entry), children: [_jsx(IconEye, { className: "size-3.5" }), t("jobs.details", { defaultValue: "Details" })] }), resource.canUpdate ? (_jsxs(_Fragment, { children: [_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", disabled: mutationPending || runAutomationMutation.isPending, onClick: () => setRunTarget(entry), children: [_jsx(IconPlayerPlay, { className: "size-3.5" }), t("jobs.runNow", { defaultValue: "Run now" })] }), entry.triggerType === "schedule" ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", disabled: mutationPending, onClick: () => setScheduleTarget(entry), children: [_jsx(IconPencil, { className: "size-3.5" }), t("jobs.edit", { defaultValue: "Edit" })] })) : null, _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "cursor-pointer px-2 text-xs", disabled: mutationPending, onClick: () => mutateEntry(entry, "update", {
214
216
  enabled: !resource.enabled,
215
217
  }), children: [resource.enabled ? (_jsx(IconPlayerPause, { className: "size-3.5" })) : (_jsx(IconPlayerPlay, { className: "size-3.5" })), resource.enabled
216
218
  ? t("jobs.pause", { defaultValue: "Pause" })
@@ -221,7 +223,8 @@ export function AgentJobsTab({ canManageOrg = false }) {
221
223
  const mutationError = personalJobsMutation.error ||
222
224
  personalAutomationsMutation.error ||
223
225
  organizationJobsMutation.error ||
224
- organizationAutomationsMutation.error;
226
+ organizationAutomationsMutation.error ||
227
+ runAutomationMutation.error;
225
228
  return (_jsxs(AgentTabFrame, { title: t("jobs.pageTitle", { defaultValue: "Automations" }), description: t("jobs.pageDescription", {
226
229
  defaultValue: "Manage agent tasks that run on a schedule or in response to events.",
227
230
  }), actions: _jsx(AgentAskPopover, { context: automationCreationContext(), prompt: t("jobs.automationPrompt", {
@@ -268,7 +271,19 @@ export function AgentJobsTab({ canManageOrg = false }) {
268
271
  if (!deleteTarget)
269
272
  return;
270
273
  mutateEntry(deleteTarget, "delete", undefined, () => setDeleteTarget(null));
271
- }, children: [mutationPending ? (_jsx(IconLoader2, { className: "size-4 animate-spin" })) : null, t("jobs.delete", { defaultValue: "Delete" })] })] })] }) }), detailsTarget ? (_jsx(AutomationDetailsDialog, { open: true, name: detailsTarget.resource.name, scope: detailsTarget.resource.scope === "organization" ? "org" : "user", triggerSummary: describeTrigger(detailsTarget, t), fields: detailsFields(detailsTarget, t, formatDateTime), condition: detailsTarget.kind === "automation"
274
+ }, children: [mutationPending ? (_jsx(IconLoader2, { className: "size-4 animate-spin" })) : null, t("jobs.delete", { defaultValue: "Delete" })] })] })] }) }), _jsx(Dialog, { open: runTarget !== null, onOpenChange: (open) => {
275
+ if (!open && !runAutomationMutation.isPending)
276
+ setRunTarget(null);
277
+ }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t("jobs.runNowTitle", { defaultValue: "Run automation now?" }) }), _jsx(DialogDescription, { children: t("jobs.runNowDescription", {
278
+ defaultValue: "This runs the automation's real actions immediately. It may send messages or change data, and it will not change the next scheduled run.",
279
+ }) })] }), runAutomationMutation.error ? (_jsx("p", { className: "text-sm text-destructive", children: runAutomationMutation.error.message })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", className: "cursor-pointer", disabled: runAutomationMutation.isPending, onClick: () => setRunTarget(null), children: t("jobs.cancel", { defaultValue: "Cancel" }) }), _jsxs(Button, { type: "button", className: "cursor-pointer", disabled: runAutomationMutation.isPending, onClick: () => {
280
+ if (!runTarget)
281
+ return;
282
+ runAutomationMutation.mutate({
283
+ name: runTarget.resource.name,
284
+ scope: runTarget.resource.scope,
285
+ }, { onSuccess: () => setRunTarget(null) });
286
+ }, children: [runAutomationMutation.isPending ? (_jsx(IconLoader2, { className: "size-4 animate-spin" })) : (_jsx(IconPlayerPlay, { className: "size-4" })), t("jobs.runNow", { defaultValue: "Run now" })] })] })] }) }), detailsTarget ? (_jsx(AutomationDetailsDialog, { open: true, name: detailsTarget.resource.name, scope: detailsTarget.resource.scope === "organization" ? "org" : "user", triggerSummary: describeTrigger(detailsTarget, t), fields: detailsFields(detailsTarget, t, formatDateTime), condition: detailsTarget.kind === "automation"
272
287
  ? detailsTarget.resource.condition
273
288
  : null, instructions: detailsTarget.kind === "automation"
274
289
  ? detailsTarget.resource.body
@@ -55,6 +55,15 @@ export type ManageJobInput = {
55
55
  timezone?: string;
56
56
  };
57
57
  export type ManageAutomationInput = ManageJobInput;
58
+ export interface RunAutomationNowInput {
59
+ name: string;
60
+ scope: "personal" | "organization";
61
+ }
62
+ export interface RunAutomationNowResult {
63
+ queued: true;
64
+ runId: string;
65
+ automationRunId: string;
66
+ }
58
67
  export interface AutomationRun {
59
68
  id: string;
60
69
  automation: string;
@@ -78,5 +87,6 @@ export declare function useManageAutomation(scope: JobsScope): import("@tanstack
78
87
  name: string;
79
88
  enabled?: boolean;
80
89
  }, Error, ManageJobInput, unknown>;
90
+ export declare function useRunAutomationNow(): import("@tanstack/react-query").UseMutationResult<RunAutomationNowResult, Error, RunAutomationNowInput, unknown>;
81
91
  export declare function useAutomationRuns(scope: JobsScope, name: string | null, active: boolean): import("@tanstack/react-query").UseQueryResult<NoInfer<AutomationRun[]>, Error>;
82
92
  //# sourceMappingURL=use-jobs.d.ts.map
@@ -68,6 +68,27 @@ export function useManageAutomation(scope) {
68
68
  },
69
69
  });
70
70
  }
71
+ export function useRunAutomationNow() {
72
+ const queryClient = useQueryClient();
73
+ return useActionMutation("run-automation-now", {
74
+ onSuccess: (_result, variables) => {
75
+ const scope = variables.scope === "organization" ? "organization" : "personal";
76
+ queryClient.invalidateQueries({
77
+ queryKey: [
78
+ "action",
79
+ "list-automation-runs",
80
+ { scope, name: variables.name },
81
+ ],
82
+ });
83
+ queryClient.invalidateQueries({
84
+ queryKey: ["action", "list-automations", { scope }],
85
+ });
86
+ queryClient.invalidateQueries({
87
+ queryKey: ["action", "list-recurring-jobs", { scope }],
88
+ });
89
+ },
90
+ });
91
+ }
71
92
  function optimisticPatch(variables) {
72
93
  const patch = {};
73
94
  if (variables.enabled !== undefined)
@@ -98,16 +98,23 @@ function KeysHeader({ availableSecrets = [], onSecret, onCustomKey, }) {
98
98
  }, children: [_jsx(IconPlus, { size: 14 }), "Custom"] }) })] })] }) })] })] }));
99
99
  }
100
100
  function SecretCard({ secret, onChanged, open, onOpenChange, focusInput, }) {
101
+ const t = useT();
101
102
  const [value, setValue] = useState("");
103
+ const [isRotating, setIsRotating] = useState(false);
102
104
  const [busy, setBusy] = useState(null);
103
105
  const [confirmDelete, setConfirmDelete] = useState(false);
104
106
  const [toast, setToast] = useState(null);
105
107
  const inputRef = React.useRef(null);
106
108
  useEffect(() => {
107
- if (open && focusInput && inputRef.current) {
109
+ if (!open) {
110
+ setValue("");
111
+ setIsRotating(false);
112
+ return;
113
+ }
114
+ if ((focusInput || isRotating) && inputRef.current) {
108
115
  inputRef.current.focus();
109
116
  }
110
- }, [focusInput, open]);
117
+ }, [focusInput, isRotating, open]);
111
118
  const setToastAndClear = (kind, text, ms = 2500) => {
112
119
  setToast({ kind, text });
113
120
  setTimeout(() => setToast(null), ms);
@@ -131,6 +138,7 @@ function SecretCard({ secret, onChanged, open, onOpenChange, focusInput, }) {
131
138
  return;
132
139
  }
133
140
  setValue("");
141
+ setIsRotating(false);
134
142
  setConfirmDelete(false);
135
143
  setToastAndClear("ok", "Saved");
136
144
  notifySecretsChanged();
@@ -166,20 +174,32 @@ function SecretCard({ secret, onChanged, open, onOpenChange, focusInput, }) {
166
174
  setBusy(null);
167
175
  }
168
176
  };
169
- const handleTest = async () => {
177
+ const handleTest = async (candidateValue) => {
170
178
  if (busy)
171
179
  return;
172
- setBusy("test");
180
+ const isCandidate = candidateValue !== undefined;
181
+ setBusy(isCandidate ? "test-candidate" : "test");
173
182
  try {
174
183
  const res = await fetch(`${ENDPOINT}/${encodeURIComponent(secret.key)}/test`, {
175
184
  method: "POST",
185
+ ...(isCandidate
186
+ ? {
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({ value: candidateValue }),
189
+ }
190
+ : {}),
176
191
  });
177
192
  const body = (await res.json().catch(() => ({})));
178
193
  if (res.ok && body.ok) {
179
- setToastAndClear("ok", "Working");
194
+ setToastAndClear("ok", isCandidate
195
+ ? t("secrets.candidateValueWorking")
196
+ : t("secrets.storedValueWorking"));
180
197
  }
181
198
  else {
182
- setToastAndClear("err", body.error ?? (body.ok === false ? "Invalid" : `Test failed`));
199
+ setToastAndClear("err", body.error ??
200
+ (body.ok === false
201
+ ? t("secrets.invalid")
202
+ : t("secrets.testFailed")));
183
203
  }
184
204
  }
185
205
  finally {
@@ -196,12 +216,16 @@ function SecretCard({ secret, onChanged, open, onOpenChange, focusInput, }) {
196
216
  return (_jsx("span", { className: "rounded-full bg-accent/60 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-muted-foreground", children: "Optional" }));
197
217
  }, [secret.status, secret.required]);
198
218
  const isOAuth = secret.kind === "oauth";
199
- return (_jsxs("div", { className: "border-b border-border last:border-b-0", children: [_jsxs(Button, { type: "button", intent: "neutral", emphasis: "ghost", "aria-expanded": open, onClick: () => onOpenChange(!open), className: "flex w-full items-center gap-2 px-2.5 py-2 text-start transition-colors hover:bg-accent/30", children: [_jsx(IconChevronRight, { size: 13, className: `shrink-0 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}` }), _jsx("span", { className: "min-w-0 flex-1 truncate text-[11px] font-medium text-foreground", children: secret.label }), secret.status === "set" && secret.last4 && (_jsxs("code", { className: "text-[10px] text-muted-foreground", children: ["\u2022\u2022\u2022\u2022", secret.last4] })), _jsx("span", { className: "shrink-0", children: pill })] }), open && (_jsxs("div", { className: "border-t border-border/60 bg-accent/20 px-3 pb-3 pt-2.5", children: [secret.description && (_jsx("p", { className: "mb-2 text-[10px] leading-relaxed text-muted-foreground", children: secret.description })), isOAuth ? (_jsxs("div", { className: "mt-2 flex items-center gap-1.5", children: [secret.oauthConnectUrl && (_jsxs("a", { href: secret.oauthConnectUrl, className: "inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium no-underline", style: { backgroundColor: "#00B5FF", color: "white" }, children: [_jsx(IconPlugConnected, { size: 10 }), secret.status === "set" ? "Reconnect" : "Connect"] })), secret.docsUrl && (_jsxs("a", { href: secret.docsUrl, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground", children: ["Docs", _jsx(IconExternalLink, { size: 10 })] }))] })) : (_jsxs("div", { className: "mt-2 space-y-1.5", children: [secret.status === "set" && (_jsxs("div", { className: "flex items-center gap-2 text-[10px] text-muted-foreground", children: [_jsx("span", { children: "Stored value ending in" }), _jsx("code", { className: "rounded bg-background px-1 py-0.5 text-foreground", children: secret.last4 })] })), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(TextField, { inputRef: inputRef, type: "password", "aria-label": secret.label, value: value, onChange: setValue, onKeyDown: (event) => {
219
+ const showRotationForm = secret.status !== "set" || isRotating;
220
+ return (_jsxs("div", { className: "border-b border-border last:border-b-0", children: [_jsxs(Button, { type: "button", intent: "neutral", emphasis: "ghost", "aria-expanded": open, onClick: () => onOpenChange(!open), className: "flex w-full items-center gap-2 px-2.5 py-2 text-start transition-colors hover:bg-accent/30", children: [_jsx(IconChevronRight, { size: 13, className: `shrink-0 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}` }), _jsx("span", { className: "min-w-0 flex-1 truncate text-[11px] font-medium text-foreground", children: secret.label }), secret.status === "set" && secret.last4 && (_jsxs("code", { className: "text-[10px] text-muted-foreground", children: ["\u2022\u2022\u2022\u2022", secret.last4] })), _jsx("span", { className: "shrink-0", children: pill })] }), open && (_jsxs("div", { className: "border-t border-border/60 bg-accent/20 px-3 pb-3 pt-2.5", children: [secret.description && (_jsx("p", { className: "mb-2 text-[10px] leading-relaxed text-muted-foreground", children: secret.description })), isOAuth ? (_jsxs("div", { className: "mt-2 flex items-center gap-1.5", children: [secret.oauthConnectUrl && (_jsxs("a", { href: secret.oauthConnectUrl, className: "inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium no-underline", style: { backgroundColor: "#00B5FF", color: "white" }, children: [_jsx(IconPlugConnected, { size: 10 }), secret.status === "set" ? "Reconnect" : "Connect"] })), secret.docsUrl && (_jsxs("a", { href: secret.docsUrl, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground", children: ["Docs", _jsx(IconExternalLink, { size: 10 })] }))] })) : (_jsxs("div", { className: "mt-2 space-y-2", children: [secret.status === "set" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 text-[10px] text-muted-foreground", children: [_jsx("span", { children: "Stored value ending in" }), _jsx("code", { className: "mt-1 block w-fit rounded bg-background px-1 py-0.5 text-foreground", children: secret.last4 })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [_jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: () => handleTest(), disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-40", children: busy === "test" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : (t("secrets.testStoredValue")) }), _jsxs(Button, { type: "button", intent: "primary", emphasis: "solid", onClick: () => setIsRotating(true), disabled: busy !== null, className: "inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium disabled:opacity-40", style: { backgroundColor: "#00B5FF", color: "white" }, children: [_jsx(IconRefresh, { size: 10 }), "Rotate"] }), _jsxs(Button, { type: "button", intent: "danger", emphasis: "outline", onClick: () => setConfirmDelete(true), disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-red-500 disabled:opacity-40", children: [_jsx(IconTrash, { size: 10 }), "Delete"] })] })] })), showRotationForm && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("hr", { className: "my-4" }), _jsx(TextField, { inputRef: inputRef, type: "password", "aria-label": secret.label, value: value, onChange: setValue, onKeyDown: (event) => {
200
221
  if (event.key === "Enter")
201
222
  handleSave();
202
223
  }, placeholder: secret.status === "set"
203
224
  ? "Enter new value to rotate"
204
- : "Paste key", className: "flex-1 text-[11px]" }), _jsx(Button, { type: "button", intent: "primary", emphasis: "solid", onClick: handleSave, disabled: !value.trim() || busy !== null, className: "inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium disabled:opacity-40", style: { backgroundColor: "#00B5FF", color: "white" }, children: busy === "save" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : secret.status === "set" ? (_jsxs(_Fragment, { children: [_jsx(IconRefresh, { size: 10 }), "Rotate"] })) : ("Save") })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [secret.status === "set" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: handleTest, disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-40", children: busy === "test" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : ("Test") }), _jsxs(Button, { type: "button", intent: "danger", emphasis: "outline", onClick: () => setConfirmDelete(true), disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-red-500 disabled:opacity-40", children: [_jsx(IconTrash, { size: 10 }), "Remove"] })] })), secret.docsUrl && (_jsxs("a", { href: secret.docsUrl, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground ms-auto", children: ["Get key", _jsx(IconExternalLink, { size: 10 })] }))] }), confirmDelete && (_jsxs("div", { className: "flex items-center gap-1.5 rounded border border-red-500/30 bg-red-500/10 px-2 py-1.5 text-[10px] text-red-500", children: [_jsx("span", { className: "min-w-0 flex-1", children: "Remove this saved value?" }), _jsx(Button, { type: "button", intent: "danger", emphasis: "solid", onClick: handleDelete, disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-red-500/40 px-1.5 py-0.5 font-medium disabled:opacity-40", children: busy === "delete" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : ("Confirm") }), _jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: () => setConfirmDelete(false), disabled: busy !== null, className: "rounded border border-border px-1.5 py-0.5 text-muted-foreground hover:text-foreground disabled:opacity-40", children: "Cancel" })] }))] })), toast && (_jsx("p", { className: `mt-1.5 text-[10px] ${toast.kind === "ok" ? "text-green-500" : "text-red-500"}`, children: toast.text }))] }))] }));
225
+ : "Paste key", className: "w-full text-[11px]" }), _jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [_jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: () => handleTest(value.trim()), disabled: !value.trim() || busy !== null, className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-40", children: busy === "test-candidate" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : (t("secrets.testStoredValue")) }), secret.docsUrl && (_jsxs("a", { href: secret.docsUrl, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground", children: ["Get key", _jsx(IconExternalLink, { size: 10 })] })), secret.status === "set" && (_jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: () => {
226
+ setValue("");
227
+ setIsRotating(false);
228
+ }, disabled: busy !== null, className: "rounded border border-border px-2 py-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-40", children: "Discard" })), _jsx(Button, { type: "button", intent: "primary", emphasis: "solid", onClick: handleSave, disabled: !value.trim() || busy !== null, className: "inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium disabled:opacity-40", style: { backgroundColor: "#00B5FF", color: "white" }, children: busy === "save" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : ("Save") })] })] })), confirmDelete && (_jsxs("div", { className: "flex items-center gap-1.5 rounded border border-red-500/30 bg-red-500/10 px-2 py-1.5 text-[10px] text-red-500", children: [_jsx("span", { className: "min-w-0 flex-1", children: "Remove this saved value?" }), _jsx(Button, { type: "button", intent: "danger", emphasis: "solid", onClick: handleDelete, disabled: busy !== null, className: "inline-flex items-center gap-1 rounded border border-red-500/40 px-1.5 py-0.5 font-medium disabled:opacity-40", children: busy === "delete" ? (_jsx(IconLoader2, { size: 10, className: "animate-spin" })) : ("Confirm") }), _jsx(Button, { type: "button", intent: "neutral", emphasis: "outline", onClick: () => setConfirmDelete(false), disabled: busy !== null, className: "rounded border border-border px-1.5 py-0.5 text-muted-foreground hover:text-foreground disabled:opacity-40", children: "Cancel" })] }))] })), toast && (_jsx("p", { className: `mt-1.5 text-[10px] ${toast.kind === "ok" ? "text-green-500" : "text-red-500"}`, children: toast.text }))] }))] }));
205
229
  }
206
230
  const ADHOC_ENDPOINT = agentNativePath("/_agent-native/secrets/adhoc");
207
231
  function AdHocKeysSection({ showForm, onShowFormChange, showEmptyState, }) {
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
- error?: undefined;
66
65
  states: {
67
66
  clientId: number;
68
67
  state: string;
69
68
  }[];
69
+ error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,10 +77,10 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
- error?: undefined;
81
80
  users: {
82
81
  clientId: number;
83
82
  lastSeen: number;
84
83
  }[];
84
+ error?: undefined;
85
85
  }>>;
86
86
  //# sourceMappingURL=awareness.d.ts.map
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -17,12 +17,12 @@ declare const _default: import("../../action.js").ActionDefinition<{
17
17
  id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
- error?: undefined;
21
20
  configured?: undefined;
22
21
  connectPath?: undefined;
23
22
  url: string;
24
23
  id: string;
25
24
  provider: string;
25
+ error?: undefined;
26
26
  }>;
27
27
  export default _default;
28
28
  //# sourceMappingURL=upload-image.d.ts.map
@@ -0,0 +1,6 @@
1
+ declare const _default: import("../../action.js").ActionDefinition<{
2
+ name: string;
3
+ scope?: "organization" | "personal";
4
+ }, import("../run-now.js").QueuedAutomationRun>;
5
+ export default _default;
6
+ //# sourceMappingURL=run-automation-now.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { defineAction } from "../../action.js";
3
+ import { queueAutomationRunNow } from "../run-now.js";
4
+ export default defineAction({
5
+ description: "Run one personal or organization automation immediately. This is an explicit send/run action and may perform the automation's real side effects.",
6
+ agentTool: false,
7
+ schema: z.object({
8
+ name: z.string().min(1),
9
+ scope: z.enum(["personal", "organization"]).default("personal"),
10
+ }),
11
+ run: async ({ name, scope }, ctx) => {
12
+ if (!ctx?.userEmail)
13
+ throw new Error("Not authenticated.");
14
+ return queueAutomationRunNow({
15
+ userEmail: ctx.userEmail,
16
+ orgId: ctx.orgId,
17
+ scope,
18
+ name,
19
+ });
20
+ },
21
+ });
22
+ //# sourceMappingURL=run-automation-now.js.map
@@ -32,6 +32,8 @@ export interface BackgroundAutomationRunOptions {
32
32
  requestContext?: Omit<RequestContext, "userEmail" | "orgId">;
33
33
  actionCaller?: ActionCaller;
34
34
  actionAutomation?: ActionAutomationContext;
35
+ /** Reuse a history row created by a durable run-now enqueue. */
36
+ historyId?: string;
35
37
  }
36
38
  export interface BackgroundAutomationRunResult {
37
39
  responseText: string;
@@ -152,29 +152,35 @@ export async function runBackgroundAutomation(options, deps) {
152
152
  // that cannot be written should cost us the record, not the automation.
153
153
  // Everything downstream tolerates a null id by skipping its own write.
154
154
  let historyId = null;
155
- try {
156
- const historyOwner = options.orgId
157
- ? organizationResourceOwner(options.orgId)
158
- : automation.resource.owner === "__shared__"
159
- ? options.ownerEmail
160
- : automation.resource.owner;
161
- historyId = await startAutomationRun({
162
- owner: historyOwner,
163
- automation: automation.name,
164
- path: automation.resource.path,
165
- scope: options.orgId ? "organization" : "personal",
166
- orgId: options.orgId ?? null,
167
- });
155
+ if (options.historyId) {
156
+ historyId = options.historyId;
168
157
  }
169
- catch (err) {
170
- console.error(`[automations] Could not open a history record for "${automation.name}"; running anyway:`, err);
158
+ else {
159
+ try {
160
+ const historyOwner = options.orgId
161
+ ? organizationResourceOwner(options.orgId)
162
+ : automation.resource.owner === "__shared__"
163
+ ? options.ownerEmail
164
+ : automation.resource.owner;
165
+ historyId = await startAutomationRun({
166
+ owner: historyOwner,
167
+ automation: automation.name,
168
+ path: automation.resource.path,
169
+ scope: options.orgId ? "organization" : "personal",
170
+ orgId: options.orgId ?? null,
171
+ });
172
+ }
173
+ catch (err) {
174
+ console.error(`[automations] Could not open a history record for "${automation.name}"; running anyway:`, err);
175
+ }
171
176
  }
172
177
  let result;
173
178
  try {
174
179
  result = await executeBackgroundAutomation(options, deps, historyId);
175
180
  }
176
181
  catch (err) {
177
- await recordRunOutcome(historyId, "error", err instanceof Error ? err.message : String(err));
182
+ const message = err instanceof Error ? err.message : String(err);
183
+ await recordRunOutcome(historyId, "error", `${message}. No delivery was confirmed.`);
178
184
  throw err;
179
185
  }
180
186
  // Outside the try: history is bookkeeping about the run, so a failure to
@@ -27,13 +27,27 @@ export interface StartAutomationRunInput {
27
27
  orgId?: string | null;
28
28
  runId?: string | null;
29
29
  threadId?: string | null;
30
+ /** A pre-created row still waiting for its background worker handoff. */
31
+ dispatchPending?: boolean;
30
32
  }
31
33
  /**
32
- * Record that an automation actually began executing. Returns the run id used
33
- * to close the record out. Only real executions get a row a tick that
34
- * declined to run the automation must not appear in its history.
34
+ * Record an automation execution. Manual runs create the row before dispatch,
35
+ * so `dispatchPending` distinguishes that durable handoff from a run that has
36
+ * already entered the worker.
35
37
  */
36
38
  export declare function startAutomationRun(input: StartAutomationRunInput): Promise<string>;
39
+ export declare function getAutomationRun(id: string): Promise<AutomationRun | null>;
40
+ /** Claim a manually queued run exactly once before loading its automation. */
41
+ export declare function claimAutomationRun(id: string): Promise<boolean>;
42
+ /**
43
+ * Find manual handoffs that have stayed unclaimed long enough to have missed
44
+ * their first self-dispatch. The row is the durable queue; callers may safely
45
+ * redeliver it because claimAutomationRun is an atomic CAS.
46
+ */
47
+ export declare function listUnclaimedAutomationRuns(options?: {
48
+ olderThanMs?: number;
49
+ limit?: number;
50
+ }): Promise<AutomationRun[]>;
37
51
  export declare function finishAutomationRun(id: string, status: Exclude<AutomationRunStatus, "running">, error?: string): Promise<void>;
38
52
  /**
39
53
  * Attach the agent thread once it exists. The thread is created after the run
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getDbExec, intType, isPostgres } from "../db/client.js";
3
- import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
3
+ import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db/ddl-guard.js";
4
4
  const TABLE = "automation_runs";
5
5
  const MAX_ERROR_LENGTH = 500;
6
6
  /**
@@ -8,6 +8,10 @@ const MAX_ERROR_LENGTH = 500;
8
8
  * (BACKGROUND_RUN_HARD_TIMEOUT_MS). Past this, no run is still alive.
9
9
  */
10
10
  const RUN_LIVENESS_CEILING_MS = 15 * 60_000;
11
+ // The background worker has a shorter hard timeout than this lease. A worker
12
+ // that dies after claiming can therefore be redelivered without overlapping a
13
+ // still-live execution under normal runtime limits.
14
+ const CLAIM_LEASE_MS = RUN_LIVENESS_CEILING_MS;
11
15
  /** Rows kept per automation, so a per-minute schedule cannot grow forever. */
12
16
  const RUNS_RETAINED_PER_AUTOMATION = 50;
13
17
  let _initPromise;
@@ -28,16 +32,39 @@ async function ensureTable() {
28
32
  status TEXT NOT NULL DEFAULT 'running',
29
33
  started_at ${intType()} NOT NULL,
30
34
  finished_at ${intType()},
31
- error TEXT
35
+ error TEXT,
36
+ claimed_at ${intType()},
37
+ dispatch_pending ${intType()} NOT NULL DEFAULT 0
32
38
  )
33
39
  `;
34
40
  const indexSql = `CREATE INDEX IF NOT EXISTS idx_${TABLE}_owner_automation ON ${TABLE} (owner, automation, started_at)`;
35
41
  if (isPostgres()) {
36
42
  await ensureTableExists(TABLE, createSql);
43
+ await ensureColumnExists(TABLE, "claimed_at", `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS claimed_at ${intType()}`);
44
+ await ensureColumnExists(TABLE, "dispatch_pending", `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS dispatch_pending ${intType()} NOT NULL DEFAULT 0`);
37
45
  await ensureIndexExists(`idx_${TABLE}_owner_automation`, indexSql);
38
46
  return;
39
47
  }
40
48
  await client.execute(createSql);
49
+ const { rows } = await client.execute(`PRAGMA table_info("${TABLE}")`);
50
+ const columns = new Set(rows.map((row) => String(row.name)));
51
+ for (const [name, definition] of [
52
+ ["claimed_at", `${intType()}`],
53
+ ["dispatch_pending", `${intType()} NOT NULL DEFAULT 0`],
54
+ ]) {
55
+ if (columns.has(name))
56
+ continue;
57
+ try {
58
+ await client.execute(`ALTER TABLE ${TABLE} ADD COLUMN ${name} ${definition}`);
59
+ }
60
+ catch (error) {
61
+ const message = String(error?.message ?? error);
62
+ if (!/duplicate column name/i.test(message) &&
63
+ !/column .* already exists/i.test(message)) {
64
+ throw error;
65
+ }
66
+ }
67
+ }
41
68
  await client.execute(indexSql);
42
69
  })().catch((err) => {
43
70
  _initPromise = undefined;
@@ -68,16 +95,16 @@ function toRun(row, now) {
68
95
  };
69
96
  }
70
97
  /**
71
- * Record that an automation actually began executing. Returns the run id used
72
- * to close the record out. Only real executions get a row a tick that
73
- * declined to run the automation must not appear in its history.
98
+ * Record an automation execution. Manual runs create the row before dispatch,
99
+ * so `dispatchPending` distinguishes that durable handoff from a run that has
100
+ * already entered the worker.
74
101
  */
75
102
  export async function startAutomationRun(input) {
76
103
  await ensureTable();
77
104
  const id = randomUUID();
78
105
  await getDbExec().execute({
79
- sql: `INSERT INTO ${TABLE} (id, owner, automation, path, scope, org_id, run_id, thread_id, status, started_at)
80
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)`,
106
+ sql: `INSERT INTO ${TABLE} (id, owner, automation, path, scope, org_id, run_id, thread_id, status, started_at, dispatch_pending)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?)`,
81
108
  args: [
82
109
  id,
83
110
  input.owner,
@@ -88,11 +115,50 @@ export async function startAutomationRun(input) {
88
115
  input.runId ?? null,
89
116
  input.threadId ?? null,
90
117
  Date.now(),
118
+ input.dispatchPending ? 1 : 0,
91
119
  ],
92
120
  });
93
121
  await pruneAutomationRuns(input.owner, input.automation);
94
122
  return id;
95
123
  }
124
+ export async function getAutomationRun(id) {
125
+ await ensureTable();
126
+ const result = await getDbExec().execute({
127
+ sql: `SELECT * FROM ${TABLE} WHERE id = ? LIMIT 1`,
128
+ args: [id],
129
+ });
130
+ const row = result.rows?.[0];
131
+ return row ? toRun(row, Date.now()) : null;
132
+ }
133
+ /** Claim a manually queued run exactly once before loading its automation. */
134
+ export async function claimAutomationRun(id) {
135
+ await ensureTable();
136
+ const now = Date.now();
137
+ const result = await getDbExec().execute({
138
+ sql: `UPDATE ${TABLE} SET claimed_at = ? WHERE id = ? AND dispatch_pending = 1 AND (claimed_at IS NULL OR claimed_at <= ?) AND status = 'running'`,
139
+ args: [now, id, now - CLAIM_LEASE_MS],
140
+ });
141
+ return Number(result.rowsAffected ?? 0) > 0;
142
+ }
143
+ /**
144
+ * Find manual handoffs that have stayed unclaimed long enough to have missed
145
+ * their first self-dispatch. The row is the durable queue; callers may safely
146
+ * redeliver it because claimAutomationRun is an atomic CAS.
147
+ */
148
+ export async function listUnclaimedAutomationRuns(options) {
149
+ await ensureTable();
150
+ const olderThanMs = Math.max(options?.olderThanMs ?? 10_000, 0);
151
+ const limit = Math.min(Math.max(options?.limit ?? 50, 1), 100);
152
+ const result = await getDbExec().execute({
153
+ sql: `SELECT * FROM ${TABLE}
154
+ WHERE dispatch_pending = 1 AND (claimed_at IS NULL OR claimed_at <= ?) AND status = 'running'
155
+ AND started_at <= ?
156
+ ORDER BY started_at ASC LIMIT ${limit}`,
157
+ args: [Date.now() - CLAIM_LEASE_MS, Date.now() - olderThanMs],
158
+ });
159
+ const now = Date.now();
160
+ return (result.rows ?? []).map((row) => toRun(row, now));
161
+ }
96
162
  /**
97
163
  * Drop the oldest rows for one automation once it exceeds the retention cap.
98
164
  *