@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,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
+ }