@agent-native/core 0.136.2 → 0.136.4

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 (33) hide show
  1. package/dist/client/agent-page/AgentJobsTab.js +19 -4
  2. package/dist/client/agent-page/use-jobs.d.ts +10 -0
  3. package/dist/client/agent-page/use-jobs.js +21 -0
  4. package/dist/collab/routes.d.ts +1 -1
  5. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  6. package/dist/jobs/actions/run-automation-now.d.ts +6 -0
  7. package/dist/jobs/actions/run-automation-now.js +22 -0
  8. package/dist/jobs/background-automation-runner.d.ts +2 -0
  9. package/dist/jobs/background-automation-runner.js +22 -16
  10. package/dist/jobs/run-history.d.ts +17 -3
  11. package/dist/jobs/run-history.js +73 -7
  12. package/dist/jobs/run-now.d.ts +20 -0
  13. package/dist/jobs/run-now.js +99 -0
  14. package/dist/jobs/scheduler.d.ts +15 -0
  15. package/dist/jobs/scheduler.js +76 -12
  16. package/dist/localization/default-messages.d.ts +3 -0
  17. package/dist/localization/default-messages.js +3 -0
  18. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  19. package/dist/notifications/routes.d.ts +2 -2
  20. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  21. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  22. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  23. package/dist/server/action-discovery.js +4 -0
  24. package/dist/server/agent-chat-plugin.js +135 -68
  25. package/dist/server/email-actions.d.ts +6 -3
  26. package/dist/server/email-actions.js +17 -83
  27. package/dist/server/email-markdown.d.ts +4 -0
  28. package/dist/server/email-markdown.js +174 -0
  29. package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
  30. package/dist/triggers/actions.d.ts +1 -1
  31. package/dist/triggers/actions.js +27 -5
  32. package/package.json +1 -1
  33. package/src/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
@@ -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)
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- error: string;
30
29
  ok?: undefined;
30
+ error: string;
31
31
  } | {
32
32
  error?: undefined;
33
33
  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;
20
21
  configured?: undefined;
21
22
  connectPath?: undefined;
22
23
  url: string;
23
24
  id: string;
24
25
  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
  *
@@ -0,0 +1,20 @@
1
+ import { type AutomationScope } from "../automations/service.js";
2
+ export interface RunAutomationNowInput {
3
+ userEmail: string;
4
+ orgId?: string | null;
5
+ scope: AutomationScope;
6
+ name: string;
7
+ }
8
+ export interface QueuedAutomationRun {
9
+ queued: true;
10
+ runId: string;
11
+ automationRunId: string;
12
+ }
13
+ export declare function queueAutomationRunNow(input: RunAutomationNowInput): Promise<QueuedAutomationRun>;
14
+ /**
15
+ * Recover manual rows whose first serverless handoff never reached a worker.
16
+ * This is intentionally a redelivery, not a second execution: the worker's
17
+ * claim CAS decides which request owns the run.
18
+ */
19
+ export declare function redispatchUnclaimedAutomationRuns(): Promise<number>;
20
+ //# sourceMappingURL=run-now.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { AGENT_CHAT_BACKGROUND_RUN_FIELD, dispatchPathTargetsNetlifyBackgroundFunction, resolveAgentChatProcessRunDispatchPath, } from "../agent/durable-background.js";
2
+ import { canUpdateAutomationResource, } from "../automations/service.js";
3
+ import { isLocalDatabase } from "../db/client.js";
4
+ import { organizationResourceOwner, resourceGetByPath, } from "../resources/store.js";
5
+ import { fireInternalDispatch } from "../server/self-dispatch.js";
6
+ import { parseJobResource } from "./frontmatter.js";
7
+ import { listUnclaimedAutomationRuns, startAutomationRun, } from "./run-history.js";
8
+ async function dispatchAutomationRun(historyId) {
9
+ const dispatchPath = resolveAgentChatProcessRunDispatchPath();
10
+ await fireInternalDispatch({
11
+ path: dispatchPath,
12
+ taskId: historyId,
13
+ body: {
14
+ [AGENT_CHAT_BACKGROUND_RUN_FIELD]: {
15
+ runId: historyId,
16
+ automationRunId: historyId,
17
+ },
18
+ },
19
+ ...(dispatchPathTargetsNetlifyBackgroundFunction(dispatchPath)
20
+ ? { awaitResponse: true, responseTimeoutMs: 5_000 }
21
+ : !isLocalDatabase()
22
+ ? { awaitResponse: true, responseTimeoutMs: 5_000 }
23
+ : {}),
24
+ });
25
+ }
26
+ function ownerForScope(input) {
27
+ if (input.scope === "personal")
28
+ return input.userEmail.trim().toLowerCase();
29
+ if (!input.orgId) {
30
+ throw Object.assign(new Error("An organization is required for organization automations."), { statusCode: 400 });
31
+ }
32
+ return organizationResourceOwner(input.orgId);
33
+ }
34
+ export async function queueAutomationRunNow(input) {
35
+ const name = input.name.trim();
36
+ if (!name || name.includes("/") || name.endsWith(".md")) {
37
+ throw Object.assign(new Error("A valid automation name is required."), {
38
+ statusCode: 400,
39
+ });
40
+ }
41
+ const owner = ownerForScope(input);
42
+ const resource = await resourceGetByPath(owner, `jobs/${name}.md`);
43
+ if (!resource) {
44
+ throw Object.assign(new Error(`Automation "${name}" not found.`), {
45
+ statusCode: 404,
46
+ });
47
+ }
48
+ if (!(await canUpdateAutomationResource(input, resource))) {
49
+ throw Object.assign(new Error("Only the automation's creator or an organization admin can run it."), { statusCode: 403 });
50
+ }
51
+ const { body } = parseJobResource(resource.content);
52
+ if (!body.trim()) {
53
+ throw Object.assign(new Error(`Automation "${name}" has no instructions.`), {
54
+ statusCode: 400,
55
+ });
56
+ }
57
+ // A manual-run request is a guaranteed app request even on hosts without a
58
+ // durable timer. Use it to recover older rows before adding the new one.
59
+ await redispatchUnclaimedAutomationRuns().catch((error) => {
60
+ console.warn("[automations] Could not sweep queued runs before run-now:", error);
61
+ });
62
+ const historyId = await startAutomationRun({
63
+ owner: resource.owner,
64
+ automation: name,
65
+ path: resource.path,
66
+ scope: input.scope,
67
+ orgId: input.scope === "organization" ? input.orgId : null,
68
+ dispatchPending: true,
69
+ });
70
+ try {
71
+ await dispatchAutomationRun(historyId);
72
+ }
73
+ catch (error) {
74
+ const message = error instanceof Error ? error.message : "Background dispatch failed";
75
+ console.warn(`[automations] Initial run-now dispatch failed; leaving ${historyId} queued for redelivery:`, message);
76
+ throw error;
77
+ }
78
+ return { queued: true, runId: historyId, automationRunId: historyId };
79
+ }
80
+ /**
81
+ * Recover manual rows whose first serverless handoff never reached a worker.
82
+ * This is intentionally a redelivery, not a second execution: the worker's
83
+ * claim CAS decides which request owns the run.
84
+ */
85
+ export async function redispatchUnclaimedAutomationRuns() {
86
+ const runs = await listUnclaimedAutomationRuns();
87
+ let attempted = 0;
88
+ for (const run of runs) {
89
+ try {
90
+ await dispatchAutomationRun(run.id);
91
+ attempted += 1;
92
+ }
93
+ catch (error) {
94
+ console.error(`[automations] Could not redeliver queued run ${run.id}:`, error);
95
+ }
96
+ }
97
+ return attempted;
98
+ }
99
+ //# sourceMappingURL=run-now.js.map
@@ -27,4 +27,19 @@ export interface SchedulerDeps extends BackgroundAutomationDeps {
27
27
  */
28
28
  export declare function processRecurringJobs(deps: SchedulerDeps): Promise<void>;
29
29
  export declare const jobRunCutOffReason: typeof backgroundRunCutOffReason;
30
+ interface JobExecutionResult {
31
+ status: "success" | "error" | "skipped";
32
+ runId?: string;
33
+ error?: string;
34
+ }
35
+ /** Execute one stored automation without changing its scheduled next run. */
36
+ export declare function runJobNow(owner: string, name: string, deps: SchedulerDeps, options?: {
37
+ historyId?: string;
38
+ }): Promise<JobExecutionResult>;
39
+ /** Process a durable run-now history row exactly once in the background worker. */
40
+ export declare function runQueuedAutomation(historyId: string, deps: SchedulerDeps): Promise<{
41
+ skipped: boolean;
42
+ runId?: string;
43
+ error?: string;
44
+ }>;
30
45
  //# sourceMappingURL=scheduler.d.ts.map