@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.
- package/LICENSE.md +9 -0
- package/README.md +173 -2
- package/client.ts +24 -0
- package/module.ts +55 -0
- package/package.json +59 -3
- package/runtime/components/AutomationCanvas.vue +488 -0
- package/runtime/components/AutomationInspector.vue +436 -0
- package/runtime/components/AutomationNodeIcon.vue +27 -0
- package/runtime/components/AutomationRunsPanel.vue +475 -0
- package/runtime/components/AutomationSidebar.vue +253 -0
- package/runtime/components/AutomationStatusIcon.vue +44 -0
- package/runtime/pages/automations.vue +1181 -0
- package/runtime/server/api/automations/[id]/endpoint.get.ts +12 -0
- package/runtime/server/api/automations/[id]/run.post.ts +30 -0
- package/runtime/server/api/automations/events.post.ts +37 -0
- package/runtime/server/api/automations/runs/[id]/resume.post.ts +23 -0
- package/runtime/server/handlers/automation-hook.post.ts +51 -0
- package/runtime/server/handlers/connect-hook.post.ts +54 -0
- package/runtime/server/handlers/integration-hook.post.ts +86 -0
- package/runtime/server/plugins/events.ts +79 -0
- package/runtime/server/plugins/maintenance.ts +20 -0
- package/runtime/server/utils/agent-runner.ts +15 -0
- package/runtime/server/utils/automation-context.ts +77 -0
- package/runtime/server/utils/automation-engine.ts +697 -0
- package/runtime/server/utils/automation-launch.ts +205 -0
- package/runtime/server/utils/automation-schedule.ts +31 -0
- package/runtime/server/utils/connection-events.ts +114 -0
- package/runtime/server/utils/maintenance.ts +255 -0
- package/runtime/server/utils/schedule-sync.ts +32 -0
- package/runtime/server/utils/transient-retry.ts +34 -0
- package/runtime/server/workflows/runner.ts +39 -0
- package/runtime/shared/agent.ts +5 -0
- package/runtime/shared/automation-run.ts +61 -0
- package/runtime/shared/automation.ts +202 -0
- package/runtime/workspace.ts +56 -0
- package/schema.ts +222 -0
- package/server.ts +2 -0
- 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
|
+
}
|