@nuxtagentic/automations 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +173 -2
  3. package/client.ts +24 -0
  4. package/module.ts +55 -0
  5. package/package.json +59 -3
  6. package/runtime/components/AutomationCanvas.vue +488 -0
  7. package/runtime/components/AutomationInspector.vue +436 -0
  8. package/runtime/components/AutomationNodeIcon.vue +27 -0
  9. package/runtime/components/AutomationRunsPanel.vue +475 -0
  10. package/runtime/components/AutomationSidebar.vue +253 -0
  11. package/runtime/components/AutomationStatusIcon.vue +44 -0
  12. package/runtime/pages/automations.vue +1181 -0
  13. package/runtime/server/api/automations/[id]/endpoint.get.ts +12 -0
  14. package/runtime/server/api/automations/[id]/run.post.ts +30 -0
  15. package/runtime/server/api/automations/events.post.ts +37 -0
  16. package/runtime/server/api/automations/runs/[id]/resume.post.ts +23 -0
  17. package/runtime/server/handlers/automation-hook.post.ts +51 -0
  18. package/runtime/server/handlers/connect-hook.post.ts +54 -0
  19. package/runtime/server/handlers/integration-hook.post.ts +86 -0
  20. package/runtime/server/plugins/events.ts +79 -0
  21. package/runtime/server/plugins/maintenance.ts +20 -0
  22. package/runtime/server/utils/agent-runner.ts +15 -0
  23. package/runtime/server/utils/automation-context.ts +77 -0
  24. package/runtime/server/utils/automation-engine.ts +697 -0
  25. package/runtime/server/utils/automation-launch.ts +205 -0
  26. package/runtime/server/utils/automation-schedule.ts +31 -0
  27. package/runtime/server/utils/connection-events.ts +114 -0
  28. package/runtime/server/utils/maintenance.ts +255 -0
  29. package/runtime/server/utils/schedule-sync.ts +32 -0
  30. package/runtime/server/utils/transient-retry.ts +34 -0
  31. package/runtime/server/workflows/runner.ts +39 -0
  32. package/runtime/shared/agent.ts +5 -0
  33. package/runtime/shared/automation-run.ts +61 -0
  34. package/runtime/shared/automation.ts +202 -0
  35. package/runtime/workspace.ts +56 -0
  36. package/schema.ts +222 -0
  37. package/server.ts +2 -0
  38. package/workflow.ts +2 -0
@@ -0,0 +1,205 @@
1
+ import { createError, type H3Event } from 'h3'
2
+ import { useRuntimeConfig } from '#imports'
3
+ import type { InferServerSelect } from '@cloudconfig/core'
4
+ import { CloudConfigClientError } from '@cloudconfig/core/client'
5
+ import { toAgentDefinition } from '@nuxtagentic/agents/runtime/shared/definition'
6
+ import { isLocalAgentRuntimeEnabled } from '@nuxtagentic/agents/runtime/server/utils/local-agent-runtime'
7
+ import { isWorkspaceModuleInstalled } from '@nuxtagentic/workspace/runtime/shared/modules'
8
+ import type { AutomationsConfig } from '../../../schema'
9
+ import type { AutomationDraft } from '../../shared/automation'
10
+ import { executeAutomation, type RunSnapshot } from './automation-engine'
11
+ import type { createJobContext, AutomationJob } from './automation-context'
12
+
13
+ type Context = Awaited<ReturnType<typeof createJobContext>>
14
+ export type AutomationRecord = InferServerSelect<AutomationsConfig, 'automation'>
15
+ export interface AutomationLaunch {
16
+ automation: AutomationRecord
17
+ payload: unknown
18
+ /** Deduplicates deliveries: a second launch with the same id returns the first run. */
19
+ eventId: string
20
+ }
21
+
22
+ /** Queues one run of an automation for an incoming event, webhook call, schedule or test. */
23
+ export async function launchAutomation(
24
+ event: H3Event | null,
25
+ context: Context,
26
+ { automation, payload, eventId }: AutomationLaunch,
27
+ options: { test?: boolean; forceError?: boolean; managed?: boolean } = {},
28
+ ) {
29
+ if (automation.workspace !== context.workspace) {
30
+ throw new Error('Choose an automation in this workspace.')
31
+ }
32
+ if (!options.test && !automation.active) {
33
+ throw new Error('Activate this automation before sending events.')
34
+ }
35
+ const managed = event
36
+ ? !!(useRuntimeConfig(event).public.workspaceModules as { managed?: boolean }).managed
37
+ : options.managed === true
38
+ if (managed) {
39
+ const installations = await context.api.workspaceInstallation.list({
40
+ pageSize: 1,
41
+ filters: {
42
+ type: 'group',
43
+ logic: 'and',
44
+ children: [
45
+ { type: 'condition', field: 'workspace', operator: 'eq', value: context.workspace },
46
+ ],
47
+ },
48
+ })
49
+ const installed = installations.data[0]?.installed as string[] | undefined
50
+ if (!isWorkspaceModuleInstalled('agents', installed)) {
51
+ throw new Error('Install Agents from Modules before running this automation.')
52
+ }
53
+ }
54
+ if (!automation.agentId) {
55
+ throw new Error('Choose an agent before running this automation.')
56
+ }
57
+ const agent = await context.api.agent.get(automation.agentId).catch((cause: unknown) => {
58
+ if (cause instanceof CloudConfigClientError && cause.status === 404) {
59
+ throw new Error('The selected agent is no longer available. Choose another agent.')
60
+ }
61
+ throw cause
62
+ })
63
+ const executionAgent = toAgentDefinition(agent)
64
+ const localEnabled = event ? isLocalAgentRuntimeEnabled(event) : false
65
+ if (!localEnabled) {
66
+ executionAgent.runtime = 'api'
67
+ }
68
+ const local = executionAgent.runtime === 'codex' || executionAgent.runtime === 'claude'
69
+ if (import.meta.dev && local) {
70
+ const { findLocalAgentBinary } =
71
+ await import('@nuxtagentic/agents/runtime/server/utils/local-agent-catalog')
72
+ if (!(await findLocalAgentBinary(executionAgent.runtime as 'codex' | 'claude'))) {
73
+ throw new Error(`Install ${executionAgent.runtime} and sign in before running this agent.`)
74
+ }
75
+ }
76
+ if (!options.test && agent.status !== 'active') {
77
+ throw new Error('Activate the selected agent before receiving events.')
78
+ }
79
+ if (!local && !context.keys[agent.provider] && !options.forceError) {
80
+ throw new Error(
81
+ `Configure the ${agent.provider} API key on the server before running this automation.`,
82
+ )
83
+ }
84
+ const uniqueId = `${automation.id}:${eventId}`
85
+ const existing = await context.api.automationRun.list({
86
+ pageSize: 1,
87
+ filters: {
88
+ type: 'group',
89
+ logic: 'and',
90
+ children: [{ type: 'condition', field: 'eventId', operator: 'eq', value: uniqueId }],
91
+ },
92
+ })
93
+ if (existing.data[0]) {
94
+ return { runId: existing.data[0].id, status: existing.data[0].status, duplicate: true }
95
+ }
96
+ // CLI execution belongs on this Node server, never in a proxied Workflow worker.
97
+ const env = (local ? undefined : (event?.context.cloudflare?.env ?? context.env)) as
98
+ Record<string, unknown> | undefined
99
+ const binding = env?.AUTOMATION_RUNNER as
100
+ { create: (input: { id: string; params: AutomationJob }) => Promise<unknown> } | undefined
101
+ if (env && !binding) {
102
+ throw createError({
103
+ statusCode: 503,
104
+ statusMessage:
105
+ 'Deploy with the Automations module to provision the AUTOMATION_RUNNER workflow binding.',
106
+ })
107
+ }
108
+ const { webhookToken: _token, ownerId: _owner, ...definition } = automation
109
+ const snapshot: RunSnapshot = {
110
+ automation: definition as unknown as AutomationDraft,
111
+ agent: executionAgent,
112
+ managed,
113
+ }
114
+ let run
115
+ try {
116
+ run = await context.api.automationRun.create({
117
+ automationId: automation.id,
118
+ workspace: context.workspace,
119
+ eventId: uniqueId,
120
+ payload,
121
+ snapshot,
122
+ ownerId: context.ownerId,
123
+ })
124
+ } catch (cause) {
125
+ const duplicate = await context.api.automationRun.list({
126
+ pageSize: 1,
127
+ filters: {
128
+ type: 'group',
129
+ logic: 'and',
130
+ children: [{ type: 'condition', field: 'eventId', operator: 'eq', value: uniqueId }],
131
+ },
132
+ })
133
+ if (duplicate.data[0]) {
134
+ return { runId: duplicate.data[0].id, status: duplicate.data[0].status, duplicate: true }
135
+ }
136
+ throw cause
137
+ }
138
+ const job: AutomationJob = {
139
+ ownerId: context.ownerId,
140
+ workspace: context.workspace,
141
+ runId: run.id,
142
+ forceError: options.forceError,
143
+ }
144
+ try {
145
+ if (binding) {
146
+ await binding.create({ id: `automation-${run.id}`, params: job })
147
+ } else {
148
+ const task = executeAutomation(job, context).catch(async () => {
149
+ await context.api.automationRun.update(run.id, {
150
+ ownerId: context.ownerId,
151
+ status: 'failed',
152
+ error: 'Execution stopped unexpectedly. Check the server logs.',
153
+ finishedAt: new Date().toISOString(),
154
+ })
155
+ })
156
+ if (event) {
157
+ event.waitUntil(task)
158
+ } else {
159
+ await task
160
+ }
161
+ }
162
+ } catch (cause) {
163
+ await context.api.automationRun.update(run.id, {
164
+ ownerId: context.ownerId,
165
+ status: 'failed',
166
+ error: 'Could not enqueue this automation. Check the workflow binding.',
167
+ finishedAt: new Date().toISOString(),
168
+ })
169
+ throw cause
170
+ }
171
+ return { runId: run.id, status: 'queued', duplicate: false }
172
+ }
173
+ export interface AutomationEvent {
174
+ name: string
175
+ payload: unknown
176
+ id: string
177
+ }
178
+
179
+ /** Launches every active automation in the workspace that listens for this event. */
180
+ export async function emitAutomationEvent(
181
+ event: H3Event,
182
+ context: Context,
183
+ { name, payload, id }: AutomationEvent,
184
+ ) {
185
+ const result = await context.api.automation.list({
186
+ pageSize: 100,
187
+ filters: {
188
+ type: 'group',
189
+ logic: 'and',
190
+ children: [
191
+ { type: 'condition', field: 'workspace', operator: 'eq', value: context.workspace },
192
+ { type: 'condition', field: 'active', operator: 'eq', value: true },
193
+ ],
194
+ },
195
+ })
196
+ const matching = result.data.filter((row) => {
197
+ const trigger = row.trigger as { kind: string; event: string }
198
+ return trigger.kind === 'event' && trigger.event === name
199
+ })
200
+ const runs = []
201
+ for (const automation of matching) {
202
+ runs.push(await launchAutomation(event, context, { automation, payload, eventId: id }))
203
+ }
204
+ return { runs }
205
+ }
@@ -0,0 +1,31 @@
1
+ import { createJobContext } from './automation-context'
2
+ import { launchAutomation } from './automation-launch'
3
+
4
+ export async function fireAutomationSchedule(
5
+ payload: Record<string, unknown>,
6
+ dueAt: string,
7
+ env: Record<string, unknown> | undefined,
8
+ ) {
9
+ const ownerId = Number(payload.ownerId)
10
+ const workspace = String(payload.workspace)
11
+ const context = await createJobContext({ ownerId, workspace, runId: 0 }, env)
12
+ const automation = await context.api.automation
13
+ .get(Number(payload.automationId))
14
+ .catch(() => null)
15
+ if (
16
+ !automation ||
17
+ !automation.active ||
18
+ automation.workspace !== workspace ||
19
+ (automation.trigger as { kind?: string }).kind !== 'schedule'
20
+ ) {
21
+ return
22
+ }
23
+ await launchAutomation(
24
+ null,
25
+ context,
26
+ { automation, payload: { scheduledAt: dueAt }, eventId: `schedule:${dueAt}` },
27
+ {
28
+ managed: true,
29
+ },
30
+ )
31
+ }
@@ -0,0 +1,114 @@
1
+ import type { H3Event } from 'h3'
2
+ import { createServerClient, type DbProvider } from '@cloudconfig/core/server'
3
+ import type { Connect, ConnectionSummary } from '@cloudconfig/core/connect'
4
+ import type { IntegrationsConfig } from '@nuxtagentic/integrations/schema'
5
+ import {
6
+ backendConfig,
7
+ type Binding,
8
+ } from '@nuxtagentic/integrations/runtime/server/utils/integration-context'
9
+ import { getWebhookContext } from './automation-context'
10
+ import { emitAutomationEvent } from './automation-launch'
11
+
12
+ interface ConnectionEventOptions {
13
+ connection: ConnectionSummary
14
+ context: { database: DbProvider; env: Record<string, unknown> | undefined }
15
+ delivery: { name: string; payload: unknown; id: string }
16
+ }
17
+
18
+ /**
19
+ * Integration connections that use one Connect connection. A vendor event has
20
+ * no signed-in user, so the query runs as the connection's own owner — or, for
21
+ * a shared connection, as its organization — and the normal access rules apply.
22
+ */
23
+ async function bindingsFor(
24
+ summary: ConnectionSummary,
25
+ context: { database: DbProvider; env: Record<string, unknown> | undefined },
26
+ ): Promise<Binding[]> {
27
+ const { organizationId, userId, id } = summary
28
+ const { database, env } = context
29
+ const organization = organizationId === null ? null : { id: organizationId, role: 'member' }
30
+ const api = createServerClient(
31
+ backendConfig as IntegrationsConfig,
32
+ { env, request: new Request('https://events.internal') },
33
+ { db: database, getUser: () => ({ id: userId ?? 0, organization }) },
34
+ )
35
+ const result = await api.integrationConnection.list({
36
+ pageSize: 100,
37
+ filters: {
38
+ type: 'group',
39
+ logic: 'and',
40
+ children: [
41
+ { type: 'condition', field: 'connectionId', operator: 'eq', value: id },
42
+ { type: 'condition', field: 'enabled', operator: 'eq', value: true },
43
+ ],
44
+ },
45
+ })
46
+ return result.data
47
+ }
48
+
49
+ /**
50
+ * Start the automations listening for `name` that may see this connection:
51
+ * its owner's for a personal connection, every current member's for a shared one.
52
+ */
53
+ export async function emitConnectionEvent(
54
+ event: H3Event,
55
+ connect: Connect,
56
+ { connection, context, delivery }: ConnectionEventOptions,
57
+ ) {
58
+ const { name, payload, id } = delivery
59
+ const runs = []
60
+ for (const binding of await bindingsFor(connection, context)) {
61
+ const { provider, scope, organizationId, ownerId: connectionOwnerId, workspace } = binding
62
+ // Events are namespaced by integration: `github.*` only reaches GitHub bindings.
63
+ if (!name.startsWith(`${provider}.`)) {
64
+ continue
65
+ }
66
+ const owners =
67
+ scope === 'workspace' && organizationId !== null
68
+ ? (await connect.members(organizationId)).map((member) => member.userId)
69
+ : [connectionOwnerId]
70
+ for (const ownerId of owners) {
71
+ const job = await getWebhookContext(event, ownerId)
72
+ job.workspace = workspace
73
+ const result = await emitAutomationEvent(event, job, {
74
+ name,
75
+ payload,
76
+ id: `${provider}:${id}`,
77
+ })
78
+ runs.push(...result.runs)
79
+ }
80
+ }
81
+ return { runs }
82
+ }
83
+
84
+ /** Maps a vendor's own event name to the catalog trigger it represents, if any. */
85
+ export function catalogTrigger(provider: string, event: string, payload: unknown): string {
86
+ const record = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>
87
+ switch (provider) {
88
+ case 'github':
89
+ if (event === 'pull_request.opened') {
90
+ return 'github.a_pull_request_is_opened'
91
+ }
92
+ if (event === 'issues.opened') {
93
+ return 'github.an_issue_is_created'
94
+ }
95
+ return event === 'push' ? 'github.a_branch_is_pushed' : ''
96
+ case 'slack':
97
+ if (event === 'app_mention') {
98
+ return 'slack.an_agent_is_mentioned'
99
+ }
100
+ return event === 'message' ? 'slack.a_message_is_posted' : ''
101
+ case 'shopify':
102
+ return event === 'orders/create' ? 'shopify.an_order_is_placed' : ''
103
+ case 'hubspot':
104
+ return event === 'deal.propertyChange' && record.propertyName === 'dealstage'
105
+ ? 'hubspot.a_deal_changes_stage'
106
+ : ''
107
+ case 'intercom':
108
+ return event === 'conversation.user.created' ? 'intercom.a_conversation_is_opened' : ''
109
+ case 'notion':
110
+ return event === 'page.created' ? 'notion.a_database_row_is_added' : ''
111
+ default:
112
+ return ''
113
+ }
114
+ }
@@ -0,0 +1,255 @@
1
+ import { CronExpressionParser } from 'cron-parser'
2
+ import { createJobContext } from './automation-context'
3
+ import { closeUnfinishedRun } from './automation-engine'
4
+ import { fireAutomationSchedule } from './automation-schedule'
5
+ import { syncAutomationSchedule } from './schedule-sync'
6
+
7
+ /** The D1 surface the job needs, so tests can supply any SQLite database. */
8
+ export interface MaintenanceDatabase {
9
+ prepare(sql: string): {
10
+ bind(...values: unknown[]): {
11
+ all<T = Record<string, unknown>>(): Promise<{ results: T[] }>
12
+ run(): Promise<{ meta?: { changes?: number } }>
13
+ }
14
+ }
15
+ }
16
+ interface WorkflowBinding {
17
+ get(id: string): Promise<{ status(): Promise<{ status: string }> }>
18
+ }
19
+ export interface MaintenanceReport {
20
+ schedulesRepaired: number
21
+ runsRecovered: number
22
+ runsClosed: number
23
+ receiptsDeleted: number
24
+ runsDeleted: number
25
+ }
26
+
27
+ const MINUTE = 60_000
28
+ /** A Durable Object alarm fires within seconds; a missing run after this is a lost schedule. */
29
+ const SCHEDULE_GRACE = 10 * MINUTE
30
+ /** Missed occurrences older than this are re-armed but not run late. */
31
+ const CATCH_UP_WINDOW = 60 * MINUTE
32
+ /** Runs untouched for this long are checked against their Workflow instance. */
33
+ const STALE_RUN = 15 * MINUTE
34
+ /** Receipts only matter while a run can still replay. */
35
+ const RECEIPT_RETENTION = 24 * 60 * MINUTE
36
+ const MAX_SCHEDULES = 2_000
37
+ const MAX_STALE_RUNS = 25
38
+ const DELETE_BATCH = 500
39
+ const FINISHED = `'succeeded', 'failed'`
40
+
41
+ /**
42
+ * Repairs what durable execution cannot repair from inside one run: schedules
43
+ * whose alarm was lost, runs whose Workflow instance ended without closing
44
+ * them, and receipts or history past their retention. Every task is bounded
45
+ * and idempotent, so the Worker cron may run it as often as it likes.
46
+ */
47
+ export async function runAutomationMaintenance(
48
+ env: Record<string, unknown>,
49
+ database: MaintenanceDatabase,
50
+ options: { now?: Date; retentionDays?: number } = {},
51
+ ): Promise<MaintenanceReport> {
52
+ const now = options.now ?? new Date()
53
+ const report: MaintenanceReport = {
54
+ schedulesRepaired: 0,
55
+ runsRecovered: 0,
56
+ runsClosed: 0,
57
+ receiptsDeleted: 0,
58
+ runsDeleted: 0,
59
+ }
60
+ const tasks = [
61
+ () => repairSchedules(env, database, now, report),
62
+ () => closeOrphanedRuns(env, database, now, report),
63
+ () => pruneHistory(database, now, options.retentionDays ?? 0, report),
64
+ ]
65
+ // One failing task never stops the others; the next cron run retries it.
66
+ for (const task of tasks) {
67
+ await task().catch((cause: unknown) => {
68
+ console.error('[automations] A maintenance task failed.', cause)
69
+ })
70
+ }
71
+ return report
72
+ }
73
+
74
+ interface ScheduledRow {
75
+ id: number
76
+ owner_id: number
77
+ workspace: string
78
+ trigger: string
79
+ updated_at: string
80
+ }
81
+
82
+ async function repairSchedules(
83
+ env: Record<string, unknown>,
84
+ database: MaintenanceDatabase,
85
+ now: Date,
86
+ report: MaintenanceReport,
87
+ ) {
88
+ const { results } = await database
89
+ .prepare(
90
+ `SELECT id, owner_id, workspace, "trigger", updated_at FROM automations
91
+ WHERE active = 1 AND json_extract("trigger", '$.kind') = 'schedule' ORDER BY id LIMIT ?`,
92
+ )
93
+ .bind(MAX_SCHEDULES)
94
+ .all<ScheduledRow>()
95
+ const cutoff = new Date(now.getTime() - SCHEDULE_GRACE)
96
+ const expected = results.flatMap((row) => {
97
+ const dueAt = lastOccurrence(row.trigger, cutoff)
98
+ // A schedule saved after this occurrence was never armed for it.
99
+ if (!dueAt || dueAt.getTime() <= Date.parse(row.updated_at)) {
100
+ return []
101
+ }
102
+ return [{ row, dueAt, eventId: `${row.id}:schedule:${dueAt.toISOString()}` }]
103
+ })
104
+ const launched = new Set<string>()
105
+ for (const ids of chunks(
106
+ expected.map((entry) => entry.eventId),
107
+ 90,
108
+ )) {
109
+ const { results: rows } = await database
110
+ .prepare(
111
+ `SELECT event_id FROM automationRuns WHERE event_id IN (${ids.map(() => '?').join(', ')})`,
112
+ )
113
+ .bind(...ids)
114
+ .all<{ event_id: string }>()
115
+ for (const row of rows) {
116
+ launched.add(row.event_id)
117
+ }
118
+ }
119
+ for (const { row, dueAt } of expected.filter((entry) => !launched.has(entry.eventId))) {
120
+ try {
121
+ const trigger = JSON.parse(row.trigger) as Record<string, unknown>
122
+ await syncAutomationSchedule(env, row.owner_id, row.id, {
123
+ active: true,
124
+ workspace: row.workspace,
125
+ trigger,
126
+ })
127
+ report.schedulesRepaired++
128
+ if (now.getTime() - dueAt.getTime() <= SCHEDULE_GRACE + CATCH_UP_WINDOW) {
129
+ // The same entry point the alarm uses; the event ID deduplicates a late alarm.
130
+ await fireAutomationSchedule(
131
+ { ownerId: row.owner_id, workspace: row.workspace, automationId: row.id },
132
+ dueAt.toISOString(),
133
+ env,
134
+ )
135
+ report.runsRecovered++
136
+ }
137
+ } catch (cause) {
138
+ console.error(`[automations] Could not repair the schedule of automation ${row.id}.`, cause)
139
+ }
140
+ }
141
+ }
142
+
143
+ function lastOccurrence(trigger: string, before: Date) {
144
+ try {
145
+ const { cron, timeZone } = JSON.parse(trigger) as { cron?: string; timeZone?: string }
146
+ return CronExpressionParser.parse(String(cron), { tz: timeZone, currentDate: before })
147
+ .prev()
148
+ .toDate()
149
+ } catch {
150
+ return undefined
151
+ }
152
+ }
153
+
154
+ async function closeOrphanedRuns(
155
+ env: Record<string, unknown>,
156
+ database: MaintenanceDatabase,
157
+ now: Date,
158
+ report: MaintenanceReport,
159
+ ) {
160
+ const workflows = env.AUTOMATION_RUNNER as WorkflowBinding | undefined
161
+ if (!workflows) {
162
+ return
163
+ }
164
+ // Random sampling keeps long, legitimately waiting runs from starving the rest.
165
+ const { results } = await database
166
+ .prepare(
167
+ `SELECT id, owner_id, workspace FROM automationRuns
168
+ WHERE status IN ('queued', 'running', 'waiting') AND updated_at < ?
169
+ ORDER BY RANDOM() LIMIT ?`,
170
+ )
171
+ .bind(new Date(now.getTime() - STALE_RUN).toISOString(), MAX_STALE_RUNS)
172
+ .all<{ id: number; owner_id: number; workspace: string }>()
173
+ for (const row of results) {
174
+ try {
175
+ const state = await workflowState(workflows, `automation-${row.id}`)
176
+ if (!['errored', 'terminated', 'complete', 'missing'].includes(state)) {
177
+ continue
178
+ }
179
+ const job = { ownerId: row.owner_id, workspace: row.workspace, runId: row.id }
180
+ const reason =
181
+ state === 'missing'
182
+ ? 'Its Workflow instance no longer exists.'
183
+ : `Its Workflow instance is ${state}.`
184
+ await closeUnfinishedRun(job, await createJobContext(job, env), new Error(reason))
185
+ report.runsClosed++
186
+ } catch (cause) {
187
+ console.error(`[automations] Could not check automation run ${row.id}.`, cause)
188
+ }
189
+ }
190
+ }
191
+
192
+ async function workflowState(workflows: WorkflowBinding, id: string) {
193
+ let instance
194
+ try {
195
+ instance = await workflows.get(id)
196
+ } catch (cause) {
197
+ if (/not.?found|does not exist/i.test(cause instanceof Error ? cause.message : '')) {
198
+ return 'missing'
199
+ }
200
+ throw cause
201
+ }
202
+ return (await instance.status()).status
203
+ }
204
+
205
+ async function pruneHistory(
206
+ database: MaintenanceDatabase,
207
+ now: Date,
208
+ retentionDays: number,
209
+ report: MaintenanceReport,
210
+ ) {
211
+ const receipts = await database
212
+ .prepare(
213
+ `DELETE FROM automationRunReceipts WHERE id IN (
214
+ SELECT receipt.id FROM automationRunReceipts AS receipt
215
+ JOIN automationRuns AS run ON run.id = receipt.run_id
216
+ WHERE run.status IN (${FINISHED}) AND run.finished_at < ? LIMIT ?)`,
217
+ )
218
+ .bind(new Date(now.getTime() - RECEIPT_RETENTION).toISOString(), DELETE_BATCH * 2)
219
+ .run()
220
+ report.receiptsDeleted += receipts.meta?.changes ?? 0
221
+ if (!(retentionDays > 0)) {
222
+ return
223
+ }
224
+ const { results } = await database
225
+ .prepare(
226
+ // Unary + keeps SQLite on the finished_at index; nearly every row has a finished status.
227
+ `SELECT id FROM automationRuns WHERE finished_at < ? AND +status IN (${FINISHED})
228
+ ORDER BY finished_at LIMIT ?`,
229
+ )
230
+ .bind(new Date(now.getTime() - retentionDays * 24 * 60 * MINUTE).toISOString(), DELETE_BATCH)
231
+ .all<{ id: number }>()
232
+ for (const ids of chunks(
233
+ results.map((row) => row.id),
234
+ 90,
235
+ )) {
236
+ const placeholders = ids.map(() => '?').join(', ')
237
+ await database
238
+ .prepare(`DELETE FROM automationRunReceipts WHERE run_id IN (${placeholders})`)
239
+ .bind(...ids)
240
+ .run()
241
+ const deleted = await database
242
+ .prepare(`DELETE FROM automationRuns WHERE id IN (${placeholders})`)
243
+ .bind(...ids)
244
+ .run()
245
+ report.runsDeleted += deleted.meta?.changes ?? 0
246
+ }
247
+ }
248
+
249
+ function chunks<T>(values: T[], size: number) {
250
+ const groups: T[][] = []
251
+ for (let index = 0; index < values.length; index += size) {
252
+ groups.push(values.slice(index, index + size))
253
+ }
254
+ return groups
255
+ }
@@ -0,0 +1,32 @@
1
+ import { getAgentPlatform } from '@cloudconfig/core/agents/platform'
2
+
3
+ interface ScheduledAutomation {
4
+ active?: unknown
5
+ workspace?: unknown
6
+ trigger?: unknown
7
+ }
8
+
9
+ /** Replace or cancel the automation's durable schedule to match its saved trigger. */
10
+ export async function syncAutomationSchedule(
11
+ env: Record<string, unknown> | undefined,
12
+ ownerId: number,
13
+ automationId: number,
14
+ row: ScheduledAutomation | undefined,
15
+ ) {
16
+ // Hosts without declared agents, such as the CLI, keep no schedules.
17
+ const platform = getAgentPlatform(env)
18
+ if (!platform) {
19
+ return
20
+ }
21
+ const trigger = row?.trigger as { kind?: string; cron?: string; timeZone?: string } | undefined
22
+ await platform.schedule(
23
+ `automation:${ownerId}:${automationId}`,
24
+ row?.active && trigger?.kind === 'schedule'
25
+ ? {
26
+ cron: String(trigger.cron),
27
+ timeZone: String(trigger.timeZone),
28
+ payload: { ownerId, workspace: row.workspace, automationId },
29
+ }
30
+ : null,
31
+ )
32
+ }
@@ -0,0 +1,34 @@
1
+ /** Failures Cloudflare documents as temporary for D1 queries and Worker subrequests. */
2
+ const TRANSIENT_FAILURE =
3
+ /network connection lost|object to be reset|reset because its code was updated|overloaded|queued for too long|internal error|connection reset|econnreset|socket hang up/i
4
+ const ATTEMPTS = 4
5
+
6
+ export function isTransientFailure(cause: unknown) {
7
+ let error = cause
8
+ for (let depth = 0; error instanceof Error && depth < 5; depth++) {
9
+ if (TRANSIENT_FAILURE.test(error.message)) {
10
+ return true
11
+ }
12
+ error = error.cause
13
+ }
14
+ return false
15
+ }
16
+
17
+ /**
18
+ * Retry an operation through brief storage and network interruptions. Only
19
+ * idempotent work belongs here: journaled tool calls are, because a retry after
20
+ * dispatch finds their "started" receipt and stops instead of repeating them.
21
+ */
22
+ export async function retryTransientFailures<T>(operation: () => Promise<T>): Promise<T> {
23
+ for (let attempt = 1; ; attempt++) {
24
+ try {
25
+ return await operation()
26
+ } catch (cause) {
27
+ if (attempt >= ATTEMPTS || !isTransientFailure(cause)) {
28
+ throw cause
29
+ }
30
+ const delay = 250 * 4 ** (attempt - 1) * (0.5 + Math.random() / 2)
31
+ await new Promise((resolve) => setTimeout(resolve, delay))
32
+ }
33
+ }
34
+ }