@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
- package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +81 -30
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
- package/corpus/templates/clips/server/plugins/auth.ts +9 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
- 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/client/settings/SecretsSection.js +32 -8
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- 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 +8 -0
- package/dist/localization/default-messages.js +8 -0
- package/dist/notifications/routes.d.ts +4 -4
- 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/secrets/routes.d.ts +5 -5
- package/dist/secrets/routes.js +41 -17
- 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
|
@@ -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)
|
|
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" })] })] })] }) }),
|
|
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
|
|
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
|
-
|
|
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",
|
|
194
|
+
setToastAndClear("ok", isCandidate
|
|
195
|
+
? t("secrets.candidateValueWorking")
|
|
196
|
+
: t("secrets.storedValueWorking"));
|
|
180
197
|
}
|
|
181
198
|
else {
|
|
182
|
-
setToastAndClear("err", body.error ??
|
|
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
|
-
|
|
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: "
|
|
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,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
|
*
|