@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,488 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
3
+ import { usePreferredReducedMotion } from '@vueuse/core'
4
+ import type { AutomationNode, AutomationPositions, AutomationStep } from '../shared/automation'
5
+ import {
6
+ getStatusColor,
7
+ formatStepDuration,
8
+ getStepStatus,
9
+ type DisplayStatus,
10
+ } from '../shared/automation-run'
11
+ import AutomationStatusIcon from './AutomationStatusIcon.vue'
12
+ import AutomationNodeIcon from './AutomationNodeIcon.vue'
13
+ const savedPositions = defineModel<AutomationPositions>('positions', { required: true })
14
+ const positions = shallowRef(savedPositions.value)
15
+ watch(savedPositions, (value) => {
16
+ positions.value = value
17
+ })
18
+ const savedView = defineModel<{ x: number; y: number; z: number }>('view', { required: true })
19
+ const view = shallowRef(savedView.value)
20
+ watch(savedView, (value) => {
21
+ view.value = value
22
+ })
23
+ const { selected, summaries, steps } = defineProps<{
24
+ selected: AutomationNode | null
25
+ summaries: Record<AutomationNode, { main: string; sub: string }>
26
+ steps: AutomationStep[]
27
+ }>()
28
+ const reducedMotion = usePreferredReducedMotion()
29
+ const canvasElement = useTemplateRef('canvasElement')
30
+ const emit = defineEmits<{
31
+ select: [node: AutomationNode | null]
32
+ dragging: [active: boolean]
33
+ }>()
34
+ const nodes = [
35
+ { id: 'trigger', label: 'Trigger', icon: 'i-lucide-zap', color: 'text-warning' },
36
+ { id: 'agent', label: 'Agent call', icon: 'i-lucide-bot', color: 'text-info' },
37
+ { id: 'success', label: 'On success', icon: 'i-lucide-check', color: 'text-success' },
38
+ { id: 'error', label: 'On error', icon: 'i-lucide-triangle-alert', color: 'text-error' },
39
+ ] as const
40
+ const nodeWidth = 280
41
+ const nodeHeight = 114
42
+ type Point = { x: number; y: number }
43
+ interface AlignmentGuide {
44
+ axis: 'x' | 'y'
45
+ value: number
46
+ start: number
47
+ end: number
48
+ }
49
+ const guides = shallowRef<AlignmentGuide[]>([])
50
+ function snapNode(node: AutomationNode, point: Point): Point {
51
+ const snapped = { ...point }
52
+ const tolerance = 6 / view.value.z
53
+ const candidates = nodes
54
+ .filter((entry) => entry.id !== node)
55
+ .map((entry) => positions.value[entry.id])
56
+ const matches: { axis: 'x' | 'y'; value: number }[] = []
57
+ for (const axis of ['x', 'y'] as const) {
58
+ const size = axis === 'x' ? nodeWidth : nodeHeight
59
+ const offsets = [size / 2, 0, size]
60
+ let best: { delta: number; value: number; offset: number } | undefined
61
+ for (const candidate of candidates) {
62
+ for (const targetOffset of offsets) {
63
+ const value = candidate[axis] + targetOffset
64
+ for (const offset of offsets) {
65
+ const delta = value - (point[axis] + offset)
66
+ if (Math.abs(delta) > tolerance || (best && Math.abs(delta) >= Math.abs(best.delta))) {
67
+ continue
68
+ }
69
+ best = { delta, value, offset }
70
+ }
71
+ }
72
+ }
73
+ if (best) {
74
+ snapped[axis] = best.value - best.offset
75
+ matches.push({ axis, value: best.value })
76
+ }
77
+ }
78
+ guides.value = matches.map(({ axis, value }) => {
79
+ const otherAxis = axis === 'x' ? 'y' : 'x'
80
+ const size = axis === 'x' ? nodeWidth : nodeHeight
81
+ const otherSize = axis === 'x' ? nodeHeight : nodeWidth
82
+ const aligned = candidates.filter((candidate) =>
83
+ [0, size / 2, size].some((offset) => Math.abs(candidate[axis] + offset - value) < 0.001),
84
+ )
85
+ const starts = [snapped[otherAxis], ...aligned.map((candidate) => candidate[otherAxis])]
86
+ const padding = 8 / view.value.z
87
+ return {
88
+ axis,
89
+ value,
90
+ start: Math.min(...starts) - padding,
91
+ end: Math.max(...starts) + otherSize + padding,
92
+ }
93
+ })
94
+ return snapped
95
+ }
96
+ const nodeStep = (id: AutomationNode) => steps.find((entry) => entry.node === id)
97
+ const nodeState = (id: AutomationNode) => getStepStatus(nodeStep(id))
98
+ const gridSize = computed(() => {
99
+ let size = 24 * view.value.z
100
+ while (size < 12) {
101
+ size *= 2
102
+ }
103
+ return size
104
+ })
105
+ const finishedLabels: Record<string, string> = { succeeded: 'Done', failed: 'Failed' }
106
+ function nodeLabel(id: AutomationNode) {
107
+ const step = nodeStep(id)
108
+ const state = nodeState(id)
109
+ if (state === 'retry') {
110
+ return `Retry ${(step?.attempt ?? 1) - 1}/${step?.retryLimit ?? 0}`
111
+ }
112
+ if (state === 'running') {
113
+ return 'Running'
114
+ }
115
+ if (state === 'skipped') {
116
+ return 'Skipped'
117
+ }
118
+ const label = finishedLabels[state ?? ''] ?? ''
119
+ const duration = formatStepDuration(step)
120
+ return label + (label && duration ? ` · ${duration}` : '')
121
+ }
122
+ function nodeBorderClass(isSelected: boolean, state: DisplayStatus) {
123
+ if (isSelected || state === 'running' || state === 'retry') {
124
+ return 'border-(--gray-800)'
125
+ }
126
+ if (state === 'succeeded') {
127
+ return 'border-[color-mix(in_oklch,var(--ui-success)_55%,var(--gray-500))]'
128
+ }
129
+ if (state === 'failed') {
130
+ return 'border-[color-mix(in_oklch,var(--ui-error)_60%,var(--gray-500))]'
131
+ }
132
+ return 'border-(--gray-500)'
133
+ }
134
+ function nodeShadowClass(isSelected: boolean, state: DisplayStatus) {
135
+ if (isSelected) {
136
+ return 'shadow-[0_0_0_1px_var(--gray-800),0_8px_24px_rgba(0,0,0,0.4)]'
137
+ }
138
+ if (state === 'running' || state === 'retry') {
139
+ return 'shadow-[0_0_0_4px_color-mix(in_oklch,var(--ui-text-highlighted)_8%,transparent),0_4px_16px_rgba(0,0,0,0.25)]'
140
+ }
141
+ return 'shadow-[0_4px_16px_rgba(0,0,0,0.25)]'
142
+ }
143
+ function nodeClasses(id: AutomationNode) {
144
+ const state = nodeState(id)
145
+ const isSelected = selected === id
146
+ return [
147
+ nodeBorderClass(isSelected, state),
148
+ nodeShadowClass(isSelected, state),
149
+ state === 'skipped' && 'opacity-45',
150
+ ]
151
+ }
152
+ const edges = computed(() =>
153
+ [
154
+ ['trigger', 'agent'],
155
+ ['agent', 'success'],
156
+ ['agent', 'error'],
157
+ ].map(([from, to]) => {
158
+ const start = positions.value[from as AutomationNode]
159
+ const end = positions.value[to as AutomationNode]
160
+ const source = { x: start.x + nodeWidth / 2, y: start.y + nodeHeight }
161
+ const target = { x: end.x + nodeWidth / 2, y: end.y - 2 }
162
+ const middle = (source.y + target.y) / 2
163
+ return {
164
+ id: to as AutomationNode,
165
+ source,
166
+ target,
167
+ middle,
168
+ path: `M${source.x},${source.y} C${source.x},${middle} ${target.x},${middle} ${target.x},${target.y}`,
169
+ status: nodeState(to as AutomationNode),
170
+ }
171
+ }),
172
+ )
173
+ function edgeColor(edge: (typeof edges.value)[number]) {
174
+ const { id, status } = edge
175
+ if (!status) {
176
+ return 'var(--gray-700)'
177
+ }
178
+ if (status === 'skipped') {
179
+ return 'var(--gray-500)'
180
+ }
181
+ if (id === 'error') {
182
+ return 'var(--ui-error)'
183
+ }
184
+ return 'var(--ui-success)'
185
+ }
186
+ function getEdgeLabelClass({ id, status }: (typeof edges.value)[number]) {
187
+ if (!status || status === 'skipped') {
188
+ return 'border-(--gray-500) text-muted'
189
+ }
190
+ return id === 'error' ? 'border-error text-error' : 'border-success text-success'
191
+ }
192
+ const drag = shallowRef<{
193
+ pointer: number
194
+ node: AutomationNode | null
195
+ x: number
196
+ y: number
197
+ startX: number
198
+ startY: number
199
+ moved: boolean
200
+ scale: number
201
+ unsnapped?: Point
202
+ }>()
203
+ let frame = 0
204
+ let pendingMove: { x: number; y: number } | undefined
205
+ let pendingView: typeof view.value | undefined
206
+ let wheelTimer: ReturnType<typeof setTimeout> | undefined
207
+ function commitView() {
208
+ clearTimeout(wheelTimer)
209
+ flushFrame()
210
+ if (savedView.value !== view.value) {
211
+ savedView.value = view.value
212
+ }
213
+ }
214
+ function flushFrame() {
215
+ if (frame) {
216
+ cancelAnimationFrame(frame)
217
+ }
218
+ frame = 0
219
+ if (pendingView) {
220
+ view.value = pendingView
221
+ pendingView = undefined
222
+ }
223
+ const point = pendingMove
224
+ pendingMove = undefined
225
+ if (!drag.value || !point) {
226
+ return
227
+ }
228
+ // Embedded previews scale the surface; pointer deltas must use its local coordinates.
229
+ const dx = (point.x - drag.value.x) / drag.value.scale
230
+ const dy = (point.y - drag.value.y) / drag.value.scale
231
+ if (!dx && !dy) {
232
+ return
233
+ }
234
+ if (Math.abs(point.x - drag.value.startX) + Math.abs(point.y - drag.value.startY) > 2) {
235
+ drag.value.moved = true
236
+ }
237
+ if (drag.value.node && drag.value.unsnapped) {
238
+ // Keep the pointer position independent of snapping so nodes release naturally.
239
+ const point = {
240
+ x: drag.value.unsnapped.x + dx / view.value.z,
241
+ y: drag.value.unsnapped.y + dy / view.value.z,
242
+ }
243
+ drag.value.unsnapped = point
244
+ positions.value = {
245
+ ...positions.value,
246
+ [drag.value.node]: drag.value.moved ? snapNode(drag.value.node, point) : point,
247
+ }
248
+ } else {
249
+ view.value = { ...view.value, x: view.value.x + dx, y: view.value.y + dy }
250
+ }
251
+ drag.value.x = point.x
252
+ drag.value.y = point.y
253
+ }
254
+ onBeforeUnmount(() => {
255
+ if (frame) {
256
+ cancelAnimationFrame(frame)
257
+ }
258
+ clearTimeout(wheelTimer)
259
+ })
260
+ function down(event: PointerEvent, node: AutomationNode | null = null) {
261
+ if (event.button !== 0 || (!node && (event.target as HTMLElement).closest('button'))) {
262
+ return
263
+ }
264
+ emit('dragging', true)
265
+ commitView()
266
+ guides.value = []
267
+ const element = event.currentTarget as HTMLElement
268
+ element.setPointerCapture(event.pointerId)
269
+ drag.value = {
270
+ pointer: event.pointerId,
271
+ node,
272
+ x: event.clientX,
273
+ y: event.clientY,
274
+ startX: event.clientX,
275
+ startY: event.clientY,
276
+ moved: false,
277
+ scale: getCanvasScale(),
278
+ unsnapped: node ? { ...positions.value[node] } : undefined,
279
+ }
280
+ if (node) {
281
+ emit('select', node)
282
+ }
283
+ }
284
+ function move(event: PointerEvent) {
285
+ if (!drag.value || event.pointerId !== drag.value.pointer) {
286
+ return
287
+ }
288
+ pendingMove = { x: event.clientX, y: event.clientY }
289
+ if (!frame) {
290
+ frame = requestAnimationFrame(flushFrame)
291
+ }
292
+ }
293
+ function up() {
294
+ flushFrame()
295
+ if (drag.value?.node) {
296
+ savedPositions.value = positions.value
297
+ } else if (drag.value) {
298
+ commitView()
299
+ }
300
+ if (drag.value && !drag.value.node && !drag.value.moved) {
301
+ emit('select', null)
302
+ }
303
+ drag.value = undefined
304
+ guides.value = []
305
+ emit('dragging', false)
306
+ }
307
+ function wheel(event: WheelEvent) {
308
+ const current = pendingView ?? view.value
309
+ if (event.ctrlKey || event.metaKey) {
310
+ pendingView = {
311
+ ...current,
312
+ z: Math.max(0.35, Math.min(1.6, current.z * Math.exp(-event.deltaY * 0.005))),
313
+ }
314
+ } else {
315
+ const scale = getCanvasScale()
316
+ pendingView = {
317
+ ...current,
318
+ x: current.x - event.deltaX / scale,
319
+ y: current.y - event.deltaY / scale,
320
+ }
321
+ }
322
+ if (!frame) {
323
+ frame = requestAnimationFrame(flushFrame)
324
+ }
325
+ clearTimeout(wheelTimer)
326
+ wheelTimer = setTimeout(commitView, 100)
327
+ }
328
+ function getCanvasScale() {
329
+ const element = canvasElement.value
330
+ return element?.clientWidth ? element.getBoundingClientRect().width / element.clientWidth : 1
331
+ }
332
+ function keyboard(event: KeyboardEvent, node: AutomationNode) {
333
+ const direction = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[
334
+ event.key
335
+ ]
336
+ if (!direction) {
337
+ return
338
+ }
339
+ event.preventDefault()
340
+ const current = positions.value[node]
341
+ const amount = event.shiftKey ? 50 : 10
342
+ positions.value = {
343
+ ...positions.value,
344
+ [node]: { x: current.x + direction[0]! * amount, y: current.y + direction[1]! * amount },
345
+ }
346
+ savedPositions.value = positions.value
347
+ }
348
+ </script>
349
+
350
+ <template>
351
+ <div
352
+ ref="canvasElement"
353
+ class="relative min-h-0 min-w-0 flex-1 touch-none overflow-hidden bg-(--gray-200) [background-image:radial-gradient(circle,var(--gray-500)_1px,transparent_1.5px)]"
354
+ :style="{
355
+ backgroundSize: `${gridSize}px ${gridSize}px`,
356
+ backgroundPosition: `calc(50% + ${view.x}px) ${view.y}px`,
357
+ }"
358
+ :class="drag ? 'cursor-grabbing' : 'cursor-grab'"
359
+ aria-label="Automation canvas. Drag nodes to move them. Use arrow keys on a focused node."
360
+ @pointerdown="down($event)"
361
+ @pointermove="move"
362
+ @pointerup="up"
363
+ @pointercancel="up"
364
+ @wheel.prevent="wheel"
365
+ >
366
+ <div
367
+ class="absolute top-0 left-1/2 origin-top-left will-change-transform"
368
+ :style="{ transform: `translate3d(${view.x}px,${view.y}px,0) scale(${view.z})` }"
369
+ >
370
+ <svg
371
+ class="pointer-events-none absolute top-0 left-0 size-px overflow-visible"
372
+ aria-hidden="true"
373
+ >
374
+ <g
375
+ v-for="edge in edges"
376
+ :key="edge.id"
377
+ >
378
+ <path
379
+ :d="edge.path"
380
+ fill="none"
381
+ :stroke="edgeColor(edge)"
382
+ stroke-width="1.5"
383
+ :stroke-dasharray="
384
+ edge.status === 'running' || edge.status === 'retry' ? '5 5' : undefined
385
+ "
386
+ class="transition-colors duration-240 motion-reduce:transition-none"
387
+ >
388
+ <animate
389
+ v-if="
390
+ reducedMotion !== 'reduce' && (edge.status === 'running' || edge.status === 'retry')
391
+ "
392
+ attributeName="stroke-dashoffset"
393
+ from="0"
394
+ to="-20"
395
+ dur="0.6s"
396
+ repeatCount="indefinite"
397
+ />
398
+ </path>
399
+ <path
400
+ :d="`M${edge.target.x - 4},${edge.target.y - 6} L${edge.target.x},${edge.target.y} L${edge.target.x + 4},${edge.target.y - 6}`"
401
+ fill="none"
402
+ :stroke="edgeColor(edge)"
403
+ stroke-width="1.5"
404
+ stroke-linecap="round"
405
+ stroke-linejoin="round"
406
+ class="transition-colors duration-240 motion-reduce:transition-none"
407
+ />
408
+ <circle
409
+ :cx="edge.source.x"
410
+ :cy="edge.source.y"
411
+ r="3.5"
412
+ :fill="edgeColor(edge)"
413
+ />
414
+ </g>
415
+ <line
416
+ v-for="guide in guides"
417
+ :key="guide.axis"
418
+ :x1="guide.axis === 'x' ? guide.value : guide.start"
419
+ :y1="guide.axis === 'x' ? guide.start : guide.value"
420
+ :x2="guide.axis === 'x' ? guide.value : guide.end"
421
+ :y2="guide.axis === 'x' ? guide.end : guide.value"
422
+ stroke="var(--ui-primary)"
423
+ stroke-width="1"
424
+ vector-effect="non-scaling-stroke"
425
+ />
426
+ </svg>
427
+ <button
428
+ v-for="edge in edges.filter((entry) => entry.id !== 'agent')"
429
+ :key="`label-${edge.id}`"
430
+ class="absolute h-6 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border bg-(--gray-300) px-2.5 text-xs whitespace-nowrap transition-colors duration-240 motion-reduce:transition-none"
431
+ :class="getEdgeLabelClass(edge)"
432
+ :style="{ left: `${(edge.source.x + edge.target.x) / 2}px`, top: `${edge.middle}px` }"
433
+ @click.stop="emit('select', edge.id)"
434
+ >
435
+ {{ edge.id === 'success' ? 'On success' : 'On error' }}
436
+ </button>
437
+ <button
438
+ v-for="node in nodes"
439
+ :key="node.id"
440
+ type="button"
441
+ class="absolute top-0 left-0 w-[280px] touch-none rounded-[14px] border bg-(--gray-300) p-1 text-left outline-offset-4 transition-[border-color,box-shadow,opacity] duration-200 will-change-transform motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-primary"
442
+ :class="nodeClasses(node.id)"
443
+ :style="{ transform: `translate3d(${positions[node.id].x}px,${positions[node.id].y}px,0)` }"
444
+ :aria-label="`${node.label}: ${summaries[node.id].main}`"
445
+ :aria-pressed="selected === node.id"
446
+ @pointerdown.stop="down($event, node.id)"
447
+ @pointermove.stop="move"
448
+ @pointerup.stop="up"
449
+ @click.stop="emit('select', node.id)"
450
+ @keydown="keyboard($event, node.id)"
451
+ >
452
+ <span class="flex h-9 items-center gap-2 pr-1.5 pl-1">
453
+ <span
454
+ class="grid size-6 place-items-center rounded-[7px] bg-(--gray-500)"
455
+ :class="node.color"
456
+ >
457
+ <AutomationNodeIcon
458
+ :node="node.id"
459
+ class="size-3.5"
460
+ />
461
+ </span>
462
+ <span class="min-w-0 flex-1 truncate text-sm text-highlighted">{{ node.label }}</span>
463
+ <span
464
+ class="flex origin-right items-center gap-1.5 text-xs whitespace-nowrap transition-[color,opacity,transform] duration-200 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
465
+ :class="[
466
+ getStatusColor(nodeState(node.id)),
467
+ nodeState(node.id) ? 'scale-100 opacity-100' : 'scale-80 opacity-0',
468
+ ]"
469
+ >
470
+ <span class="tabular-nums">{{ nodeLabel(node.id) }}</span>
471
+ <AutomationStatusIcon
472
+ :status="nodeState(node.id)"
473
+ class="size-3.5"
474
+ />
475
+ </span>
476
+ </span>
477
+ <span
478
+ class="flex h-[68px] flex-col justify-center gap-1 overflow-hidden rounded-[10px] border border-(--gray-400) bg-(--gray-100) px-3.5 py-3"
479
+ >
480
+ <span class="truncate font-mono text-[13px] leading-[18px]">
481
+ {{ summaries[node.id].main }}
482
+ </span>
483
+ <span class="truncate text-xs leading-4 text-muted">{{ summaries[node.id].sub }}</span>
484
+ </span>
485
+ </button>
486
+ </div>
487
+ </div>
488
+ </template>