@nuxtagentic/automations 0.0.0-stage → 0.1.0
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,697 @@
|
|
|
1
|
+
import type { AgentDefinition } from '@cloudconfig/core/agents'
|
|
2
|
+
import { CloudConfigClientError } from '@cloudconfig/core/client'
|
|
3
|
+
import { getFieldSpec } from '@cloudconfig/core'
|
|
4
|
+
import { createServerClient } from '@cloudconfig/core/server'
|
|
5
|
+
import {
|
|
6
|
+
actorOf,
|
|
7
|
+
backendConfig,
|
|
8
|
+
listGrantedTools,
|
|
9
|
+
OwnerLeftError,
|
|
10
|
+
performIntegrationAction,
|
|
11
|
+
type IntegrationServiceContext,
|
|
12
|
+
} from '@nuxtagentic/integrations/runtime/server/utils/integration-context'
|
|
13
|
+
import { limitToolResult } from '@nuxtagentic/agents/runtime/server/utils/tool-result'
|
|
14
|
+
import { isWorkspaceModuleInstalled } from '@nuxtagentic/workspace/runtime/shared/modules'
|
|
15
|
+
import {
|
|
16
|
+
type AutomationDraft,
|
|
17
|
+
type AutomationNode,
|
|
18
|
+
type AutomationOutcome,
|
|
19
|
+
type AutomationStep,
|
|
20
|
+
SUCCESS_ACTIONS,
|
|
21
|
+
ERROR_ACTIONS,
|
|
22
|
+
describeTrigger,
|
|
23
|
+
} from '../../shared/automation'
|
|
24
|
+
import type { createJobContext, AutomationJob } from './automation-context'
|
|
25
|
+
import { runSavedAgent } from './agent-runner'
|
|
26
|
+
|
|
27
|
+
type Context = Awaited<ReturnType<typeof createJobContext>>
|
|
28
|
+
class PendingAutomationApproval extends Error {}
|
|
29
|
+
class InterruptedTool extends Error {}
|
|
30
|
+
export type DurableStep = <T>(name: string, callback: () => Promise<T>) => Promise<T>
|
|
31
|
+
export type ApprovalWait = (id: number) => Promise<void>
|
|
32
|
+
export interface RunSnapshot {
|
|
33
|
+
automation: AutomationDraft
|
|
34
|
+
agent: AgentDefinition
|
|
35
|
+
managed: boolean
|
|
36
|
+
integrationCallStarted?: boolean
|
|
37
|
+
}
|
|
38
|
+
function errorMessage(cause: unknown) {
|
|
39
|
+
if (cause instanceof CloudConfigClientError && cause.issues.length) {
|
|
40
|
+
return cause.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')
|
|
41
|
+
}
|
|
42
|
+
if (!(cause instanceof Error)) {
|
|
43
|
+
return 'The automation could not complete.'
|
|
44
|
+
}
|
|
45
|
+
if (cause.name === 'TimeoutError' || cause.name === 'AbortError') {
|
|
46
|
+
return 'The agent exceeded its configured timeout.'
|
|
47
|
+
}
|
|
48
|
+
return cause.message
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeTriggerReceipt(trigger: AutomationDraft['trigger']) {
|
|
52
|
+
switch (trigger.kind) {
|
|
53
|
+
case 'webhook':
|
|
54
|
+
return 'Webhook received.'
|
|
55
|
+
case 'schedule':
|
|
56
|
+
return 'Scheduled run received.'
|
|
57
|
+
default:
|
|
58
|
+
return `Received ${trigger.event}.`
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLogLevel(status: AutomationStep['status'], message: string) {
|
|
63
|
+
if (status === 'failed') {
|
|
64
|
+
return 'error'
|
|
65
|
+
}
|
|
66
|
+
return message.includes(' failed:') ? 'warning' : 'info'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const localExecutions = new Map<string, ReturnType<typeof runAutomation>>()
|
|
70
|
+
export async function executeAutomation(
|
|
71
|
+
job: AutomationJob,
|
|
72
|
+
context: Context,
|
|
73
|
+
step: DurableStep = async (_name, callback) => callback(),
|
|
74
|
+
waitForApproval?: ApprovalWait,
|
|
75
|
+
) {
|
|
76
|
+
// Workflows serialize a deployed run. Node approval retries need the same protection.
|
|
77
|
+
if (context.env) {
|
|
78
|
+
return runAutomation(job, context, step, waitForApproval)
|
|
79
|
+
}
|
|
80
|
+
const key = `${context.database.id}:${job.ownerId}:${job.runId}`
|
|
81
|
+
const active = localExecutions.get(key)
|
|
82
|
+
if (active) {
|
|
83
|
+
return active
|
|
84
|
+
}
|
|
85
|
+
const task = runAutomation(job, context, step, waitForApproval)
|
|
86
|
+
localExecutions.set(key, task)
|
|
87
|
+
try {
|
|
88
|
+
return await task
|
|
89
|
+
} finally {
|
|
90
|
+
localExecutions.delete(key)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A run must never stay queued, running or waiting after its execution has stopped. */
|
|
95
|
+
export async function closeUnfinishedRun(job: AutomationJob, context: Context, cause: unknown) {
|
|
96
|
+
const run = await context.api.automationRun.get(job.runId)
|
|
97
|
+
if (['succeeded', 'failed'].includes(run.status)) {
|
|
98
|
+
return run.status
|
|
99
|
+
}
|
|
100
|
+
await context.api.automationRun.update(run.id, {
|
|
101
|
+
ownerId: job.ownerId,
|
|
102
|
+
status: 'failed',
|
|
103
|
+
error: `Execution stopped unexpectedly: ${errorMessage(cause)}`,
|
|
104
|
+
finishedAt: new Date().toISOString(),
|
|
105
|
+
})
|
|
106
|
+
return 'failed'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The integration view of a run: its owner, workspace and run-as choice. */
|
|
110
|
+
function integrationContext(
|
|
111
|
+
job: AutomationJob,
|
|
112
|
+
context: Context,
|
|
113
|
+
runAs?: 'owner' | 'workspace',
|
|
114
|
+
): IntegrationServiceContext {
|
|
115
|
+
return {
|
|
116
|
+
ownerId: job.ownerId,
|
|
117
|
+
workspace: job.workspace,
|
|
118
|
+
env: context.env,
|
|
119
|
+
database: context.database,
|
|
120
|
+
request: context.request,
|
|
121
|
+
...(runAs ? { runAs } : {}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runAutomation(
|
|
126
|
+
job: AutomationJob,
|
|
127
|
+
context: Context,
|
|
128
|
+
step: DurableStep = async (_name, callback) => callback(),
|
|
129
|
+
waitForApproval?: ApprovalWait,
|
|
130
|
+
) {
|
|
131
|
+
const run = await step('load run', () => context.api.automationRun.get(job.runId))
|
|
132
|
+
if (['succeeded', 'failed'].includes(run.status)) {
|
|
133
|
+
return { status: run.status, runId: run.id }
|
|
134
|
+
}
|
|
135
|
+
const snapshot = run.snapshot as RunSnapshot
|
|
136
|
+
const automation = snapshot.automation
|
|
137
|
+
const integrations = integrationContext(job, context, automation.runAs)
|
|
138
|
+
// A run acts as its owner; once they leave the workspace it must not act at all.
|
|
139
|
+
const ownerPresent = await step('check owner membership', async () => {
|
|
140
|
+
try {
|
|
141
|
+
await actorOf(integrations)
|
|
142
|
+
return true
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
if (cause instanceof OwnerLeftError) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
throw cause
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
if (!ownerPresent) {
|
|
151
|
+
await step('pause automation of a departed owner', async () => {
|
|
152
|
+
await context.api.automation
|
|
153
|
+
.update(run.automationId, { active: false })
|
|
154
|
+
.catch(() => undefined)
|
|
155
|
+
await context.api.automationRun.update(run.id, {
|
|
156
|
+
ownerId: job.ownerId,
|
|
157
|
+
status: 'failed',
|
|
158
|
+
error:
|
|
159
|
+
'The owner left this workspace, so the automation was paused. A member can take it over by recreating it.',
|
|
160
|
+
finishedAt: new Date().toISOString(),
|
|
161
|
+
})
|
|
162
|
+
return true
|
|
163
|
+
})
|
|
164
|
+
return { status: 'failed', runId: run.id }
|
|
165
|
+
}
|
|
166
|
+
let output = ''
|
|
167
|
+
let failure = ''
|
|
168
|
+
let attempts = 0
|
|
169
|
+
let modulesAvailable = false
|
|
170
|
+
const source = describeTrigger(automation.trigger)
|
|
171
|
+
const agentName = snapshot.agent.markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() || 'Agent'
|
|
172
|
+
function stepSummary(node: AutomationNode) {
|
|
173
|
+
if (node === 'trigger') {
|
|
174
|
+
return source
|
|
175
|
+
}
|
|
176
|
+
if (node === 'agent') {
|
|
177
|
+
return agentName
|
|
178
|
+
}
|
|
179
|
+
const action = node === 'success' ? automation.success : automation.failure
|
|
180
|
+
const label =
|
|
181
|
+
(node === 'success' ? SUCCESS_ACTIONS : ERROR_ACTIONS).find(
|
|
182
|
+
(entry) => entry.value === action.action,
|
|
183
|
+
)?.label ?? action.action
|
|
184
|
+
return `${label}${action.target ? ` → ${action.target}` : ''}`
|
|
185
|
+
}
|
|
186
|
+
function stepDetails(node: AutomationNode): AutomationStep['details'] {
|
|
187
|
+
if (node === 'trigger') {
|
|
188
|
+
return { 'Event ID': run.eventId }
|
|
189
|
+
}
|
|
190
|
+
if (node !== 'agent') {
|
|
191
|
+
return { Action: stepSummary(node) }
|
|
192
|
+
}
|
|
193
|
+
const { agent } = snapshot
|
|
194
|
+
const enabledTools = agent.tools.filter((tool) => tool.enabled).map((tool) => tool.name)
|
|
195
|
+
return {
|
|
196
|
+
Provider: agent.provider,
|
|
197
|
+
Model: agent.model,
|
|
198
|
+
// Deployed Workers can only call provider APIs; local CLI runtimes exist in dev only.
|
|
199
|
+
Runtime: import.meta.dev ? (agent.runtime ?? 'api') : 'api',
|
|
200
|
+
...(agent.localModel ? { 'Local model': agent.localModel } : {}),
|
|
201
|
+
Timeout: `${automation.timeout}s per model or tool call`,
|
|
202
|
+
Attempts: String(attempts),
|
|
203
|
+
Instructions: automation.instructions,
|
|
204
|
+
...(enabledTools.length ? { Tools: enabledTools.join(', ') } : {}),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function mark(node: AutomationNode, status: AutomationStep['status'], message: string) {
|
|
208
|
+
const current = await context.api.automationRun.get(run.id)
|
|
209
|
+
const steps = current.steps as AutomationStep[]
|
|
210
|
+
const existing = steps.findIndex((item) => item.node === node)
|
|
211
|
+
const at = new Date().toISOString()
|
|
212
|
+
const previous = existing === -1 ? undefined : steps[existing]
|
|
213
|
+
const entry: AutomationStep = {
|
|
214
|
+
node,
|
|
215
|
+
status,
|
|
216
|
+
message,
|
|
217
|
+
at,
|
|
218
|
+
summary: stepSummary(node),
|
|
219
|
+
startedAt: previous?.startedAt ?? at,
|
|
220
|
+
...(status === 'running' ? {} : { finishedAt: at }),
|
|
221
|
+
...(node === 'agent' ? { attempt: attempts, retryLimit: automation.retries } : {}),
|
|
222
|
+
details: stepDetails(node),
|
|
223
|
+
logs: [
|
|
224
|
+
...(previous?.logs ?? []),
|
|
225
|
+
{
|
|
226
|
+
at,
|
|
227
|
+
status,
|
|
228
|
+
message,
|
|
229
|
+
level: getLogLevel(status, message),
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
if (existing === -1) {
|
|
234
|
+
steps.push(entry)
|
|
235
|
+
} else {
|
|
236
|
+
steps[existing] = entry
|
|
237
|
+
}
|
|
238
|
+
await context.api.automationRun.update(run.id, { ownerId: job.ownerId, steps: [...steps] })
|
|
239
|
+
return entry
|
|
240
|
+
}
|
|
241
|
+
await step('start run', async () => {
|
|
242
|
+
await context.api.automationRun.update(run.id, {
|
|
243
|
+
ownerId: job.ownerId,
|
|
244
|
+
status: 'running',
|
|
245
|
+
startedAt: new Date().toISOString(),
|
|
246
|
+
})
|
|
247
|
+
return mark('trigger', 'succeeded', describeTriggerReceipt(automation.trigger))
|
|
248
|
+
})
|
|
249
|
+
// D1 also records dispatch before external I/O. Workflow completion alone
|
|
250
|
+
// cannot cover a crash between an external write and committing its result.
|
|
251
|
+
// Each receipt is its own row, so long runs never outgrow D1's row limit.
|
|
252
|
+
type Receipt = { status: 'started' | 'completed'; value?: unknown }
|
|
253
|
+
async function journal<T>(
|
|
254
|
+
name: string,
|
|
255
|
+
callback: () => Promise<T>,
|
|
256
|
+
external = false,
|
|
257
|
+
): Promise<T> {
|
|
258
|
+
return step(name, async () => {
|
|
259
|
+
const key = `${run.id}:${name}`
|
|
260
|
+
const current = await context.api.automationRun.get(run.id)
|
|
261
|
+
const stored = await context.api.automationRunReceipt.list({
|
|
262
|
+
pageSize: 1,
|
|
263
|
+
filters: {
|
|
264
|
+
type: 'group',
|
|
265
|
+
logic: 'and',
|
|
266
|
+
children: [{ type: 'condition', field: 'key', operator: 'eq', value: key }],
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
// Runs started before receipts moved out of the run row keep their journal.
|
|
270
|
+
const legacy = (current.journal as Record<string, Receipt> | null)?.[name]
|
|
271
|
+
const receipt = (stored.data[0] as Receipt | undefined) ?? legacy
|
|
272
|
+
if (receipt?.status === 'completed') {
|
|
273
|
+
return receipt.value as T
|
|
274
|
+
}
|
|
275
|
+
if (receipt) {
|
|
276
|
+
throw new InterruptedTool(
|
|
277
|
+
'A tool call was interrupted after dispatch. Check its external result before retrying in a new run.',
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
let started: number | undefined
|
|
281
|
+
if (external) {
|
|
282
|
+
started = (
|
|
283
|
+
await context.api.automationRunReceipt.create({
|
|
284
|
+
runId: run.id,
|
|
285
|
+
key,
|
|
286
|
+
status: 'started',
|
|
287
|
+
ownerId: job.ownerId,
|
|
288
|
+
})
|
|
289
|
+
).id
|
|
290
|
+
if (!(current.snapshot as RunSnapshot).integrationCallStarted) {
|
|
291
|
+
await context.api.automationRun.update(run.id, {
|
|
292
|
+
ownerId: job.ownerId,
|
|
293
|
+
snapshot: { ...(current.snapshot as RunSnapshot), integrationCallStarted: true },
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const value = (await callback()) ?? null
|
|
298
|
+
const completed = { status: 'completed' as const, value, ownerId: job.ownerId }
|
|
299
|
+
if (started) {
|
|
300
|
+
await context.api.automationRunReceipt.update(started, completed)
|
|
301
|
+
} else {
|
|
302
|
+
await context.api.automationRunReceipt.create({ runId: run.id, key, ...completed })
|
|
303
|
+
}
|
|
304
|
+
return value as T
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
async function approvedResult(result: unknown): Promise<unknown> {
|
|
308
|
+
if (
|
|
309
|
+
!result ||
|
|
310
|
+
typeof result !== 'object' ||
|
|
311
|
+
!('approvalRequired' in result) ||
|
|
312
|
+
!('approvalId' in result)
|
|
313
|
+
) {
|
|
314
|
+
return result
|
|
315
|
+
}
|
|
316
|
+
const id = Number(result.approvalId)
|
|
317
|
+
await context.api.automationRun.update(run.id, { ownerId: job.ownerId, status: 'waiting' })
|
|
318
|
+
for (let wake = 0; wake < 10; wake++) {
|
|
319
|
+
const approval = await context.api.integrationApproval.get(id)
|
|
320
|
+
if (approval.status === 'completed') {
|
|
321
|
+
await context.api.automationRun.update(run.id, { ownerId: job.ownerId, status: 'running' })
|
|
322
|
+
return limitToolResult(approval.result)
|
|
323
|
+
}
|
|
324
|
+
if (approval.status === 'rejected' || approval.status === 'failed') {
|
|
325
|
+
throw new Error(`The integration action was ${approval.status}.`)
|
|
326
|
+
}
|
|
327
|
+
if (!waitForApproval) {
|
|
328
|
+
throw new PendingAutomationApproval('Approve this action in Integrations.')
|
|
329
|
+
}
|
|
330
|
+
await waitForApproval(id)
|
|
331
|
+
}
|
|
332
|
+
throw new Error('The integration approval did not complete.')
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
modulesAvailable = await step('check modules', async () => {
|
|
336
|
+
if (!snapshot.managed) {
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
const installations = await context.api.workspaceInstallation.list({
|
|
340
|
+
pageSize: 1,
|
|
341
|
+
filters: {
|
|
342
|
+
type: 'group',
|
|
343
|
+
logic: 'and',
|
|
344
|
+
children: [
|
|
345
|
+
{ type: 'condition', field: 'workspace', operator: 'eq', value: job.workspace },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
const installed = installations.data[0]?.installed as string[] | undefined
|
|
350
|
+
if (!isWorkspaceModuleInstalled('agents', installed)) {
|
|
351
|
+
throw new Error('Install Agents from Modules before running this automation.')
|
|
352
|
+
}
|
|
353
|
+
return true
|
|
354
|
+
})
|
|
355
|
+
if (job.forceError) {
|
|
356
|
+
throw new Error('Error path test requested.')
|
|
357
|
+
}
|
|
358
|
+
const tools = await step('load agent tools', async () => {
|
|
359
|
+
const wanted = snapshot.agent.tools.some((tool) => tool.enabled)
|
|
360
|
+
const available = wanted ? await listGrantedTools(integrations) : []
|
|
361
|
+
return snapshot.agent.tools
|
|
362
|
+
.filter((tool) => tool.enabled && snapshot.agent.toolChoice !== 'none')
|
|
363
|
+
.map((tool) => {
|
|
364
|
+
const action = available.find((entry) => entry.name === tool.name)
|
|
365
|
+
if (!action) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`Tool ${tool.name} has no connected handler or permission. Add an available integration tool, or disable this tool.`,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
name: action.name,
|
|
372
|
+
description: tool.description || action.description,
|
|
373
|
+
parameters: action.parameters,
|
|
374
|
+
provider: action.provider,
|
|
375
|
+
action: action.action,
|
|
376
|
+
bindingId: action.bindingId,
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
for (let attempt = 0; attempt <= automation.retries; attempt++) {
|
|
381
|
+
attempts = attempt + 1
|
|
382
|
+
try {
|
|
383
|
+
await step(`start agent attempt ${attempts}`, async () => {
|
|
384
|
+
await context.api.automationRun.update(run.id, { ownerId: job.ownerId, attempts })
|
|
385
|
+
return mark('agent', 'running', `Attempt ${attempts}`)
|
|
386
|
+
})
|
|
387
|
+
output = await runSavedAgent({
|
|
388
|
+
agent: snapshot.agent,
|
|
389
|
+
instructions: automation.instructions,
|
|
390
|
+
payload: run.payload,
|
|
391
|
+
keys: context.keys,
|
|
392
|
+
tools,
|
|
393
|
+
// A fresh deadline per call; Workflow approval waits must not consume it.
|
|
394
|
+
timeoutMs: automation.timeout * 1000,
|
|
395
|
+
runId: `automation-${run.id}`,
|
|
396
|
+
checkpoint: (name, callback) => journal(`attempt ${attempts} ${name}`, callback),
|
|
397
|
+
callTool: async (name, input, call) => {
|
|
398
|
+
const tool = tools.find((entry) => entry.name === name)
|
|
399
|
+
if (!tool) {
|
|
400
|
+
throw new Error('The agent requested a tool it has not been granted.')
|
|
401
|
+
}
|
|
402
|
+
const result = await journal(
|
|
403
|
+
`attempt ${attempts} tool ${call.callId}`,
|
|
404
|
+
async () =>
|
|
405
|
+
limitToolResult(
|
|
406
|
+
await performIntegrationAction(
|
|
407
|
+
integrations,
|
|
408
|
+
{
|
|
409
|
+
provider: tool.provider,
|
|
410
|
+
action: tool.action,
|
|
411
|
+
input,
|
|
412
|
+
bindingId: tool.bindingId,
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
signal: AbortSignal.timeout(automation.timeout * 1000),
|
|
416
|
+
idempotencyKey: call.idempotencyKey,
|
|
417
|
+
continuation: {
|
|
418
|
+
workflowId: `automation-${run.id}`,
|
|
419
|
+
automationRunId: run.id,
|
|
420
|
+
workspace: job.workspace,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
true,
|
|
426
|
+
)
|
|
427
|
+
return approvedResult(result)
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
break
|
|
431
|
+
} catch (cause) {
|
|
432
|
+
if (cause instanceof PendingAutomationApproval || cause instanceof InterruptedTool) {
|
|
433
|
+
throw cause
|
|
434
|
+
}
|
|
435
|
+
await step(`record agent attempt ${attempts} failure`, () =>
|
|
436
|
+
mark('agent', 'running', `Attempt ${attempts} failed: ${errorMessage(cause)}`),
|
|
437
|
+
)
|
|
438
|
+
const integrationCallStarted = await step(`retry policy ${attempts}`, async () => {
|
|
439
|
+
const current = await context.api.automationRun.get(run.id)
|
|
440
|
+
return !!(current.snapshot as RunSnapshot).integrationCallStarted
|
|
441
|
+
})
|
|
442
|
+
if (attempt === automation.retries || integrationCallStarted) {
|
|
443
|
+
throw cause
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
await step('agent result', async () => {
|
|
448
|
+
await context.api.automationRun.update(run.id, { ownerId: job.ownerId, output })
|
|
449
|
+
return mark(
|
|
450
|
+
'agent',
|
|
451
|
+
'succeeded',
|
|
452
|
+
`Completed in ${attempts} ${attempts === 1 ? 'attempt' : 'attempts'}.`,
|
|
453
|
+
)
|
|
454
|
+
})
|
|
455
|
+
await step('start success action', () =>
|
|
456
|
+
mark('success', 'running', 'Applying the success action.'),
|
|
457
|
+
)
|
|
458
|
+
const successResult = await journal(
|
|
459
|
+
'success action dispatch',
|
|
460
|
+
() =>
|
|
461
|
+
outcome(context, automation.success, {
|
|
462
|
+
result: { payload: run.payload, output, error: '', runId: run.id, name: automation.name },
|
|
463
|
+
job,
|
|
464
|
+
runAs: automation.runAs,
|
|
465
|
+
}),
|
|
466
|
+
true,
|
|
467
|
+
)
|
|
468
|
+
await approvedResult(successResult)
|
|
469
|
+
await step('finish success action', () =>
|
|
470
|
+
mark(
|
|
471
|
+
'success',
|
|
472
|
+
'succeeded',
|
|
473
|
+
automation.success.action === 'none' ? 'Result saved.' : 'Action completed.',
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
await step('skip error action', () => mark('error', 'skipped', 'No failure.'))
|
|
477
|
+
} catch (cause) {
|
|
478
|
+
if (cause instanceof PendingAutomationApproval) {
|
|
479
|
+
return { status: 'waiting', runId: run.id }
|
|
480
|
+
}
|
|
481
|
+
failure = errorMessage(cause)
|
|
482
|
+
await step('record failure', async () => {
|
|
483
|
+
if (!output) {
|
|
484
|
+
await mark('agent', 'failed', failure)
|
|
485
|
+
await mark('success', 'skipped', 'Agent failed.')
|
|
486
|
+
} else {
|
|
487
|
+
await mark('success', 'failed', failure)
|
|
488
|
+
}
|
|
489
|
+
return failure
|
|
490
|
+
})
|
|
491
|
+
try {
|
|
492
|
+
if (!modulesAvailable) {
|
|
493
|
+
await step('skip failure action', () =>
|
|
494
|
+
mark('error', 'skipped', 'A required module was removed.'),
|
|
495
|
+
)
|
|
496
|
+
} else {
|
|
497
|
+
await step('start failure action', () =>
|
|
498
|
+
mark('error', 'running', 'Applying the failure action.'),
|
|
499
|
+
)
|
|
500
|
+
const failureResult = await journal(
|
|
501
|
+
'failure action dispatch',
|
|
502
|
+
() =>
|
|
503
|
+
outcome(context, automation.failure, {
|
|
504
|
+
result: {
|
|
505
|
+
payload: run.payload,
|
|
506
|
+
output,
|
|
507
|
+
error: failure,
|
|
508
|
+
runId: run.id,
|
|
509
|
+
name: automation.name,
|
|
510
|
+
},
|
|
511
|
+
job,
|
|
512
|
+
runAs: automation.runAs,
|
|
513
|
+
}),
|
|
514
|
+
true,
|
|
515
|
+
)
|
|
516
|
+
await approvedResult(failureResult)
|
|
517
|
+
await step('finish failure action', () =>
|
|
518
|
+
mark(
|
|
519
|
+
'error',
|
|
520
|
+
'succeeded',
|
|
521
|
+
automation.failure.action === 'none' ? 'Failure recorded.' : 'Notification sent.',
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
} catch (notificationError) {
|
|
526
|
+
if (notificationError instanceof PendingAutomationApproval) {
|
|
527
|
+
return { status: 'waiting', runId: run.id }
|
|
528
|
+
}
|
|
529
|
+
failure += ` Notification failed: ${errorMessage(notificationError)}`
|
|
530
|
+
await step('record notification failure', () =>
|
|
531
|
+
mark('error', 'failed', errorMessage(notificationError)),
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const status = failure ? 'failed' : 'succeeded'
|
|
536
|
+
await step('finish run', () =>
|
|
537
|
+
context.api.automationRun.update(run.id, {
|
|
538
|
+
ownerId: job.ownerId,
|
|
539
|
+
status,
|
|
540
|
+
output,
|
|
541
|
+
error: failure,
|
|
542
|
+
attempts,
|
|
543
|
+
finishedAt: new Date().toISOString(),
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
546
|
+
return { status, runId: run.id }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function outcome(
|
|
550
|
+
context: Context,
|
|
551
|
+
action: AutomationOutcome,
|
|
552
|
+
{
|
|
553
|
+
result,
|
|
554
|
+
job,
|
|
555
|
+
runAs,
|
|
556
|
+
}: {
|
|
557
|
+
result: { payload: unknown; output: string; error: string; runId: number; name: string }
|
|
558
|
+
job: AutomationJob
|
|
559
|
+
runAs?: 'owner' | 'workspace'
|
|
560
|
+
},
|
|
561
|
+
) {
|
|
562
|
+
const { action: actionKind, target: targetTemplate } = action
|
|
563
|
+
const { payload, output, error, runId, name } = result
|
|
564
|
+
if (actionKind === 'none') {
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
const text = error ? `Automation ${name} failed.\n\n${error}\n\nRun: ${runId}` : output
|
|
568
|
+
const target = targetTemplate.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, path: string) =>
|
|
569
|
+
String(atPath(payload, path) ?? ''),
|
|
570
|
+
)
|
|
571
|
+
if (actionKind === 'email' || actionKind === 'notify') {
|
|
572
|
+
const recipient = /^(user|payload)\./.test(target)
|
|
573
|
+
? String(atPath(payload, target.replace(/^payload\./, '')) ?? '')
|
|
574
|
+
: target
|
|
575
|
+
if (!recipient || !recipient.includes('@')) {
|
|
576
|
+
throw new Error('Set a recipient email address, or a payload path such as user.email.')
|
|
577
|
+
}
|
|
578
|
+
return performIntegrationAction(
|
|
579
|
+
integrationContext(job, context, runAs),
|
|
580
|
+
{
|
|
581
|
+
provider: 'gmail',
|
|
582
|
+
action: 'send_email',
|
|
583
|
+
input: {
|
|
584
|
+
body: {
|
|
585
|
+
to: recipient,
|
|
586
|
+
subject: error ? `${name} failed` : name,
|
|
587
|
+
text,
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
idempotencyKey: `automation-${runId}-${error ? 'failure' : 'success'}`,
|
|
593
|
+
continuation: {
|
|
594
|
+
workflowId: `automation-${runId}`,
|
|
595
|
+
automationRunId: runId,
|
|
596
|
+
workspace: job.workspace,
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
} else if (actionKind === 'slack') {
|
|
601
|
+
return performIntegrationAction(
|
|
602
|
+
integrationContext(job, context, runAs),
|
|
603
|
+
{
|
|
604
|
+
provider: 'slack',
|
|
605
|
+
action: 'post_messages',
|
|
606
|
+
input: {
|
|
607
|
+
body: { channel: target, text },
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
idempotencyKey: `automation-${runId}-${error ? 'failure' : 'success'}`,
|
|
612
|
+
continuation: {
|
|
613
|
+
workflowId: `automation-${runId}`,
|
|
614
|
+
automationRunId: runId,
|
|
615
|
+
workspace: job.workspace,
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
} else if (actionKind === 'webhook') {
|
|
620
|
+
const url = new URL(target)
|
|
621
|
+
const allowed = String(
|
|
622
|
+
context.env?.NUXT_INTEGRATIONS_WEBHOOK_ORIGINS ??
|
|
623
|
+
process.env.NUXT_INTEGRATIONS_WEBHOOK_ORIGINS ??
|
|
624
|
+
'',
|
|
625
|
+
)
|
|
626
|
+
.split(',')
|
|
627
|
+
.map((entry) => entry.trim())
|
|
628
|
+
.filter(Boolean)
|
|
629
|
+
if (
|
|
630
|
+
url.protocol !== 'https:' ||
|
|
631
|
+
url.username ||
|
|
632
|
+
url.password ||
|
|
633
|
+
!allowed.includes(url.origin)
|
|
634
|
+
) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
'Add this HTTPS origin to NUXT_INTEGRATIONS_WEBHOOK_ORIGINS before calling it.',
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
const response = await fetch(url, {
|
|
640
|
+
method: 'POST',
|
|
641
|
+
headers: {
|
|
642
|
+
'Content-Type': 'application/json',
|
|
643
|
+
'Idempotency-Key': `automation-${runId}`,
|
|
644
|
+
},
|
|
645
|
+
body: JSON.stringify(result),
|
|
646
|
+
signal: AbortSignal.timeout(15000),
|
|
647
|
+
redirect: 'manual',
|
|
648
|
+
})
|
|
649
|
+
await response.body?.cancel()
|
|
650
|
+
if (!response.ok) {
|
|
651
|
+
throw new Error(`The result webhook returned ${response.status}.`)
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
const recordPayload = payload as { record?: { model?: string; id?: number } }
|
|
655
|
+
const record = recordPayload?.record
|
|
656
|
+
const model = record?.model ?? ''
|
|
657
|
+
const spec = backendConfig.models[model]
|
|
658
|
+
if (
|
|
659
|
+
!record?.id ||
|
|
660
|
+
!spec ||
|
|
661
|
+
!spec.fields[target] ||
|
|
662
|
+
getFieldSpec(spec.fields[target]!).serverOnly ||
|
|
663
|
+
target === 'id' ||
|
|
664
|
+
target.endsWith('Id') ||
|
|
665
|
+
[
|
|
666
|
+
'automation',
|
|
667
|
+
'automationRun',
|
|
668
|
+
'integrationConnection',
|
|
669
|
+
'integrationApproval',
|
|
670
|
+
'workspaceInstallation',
|
|
671
|
+
'agent',
|
|
672
|
+
].includes(model)
|
|
673
|
+
) {
|
|
674
|
+
throw new Error(
|
|
675
|
+
'Use an editable field on the triggering record. The payload must include record: { model, id }.',
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
// The canonical server client still applies the model's ownership, validation and hooks.
|
|
679
|
+
const api = createServerClient(
|
|
680
|
+
backendConfig,
|
|
681
|
+
{ env: context.env, request: context.request },
|
|
682
|
+
{ db: context.database, getUser: () => ({ id: job.ownerId }) },
|
|
683
|
+
)
|
|
684
|
+
await api[model]!.update(record.id, { [target]: output })
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function atPath(value: unknown, path: string): unknown {
|
|
688
|
+
return path
|
|
689
|
+
.split('.')
|
|
690
|
+
.reduce<unknown>(
|
|
691
|
+
(entry, key) =>
|
|
692
|
+
entry && typeof entry === 'object' && Object.hasOwn(entry, key)
|
|
693
|
+
? (entry as Record<string, unknown>)[key]
|
|
694
|
+
: undefined,
|
|
695
|
+
value,
|
|
696
|
+
)
|
|
697
|
+
}
|