@agent-native/core 0.136.3 → 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.
- package/dist/client/agent-page/AgentJobsTab.js +19 -4
- package/dist/client/agent-page/use-jobs.d.ts +10 -0
- package/dist/client/agent-page/use-jobs.js +21 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/jobs/actions/run-automation-now.d.ts +6 -0
- package/dist/jobs/actions/run-automation-now.js +22 -0
- package/dist/jobs/background-automation-runner.d.ts +2 -0
- package/dist/jobs/background-automation-runner.js +22 -16
- package/dist/jobs/run-history.d.ts +17 -3
- package/dist/jobs/run-history.js +73 -7
- package/dist/jobs/run-now.d.ts +20 -0
- package/dist/jobs/run-now.js +99 -0
- package/dist/jobs/scheduler.d.ts +15 -0
- package/dist/jobs/scheduler.js +76 -12
- package/dist/localization/default-messages.d.ts +3 -0
- package/dist/localization/default-messages.js +3 -0
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/action-discovery.js +4 -0
- package/dist/server/agent-chat-plugin.js +135 -68
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/email-actions.d.ts +6 -3
- package/dist/server/email-actions.js +17 -83
- package/dist/server/email-markdown.d.ts +4 -0
- package/dist/server/email-markdown.js +174 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +1 -0
- package/dist/triggers/actions.d.ts +1 -1
- package/dist/triggers/actions.js +27 -5
- package/package.json +1 -1
- 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" })] })] })] }) }),
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
package/dist/jobs/run-history.js
CHANGED
|
@@ -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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
package/dist/jobs/scheduler.d.ts
CHANGED
|
@@ -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
|