@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,39 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers'
|
|
3
|
+
import { createJobContext, type AutomationJob } from '../utils/automation-context'
|
|
4
|
+
import { closeUnfinishedRun, executeAutomation, type DurableStep } from '../utils/automation-engine'
|
|
5
|
+
import { retryTransientFailures } from '../utils/transient-retry'
|
|
6
|
+
|
|
7
|
+
// Application failures are recorded per agent attempt by the engine, so the
|
|
8
|
+
// platform never replays a step; brief D1 and network interruptions are retried
|
|
9
|
+
// inside it instead.
|
|
10
|
+
const STEP = { retries: { limit: 0, delay: '1 second' }, timeout: '10 minutes' } as const
|
|
11
|
+
|
|
12
|
+
export class AutomationRunner extends WorkflowEntrypoint<Cloudflare.Env, AutomationJob> {
|
|
13
|
+
async run(event: WorkflowEvent<AutomationJob>, step: WorkflowStep) {
|
|
14
|
+
const context = await retryTransientFailures(() =>
|
|
15
|
+
createJobContext(event.payload, this.env as Record<string, unknown>),
|
|
16
|
+
)
|
|
17
|
+
const durable: DurableStep = (name, callback) =>
|
|
18
|
+
step.do(name, STEP, async () =>
|
|
19
|
+
JSON.parse(JSON.stringify((await retryTransientFailures(callback)) ?? null)),
|
|
20
|
+
)
|
|
21
|
+
let wake = 0
|
|
22
|
+
try {
|
|
23
|
+
return await executeAutomation(event.payload, context, durable, async (id) => {
|
|
24
|
+
await step.waitForEvent(`approval ${id} wake ${wake++}`, {
|
|
25
|
+
type: `integration-approval-${id}`,
|
|
26
|
+
timeout: '30 days',
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
// Close the run's record before the instance errors, so history never shows it running.
|
|
31
|
+
await durable('close unfinished run', () =>
|
|
32
|
+
closeUnfinishedRun(event.payload, context, cause),
|
|
33
|
+
).catch((closeError: unknown) => {
|
|
34
|
+
console.error('[automations] Could not close an interrupted run.', closeError)
|
|
35
|
+
})
|
|
36
|
+
throw cause
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { WorkspaceMetadata } from '@nuxtagentic/prompt/server-contributions'
|
|
2
|
+
export default {
|
|
3
|
+
excludedModels: ['automationRun', 'automationFolder'],
|
|
4
|
+
resourceAliases: { automation: ['automation', 'automations', 'workflow', 'workflows'] },
|
|
5
|
+
} satisfies WorkspaceMetadata
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AutomationStep } from './automation'
|
|
2
|
+
|
|
3
|
+
export type RunStatus = 'queued' | 'running' | 'waiting' | 'succeeded' | 'failed'
|
|
4
|
+
export type DisplayStatus = RunStatus | 'skipped' | 'retry' | undefined
|
|
5
|
+
|
|
6
|
+
const statusColors: Partial<Record<NonNullable<DisplayStatus>, string>> = {
|
|
7
|
+
succeeded: 'text-success',
|
|
8
|
+
failed: 'text-error',
|
|
9
|
+
retry: 'text-warning',
|
|
10
|
+
running: 'text-highlighted',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getStatusColor(status: DisplayStatus) {
|
|
14
|
+
return (status && statusColors[status]) || 'text-dimmed'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getStepStatus(step: AutomationStep | undefined): DisplayStatus {
|
|
18
|
+
return step?.status === 'running' && (step.attempt ?? 0) > 1 ? 'retry' : step?.status
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatElapsedDuration(
|
|
22
|
+
start: string | null | undefined,
|
|
23
|
+
end: string | null | undefined,
|
|
24
|
+
) {
|
|
25
|
+
if (!start || !end) {
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
const seconds = (new Date(end).getTime() - new Date(start).getTime()) / 1000
|
|
29
|
+
return Number.isFinite(seconds) && seconds >= 0 ? `${seconds.toFixed(1)}s` : ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatStepDuration(step: AutomationStep | undefined) {
|
|
33
|
+
if (!step) {
|
|
34
|
+
return ''
|
|
35
|
+
}
|
|
36
|
+
const { status, startedAt, finishedAt, logs, at } = step
|
|
37
|
+
if (status === 'skipped') {
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
if (status === 'running') {
|
|
41
|
+
return '…'
|
|
42
|
+
}
|
|
43
|
+
if (!startedAt && (logs?.length ?? 0) < 2) {
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
return formatElapsedDuration(
|
|
47
|
+
startedAt ?? logs?.[0]?.at ?? at,
|
|
48
|
+
finishedAt ?? logs?.at(-1)?.at ?? at,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getStepLogs(step: AutomationStep | undefined) {
|
|
53
|
+
if (!step) {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
const { status, logs, at, message } = step
|
|
57
|
+
if (status === 'skipped') {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
return logs?.length ? logs : [{ at, status, message }]
|
|
61
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export type AutomationNode = 'trigger' | 'agent' | 'success' | 'error'
|
|
2
|
+
export type AutomationAction = 'none' | 'email' | 'slack' | 'record' | 'webhook' | 'notify'
|
|
3
|
+
export interface AutomationTrigger {
|
|
4
|
+
kind: 'event' | 'webhook' | 'schedule'
|
|
5
|
+
event: string
|
|
6
|
+
slug: string
|
|
7
|
+
cron?: string
|
|
8
|
+
timeZone?: string
|
|
9
|
+
}
|
|
10
|
+
export interface AutomationOutcome {
|
|
11
|
+
action: AutomationAction
|
|
12
|
+
target: string
|
|
13
|
+
}
|
|
14
|
+
export type AutomationPositions = Record<AutomationNode, { x: number; y: number }>
|
|
15
|
+
export interface AutomationDraft {
|
|
16
|
+
name: string
|
|
17
|
+
folder: string
|
|
18
|
+
active: boolean
|
|
19
|
+
workspace: string
|
|
20
|
+
trigger: AutomationTrigger
|
|
21
|
+
agentId: number
|
|
22
|
+
instructions: string
|
|
23
|
+
timeout: number
|
|
24
|
+
retries: number
|
|
25
|
+
success: AutomationOutcome
|
|
26
|
+
failure: AutomationOutcome
|
|
27
|
+
positions: AutomationPositions
|
|
28
|
+
/**
|
|
29
|
+
* Whose connections runs may use: the owner's own plus the workspace's, or
|
|
30
|
+
* only the workspace's shared ones so the automation never acts as a person.
|
|
31
|
+
*/
|
|
32
|
+
runAs?: 'owner' | 'workspace'
|
|
33
|
+
}
|
|
34
|
+
export interface AutomationStep {
|
|
35
|
+
node: AutomationNode
|
|
36
|
+
status: 'running' | 'succeeded' | 'failed' | 'skipped'
|
|
37
|
+
at: string
|
|
38
|
+
message: string
|
|
39
|
+
summary?: string
|
|
40
|
+
startedAt?: string
|
|
41
|
+
finishedAt?: string
|
|
42
|
+
attempt?: number
|
|
43
|
+
retryLimit?: number
|
|
44
|
+
details?: Record<string, string>
|
|
45
|
+
logs?: {
|
|
46
|
+
at: string
|
|
47
|
+
status: AutomationStep['status']
|
|
48
|
+
message: string
|
|
49
|
+
level?: 'info' | 'warning' | 'error'
|
|
50
|
+
}[]
|
|
51
|
+
}
|
|
52
|
+
export const AUTOMATION_POSITIONS: AutomationPositions = {
|
|
53
|
+
trigger: { x: -140, y: 40 },
|
|
54
|
+
agent: { x: -140, y: 250 },
|
|
55
|
+
success: { x: -330, y: 480 },
|
|
56
|
+
error: { x: 50, y: 480 },
|
|
57
|
+
}
|
|
58
|
+
export const LOCAL_EVENTS = [
|
|
59
|
+
{
|
|
60
|
+
id: 'user.created',
|
|
61
|
+
label: 'A user is created',
|
|
62
|
+
detail: 'An administrator creates an account.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'user.deactivated',
|
|
66
|
+
label: 'A user is deactivated',
|
|
67
|
+
detail: 'An administrator disables an account.',
|
|
68
|
+
},
|
|
69
|
+
{ id: 'file.uploaded', label: 'A file is uploaded', detail: 'A local file finishes uploading.' },
|
|
70
|
+
{
|
|
71
|
+
id: 'task.moved',
|
|
72
|
+
label: 'A task changes status',
|
|
73
|
+
detail: 'A board card moves to another status.',
|
|
74
|
+
},
|
|
75
|
+
] as const
|
|
76
|
+
export const SUCCESS_ACTIONS = [
|
|
77
|
+
{
|
|
78
|
+
value: 'email',
|
|
79
|
+
label: 'Send email',
|
|
80
|
+
icon: 'i-lucide-mail',
|
|
81
|
+
detail: 'Send the result through your Gmail connection.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
value: 'slack',
|
|
85
|
+
label: 'Post to Slack',
|
|
86
|
+
icon: 'i-lucide-hash',
|
|
87
|
+
detail: 'Post the result to a connected channel.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
value: 'record',
|
|
91
|
+
label: 'Update record',
|
|
92
|
+
icon: 'i-lucide-database',
|
|
93
|
+
detail: 'Write the result to a field on the triggering record.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
value: 'webhook',
|
|
97
|
+
label: 'Call webhook',
|
|
98
|
+
icon: 'i-lucide-webhook',
|
|
99
|
+
detail: 'POST the result to an allowed HTTPS endpoint.',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
value: 'none',
|
|
103
|
+
label: 'Keep result',
|
|
104
|
+
icon: 'i-lucide-check',
|
|
105
|
+
detail: 'Save the result in run history.',
|
|
106
|
+
},
|
|
107
|
+
] as const
|
|
108
|
+
export const ERROR_ACTIONS = [
|
|
109
|
+
{
|
|
110
|
+
value: 'notify',
|
|
111
|
+
label: 'Notify owner',
|
|
112
|
+
icon: 'i-lucide-mail',
|
|
113
|
+
detail: 'Email a failure report through Gmail.',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
value: 'slack',
|
|
117
|
+
label: 'Post to Slack',
|
|
118
|
+
icon: 'i-lucide-hash',
|
|
119
|
+
detail: 'Post a failure alert to a connected channel.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
value: 'none',
|
|
123
|
+
label: 'Stop silently',
|
|
124
|
+
icon: 'i-lucide-circle-stop',
|
|
125
|
+
detail: 'Record the failure without sending a notification.',
|
|
126
|
+
},
|
|
127
|
+
] as const
|
|
128
|
+
export function createAutomationDraft(workspace: string, folder = 'Unsorted'): AutomationDraft {
|
|
129
|
+
return {
|
|
130
|
+
name: 'Untitled automation',
|
|
131
|
+
folder,
|
|
132
|
+
active: false,
|
|
133
|
+
workspace,
|
|
134
|
+
trigger: { kind: 'event', event: 'user.created', slug: 'new-signup' },
|
|
135
|
+
agentId: 0,
|
|
136
|
+
instructions: 'Use the event payload to complete your task. Return a concise result.',
|
|
137
|
+
timeout: 30,
|
|
138
|
+
retries: 2,
|
|
139
|
+
success: { action: 'none', target: '' },
|
|
140
|
+
failure: { action: 'none', target: '' },
|
|
141
|
+
positions: structuredClone(AUTOMATION_POSITIONS),
|
|
142
|
+
runAs: 'owner',
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function getTargetLabel(action: AutomationAction) {
|
|
146
|
+
return {
|
|
147
|
+
none: '',
|
|
148
|
+
email: 'Recipient',
|
|
149
|
+
notify: 'Owner email',
|
|
150
|
+
slack: 'Channel',
|
|
151
|
+
record: 'Field',
|
|
152
|
+
webhook: 'URL',
|
|
153
|
+
}[action]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The short label for what starts an automation, such as its webhook route or cron schedule. */
|
|
157
|
+
export function describeTrigger(trigger: AutomationTrigger) {
|
|
158
|
+
switch (trigger.kind) {
|
|
159
|
+
case 'webhook':
|
|
160
|
+
return `POST /hooks/${trigger.slug}`
|
|
161
|
+
case 'schedule':
|
|
162
|
+
return `${trigger.cron ?? ''} · ${trigger.timeZone ?? ''}`
|
|
163
|
+
default:
|
|
164
|
+
return trigger.event
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Labels shared by the workspace canvas and embedded examples. */
|
|
169
|
+
export function describeAutomationNodes(
|
|
170
|
+
draft: AutomationDraft,
|
|
171
|
+
context: { agentName?: string; eventLabel?: string },
|
|
172
|
+
): Record<AutomationNode, { main: string; sub: string }> {
|
|
173
|
+
let triggerSource = context.eventLabel ?? 'Connected application event'
|
|
174
|
+
if (draft.trigger.kind === 'webhook') {
|
|
175
|
+
triggerSource = 'Authenticated webhook'
|
|
176
|
+
} else if (draft.trigger.kind === 'schedule') {
|
|
177
|
+
triggerSource = 'Scheduled run'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const successLabel =
|
|
181
|
+
SUCCESS_ACTIONS.find((action) => action.value === draft.success.action)?.label ?? 'Keep result'
|
|
182
|
+
const failureLabel =
|
|
183
|
+
ERROR_ACTIONS.find((action) => action.value === draft.failure.action)?.label ?? 'Stop silently'
|
|
184
|
+
const errorSummary =
|
|
185
|
+
draft.retries > 0 ? `Retry ${draft.retries}×, then ${failureLabel.toLowerCase()}` : failureLabel
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
trigger: { main: describeTrigger(draft.trigger), sub: triggerSource },
|
|
189
|
+
agent: {
|
|
190
|
+
main: context.agentName ?? 'Choose an agent',
|
|
191
|
+
sub: context.agentName ? draft.instructions : 'Select a saved agent and instructions',
|
|
192
|
+
},
|
|
193
|
+
success: {
|
|
194
|
+
main: successLabel,
|
|
195
|
+
sub: draft.success.target || 'Save the agent result in run history',
|
|
196
|
+
},
|
|
197
|
+
error: {
|
|
198
|
+
main: errorSummary,
|
|
199
|
+
sub: draft.failure.target || 'Failure is logged in run history',
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { WorkspaceContribution } from '@nuxtagentic/workspace/runtime/types'
|
|
3
|
+
import type { AgentAction } from '@nuxtagentic/workspace/runtime/shared/agent-actions'
|
|
4
|
+
|
|
5
|
+
const commands = [
|
|
6
|
+
['save-automation', 'Save automation', 'Save this automation.', 'i-lucide-save'],
|
|
7
|
+
['test-automation', 'Test run', 'Run this automation with a sample event.', 'i-lucide-play'],
|
|
8
|
+
[
|
|
9
|
+
'webhook-automation',
|
|
10
|
+
'Trigger on a webhook',
|
|
11
|
+
'Use an authenticated webhook trigger.',
|
|
12
|
+
'i-lucide-webhook',
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
'retry-automation',
|
|
16
|
+
'Retry 3 times on error',
|
|
17
|
+
'Set agent retries to three.',
|
|
18
|
+
'i-lucide-rotate-cw',
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
'slack-automation',
|
|
22
|
+
'Post errors to Slack',
|
|
23
|
+
'Choose a Slack failure notification.',
|
|
24
|
+
'i-lucide-hash',
|
|
25
|
+
],
|
|
26
|
+
] as const
|
|
27
|
+
const actions: AgentAction[] = commands.map(([id, name, description, icon]) => ({
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
description,
|
|
31
|
+
icon,
|
|
32
|
+
available: (context) => context.canRun(id),
|
|
33
|
+
run: (context) => context.run(id),
|
|
34
|
+
}))
|
|
35
|
+
export const automationsContribution = {
|
|
36
|
+
id: 'automations',
|
|
37
|
+
label: 'Automations',
|
|
38
|
+
icon: 'i-lucide-workflow',
|
|
39
|
+
selectionKey: 'automation',
|
|
40
|
+
matchesRoute: (route) => route.path === '/dashboard/automations',
|
|
41
|
+
navigation: { order: 70, group: 'workspace' },
|
|
42
|
+
navigationShortcuts: [
|
|
43
|
+
{ label: 'Automations', icon: 'i-lucide-workflow', to: '/dashboard/automations' },
|
|
44
|
+
],
|
|
45
|
+
layout: {
|
|
46
|
+
navbar: true,
|
|
47
|
+
hideTitle: true,
|
|
48
|
+
navbarClass: 'gap-2.5 bg-(--gray-200) px-3 ps-2.5 sm:px-3 sm:ps-2.5',
|
|
49
|
+
edgeToEdge: true,
|
|
50
|
+
flat: true,
|
|
51
|
+
hideExpand: true,
|
|
52
|
+
ownsDetailAside: true,
|
|
53
|
+
},
|
|
54
|
+
usePromptPresentation: () => computed(() => ({ placeholder: 'Describe your automation…' })),
|
|
55
|
+
actions: { automations: actions },
|
|
56
|
+
} satisfies WorkspaceContribution
|
package/schema.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { model, t } from '@cloudconfig/core'
|
|
2
|
+
import { createAutomationDraft } from './runtime/shared/automation'
|
|
3
|
+
import { nextAgentSchedule } from '@cloudconfig/core/agents/schedules'
|
|
4
|
+
import { syncAutomationSchedule } from './runtime/server/utils/schedule-sync'
|
|
5
|
+
const defaults = createAutomationDraft('personal')
|
|
6
|
+
|
|
7
|
+
export const automationsModels = {
|
|
8
|
+
automation: model(
|
|
9
|
+
{
|
|
10
|
+
name: t.text().required(),
|
|
11
|
+
folder: t.text().default('Unsorted'),
|
|
12
|
+
active: t.boolean().default(false),
|
|
13
|
+
workspace: t.text().required(),
|
|
14
|
+
trigger: t.json().default(defaults.trigger),
|
|
15
|
+
agentId: t.number().default(0),
|
|
16
|
+
instructions: t.textarea().default(defaults.instructions),
|
|
17
|
+
timeout: t.number().default(30),
|
|
18
|
+
retries: t.number().default(2),
|
|
19
|
+
success: t.json().default(defaults.success),
|
|
20
|
+
failure: t.json().default(defaults.failure),
|
|
21
|
+
positions: t.json().default(defaults.positions),
|
|
22
|
+
runAs: t.select(['owner', 'workspace']).default('owner'),
|
|
23
|
+
webhookToken: t.text().required().serverOnly(),
|
|
24
|
+
ownerId: t.number().required().serverOnly(),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
access: {
|
|
28
|
+
list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
29
|
+
read: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
30
|
+
create: ({ user }) => !!user,
|
|
31
|
+
update: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
32
|
+
delete: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
33
|
+
},
|
|
34
|
+
hooks: {
|
|
35
|
+
beforeCreate: ({ user }, input) => ({
|
|
36
|
+
...input,
|
|
37
|
+
ownerId: user!.id,
|
|
38
|
+
webhookToken: crypto.randomUUID() + crypto.randomUUID(),
|
|
39
|
+
}),
|
|
40
|
+
beforeUpdate: ({ user }, input) => ({ ...input, ownerId: user!.id }),
|
|
41
|
+
afterChange: async ({ user, env }, change) => {
|
|
42
|
+
if (user) {
|
|
43
|
+
await syncAutomationSchedule(env, Number(user.id), Number(change.id), change.row)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
validate: (input, existing) => {
|
|
48
|
+
const issues: { path: string; message: string }[] = []
|
|
49
|
+
if (!String(input.name ?? '').trim() || String(input.name).length > 200) {
|
|
50
|
+
issues.push({
|
|
51
|
+
path: 'name',
|
|
52
|
+
message: 'Use an automation name between 1 and 200 characters.',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
if (!String(input.folder ?? '').trim() || String(input.folder).length > 80) {
|
|
56
|
+
issues.push({ path: 'folder', message: 'Use a folder name between 1 and 80 characters.' })
|
|
57
|
+
}
|
|
58
|
+
if (!input.workspace || (existing && input.workspace !== existing.workspace)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
path: 'workspace',
|
|
61
|
+
message: 'Keep this automation in its original workspace.',
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
const trigger = input.trigger as Record<string, unknown> | null
|
|
65
|
+
if (
|
|
66
|
+
!trigger ||
|
|
67
|
+
!['event', 'webhook', 'schedule'].includes(String(trigger.kind)) ||
|
|
68
|
+
(trigger.kind === 'event' && typeof trigger.event !== 'string') ||
|
|
69
|
+
(trigger.kind === 'webhook' && !/^[a-z0-9-]{1,80}$/.test(String(trigger.slug)))
|
|
70
|
+
) {
|
|
71
|
+
issues.push({
|
|
72
|
+
path: 'trigger',
|
|
73
|
+
message:
|
|
74
|
+
'Choose an event, schedule, or webhook path using lowercase letters, numbers and hyphens.',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
if (trigger?.kind === 'schedule') {
|
|
78
|
+
try {
|
|
79
|
+
nextAgentSchedule(String(trigger.cron ?? ''), String(trigger.timeZone ?? ''))
|
|
80
|
+
} catch {
|
|
81
|
+
issues.push({
|
|
82
|
+
path: 'trigger',
|
|
83
|
+
message:
|
|
84
|
+
'Use a five-field cron expression and an IANA time zone, such as Europe/Amsterdam.',
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (![15, 30, 60].includes(Number(input.timeout))) {
|
|
89
|
+
issues.push({ path: 'timeout', message: 'Choose a timeout of 15, 30 or 60 seconds.' })
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
!Number.isInteger(input.retries) ||
|
|
93
|
+
Number(input.retries) < 0 ||
|
|
94
|
+
Number(input.retries) > 5
|
|
95
|
+
) {
|
|
96
|
+
issues.push({ path: 'retries', message: 'Choose between 0 and 5 retries.' })
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
!Number.isSafeInteger(input.agentId) ||
|
|
100
|
+
Number(input.agentId) < 0 ||
|
|
101
|
+
(input.active && !input.agentId)
|
|
102
|
+
) {
|
|
103
|
+
issues.push({
|
|
104
|
+
path: 'agentId',
|
|
105
|
+
message: 'Choose an agent before activating this automation.',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
if (String(input.instructions).length > 20000) {
|
|
109
|
+
issues.push({
|
|
110
|
+
path: 'instructions',
|
|
111
|
+
message: 'Use instructions of 20,000 characters or fewer.',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
for (const [field, actions] of [
|
|
115
|
+
['success', ['none', 'email', 'slack', 'record', 'webhook']],
|
|
116
|
+
['failure', ['none', 'notify', 'slack']],
|
|
117
|
+
] as const) {
|
|
118
|
+
const outcome = input[field] as Record<string, unknown> | null
|
|
119
|
+
if (
|
|
120
|
+
!outcome ||
|
|
121
|
+
!actions.includes(String(outcome.action) as never) ||
|
|
122
|
+
typeof outcome.target !== 'string' ||
|
|
123
|
+
outcome.target.length > 2048 ||
|
|
124
|
+
(outcome.action !== 'none' && !outcome.target.trim())
|
|
125
|
+
) {
|
|
126
|
+
issues.push({ path: field, message: 'Choose an action and enter its destination.' })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const positions = input.positions as Record<string, { x: number; y: number }> | null
|
|
130
|
+
if (
|
|
131
|
+
!positions ||
|
|
132
|
+
['trigger', 'agent', 'success', 'error'].some(
|
|
133
|
+
(node) => !Number.isFinite(positions[node]?.x) || !Number.isFinite(positions[node]?.y),
|
|
134
|
+
)
|
|
135
|
+
) {
|
|
136
|
+
issues.push({ path: 'positions', message: 'Use valid node positions.' })
|
|
137
|
+
}
|
|
138
|
+
return issues
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
),
|
|
142
|
+
automationFolder: model(
|
|
143
|
+
{
|
|
144
|
+
name: t.text().required(),
|
|
145
|
+
workspace: t.text().required(),
|
|
146
|
+
ownerId: t.number().required().serverOnly(),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
access: {
|
|
150
|
+
list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
151
|
+
read: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
152
|
+
create: ({ user }) => !!user,
|
|
153
|
+
delete: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
154
|
+
},
|
|
155
|
+
hooks: {
|
|
156
|
+
beforeCreate: ({ user }, input) => ({
|
|
157
|
+
...input,
|
|
158
|
+
name: String(input.name ?? '').trim(),
|
|
159
|
+
ownerId: user!.id,
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
validate: (input) => {
|
|
163
|
+
const name = String(input.name ?? '')
|
|
164
|
+
if (!name || name.length > 80) {
|
|
165
|
+
return [{ path: 'name', message: 'Use a folder name between 1 and 80 characters.' }]
|
|
166
|
+
}
|
|
167
|
+
if (name.toLowerCase() === 'unsorted') {
|
|
168
|
+
return [{ path: 'name', message: 'Choose a different folder name.' }]
|
|
169
|
+
}
|
|
170
|
+
return []
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
automationRun: model(
|
|
175
|
+
{
|
|
176
|
+
automationId: t.number().required(),
|
|
177
|
+
workspace: t.text().required(),
|
|
178
|
+
eventId: t.text().required().unique(),
|
|
179
|
+
status: t.select(['queued', 'running', 'waiting', 'succeeded', 'failed']).default('queued'),
|
|
180
|
+
payload: t.json().required(),
|
|
181
|
+
steps: t.json().default([]),
|
|
182
|
+
output: t.textarea().default(''),
|
|
183
|
+
error: t.textarea().default(''),
|
|
184
|
+
attempts: t.number().default(0),
|
|
185
|
+
startedAt: t.datetime(),
|
|
186
|
+
finishedAt: t.datetime(),
|
|
187
|
+
snapshot: t.json().required().serverOnly(),
|
|
188
|
+
journal: t.json().default({}).serverOnly(),
|
|
189
|
+
ownerId: t.number().required().serverOnly(),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
access: {
|
|
193
|
+
list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
194
|
+
read: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
195
|
+
create: ({ user, input }) => !!user && input?.ownerId === user.id,
|
|
196
|
+
update: ({ user, input, where }) =>
|
|
197
|
+
user && input?.ownerId === user.id ? where('ownerId', 'eq', user.id) : false,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
),
|
|
201
|
+
// One row per journaled model or tool call, so a long run never outgrows D1's row limit.
|
|
202
|
+
automationRunReceipt: model(
|
|
203
|
+
{
|
|
204
|
+
runId: t.number().required(),
|
|
205
|
+
key: t.text().required().unique(),
|
|
206
|
+
status: t.select(['started', 'completed']).default('started').serverOnly(),
|
|
207
|
+
value: t.json().serverOnly(),
|
|
208
|
+
ownerId: t.number().required().serverOnly(),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
// Writes require the server-only owner, so only the automation engine records receipts.
|
|
212
|
+
access: {
|
|
213
|
+
list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
214
|
+
read: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
215
|
+
create: ({ user, input }) => !!user && input?.ownerId === user.id,
|
|
216
|
+
update: ({ user, input, where }) =>
|
|
217
|
+
user && input?.ownerId === user.id ? where('ownerId', 'eq', user.id) : false,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
),
|
|
221
|
+
}
|
|
222
|
+
export type AutomationsConfig = { models: typeof automationsModels }
|
package/server.ts
ADDED
package/workflow.ts
ADDED