@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.
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,475 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
3
+ import type { InferSelect } from '@cloudconfig/core'
4
+ import type { AutomationsConfig } from '../../schema'
5
+ import type { AutomationNode, AutomationStep } from '../shared/automation'
6
+ import {
7
+ formatElapsedDuration,
8
+ getStatusColor,
9
+ formatStepDuration,
10
+ getStepLogs,
11
+ getStepStatus,
12
+ } from '../shared/automation-run'
13
+ import AutomationStatusIcon from './AutomationStatusIcon.vue'
14
+
15
+ type Run = InferSelect<AutomationsConfig, 'automationRun'>
16
+ const { runs, total, hasMore, selectedRun, defaultSource, loading, running } = defineProps<{
17
+ runs: Run[]
18
+ total: number
19
+ hasMore: boolean
20
+ selectedRun: Run | null
21
+ defaultSource: string
22
+ loading: boolean
23
+ running: boolean
24
+ }>()
25
+ const emit = defineEmits<{
26
+ select: [id: number]
27
+ back: []
28
+ close: []
29
+ rerun: [run: Run]
30
+ loadMore: []
31
+ }>()
32
+ // Retain the detail while the track slides back to the list.
33
+ const lastRun = shallowRef<Run | null>(selectedRun)
34
+ const detail = computed(() => selectedRun ?? lastRun.value)
35
+ const expanded = ref<Partial<Record<AutomationNode, boolean>>>({})
36
+ const copyLabel = ref('Copy')
37
+ let copyTimer: ReturnType<typeof setTimeout> | undefined
38
+ watch(
39
+ () => selectedRun,
40
+ (run) => {
41
+ if (!run) {
42
+ return
43
+ }
44
+ if (lastRun.value?.id !== run.id) {
45
+ expanded.value = {}
46
+ copyLabel.value = 'Copy'
47
+ }
48
+ lastRun.value = run
49
+ },
50
+ { immediate: true },
51
+ )
52
+ onBeforeUnmount(() => clearTimeout(copyTimer))
53
+ function selectRun(id: number) {
54
+ expanded.value = {}
55
+ emit('select', id)
56
+ }
57
+ const nodes = [
58
+ { id: 'trigger', label: 'Trigger' },
59
+ { id: 'agent', label: 'Agent call' },
60
+ { id: 'success', label: 'On success' },
61
+ { id: 'error', label: 'On error' },
62
+ ] as const
63
+ const steps = computed(() =>
64
+ nodes.map((node) => {
65
+ const step = (detail.value?.steps as AutomationStep[] | undefined)?.find(
66
+ (entry) => entry.node === node.id,
67
+ )
68
+ const run = detail.value
69
+ const details: Record<string, string> = {
70
+ ...(node.id === 'trigger' && run ? { 'Event ID': run.eventId } : {}),
71
+ ...(node.id === 'agent' && run ? { Attempts: String(run.attempts) } : {}),
72
+ ...step?.details,
73
+ ...(step?.startedAt ? { Started: new Date(step.startedAt).toLocaleString() } : {}),
74
+ ...(step?.finishedAt ? { Finished: new Date(step.finishedAt).toLocaleString() } : {}),
75
+ }
76
+ return { ...node, step, details, logs: getStepLogs(step), status: getStepStatus(step) }
77
+ }),
78
+ )
79
+ function statusLabel(status: Run['status']) {
80
+ return {
81
+ queued: 'Queued',
82
+ running: 'Running',
83
+ waiting: 'Waiting for approval',
84
+ succeeded: 'Succeeded',
85
+ failed: 'Failed',
86
+ }[status]
87
+ }
88
+ function time(value: string | null | undefined) {
89
+ if (!value) {
90
+ return '—'
91
+ }
92
+ return new Date(value).toLocaleTimeString([], {
93
+ hour: '2-digit',
94
+ minute: '2-digit',
95
+ second: '2-digit',
96
+ hour12: false,
97
+ })
98
+ }
99
+ function duration(run: Run) {
100
+ return formatElapsedDuration(run.startedAt, run.finishedAt) || '…'
101
+ }
102
+ function ago(value: string | null | undefined) {
103
+ if (!value) {
104
+ return 'Just now'
105
+ }
106
+ const minutes = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 60000))
107
+ if (minutes < 1) {
108
+ return 'Just now'
109
+ }
110
+ if (minutes < 60) {
111
+ return `${minutes}m ago`
112
+ }
113
+ if (minutes < 1440) {
114
+ return `${Math.floor(minutes / 60)}h ago`
115
+ }
116
+ return `${Math.floor(minutes / 1440)}d ago`
117
+ }
118
+ function isTest(run: Run) {
119
+ return run.eventId.replace(/^\d+:/, '').startsWith('test:')
120
+ }
121
+ function source(run: Run, withTest = false) {
122
+ const trigger = (run.steps as AutomationStep[]).find((step) => step.node === 'trigger')
123
+ const event =
124
+ trigger?.summary ??
125
+ trigger?.message.match(/^Received (.+)\.$/)?.[1] ??
126
+ (run.eventId.replace(/^\d+:/, '').startsWith('webhook:') ? 'Webhook' : defaultSource)
127
+ return `${withTest && isTest(run) ? 'Test · ' : ''}${event}`
128
+ }
129
+ function summary(node: AutomationNode, step: AutomationStep | undefined) {
130
+ if (step?.status === 'skipped') {
131
+ return 'Skipped'
132
+ }
133
+ if (step?.summary) {
134
+ return step.summary
135
+ }
136
+ if (node === 'trigger' && detail.value) {
137
+ return source(detail.value)
138
+ }
139
+ return step?.message ?? (detail.value?.finishedAt ? 'Skipped' : 'Waiting')
140
+ }
141
+ function elapsed(at: string, startedAt: string | null) {
142
+ if (!startedAt) {
143
+ return time(at)
144
+ }
145
+ const seconds = (new Date(at).getTime() - new Date(startedAt).getTime()) / 1000
146
+ return `${seconds >= 0 ? '+' : ''}${seconds.toFixed(2)}`
147
+ }
148
+ function toggleStep(node: AutomationNode) {
149
+ expanded.value = { ...expanded.value, [node]: !expanded.value[node] }
150
+ }
151
+ function getLogColor({ status, level }: NonNullable<AutomationStep['logs']>[number]) {
152
+ if (status === 'failed' || level === 'error') {
153
+ return 'text-error'
154
+ }
155
+ return level === 'warning' ? 'text-warning' : 'text-(--gray-1000)'
156
+ }
157
+ async function copyOutput() {
158
+ if (!detail.value?.output) {
159
+ return
160
+ }
161
+ clearTimeout(copyTimer)
162
+ try {
163
+ await navigator.clipboard.writeText(detail.value.output)
164
+ copyLabel.value = 'Copied'
165
+ } catch {
166
+ copyLabel.value = 'Copy failed'
167
+ }
168
+ copyTimer = setTimeout(() => {
169
+ copyLabel.value = 'Copy'
170
+ }, 1400)
171
+ }
172
+ </script>
173
+
174
+ <template>
175
+ <div class="h-full w-full overflow-hidden">
176
+ <div
177
+ class="flex h-full w-[200%] transition-transform duration-260 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
178
+ :class="selectedRun ? '-translate-x-1/2' : 'translate-x-0'"
179
+ >
180
+ <section
181
+ class="flex h-full w-1/2 min-w-0 flex-col"
182
+ :inert="!!selectedRun"
183
+ >
184
+ <div class="flex h-13 shrink-0 items-center gap-2.5 border-b border-(--gray-400) pr-2 pl-4">
185
+ <span class="text-[15px] text-highlighted">Runs</span>
186
+ <span class="flex-1 text-xs text-dimmed">
187
+ {{ Math.max(total, runs.length) }}
188
+ {{ Math.max(total, runs.length) === 1 ? 'run' : 'runs' }}
189
+ </span>
190
+ <UButton
191
+ icon="i-lucide-x"
192
+ aria-label="Close runs"
193
+ color="neutral"
194
+ variant="ghost"
195
+ class="size-8 justify-center rounded-md p-0 text-muted hover:bg-(--gray-400) hover:text-highlighted"
196
+ :ui="{ leadingIcon: 'size-4' }"
197
+ @click="emit('close')"
198
+ />
199
+ </div>
200
+ <div
201
+ class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-x-hidden overflow-y-auto p-2 pb-[120px]"
202
+ >
203
+ <div
204
+ v-if="loading && !runs.length"
205
+ class="flex justify-center p-8"
206
+ >
207
+ <AutomationStatusIcon
208
+ status="running"
209
+ class="size-4"
210
+ />
211
+ </div>
212
+ <p
213
+ v-else-if="!runs.length"
214
+ class="px-4 py-8 text-center text-[13px] text-dimmed"
215
+ >
216
+ No runs yet. Use Test run to try it.
217
+ </p>
218
+ <button
219
+ v-for="run in runs"
220
+ :key="run.id"
221
+ type="button"
222
+ class="flex min-h-14 w-full shrink-0 items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors duration-160 hover:bg-(--gray-400) active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-primary motion-reduce:transition-none"
223
+ :class="selectedRun?.id === run.id && 'bg-(--gray-400)'"
224
+ @click="selectRun(run.id)"
225
+ >
226
+ <AutomationStatusIcon
227
+ :status="run.status"
228
+ class="size-4"
229
+ />
230
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
231
+ <span class="flex items-center gap-2 text-sm leading-[18px] text-highlighted">
232
+ Run #{{ run.id }}
233
+ <span
234
+ v-if="isTest(run)"
235
+ class="flex h-[18px] items-center rounded-[5px] border border-(--gray-500) px-1.5 text-[11px] text-muted"
236
+ >
237
+ Test
238
+ </span>
239
+ </span>
240
+ <span class="truncate text-xs leading-4 text-dimmed">
241
+ {{ statusLabel(run.status) }}{{ run.finishedAt ? ` · ${duration(run)}` : '' }} ·
242
+ {{ source(run) }}
243
+ </span>
244
+ </span>
245
+ <span class="shrink-0 text-xs tabular-nums text-dimmed">{{ ago(run.startedAt) }}</span>
246
+ </button>
247
+ <UButton
248
+ v-if="hasMore"
249
+ label="Load more runs"
250
+ color="neutral"
251
+ variant="ghost"
252
+ class="mt-2 w-full justify-center text-xs"
253
+ :loading="loading"
254
+ @click="emit('loadMore')"
255
+ />
256
+ </div>
257
+ </section>
258
+ <section
259
+ class="flex h-full w-1/2 min-w-0 flex-col"
260
+ :inert="!selectedRun"
261
+ >
262
+ <div class="flex h-13 shrink-0 items-center gap-1.5 border-b border-(--gray-400) px-2">
263
+ <UButton
264
+ icon="i-lucide-chevron-left"
265
+ aria-label="All runs"
266
+ title="All runs"
267
+ color="neutral"
268
+ variant="ghost"
269
+ class="size-8 justify-center rounded-md p-0 text-muted hover:bg-(--gray-400) hover:text-highlighted"
270
+ :ui="{ leadingIcon: 'size-4' }"
271
+ @click="emit('back')"
272
+ />
273
+ <span class="text-[15px] text-highlighted">Run #{{ detail?.id }}</span>
274
+ <span
275
+ v-if="detail"
276
+ class="flex min-w-0 flex-1 items-center gap-1.5 text-xs"
277
+ :class="getStatusColor(detail.status)"
278
+ >
279
+ <AutomationStatusIcon
280
+ :status="detail.status"
281
+ class="size-3.5"
282
+ />
283
+ {{ statusLabel(detail.status) }}
284
+ </span>
285
+ <UButton
286
+ icon="i-lucide-x"
287
+ aria-label="Close runs"
288
+ color="neutral"
289
+ variant="ghost"
290
+ class="size-8 justify-center rounded-md p-0 text-muted hover:bg-(--gray-400) hover:text-highlighted"
291
+ :ui="{ leadingIcon: 'size-4' }"
292
+ @click="emit('close')"
293
+ />
294
+ </div>
295
+ <div
296
+ v-if="detail"
297
+ class="flex min-h-0 flex-1 flex-col gap-6 overflow-x-hidden overflow-y-auto p-4 pb-[120px]"
298
+ >
299
+ <div class="flex flex-wrap gap-x-3 gap-y-1.5 text-xs">
300
+ <span
301
+ class="flex flex-col gap-0.5"
302
+ :title="detail.startedAt ? new Date(detail.startedAt).toLocaleString() : undefined"
303
+ >
304
+ <span class="text-dimmed">Started</span>
305
+ <span class="tabular-nums text-highlighted">{{ time(detail.startedAt) }}</span>
306
+ </span>
307
+ <span
308
+ class="flex flex-col gap-0.5"
309
+ :title="
310
+ detail.finishedAt
311
+ ? `Finished: ${new Date(detail.finishedAt).toLocaleString()}`
312
+ : undefined
313
+ "
314
+ >
315
+ <span class="text-dimmed">Duration</span>
316
+ <span class="tabular-nums text-highlighted">{{ duration(detail) }}</span>
317
+ </span>
318
+ <span class="flex min-w-0 flex-col gap-0.5">
319
+ <span class="text-dimmed">Source</span>
320
+ <span class="break-words font-mono text-highlighted">{{ source(detail, true) }}</span>
321
+ </span>
322
+ </div>
323
+ <div class="flex flex-col gap-2">
324
+ <span class="text-xs text-dimmed">Steps</span>
325
+ <div class="flex flex-col">
326
+ <div
327
+ v-for="(item, index) in steps"
328
+ :key="item.id"
329
+ class="grid grid-cols-[24px_minmax(0,1fr)] gap-x-2.5 transition-opacity duration-240 motion-reduce:transition-none"
330
+ :class="(!item.step || item.step.status === 'skipped') && 'opacity-50'"
331
+ >
332
+ <div class="flex flex-col items-center pt-2.5">
333
+ <AutomationStatusIcon
334
+ :status="item.status ?? 'skipped'"
335
+ class="size-4"
336
+ />
337
+ <span
338
+ v-if="index < steps.length - 1"
339
+ class="mt-1.5 w-px flex-1 bg-(--gray-500)"
340
+ />
341
+ </div>
342
+ <div class="min-w-0 pb-1.5">
343
+ <button
344
+ type="button"
345
+ class="-ml-1.5 flex min-h-9 w-full items-center gap-2 rounded-md px-1.5 py-1 text-left enabled:hover:bg-(--gray-300) focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-default"
346
+ :disabled="!item.logs.length"
347
+ :aria-expanded="!!expanded[item.id]"
348
+ @click="toggleStep(item.id)"
349
+ >
350
+ <span class="flex min-w-0 flex-1 flex-col gap-px">
351
+ <span class="text-[13px] text-highlighted">{{ item.label }}</span>
352
+ <span class="truncate text-xs text-muted">
353
+ {{ summary(item.id, item.step) }}
354
+ </span>
355
+ </span>
356
+ <span class="shrink-0 text-xs tabular-nums text-dimmed">
357
+ {{ formatStepDuration(item.step) }}
358
+ </span>
359
+ <UIcon
360
+ name="i-lucide-chevron-down"
361
+ class="size-3.5 shrink-0 text-dimmed transition-[transform,opacity] duration-220 motion-reduce:transition-none"
362
+ :class="[expanded[item.id] && 'rotate-180', !item.logs.length && 'opacity-0']"
363
+ />
364
+ </button>
365
+ <div
366
+ class="grid transition-[grid-template-rows] duration-240 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
367
+ :class="expanded[item.id] ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
368
+ >
369
+ <div
370
+ class="min-h-0 overflow-hidden"
371
+ :inert="!expanded[item.id]"
372
+ >
373
+ <div
374
+ class="mt-1 mb-1.5 flex flex-col gap-1 rounded-lg border border-(--gray-400) bg-(--gray-100) px-2.5 py-2 font-mono text-[11px] leading-[17px]"
375
+ >
376
+ <div
377
+ v-for="(log, logIndex) in item.logs"
378
+ :key="logIndex"
379
+ class="grid grid-cols-[42px_minmax(0,1fr)] gap-2"
380
+ >
381
+ <span
382
+ class="tabular-nums text-dimmed"
383
+ :title="log.at"
384
+ >
385
+ {{ elapsed(log.at, detail.startedAt) }}
386
+ </span>
387
+ <span
388
+ class="break-words"
389
+ :class="getLogColor(log)"
390
+ >
391
+ {{ log.message }}
392
+ </span>
393
+ </div>
394
+ <dl
395
+ v-if="Object.keys(item.details).length"
396
+ class="mt-1 flex flex-col gap-1 border-t border-(--gray-400) pt-2"
397
+ >
398
+ <div
399
+ v-for="(value, label) in item.details"
400
+ :key="label"
401
+ class="flex flex-col"
402
+ >
403
+ <dt class="text-dimmed">{{ label }}</dt>
404
+ <dd class="m-0 break-words whitespace-pre-wrap text-(--gray-1000)">
405
+ {{ value }}
406
+ </dd>
407
+ </div>
408
+ </dl>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ <div
417
+ v-if="detail.output"
418
+ class="flex flex-col gap-2"
419
+ >
420
+ <span class="text-xs text-dimmed">Agent response</span>
421
+ <div class="overflow-hidden rounded-[10px] border border-(--gray-400)">
422
+ <div
423
+ class="px-3.5 py-3 text-[13px] leading-5 break-words whitespace-pre-wrap text-default [text-wrap:pretty]"
424
+ >
425
+ {{ detail.output }}
426
+ </div>
427
+ <div class="border-t border-(--gray-400) p-2">
428
+ <UButton
429
+ :label="copyLabel"
430
+ color="neutral"
431
+ variant="soft"
432
+ class="h-7 rounded-md bg-(--gray-400) px-2.5 text-xs font-normal text-highlighted hover:bg-(--gray-500)"
433
+ @click="copyOutput"
434
+ />
435
+ </div>
436
+ </div>
437
+ </div>
438
+ <div
439
+ v-if="detail.error"
440
+ class="flex flex-col gap-2"
441
+ >
442
+ <span class="text-xs text-dimmed">Error</span>
443
+ <div
444
+ class="flex items-start gap-2.5 rounded-[10px] border border-(--gray-400) px-3 py-2.5"
445
+ >
446
+ <UIcon
447
+ name="i-lucide-circle-alert"
448
+ class="size-3.5 shrink-0 text-error"
449
+ />
450
+ <span class="break-words font-mono text-xs leading-[18px] text-highlighted">
451
+ {{ detail.error }}
452
+ </span>
453
+ </div>
454
+ </div>
455
+ <div class="flex flex-col gap-2">
456
+ <span class="text-xs text-dimmed">Input</span>
457
+ <pre
458
+ class="m-0 rounded-lg border border-(--gray-400) bg-(--gray-100) p-3 font-mono text-xs leading-[18px] break-words whitespace-pre-wrap text-(--gray-1000)"
459
+ >{{ JSON.stringify(detail.payload, null, 2) ?? '{}' }}</pre>
460
+ </div>
461
+ <UButton
462
+ label="Re-run with this input"
463
+ icon="i-lucide-rotate-ccw"
464
+ color="neutral"
465
+ variant="outline"
466
+ class="h-8 self-start gap-1.5 rounded-lg px-3 text-[13px] font-normal text-highlighted ring-(--gray-500) hover:bg-(--gray-400) active:scale-[0.98]"
467
+ :ui="{ leadingIcon: 'size-[13px]' }"
468
+ :disabled="running"
469
+ @click="emit('rerun', detail)"
470
+ />
471
+ </div>
472
+ </section>
473
+ </div>
474
+ </div>
475
+ </template>