@antelopejs/dms-automation 0.0.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 (305) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +95 -0
  3. package/dist/cluster/bus.d.ts +17 -0
  4. package/dist/cluster/bus.js +82 -0
  5. package/dist/cluster/bus.js.map +1 -0
  6. package/dist/cluster/instance.d.ts +1 -0
  7. package/dist/cluster/instance.js +6 -0
  8. package/dist/cluster/instance.js.map +1 -0
  9. package/dist/cluster/leader.d.ts +7 -0
  10. package/dist/cluster/leader.js +123 -0
  11. package/dist/cluster/leader.js.map +1 -0
  12. package/dist/cluster/mode.d.ts +3 -0
  13. package/dist/cluster/mode.js +25 -0
  14. package/dist/cluster/mode.js.map +1 -0
  15. package/dist/data/procedures-table.d.ts +66 -0
  16. package/dist/data/procedures-table.js +271 -0
  17. package/dist/data/procedures-table.js.map +1 -0
  18. package/dist/data/runs-table.d.ts +23 -0
  19. package/dist/data/runs-table.js +125 -0
  20. package/dist/data/runs-table.js.map +1 -0
  21. package/dist/db/index.d.ts +7 -0
  22. package/dist/db/index.js +24 -0
  23. package/dist/db/index.js.map +1 -0
  24. package/dist/db/models/automation_template.model.d.ts +58 -0
  25. package/dist/db/models/automation_template.model.js +134 -0
  26. package/dist/db/models/automation_template.model.js.map +1 -0
  27. package/dist/db/models/index.d.ts +3 -0
  28. package/dist/db/models/index.js +20 -0
  29. package/dist/db/models/index.js.map +1 -0
  30. package/dist/db/models/procedure.model.d.ts +25 -0
  31. package/dist/db/models/procedure.model.js +28 -0
  32. package/dist/db/models/procedure.model.js.map +1 -0
  33. package/dist/db/models/procedure_run.model.d.ts +34 -0
  34. package/dist/db/models/procedure_run.model.js +39 -0
  35. package/dist/db/models/procedure_run.model.js.map +1 -0
  36. package/dist/db/models/stats.model.d.ts +73 -0
  37. package/dist/db/models/stats.model.js +267 -0
  38. package/dist/db/models/stats.model.js.map +1 -0
  39. package/dist/db/models/type_config.model.d.ts +47 -0
  40. package/dist/db/models/type_config.model.js +51 -0
  41. package/dist/db/models/type_config.model.js.map +1 -0
  42. package/dist/db/tables/action_type_config.table.d.ts +7 -0
  43. package/dist/db/tables/action_type_config.table.js +35 -0
  44. package/dist/db/tables/action_type_config.table.js.map +1 -0
  45. package/dist/db/tables/automation_template.table.d.ts +11 -0
  46. package/dist/db/tables/automation_template.table.js +52 -0
  47. package/dist/db/tables/automation_template.table.js.map +1 -0
  48. package/dist/db/tables/index.d.ts +5 -0
  49. package/dist/db/tables/index.js +22 -0
  50. package/dist/db/tables/index.js.map +1 -0
  51. package/dist/db/tables/procedure.table.d.ts +10 -0
  52. package/dist/db/tables/procedure.table.js +48 -0
  53. package/dist/db/tables/procedure.table.js.map +1 -0
  54. package/dist/db/tables/procedure_run.table.d.ts +13 -0
  55. package/dist/db/tables/procedure_run.table.js +57 -0
  56. package/dist/db/tables/procedure_run.table.js.map +1 -0
  57. package/dist/db/tables/trigger_type_config.table.d.ts +7 -0
  58. package/dist/db/tables/trigger_type_config.table.js +35 -0
  59. package/dist/db/tables/trigger_type_config.table.js.map +1 -0
  60. package/dist/implementations/dms-automation/index.d.ts +19 -0
  61. package/dist/implementations/dms-automation/index.js +50 -0
  62. package/dist/implementations/dms-automation/index.js.map +1 -0
  63. package/dist/index.d.ts +14 -0
  64. package/dist/index.js +121 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/pages/builder.d.ts +10 -0
  67. package/dist/pages/builder.js +28 -0
  68. package/dist/pages/builder.js.map +1 -0
  69. package/dist/pages/category.d.ts +1 -0
  70. package/dist/pages/category.js +10 -0
  71. package/dist/pages/category.js.map +1 -0
  72. package/dist/pages/index.d.ts +6 -0
  73. package/dist/pages/index.js +9 -0
  74. package/dist/pages/index.js.map +1 -0
  75. package/dist/pages/library.d.ts +10 -0
  76. package/dist/pages/library.js +28 -0
  77. package/dist/pages/library.js.map +1 -0
  78. package/dist/pages/overview.d.ts +10 -0
  79. package/dist/pages/overview.js +28 -0
  80. package/dist/pages/overview.js.map +1 -0
  81. package/dist/pages/procedures.d.ts +10 -0
  82. package/dist/pages/procedures.js +72 -0
  83. package/dist/pages/procedures.js.map +1 -0
  84. package/dist/pages/runs.d.ts +11 -0
  85. package/dist/pages/runs.js +61 -0
  86. package/dist/pages/runs.js.map +1 -0
  87. package/dist/routes/action-types.d.ts +23 -0
  88. package/dist/routes/action-types.js +80 -0
  89. package/dist/routes/action-types.js.map +1 -0
  90. package/dist/routes/data-node-types.d.ts +6 -0
  91. package/dist/routes/data-node-types.js +36 -0
  92. package/dist/routes/data-node-types.js.map +1 -0
  93. package/dist/routes/graphSchema.d.ts +92 -0
  94. package/dist/routes/graphSchema.js +60 -0
  95. package/dist/routes/graphSchema.js.map +1 -0
  96. package/dist/routes/index.d.ts +8 -0
  97. package/dist/routes/index.js +11 -0
  98. package/dist/routes/index.js.map +1 -0
  99. package/dist/routes/node-kinds.d.ts +9 -0
  100. package/dist/routes/node-kinds.js +35 -0
  101. package/dist/routes/node-kinds.js.map +1 -0
  102. package/dist/routes/procedures.d.ts +23 -0
  103. package/dist/routes/procedures.js +181 -0
  104. package/dist/routes/procedures.js.map +1 -0
  105. package/dist/routes/runs.d.ts +7 -0
  106. package/dist/routes/runs.js +81 -0
  107. package/dist/routes/runs.js.map +1 -0
  108. package/dist/routes/stats.d.ts +6 -0
  109. package/dist/routes/stats.js +35 -0
  110. package/dist/routes/stats.js.map +1 -0
  111. package/dist/routes/templates.d.ts +20 -0
  112. package/dist/routes/templates.js +243 -0
  113. package/dist/routes/templates.js.map +1 -0
  114. package/dist/routes/trigger-types.d.ts +24 -0
  115. package/dist/routes/trigger-types.js +80 -0
  116. package/dist/routes/trigger-types.js.map +1 -0
  117. package/dist/routes/typeConfigShared.d.ts +22 -0
  118. package/dist/routes/typeConfigShared.js +37 -0
  119. package/dist/routes/typeConfigShared.js.map +1 -0
  120. package/dist/runtime/builtins/actions/http.d.ts +13 -0
  121. package/dist/runtime/builtins/actions/http.js +47 -0
  122. package/dist/runtime/builtins/actions/http.js.map +1 -0
  123. package/dist/runtime/builtins/actions/index.d.ts +2 -0
  124. package/dist/runtime/builtins/actions/index.js +7 -0
  125. package/dist/runtime/builtins/actions/index.js.map +1 -0
  126. package/dist/runtime/builtins/actions/log.d.ts +13 -0
  127. package/dist/runtime/builtins/actions/log.js +49 -0
  128. package/dist/runtime/builtins/actions/log.js.map +1 -0
  129. package/dist/runtime/builtins/data/boolean.d.ts +2 -0
  130. package/dist/runtime/builtins/data/boolean.js +39 -0
  131. package/dist/runtime/builtins/data/boolean.js.map +1 -0
  132. package/dist/runtime/builtins/data/compare.d.ts +2 -0
  133. package/dist/runtime/builtins/data/compare.js +29 -0
  134. package/dist/runtime/builtins/data/compare.js.map +1 -0
  135. package/dist/runtime/builtins/data/constant.d.ts +2 -0
  136. package/dist/runtime/builtins/data/constant.js +29 -0
  137. package/dist/runtime/builtins/data/constant.js.map +1 -0
  138. package/dist/runtime/builtins/data/index.d.ts +2 -0
  139. package/dist/runtime/builtins/data/index.js +20 -0
  140. package/dist/runtime/builtins/data/index.js.map +1 -0
  141. package/dist/runtime/builtins/data/json.d.ts +2 -0
  142. package/dist/runtime/builtins/data/json.js +46 -0
  143. package/dist/runtime/builtins/data/json.js.map +1 -0
  144. package/dist/runtime/builtins/data/math.d.ts +2 -0
  145. package/dist/runtime/builtins/data/math.js +53 -0
  146. package/dist/runtime/builtins/data/math.js.map +1 -0
  147. package/dist/runtime/builtins/data/object.d.ts +2 -0
  148. package/dist/runtime/builtins/data/object.js +121 -0
  149. package/dist/runtime/builtins/data/object.js.map +1 -0
  150. package/dist/runtime/builtins/data/string.d.ts +2 -0
  151. package/dist/runtime/builtins/data/string.js +143 -0
  152. package/dist/runtime/builtins/data/string.js.map +1 -0
  153. package/dist/runtime/builtins/index.d.ts +17 -0
  154. package/dist/runtime/builtins/index.js +24 -0
  155. package/dist/runtime/builtins/index.js.map +1 -0
  156. package/dist/runtime/builtins/nodes/action.d.ts +1 -0
  157. package/dist/runtime/builtins/nodes/action.js +36 -0
  158. package/dist/runtime/builtins/nodes/action.js.map +1 -0
  159. package/dist/runtime/builtins/nodes/data.d.ts +1 -0
  160. package/dist/runtime/builtins/nodes/data.js +28 -0
  161. package/dist/runtime/builtins/nodes/data.js.map +1 -0
  162. package/dist/runtime/builtins/nodes/delay.d.ts +1 -0
  163. package/dist/runtime/builtins/nodes/delay.js +41 -0
  164. package/dist/runtime/builtins/nodes/delay.js.map +1 -0
  165. package/dist/runtime/builtins/nodes/forRange.d.ts +1 -0
  166. package/dist/runtime/builtins/nodes/forRange.js +52 -0
  167. package/dist/runtime/builtins/nodes/forRange.js.map +1 -0
  168. package/dist/runtime/builtins/nodes/foreach.d.ts +1 -0
  169. package/dist/runtime/builtins/nodes/foreach.js +31 -0
  170. package/dist/runtime/builtins/nodes/foreach.js.map +1 -0
  171. package/dist/runtime/builtins/nodes/groupInput.d.ts +1 -0
  172. package/dist/runtime/builtins/nodes/groupInput.js +11 -0
  173. package/dist/runtime/builtins/nodes/groupInput.js.map +1 -0
  174. package/dist/runtime/builtins/nodes/groupOutput.d.ts +1 -0
  175. package/dist/runtime/builtins/nodes/groupOutput.js +11 -0
  176. package/dist/runtime/builtins/nodes/groupOutput.js.map +1 -0
  177. package/dist/runtime/builtins/nodes/if.d.ts +1 -0
  178. package/dist/runtime/builtins/nodes/if.js +23 -0
  179. package/dist/runtime/builtins/nodes/if.js.map +1 -0
  180. package/dist/runtime/builtins/nodes/parallel.d.ts +1 -0
  181. package/dist/runtime/builtins/nodes/parallel.js +34 -0
  182. package/dist/runtime/builtins/nodes/parallel.js.map +1 -0
  183. package/dist/runtime/builtins/nodes/retry.d.ts +1 -0
  184. package/dist/runtime/builtins/nodes/retry.js +46 -0
  185. package/dist/runtime/builtins/nodes/retry.js.map +1 -0
  186. package/dist/runtime/builtins/nodes/setVariable.d.ts +1 -0
  187. package/dist/runtime/builtins/nodes/setVariable.js +31 -0
  188. package/dist/runtime/builtins/nodes/setVariable.js.map +1 -0
  189. package/dist/runtime/builtins/nodes/switch.d.ts +1 -0
  190. package/dist/runtime/builtins/nodes/switch.js +33 -0
  191. package/dist/runtime/builtins/nodes/switch.js.map +1 -0
  192. package/dist/runtime/builtins/nodes/trigger.d.ts +1 -0
  193. package/dist/runtime/builtins/nodes/trigger.js +25 -0
  194. package/dist/runtime/builtins/nodes/trigger.js.map +1 -0
  195. package/dist/runtime/builtins/nodes/tryCatch.d.ts +1 -0
  196. package/dist/runtime/builtins/nodes/tryCatch.js +35 -0
  197. package/dist/runtime/builtins/nodes/tryCatch.js.map +1 -0
  198. package/dist/runtime/builtins/triggers/cron.d.ts +9 -0
  199. package/dist/runtime/builtins/triggers/cron.js +52 -0
  200. package/dist/runtime/builtins/triggers/cron.js.map +1 -0
  201. package/dist/runtime/builtins/triggers/index.d.ts +2 -0
  202. package/dist/runtime/builtins/triggers/index.js +12 -0
  203. package/dist/runtime/builtins/triggers/index.js.map +1 -0
  204. package/dist/runtime/builtins/triggers/manual.d.ts +3 -0
  205. package/dist/runtime/builtins/triggers/manual.js +20 -0
  206. package/dist/runtime/builtins/triggers/manual.js.map +1 -0
  207. package/dist/runtime/builtins/triggers/webhook.d.ts +14 -0
  208. package/dist/runtime/builtins/triggers/webhook.js +90 -0
  209. package/dist/runtime/builtins/triggers/webhook.js.map +1 -0
  210. package/dist/runtime/executionResult.d.ts +6 -0
  211. package/dist/runtime/executionResult.js +3 -0
  212. package/dist/runtime/executionResult.js.map +1 -0
  213. package/dist/runtime/executor.d.ts +9 -0
  214. package/dist/runtime/executor.js +300 -0
  215. package/dist/runtime/executor.js.map +1 -0
  216. package/dist/runtime/flatten.d.ts +3 -0
  217. package/dist/runtime/flatten.js +188 -0
  218. package/dist/runtime/flatten.js.map +1 -0
  219. package/dist/runtime/nodeKinds.d.ts +64 -0
  220. package/dist/runtime/nodeKinds.js +43 -0
  221. package/dist/runtime/nodeKinds.js.map +1 -0
  222. package/dist/runtime/reconcileDiff.d.ts +9 -0
  223. package/dist/runtime/reconcileDiff.js +26 -0
  224. package/dist/runtime/reconcileDiff.js.map +1 -0
  225. package/dist/runtime/registry.d.ts +19 -0
  226. package/dist/runtime/registry.js +47 -0
  227. package/dist/runtime/registry.js.map +1 -0
  228. package/dist/runtime/resolveInputs.d.ts +14 -0
  229. package/dist/runtime/resolveInputs.js +64 -0
  230. package/dist/runtime/resolveInputs.js.map +1 -0
  231. package/dist/runtime/resolveSubgraph.d.ts +7 -0
  232. package/dist/runtime/resolveSubgraph.js +15 -0
  233. package/dist/runtime/resolveSubgraph.js.map +1 -0
  234. package/dist/runtime/subscriptions.d.ts +16 -0
  235. package/dist/runtime/subscriptions.js +419 -0
  236. package/dist/runtime/subscriptions.js.map +1 -0
  237. package/dist/runtime/subwalk.d.ts +17 -0
  238. package/dist/runtime/subwalk.js +74 -0
  239. package/dist/runtime/subwalk.js.map +1 -0
  240. package/dist/runtime/topo.d.ts +3 -0
  241. package/dist/runtime/topo.js +75 -0
  242. package/dist/runtime/topo.js.map +1 -0
  243. package/dist/runtime/typeOrigin.d.ts +7 -0
  244. package/dist/runtime/typeOrigin.js +24 -0
  245. package/dist/runtime/typeOrigin.js.map +1 -0
  246. package/dist/runtime/validate.d.ts +10 -0
  247. package/dist/runtime/validate.js +185 -0
  248. package/dist/runtime/validate.js.map +1 -0
  249. package/dist/runtime/validateTemplate.d.ts +3 -0
  250. package/dist/runtime/validateTemplate.js +54 -0
  251. package/dist/runtime/validateTemplate.js.map +1 -0
  252. package/dist/types/constants.d.ts +6 -0
  253. package/dist/types/constants.js +10 -0
  254. package/dist/types/constants.js.map +1 -0
  255. package/dist/types/graph.d.ts +51 -0
  256. package/dist/types/graph.js +23 -0
  257. package/dist/types/graph.js.map +1 -0
  258. package/dist/types/pagination.d.ts +8 -0
  259. package/dist/types/pagination.js +3 -0
  260. package/dist/types/pagination.js.map +1 -0
  261. package/dist/types/runLog.d.ts +26 -0
  262. package/dist/types/runLog.js +3 -0
  263. package/dist/types/runLog.js.map +1 -0
  264. package/frontend-vue/.prettierignore +4 -0
  265. package/frontend-vue/app/components/Actions.vue +141 -0
  266. package/frontend-vue/app/components/Catalog/TypeConfigForm.vue +166 -0
  267. package/frontend-vue/app/components/Catalog/TypeList.vue +198 -0
  268. package/frontend-vue/app/components/DataNodes.vue +259 -0
  269. package/frontend-vue/app/components/Editor/BuilderPalette.vue +234 -0
  270. package/frontend-vue/app/components/Editor/GraphCanvas.vue +994 -0
  271. package/frontend-vue/app/components/Editor/GroupPortEditor.vue +289 -0
  272. package/frontend-vue/app/components/Editor/JsonField.vue +63 -0
  273. package/frontend-vue/app/components/Editor/NodeInspector.vue +356 -0
  274. package/frontend-vue/app/components/Editor/ProcedureList.vue +156 -0
  275. package/frontend-vue/app/components/Editor/RunsDrawer.vue +312 -0
  276. package/frontend-vue/app/components/Editor/WiredField.vue +42 -0
  277. package/frontend-vue/app/components/Editor/nodes/GenericNode.vue +161 -0
  278. package/frontend-vue/app/components/Editor/nodes/GroupInputNode.vue +19 -0
  279. package/frontend-vue/app/components/Editor/nodes/GroupNode.vue +200 -0
  280. package/frontend-vue/app/components/Editor/nodes/GroupOutputNode.vue +19 -0
  281. package/frontend-vue/app/components/Editor/nodes/GroupSentinel.vue +161 -0
  282. package/frontend-vue/app/components/Editor/nodes/styles.ts +53 -0
  283. package/frontend-vue/app/components/Editor.vue +2504 -0
  284. package/frontend-vue/app/components/Library.vue +67 -0
  285. package/frontend-vue/app/components/Overview.vue +503 -0
  286. package/frontend-vue/app/components/RunDetailDrawer.vue +165 -0
  287. package/frontend-vue/app/components/RunFireTree.vue +170 -0
  288. package/frontend-vue/app/components/RunTraceDrawer.vue +59 -0
  289. package/frontend-vue/app/components/RunsStats.vue +126 -0
  290. package/frontend-vue/app/components/RunsTimelineDisplay.vue +287 -0
  291. package/frontend-vue/app/components/Templates.vue +663 -0
  292. package/frontend-vue/app/components/Triggers.vue +137 -0
  293. package/frontend-vue/app/composables/useAutomationNodeKinds.ts +48 -0
  294. package/frontend-vue/app/composables/useAutomationRuns.ts +75 -0
  295. package/frontend-vue/app/composables/useGraphConnect.ts +542 -0
  296. package/frontend-vue/app/plugins/runs-timeline-display.client.ts +16 -0
  297. package/frontend-vue/app/utils/automation.ts +51 -0
  298. package/frontend-vue/dms.frontend.ts +26 -0
  299. package/frontend-vue/eslint.config.mjs +24 -0
  300. package/frontend-vue/i18n/locales/dms-automation-en-GB.json +369 -0
  301. package/frontend-vue/i18n/locales/dms-automation-fr-FR.json +369 -0
  302. package/frontend-vue/package.json +33 -0
  303. package/frontend-vue/pnpm-lock.yaml +6483 -0
  304. package/frontend-vue/pnpm-workspace.yaml +2 -0
  305. package/package.json +81 -0
@@ -0,0 +1,2504 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { useElementBounding } from '@vueuse/core'
4
+ import type { BreadcrumbItem } from '@nuxt/ui'
5
+ import type { NodeKindEntry } from '../composables/useAutomationNodeKinds'
6
+ import { type ListEnvelope, newId, unwrapList } from '../utils/automation'
7
+ import type { GroupPort } from '../composables/useGraphConnect'
8
+
9
+ // Editor mode.
10
+ //
11
+ // 'procedure' (default) — loads /api/automation/procedures,
12
+ // manages the selected procedure list, saves via PUT /procedures/:id, exposes
13
+ // the manual Run-now button.
14
+ // 'template' — loads ONE template via GET /api/automation/templates/:id and
15
+ // edits its `subgraph` directly. The template's `ports` are injected as the
16
+ // root canvas's `parentGroupPorts` so the groupInput/groupOutput sentinels at
17
+ // the root level render the template's outer-face handles. Save goes to
18
+ // PUT /api/automation/templates/:id. Run, procedure list, and enabled toggle
19
+ // are hidden.
20
+ //
21
+ // Mode is resolved from props first (so a host page can hard-wire it) and
22
+ // otherwise from the route query: `?template=<id>` switches into template mode.
23
+ // This keeps the route wiring trivial (the Templates catalog navigates
24
+ // to `/modules/automation/builder?template=<id>`) without needing a
25
+ // dedicated page.
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ mode?: 'procedure' | 'template'
29
+ entityId?: string
30
+ }>(),
31
+ { mode: undefined, entityId: undefined },
32
+ )
33
+
34
+ const route = useDmsRoute()
35
+
36
+ // Editor height.
37
+ //
38
+ // The graph canvas must fill the page instead of growing with its content,
39
+ // otherwise the whole page scrolls under a canvas that pans on its own. The
40
+ // dms layout drops page components into plain block containers (UContainer >
41
+ // dms-page-stack), so there is no flex parent whose height we could claim —
42
+ // the height has to come from the viewport.
43
+ //
44
+ // The offset to subtract is measured rather than hardcoded: everything above
45
+ // us (dashboard header, the native page header, the panel's responsive
46
+ // padding) belongs to the dms layer and shifts between its releases and
47
+ // across breakpoints. `100dvh` keeps the viewport half reactive in CSS, so
48
+ // only the offset needs re-measuring. Before the first measurement (SSR,
49
+ // pre-mount) no height is applied and the `min-h-96` floor takes over.
50
+ const editorRoot = ref<HTMLElement | null>(null)
51
+ const { top: editorRootTop } = useElementBounding(editorRoot)
52
+ // Breathing room below the editor, matching the layout panel's bottom padding.
53
+ const EDITOR_BOTTOM_GAP_PX = 24
54
+ const editorHeight = computed(() =>
55
+ editorRootTop.value > 0
56
+ ? `calc(100dvh - ${Math.round(editorRootTop.value)}px - ${EDITOR_BOTTOM_GAP_PX}px)`
57
+ : undefined,
58
+ )
59
+
60
+ const templateQueryId = computed<string | null>(() => {
61
+ const q = route.query.template
62
+ if (typeof q === 'string' && q.length > 0) return q
63
+ if (Array.isArray(q) && typeof q[0] === 'string' && q[0]!.length > 0) return q[0]!
64
+ return null
65
+ })
66
+
67
+ const editorMode = computed<'procedure' | 'template'>(() => {
68
+ if (props.mode) return props.mode
69
+ if (templateQueryId.value) return 'template'
70
+ return 'procedure'
71
+ })
72
+
73
+ const editorTemplateId = computed<string | null>(() => {
74
+ if (editorMode.value !== 'template') return null
75
+ return props.entityId ?? templateQueryId.value
76
+ })
77
+
78
+ interface GraphNode {
79
+ id: string
80
+ // Node kind is a free-form string keyed off the backend kind registry
81
+ // (`/api/automation/node-kinds`); the few kinds with bespoke Vue
82
+ // components (group / groupInput / groupOutput) are compared as
83
+ // literals where needed.
84
+ kind: string
85
+ typeId?: string
86
+ config: Record<string, unknown>
87
+ position: { x: number; y: number }
88
+ // Group-only extras — present iff kind === 'group'.
89
+ ports?: GroupPort[]
90
+ templateId?: string
91
+ subgraph?: ProcedureGraph
92
+ }
93
+
94
+ interface TriggerEdge {
95
+ id: string
96
+ from: { node: string; branch?: string }
97
+ // `to.branch` carries the target's trigger-in port name when the target is
98
+ // a group node or groupOutput sentinel — see src/types/graph.ts.
99
+ to: { node: string; branch?: string }
100
+ }
101
+
102
+ interface DataEdge {
103
+ id: string
104
+ from: { node: string; port: string }
105
+ to: { node: string; field: string }
106
+ }
107
+
108
+ interface ProcedureGraph {
109
+ nodes: GraphNode[]
110
+ triggerEdges: TriggerEdge[]
111
+ dataEdges: DataEdge[]
112
+ }
113
+
114
+ interface ProcedureRecord {
115
+ _id: string
116
+ name: string
117
+ description: string
118
+ enabled: boolean
119
+ graph: ProcedureGraph
120
+ }
121
+
122
+ interface ProcedureApiRecord {
123
+ _id: string
124
+ name: string
125
+ description?: string
126
+ enabled: boolean
127
+ graph: string | ProcedureGraph
128
+ }
129
+
130
+ interface JsonSchemaProperty {
131
+ type?: string
132
+ [key: string]: unknown
133
+ }
134
+
135
+ interface JsonSchema {
136
+ type?: string
137
+ properties?: Record<string, JsonSchemaProperty>
138
+ [key: string]: unknown
139
+ }
140
+
141
+ interface TriggerType {
142
+ id: string
143
+ name: string
144
+ description?: string
145
+ icon?: string
146
+ configSchema?: JsonSchema
147
+ outputSchema?: JsonSchema
148
+ }
149
+
150
+ interface ActionType {
151
+ id: string
152
+ name: string
153
+ description?: string
154
+ icon?: string
155
+ inputSchema?: JsonSchema
156
+ outputSchema?: JsonSchema
157
+ }
158
+
159
+ interface DataNodeType {
160
+ id: string
161
+ category: string
162
+ name: string
163
+ description: string
164
+ icon: string
165
+ inputSchema: JsonSchema
166
+ outputSchema: JsonSchema
167
+ // Kept in sync with the canonical DataNodeType (useGraphConnect.ts).
168
+ configSchema?: JsonSchema
169
+ }
170
+
171
+ const { $authFetch } = useAuthFetch()
172
+ const toast = useToast()
173
+
174
+ const procedures = ref<ProcedureRecord[]>([])
175
+ const selectedId = ref<string | null>(null)
176
+ const showProcedures = ref(false)
177
+ const loading = ref(false)
178
+ const loadError = ref<string | null>(null)
179
+ const saving = ref(false)
180
+ const running = ref(false)
181
+
182
+ // Template-mode state. `templatePorts` holds the OUTER-face ports of the
183
+ // edited template — they're not part of the editable subgraph but they ARE
184
+ // the source of truth for the root-level groupInput/groupOutput sentinels'
185
+ // handle sets. We pipe them through `currentParentGroupPorts` so GraphCanvas
186
+ // renders them identically to how it renders a nested group's sentinels.
187
+ const templatePorts = ref<GroupPort[]>([])
188
+ const templateLoadKey = ref<string | null>(null)
189
+
190
+ const triggerTypes = ref<TriggerType[]>([])
191
+ const actionTypes = ref<ActionType[]>([])
192
+ const dataNodeTypes = ref<DataNodeType[]>([])
193
+ // Per-NodeKind UI metadata served by the backend
194
+ // (`/api/automation/node-kinds`). Drives the editor's GenericNode renderer
195
+ // so the frontend doesn't need to know about specific kinds.
196
+ const nodeKinds = ref<NodeKindEntry[]>([])
197
+
198
+ // Workspace-global templates surfaced in the canvas's node palette.
199
+ // Fetched once on mount alongside the type catalogs. Empty list → palette
200
+ // section hidden (handled inside BuilderPalette).
201
+ //
202
+ // Only the fields the palette + template-instance creation need are kept on
203
+ // the client (id, name, icon, ports). The list endpoint still returns full
204
+ // rows (description, usageCount, hydrated subgraph); the `.map()` below
205
+ // simply discards the extra fields after the fetch.
206
+ interface TemplatePaletteRow {
207
+ _id: string
208
+ name: string
209
+ icon?: string
210
+ ports: GroupPort[]
211
+ }
212
+ interface TemplateListResponse {
213
+ results?: TemplatePaletteRow[]
214
+ items?: TemplatePaletteRow[]
215
+ }
216
+ const templates = ref<TemplatePaletteRow[]>([])
217
+ const templatesLoadError = ref<string | null>(null)
218
+
219
+ async function loadTemplates() {
220
+ try {
221
+ const data = await $authFetch<
222
+ TemplatePaletteRow[] | TemplateListResponse
223
+ >('/api/automation/templates')
224
+ const list = unwrapList<TemplatePaletteRow>(data)
225
+ templates.value = list.map((t) => ({
226
+ _id: t._id,
227
+ name: t.name,
228
+ icon: t.icon,
229
+ ports: Array.isArray(t.ports) ? t.ports : [],
230
+ }))
231
+ templatesLoadError.value = null
232
+ } catch (e) {
233
+ templatesLoadError.value = (e as Error).message ?? 'Failed to load templates'
234
+ templates.value = []
235
+ }
236
+ }
237
+
238
+ // Hide the palette section while editing a template (self-edit). The
239
+ // server's cycle check would still catch any reference cycle, but blocking
240
+ // it at the UI level keeps the UX cleaner — the user can't even attempt
241
+ // to drop a template (including the one currently open) into itself.
242
+ const palettesTemplates = computed<TemplatePaletteRow[] | undefined>(() =>
243
+ editorMode.value === 'template' ? undefined : templates.value,
244
+ )
245
+
246
+ const selectedNodeId = ref<string | null>(null)
247
+ // Mirrors GraphCanvas's @selection-change — full set of currently selected
248
+ // node ids in the canvas, sentinel-filtered. Drives the "Group selection"
249
+ // toolbar button (see canGroupSelection / createGroupFromSelection).
250
+ const selectedNodeIds = ref<string[]>([])
251
+
252
+ function onSelectionChange(payload: { ids: string[] }) {
253
+ selectedNodeIds.value = payload.ids
254
+ }
255
+
256
+ // Subgraph navigation stack. The first entry is always the procedure root;
257
+ // each subsequent entry is a group / template segment the user has entered.
258
+ // Cleared and re-seeded whenever the selected procedure changes (see the
259
+ // watch on selectedId below).
260
+ //
261
+ // kind === 'procedure' — id is the procedure _id, name is its display name.
262
+ // kind === 'group' — id is the group node's id within its parent graph.
263
+ // kind === 'template' — reserved for the dedicated template editor view.
264
+ // Navigation into local groups pushes as 'group';
265
+ // template instances likewise push as 'group' with
266
+ // the read-only signal taken from the node having a
267
+ // templateId, not from the path kind.
268
+ interface SubgraphPathSegment {
269
+ kind: 'procedure' | 'group' | 'template'
270
+ id: string
271
+ name: string
272
+ }
273
+
274
+ const subgraphPath = ref<SubgraphPathSegment[]>([])
275
+
276
+ interface ProcedureSnapshot {
277
+ name: string
278
+ enabled: boolean
279
+ graph: ProcedureGraph
280
+ }
281
+
282
+ const graphHistory = ref<ProcedureGraph[]>([])
283
+ const graphHistoryIndex = ref<number>(-1)
284
+ const savedSnapshot = ref<ProcedureSnapshot | null>(null)
285
+ const HISTORY_LIMIT = 100
286
+
287
+ function cloneGraph(g: ProcedureGraph): ProcedureGraph {
288
+ return JSON.parse(JSON.stringify(g))
289
+ }
290
+
291
+ function takeSnapshot(p: ProcedureRecord): ProcedureSnapshot {
292
+ return { name: p.name, enabled: p.enabled, graph: cloneGraph(p.graph) }
293
+ }
294
+
295
+ // Strip per-node position so pure drag-to-reposition doesn't add an undo
296
+ // step on every pixel. The drag's final position still gets saved by Apply
297
+ // and reverted by Cancel because savedSnapshot keeps the full graph.
298
+ function structuralKey(g: ProcedureGraph): string {
299
+ return JSON.stringify({
300
+ nodes: g.nodes.map(({ position: _pos, ...rest }) => rest),
301
+ triggerEdges: g.triggerEdges,
302
+ dataEdges: g.dataEdges,
303
+ })
304
+ }
305
+
306
+ function pushHistory(g: ProcedureGraph) {
307
+ const current = graphHistory.value[graphHistoryIndex.value]
308
+ if (current && structuralKey(current) === structuralKey(g)) return
309
+ graphHistory.value = graphHistory.value.slice(0, graphHistoryIndex.value + 1)
310
+ graphHistory.value.push(cloneGraph(g))
311
+ graphHistoryIndex.value = graphHistory.value.length - 1
312
+ if (graphHistory.value.length > HISTORY_LIMIT) {
313
+ graphHistory.value.shift()
314
+ graphHistoryIndex.value--
315
+ }
316
+ }
317
+
318
+ function initHistoryAndSaved() {
319
+ if (selected.value) {
320
+ graphHistory.value = [cloneGraph(selected.value.graph)]
321
+ graphHistoryIndex.value = 0
322
+ savedSnapshot.value = takeSnapshot(selected.value)
323
+ } else {
324
+ graphHistory.value = []
325
+ graphHistoryIndex.value = -1
326
+ savedSnapshot.value = null
327
+ }
328
+ }
329
+
330
+ function emptyGraph(): ProcedureGraph {
331
+ return {
332
+ nodes: [
333
+ {
334
+ id: 'trigger-1',
335
+ kind: 'trigger',
336
+ typeId: 'manual',
337
+ config: {},
338
+ position: { x: 100, y: 100 },
339
+ },
340
+ ],
341
+ triggerEdges: [],
342
+ dataEdges: [],
343
+ }
344
+ }
345
+
346
+ function parseGraph(g: string | ProcedureGraph | undefined | null): ProcedureGraph {
347
+ if (!g) return emptyGraph()
348
+ if (typeof g === 'string') {
349
+ try {
350
+ const parsed = JSON.parse(g) as ProcedureGraph
351
+ return parsed && Array.isArray(parsed.nodes) ? parsed : emptyGraph()
352
+ } catch {
353
+ return emptyGraph()
354
+ }
355
+ }
356
+ return g
357
+ }
358
+
359
+ function normalize(r: ProcedureApiRecord): ProcedureRecord {
360
+ return {
361
+ _id: r._id,
362
+ name: r.name,
363
+ description: r.description ?? '',
364
+ enabled: !!r.enabled,
365
+ graph: parseGraph(r.graph),
366
+ }
367
+ }
368
+
369
+ async function loadList() {
370
+ loading.value = true
371
+ loadError.value = null
372
+ try {
373
+ const data = await $authFetch<
374
+ ProcedureApiRecord[] | ListEnvelope<ProcedureApiRecord>
375
+ >('/api/automation/procedures')
376
+ const list = unwrapList<ProcedureApiRecord>(data)
377
+ procedures.value = list.map(normalize)
378
+ if (
379
+ selectedId.value &&
380
+ !procedures.value.some((p) => p._id === selectedId.value)
381
+ ) {
382
+ selectedId.value = null
383
+ }
384
+ if (!selectedId.value && procedures.value.length > 0) {
385
+ const first = procedures.value[0]
386
+ selectedId.value = first ? first._id : null
387
+ }
388
+ } catch (e) {
389
+ loadError.value = (e as Error).message ?? 'Failed to load procedures'
390
+ } finally {
391
+ loading.value = false
392
+ }
393
+ }
394
+
395
+ async function loadTypeCatalogs() {
396
+ try {
397
+ triggerTypes.value = unwrapList<TriggerType>(
398
+ await $authFetch<TriggerType[] | ListEnvelope<TriggerType>>(
399
+ '/api/automation/trigger-types',
400
+ ),
401
+ )
402
+ actionTypes.value = unwrapList<ActionType>(
403
+ await $authFetch<ActionType[] | ListEnvelope<ActionType>>(
404
+ '/api/automation/action-types',
405
+ ),
406
+ )
407
+ dataNodeTypes.value = unwrapList<DataNodeType>(
408
+ await $authFetch<DataNodeType[] | ListEnvelope<DataNodeType>>(
409
+ '/api/automation/data-node-types',
410
+ ),
411
+ )
412
+ nodeKinds.value = await useAutomationNodeKinds().list()
413
+ } catch {
414
+ // Catalogs are optional for editing — silently ignore.
415
+ }
416
+ }
417
+
418
+ interface AutomationTemplateApi {
419
+ _id: string
420
+ name: string
421
+ description?: string
422
+ icon?: string
423
+ ports?: GroupPort[]
424
+ subgraph?: string | ProcedureGraph
425
+ }
426
+
427
+ async function loadTemplate(id: string) {
428
+ loading.value = true
429
+ loadError.value = null
430
+ try {
431
+ const tpl = await $authFetch<AutomationTemplateApi>(
432
+ `/api/automation/templates/${id}`,
433
+ )
434
+ const subgraph = parseGraph(tpl.subgraph)
435
+ // Adapt the template into the ProcedureRecord shape so the rest of
436
+ // the editor (history, dirty-tracking, sentinel sync, group toolbar)
437
+ // keeps working unchanged. `enabled` is meaningless for templates;
438
+ // pin it to true so the "disabled" warning banner never appears.
439
+ const adapted: ProcedureRecord = {
440
+ _id: tpl._id,
441
+ name: tpl.name,
442
+ description: tpl.description ?? '',
443
+ enabled: true,
444
+ graph: subgraph,
445
+ }
446
+ procedures.value = [adapted]
447
+ selectedId.value = adapted._id
448
+ templatePorts.value = Array.isArray(tpl.ports)
449
+ ? tpl.ports.map((p) => ({ ...p }))
450
+ : []
451
+ templateLoadKey.value = id
452
+ } catch (e) {
453
+ loadError.value = (e as Error).message ?? 'Failed to load template'
454
+ procedures.value = []
455
+ selectedId.value = null
456
+ templatePorts.value = []
457
+ } finally {
458
+ loading.value = false
459
+ }
460
+ }
461
+
462
+ if (editorMode.value === 'template') {
463
+ const id = editorTemplateId.value
464
+ if (id) {
465
+ await loadTemplate(id)
466
+ } else {
467
+ loadError.value = 'No template id provided'
468
+ }
469
+ } else {
470
+ await loadList()
471
+ }
472
+ await loadTypeCatalogs()
473
+ await loadTemplates()
474
+
475
+ const selected = computed<ProcedureRecord | null>(
476
+ () => procedures.value.find((p) => p._id === selectedId.value) ?? null,
477
+ )
478
+
479
+ // `watch(selectedId, …)` (declared below) doesn't fire for the value
480
+ // assigned during the awaits above, so seed history + savedSnapshot once
481
+ // for the auto-selected procedure. Must come after `selected` is declared
482
+ // — initHistoryAndSaved() reads selected.value.
483
+ initHistoryAndSaved()
484
+ seedSubgraphPath()
485
+
486
+ function seedSubgraphPath() {
487
+ const p = selected.value
488
+ if (!p) {
489
+ subgraphPath.value = []
490
+ return
491
+ }
492
+ // In template mode the root segment is the template itself (we reuse the
493
+ // 'template' kind already declared on SubgraphPathSegment). The breadcrumb
494
+ // renders it with the i-ph-link icon, which is exactly what we want for
495
+ // "Templates ▸ <name>".
496
+ if (editorMode.value === 'template') {
497
+ subgraphPath.value = [
498
+ { kind: 'template', id: p._id, name: p.name || 'Template' },
499
+ ]
500
+ return
501
+ }
502
+ subgraphPath.value = [
503
+ { kind: 'procedure', id: p._id, name: p.name || 'Procedure' },
504
+ ]
505
+ }
506
+
507
+ // Walk the procedure graph following subgraphPath. Returns the full chain
508
+ // of (segment, containerGraph, groupNode|null) so currentGraph and
509
+ // currentParentGroupPorts can be derived from one traversal without
510
+ // re-walking.
511
+ //
512
+ // If a path segment can't be resolved (e.g. the group node was deleted in
513
+ // the outer graph), traversal stops at the last valid level — the caller
514
+ // is expected to also watch the stale path and trim it (see the watch on
515
+ // `selected.graph`).
516
+ interface SubgraphFrame {
517
+ segment: SubgraphPathSegment
518
+ graph: ProcedureGraph
519
+ // The group node that owns `graph` (i.e. graph === groupNode.subgraph).
520
+ // Null for the procedure root.
521
+ groupNode: GraphNode | null
522
+ }
523
+
524
+ const subgraphFrames = computed<SubgraphFrame[]>(() => {
525
+ const p = selected.value
526
+ if (!p) return []
527
+ const path = subgraphPath.value
528
+ if (path.length === 0) return []
529
+ const frames: SubgraphFrame[] = [
530
+ { segment: path[0]!, graph: p.graph, groupNode: null },
531
+ ]
532
+ for (let i = 1; i < path.length; i++) {
533
+ const seg = path[i]!
534
+ const parent = frames[i - 1]!
535
+ const node = parent.graph.nodes.find(
536
+ (n) => n.id === seg.id && n.kind === 'group',
537
+ )
538
+ if (!node) break
539
+ // Local group → inline subgraph. Template instance → no inline
540
+ // subgraph on the client, so we can't navigate in. The
541
+ // double-click handler guards against pushing a template-instance
542
+ // segment in the first place; this is a safety net.
543
+ const sub = node.subgraph
544
+ if (!sub) break
545
+ frames.push({ segment: seg, graph: sub, groupNode: node })
546
+ }
547
+ return frames
548
+ })
549
+
550
+ const currentGraph = computed<ProcedureGraph | null>(() => {
551
+ const frames = subgraphFrames.value
552
+ if (frames.length === 0) return null
553
+ return frames[frames.length - 1]!.graph
554
+ })
555
+
556
+ // The parent group node whose subgraph we're currently viewing — null at
557
+ // the procedure root.
558
+ const currentParentGroup = computed<GraphNode | null>(() => {
559
+ const frames = subgraphFrames.value
560
+ if (frames.length <= 1) return null
561
+ return frames[frames.length - 1]!.groupNode
562
+ })
563
+
564
+ const currentParentGroupPorts = computed<GroupPort[] | undefined>(() => {
565
+ const g = currentParentGroup.value
566
+ if (g) return Array.isArray(g.ports) ? g.ports : []
567
+ // Root level in template mode: the template's own `ports` are the
568
+ // outer-face of the edited subgraph. Inject them so the root-level
569
+ // groupInput / groupOutput sentinels render the right handles.
570
+ if (editorMode.value === 'template') return templatePorts.value
571
+ return undefined
572
+ })
573
+
574
+ // Read-only mode kicks in when viewing the inner subgraph of a template
575
+ // instance (a group node carrying `templateId`). The fork action lets
576
+ // the user promote it to a local group; until then we just show a
577
+ // banner and disable mutations.
578
+ const isCurrentGraphReadOnly = computed<boolean>(() => {
579
+ const g = currentParentGroup.value
580
+ if (!g) return false
581
+ return Boolean(g.templateId)
582
+ })
583
+
584
+ const breadcrumbItems = computed<BreadcrumbItem[]>(() =>
585
+ subgraphPath.value.map((seg, idx) => ({
586
+ label: seg.name,
587
+ icon:
588
+ seg.kind === 'procedure'
589
+ ? 'i-ph-flow-arrow'
590
+ : seg.kind === 'template'
591
+ ? 'i-ph-link'
592
+ : 'i-ph-package',
593
+ // onSelect fires for clicks; popping the path to `idx + 1` keeps
594
+ // the clicked segment and discards everything deeper.
595
+ onSelect: (ev: Event) => {
596
+ ev.preventDefault()
597
+ if (idx < subgraphPath.value.length - 1) {
598
+ subgraphPath.value = subgraphPath.value.slice(0, idx + 1)
599
+ // Clear node selection — the old selected node id may not
600
+ // exist in the new current graph.
601
+ selectedNodeId.value = null
602
+ }
603
+ },
604
+ })),
605
+ )
606
+
607
+ // If the user deletes a group node from an outer graph while their path
608
+ // points into its subgraph, prune the path back to the deepest valid
609
+ // frame. Cheap because subgraphFrames already does the traversal.
610
+ watch(
611
+ () => subgraphFrames.value.length,
612
+ (framesLen) => {
613
+ if (framesLen > 0 && framesLen < subgraphPath.value.length) {
614
+ subgraphPath.value = subgraphPath.value.slice(0, framesLen)
615
+ selectedNodeId.value = null
616
+ }
617
+ },
618
+ )
619
+
620
+ // Keep the procedure-root breadcrumb label in sync with renames.
621
+ watch(
622
+ () => selected.value?.name,
623
+ (name) => {
624
+ const first = subgraphPath.value[0]
625
+ if (first && first.kind === 'procedure' && name) first.name = name
626
+ },
627
+ )
628
+
629
+ function exitSubgraph() {
630
+ subgraphPath.value = subgraphPath.value.slice(0, -1)
631
+ selectedNodeId.value = null
632
+ }
633
+
634
+ function onGraphUpdate(g: ProcedureGraph) {
635
+ if (!selected.value) return
636
+ const path = subgraphPath.value
637
+ if (path.length <= 1) {
638
+ // At procedure root — write straight through, same as before
639
+ // subgraph navigation existed.
640
+ selected.value.graph = g
641
+ pushHistory(g)
642
+ return
643
+ }
644
+ // At a deeper level — `g` is the subgraph of the deepest group node.
645
+ // Walk the path from the outer procedure graph down, cloning the
646
+ // chain so Vue's reactivity sees a fresh top-level graph (history
647
+ // keys structuralKey() off node identity at every level).
648
+ const rootGraph = cloneGraph(selected.value.graph)
649
+ let containerNodes = rootGraph.nodes
650
+ for (let i = 1; i < path.length; i++) {
651
+ const seg = path[i]!
652
+ const idx = containerNodes.findIndex(
653
+ (n) => n.id === seg.id && n.kind === 'group',
654
+ )
655
+ if (idx === -1) {
656
+ // Path was stale — bail out without mutating; the watch on
657
+ // subgraphFrames.length will prune the path.
658
+ return
659
+ }
660
+ // Replace the node at idx with a shallow clone so we can mutate
661
+ // its `subgraph` without aliasing back into the original (the
662
+ // outer graph already came from cloneGraph above, so its node
663
+ // objects are fresh too — but the inner subgraph reference
664
+ // chain needs explicit substitution).
665
+ const cloned: GraphNode = { ...containerNodes[idx]! }
666
+ containerNodes[idx] = cloned
667
+ if (i === path.length - 1) {
668
+ cloned.subgraph = g
669
+ } else {
670
+ // Mid-chain — keep walking, ensuring each intermediate
671
+ // subgraph is also a fresh object for the next iteration's
672
+ // nodes array.
673
+ if (!cloned.subgraph) return
674
+ cloned.subgraph = {
675
+ ...cloned.subgraph,
676
+ nodes: [...cloned.subgraph.nodes],
677
+ }
678
+ containerNodes = cloned.subgraph.nodes
679
+ }
680
+ }
681
+ selected.value.graph = rootGraph
682
+ pushHistory(rootGraph)
683
+ }
684
+
685
+ const canUndo = computed<boolean>(
686
+ () => graphHistoryIndex.value > 0,
687
+ )
688
+ const canRedo = computed<boolean>(
689
+ () => graphHistoryIndex.value < graphHistory.value.length - 1,
690
+ )
691
+ const isDirty = computed<boolean>(() => {
692
+ if (!selected.value || !savedSnapshot.value) return false
693
+ return (
694
+ JSON.stringify(takeSnapshot(selected.value)) !==
695
+ JSON.stringify(savedSnapshot.value)
696
+ )
697
+ })
698
+
699
+ function onUndo() {
700
+ if (!canUndo.value || !selected.value) return
701
+ graphHistoryIndex.value--
702
+ const g = graphHistory.value[graphHistoryIndex.value]
703
+ if (g) selected.value.graph = cloneGraph(g)
704
+ }
705
+
706
+ function onRedo() {
707
+ if (!canRedo.value || !selected.value) return
708
+ graphHistoryIndex.value++
709
+ const g = graphHistory.value[graphHistoryIndex.value]
710
+ if (g) selected.value.graph = cloneGraph(g)
711
+ }
712
+
713
+ function onCancel() {
714
+ if (!selected.value || !savedSnapshot.value) return
715
+ const snap = savedSnapshot.value
716
+ selected.value.name = snap.name
717
+ selected.value.enabled = snap.enabled
718
+ selected.value.graph = cloneGraph(snap.graph)
719
+ pushHistory(selected.value.graph)
720
+ }
721
+
722
+ const selectedNode = computed<GraphNode | null>(() => {
723
+ const id = selectedNodeId.value
724
+ if (!id) return null
725
+ // Lookup is scoped to whichever graph the canvas is currently
726
+ // bound to, not always the procedure root — otherwise selecting a
727
+ // node inside a subgraph would resolve to null.
728
+ const g = currentGraph.value
729
+ if (!g) return null
730
+ return g.nodes.find((n) => n.id === id) ?? null
731
+ })
732
+
733
+ const selectedNodeTriggerType = computed<TriggerType | null>(() => {
734
+ const n = selectedNode.value
735
+ if (!n || n.kind !== 'trigger') return null
736
+ return triggerTypes.value.find((t) => t.id === n.typeId) ?? null
737
+ })
738
+
739
+ const selectedNodeActionType = computed<ActionType | null>(() => {
740
+ const n = selectedNode.value
741
+ if (!n || n.kind !== 'action') return null
742
+ return actionTypes.value.find((a) => a.id === n.typeId) ?? null
743
+ })
744
+
745
+ function onSelectNode(id: string | null) {
746
+ selectedNodeId.value = id
747
+ }
748
+
749
+ // The node palette lives in the left BuilderPalette sidebar; its add events are
750
+ // forwarded to the canvas' exposed helpers via this template ref.
751
+ const graphCanvasRef = ref<{
752
+ addNode: (kind: string, typeId?: string) => void
753
+ addTemplateInstance: (template: { _id: string; name: string; icon?: string; ports: GroupPort[] }) => void
754
+ } | null>(null)
755
+
756
+ // Runs panel (toolbar button + right slideover). Kept so onRun can refresh the
757
+ // list and its trigger badge once a run has been queued.
758
+ const runsDrawerRef = ref<{ reload: () => void } | null>(null)
759
+
760
+ function onPaletteAddNode(payload: { kind: string; typeId?: string }) {
761
+ graphCanvasRef.value?.addNode(payload.kind, payload.typeId)
762
+ }
763
+ function onPaletteAddTemplate(template: { _id: string; name: string; icon?: string; ports: GroupPort[] }) {
764
+ graphCanvasRef.value?.addTemplateInstance(template)
765
+ }
766
+
767
+ function onProcedurePicked(id: string | null) {
768
+ selectedId.value = id
769
+ if (id) showProcedures.value = false
770
+ }
771
+
772
+ watch(selectedId, () => {
773
+ selectedNodeId.value = null
774
+ initHistoryAndSaved()
775
+ seedSubgraphPath()
776
+ })
777
+
778
+ // Re-fetch when the route's `?template=<id>` changes between templates while
779
+ // the editor is mounted. Editor is mounted once per route navigation so this
780
+ // is mostly a guard for in-place query updates (e.g. catalog "Edit" buttons
781
+ // using router.push).
782
+ watch(templateQueryId, async (id) => {
783
+ if (editorMode.value !== 'template') return
784
+ if (!id || id === templateLoadKey.value) return
785
+ await loadTemplate(id)
786
+ })
787
+
788
+ function onGroupEnter(payload: { id: string }) {
789
+ const g = currentGraph.value
790
+ if (!g) return
791
+ const node = g.nodes.find((n) => n.id === payload.id && n.kind === 'group')
792
+ if (!node) return
793
+ // Template instances have no inline subgraph — the frame walker would
794
+ // stop at this segment and the watcher would prune it back. Bail early so
795
+ // the path doesn't briefly include a doomed segment.
796
+ if (node.templateId && !node.subgraph) return
797
+ const name =
798
+ (node.config?.name as string | undefined) ?? 'Group'
799
+ subgraphPath.value = [
800
+ ...subgraphPath.value,
801
+ { kind: 'group', id: node.id, name },
802
+ ]
803
+ selectedNodeId.value = null
804
+ }
805
+
806
+ // "Enter group" affordance — true iff the currently selected node is a group
807
+ // (local, or a template instance with a resolved subgraph). Read-only state
808
+ // is not consulted: entering a group from a read-only view is allowed.
809
+ // Drives the toolbar button, the inspector button, and the Tab keybinding.
810
+ const canEnterGroup = computed<boolean>(() => {
811
+ const n = selectedNode.value
812
+ if (!n || n.kind !== 'group') return false
813
+ // Template instances need a resolved subgraph to navigate into; without
814
+ // one we can't render the inner view.
815
+ if (n.templateId && !n.subgraph) return false
816
+ return true
817
+ })
818
+
819
+ function enterSelectedGroup() {
820
+ const n = selectedNode.value
821
+ if (!n || n.kind !== 'group') return
822
+ onGroupEnter({ id: n.id })
823
+ }
824
+
825
+ function onUpdateNode(updated: GraphNode) {
826
+ const g = currentGraph.value
827
+ if (!g) return
828
+ const nextGraph: ProcedureGraph = {
829
+ ...g,
830
+ nodes: g.nodes.map((n) => (n.id === updated.id ? updated : n)),
831
+ }
832
+ onGraphUpdate(nextGraph)
833
+ }
834
+
835
+ // Detect port renames between two port arrays of the same group node.
836
+ //
837
+ // Two-pass matching, scoped per (kind, direction):
838
+ // 1. Ports present in BOTH arrays under the same (kind, direction, name) are
839
+ // considered identical — no rename.
840
+ // 2. Of the remaining old/new ports, pair them positionally within each
841
+ // (kind, direction) bucket. Each pair is a rename (old.name → new.name).
842
+ // Unpaired old-only ports are true deletions; unpaired new-only ports are
843
+ // true additions.
844
+ //
845
+ // This is heuristic but the GroupPortEditor emits one mutation per UX action,
846
+ // so each ports:update typically carries a single delta and the positional
847
+ // pairing is unambiguous.
848
+ interface PortRename {
849
+ kind: 'data' | 'trigger'
850
+ direction: 'in' | 'out'
851
+ oldName: string
852
+ newName: string
853
+ }
854
+
855
+ function detectPortRenames(
856
+ oldPorts: GroupPort[],
857
+ newPorts: GroupPort[],
858
+ ): PortRename[] {
859
+ const renames: PortRename[] = []
860
+ const buckets: Array<{ kind: 'data' | 'trigger'; direction: 'in' | 'out' }> = [
861
+ { kind: 'data', direction: 'in' },
862
+ { kind: 'data', direction: 'out' },
863
+ { kind: 'trigger', direction: 'in' },
864
+ { kind: 'trigger', direction: 'out' },
865
+ ]
866
+ for (const b of buckets) {
867
+ const oldInBucket = oldPorts.filter(
868
+ (p) => p.kind === b.kind && p.direction === b.direction,
869
+ )
870
+ const newInBucket = newPorts.filter(
871
+ (p) => p.kind === b.kind && p.direction === b.direction,
872
+ )
873
+ const newNames = new Set(newInBucket.map((p) => p.name))
874
+ const oldNames = new Set(oldInBucket.map((p) => p.name))
875
+ // Ports that exist in both arrays with the same name didn't change.
876
+ const oldOnly = oldInBucket.filter((p) => !newNames.has(p.name))
877
+ const newOnly = newInBucket.filter((p) => !oldNames.has(p.name))
878
+ const pairCount = Math.min(oldOnly.length, newOnly.length)
879
+ for (let i = 0; i < pairCount; i++) {
880
+ renames.push({
881
+ kind: b.kind,
882
+ direction: b.direction,
883
+ oldName: oldOnly[i]!.name,
884
+ newName: newOnly[i]!.name,
885
+ })
886
+ }
887
+ }
888
+ return renames
889
+ }
890
+
891
+ // Rewrite edge endpoints that referenced the renamed port names. Mutates the
892
+ // provided edge arrays in place.
893
+ //
894
+ // `nodeId` is the node owning the renamed ports — either the outer group node
895
+ // (for the outer graph's edges) or a sentinel node id inside the subgraph (for
896
+ // the inner edges). The direction of the rename determines which edge end is
897
+ // affected:
898
+ // • data/in → edge.to.field when edge.to.node === nodeId
899
+ // • data/out → edge.from.port when edge.from.node === nodeId
900
+ // • trigger/in → edge.to.branch when edge.to.node === nodeId
901
+ // • trigger/out → edge.from.branch when edge.from.node === nodeId
902
+ //
903
+ // For the inner subgraph, the sentinels invert the direction (an inbound port
904
+ // on the group is an OUTBOUND handle on the groupInput sentinel and vice
905
+ // versa). Callers pass `invertDirection: true` so a group-level "in" rename is
906
+ // applied to the sentinel's "out" handles, etc.
907
+ function applyPortRenamesToEdges(
908
+ triggerEdges: TriggerEdge[],
909
+ dataEdges: DataEdge[],
910
+ nodeId: string,
911
+ renames: PortRename[],
912
+ invertDirection: boolean,
913
+ ): void {
914
+ for (const r of renames) {
915
+ const effectiveDirection = invertDirection
916
+ ? r.direction === 'in'
917
+ ? 'out'
918
+ : 'in'
919
+ : r.direction
920
+ if (r.kind === 'data') {
921
+ if (effectiveDirection === 'out') {
922
+ for (const e of dataEdges) {
923
+ if (e.from.node === nodeId && e.from.port === r.oldName) {
924
+ e.from.port = r.newName
925
+ }
926
+ }
927
+ } else {
928
+ for (const e of dataEdges) {
929
+ if (e.to.node === nodeId && e.to.field === r.oldName) {
930
+ e.to.field = r.newName
931
+ }
932
+ }
933
+ }
934
+ } else {
935
+ if (effectiveDirection === 'out') {
936
+ for (const e of triggerEdges) {
937
+ if (e.from.node === nodeId && e.from.branch === r.oldName) {
938
+ e.from.branch = r.newName
939
+ }
940
+ }
941
+ } else {
942
+ for (const e of triggerEdges) {
943
+ if (e.to.node === nodeId && e.to.branch === r.oldName) {
944
+ e.to.branch = r.newName
945
+ }
946
+ }
947
+ }
948
+ }
949
+ }
950
+ }
951
+
952
+ // Make sure the group node's local subgraph has the sentinels and that no
953
+ // inner edge references a port that was just removed. The sentinels are
954
+ // auto-managed (the user never creates them by hand); their handle set is
955
+ // derived at render time from the parent group's `ports` array (see
956
+ // GraphCanvas.vue `parent-group-ports` prop plumbing), so this
957
+ // function only owns four responsibilities:
958
+ // 1. Ensure a single groupInput and a single groupOutput exist.
959
+ // 2. Drop data edges whose source/target port no longer exists.
960
+ // 3. Drop trigger edges whose source branch (originating at groupInput)
961
+ // no longer exists.
962
+ // 4. Drop trigger edges into groupOutput whose target port (`to.branch`)
963
+ // no longer exists.
964
+ function syncSentinels(group: GraphNode): void {
965
+ if (!group.subgraph) return
966
+
967
+ let gi = group.subgraph.nodes.find((n) => n.kind === 'groupInput')
968
+ let go = group.subgraph.nodes.find((n) => n.kind === 'groupOutput')
969
+
970
+ if (!gi) {
971
+ gi = {
972
+ id: newId(),
973
+ kind: 'groupInput',
974
+ config: {},
975
+ position: { x: 0, y: 0 },
976
+ }
977
+ group.subgraph.nodes.push(gi)
978
+ }
979
+ if (!go) {
980
+ go = {
981
+ id: newId(),
982
+ kind: 'groupOutput',
983
+ config: {},
984
+ position: { x: 600, y: 0 },
985
+ }
986
+ group.subgraph.nodes.push(go)
987
+ }
988
+
989
+ const ports = Array.isArray(group.ports) ? group.ports : []
990
+ const validDataIn = new Set(
991
+ ports
992
+ .filter((p) => p.kind === 'data' && p.direction === 'in')
993
+ .map((p) => p.name),
994
+ )
995
+ const validDataOut = new Set(
996
+ ports
997
+ .filter((p) => p.kind === 'data' && p.direction === 'out')
998
+ .map((p) => p.name),
999
+ )
1000
+ const validTriggerIn = new Set(
1001
+ ports
1002
+ .filter((p) => p.kind === 'trigger' && p.direction === 'in')
1003
+ .map((p) => p.name),
1004
+ )
1005
+ const validTriggerOut = new Set(
1006
+ ports
1007
+ .filter((p) => p.kind === 'trigger' && p.direction === 'out')
1008
+ .map((p) => p.name),
1009
+ )
1010
+
1011
+ const giId = gi.id
1012
+ const goId = go.id
1013
+
1014
+ group.subgraph.dataEdges = group.subgraph.dataEdges.filter((e) => {
1015
+ if (e.from.node === giId && !validDataIn.has(e.from.port)) return false
1016
+ if (e.to.node === goId && !validDataOut.has(e.to.field)) return false
1017
+ return true
1018
+ })
1019
+
1020
+ group.subgraph.triggerEdges = group.subgraph.triggerEdges.filter((e) => {
1021
+ // trigger edges OUT OF groupInput carry the port name as `from.branch`
1022
+ // (the same convention if/switch use for named branches).
1023
+ if (e.from.node === giId) {
1024
+ const branch = e.from.branch ?? 'main'
1025
+ if (!validTriggerIn.has(branch)) return false
1026
+ }
1027
+ // trigger edges INTO groupOutput carry the target port name on
1028
+ // `to.branch`. If the port has been removed from the group's
1029
+ // declarations, drop the edge.
1030
+ if (e.to.node === goId && e.to.branch !== undefined) {
1031
+ if (!validTriggerOut.has(e.to.branch)) return false
1032
+ }
1033
+ return true
1034
+ })
1035
+ }
1036
+
1037
+ function onPortsUpdate(payload: { nodeId: string; ports: GroupPort[] }) {
1038
+ const g = currentGraph.value
1039
+ if (!g) return
1040
+ const idx = g.nodes.findIndex(
1041
+ (n) => n.id === payload.nodeId && n.kind === 'group',
1042
+ )
1043
+ if (idx === -1) return
1044
+ const existing = g.nodes[idx]!
1045
+ // Template instances don't own a local subgraph — their `ports` come from
1046
+ // the template definition and are read-only on the instance. Defensive:
1047
+ // silently no-op if someone managed to invoke the editor on one.
1048
+ if (existing.templateId) return
1049
+
1050
+ // Detect renames against the PREVIOUS ports array BEFORE we replace it.
1051
+ // Pure additions and pure deletions yield no renames; syncSentinels still
1052
+ // handles deletions by dropping orphaned inner edges, and the outer graph
1053
+ // has no edges for added ports yet, so neither needs port-name rewrites.
1054
+ const oldPorts = Array.isArray(existing.ports) ? existing.ports : []
1055
+ const renames = detectPortRenames(oldPorts, payload.ports)
1056
+
1057
+ // Build a deep-cloned group node so syncSentinels can mutate its subgraph
1058
+ // freely without aliasing into the current graph's state. The outer
1059
+ // onUpdateNode/onGraphUpdate path expects a fresh node reference.
1060
+ const clonedGroup: GraphNode = {
1061
+ ...existing,
1062
+ ports: payload.ports.map((p) => ({ ...p })),
1063
+ subgraph: existing.subgraph
1064
+ ? (JSON.parse(JSON.stringify(existing.subgraph)) as ProcedureGraph)
1065
+ : undefined,
1066
+ }
1067
+
1068
+ // Cascade renames into the INNER subgraph before syncSentinels runs — if
1069
+ // we waited until after, syncSentinels would treat the old port names
1070
+ // still on inner edges as references to deleted ports and drop them.
1071
+ if (renames.length > 0 && clonedGroup.subgraph) {
1072
+ const gi = clonedGroup.subgraph.nodes.find((n) => n.kind === 'groupInput')
1073
+ const go = clonedGroup.subgraph.nodes.find((n) => n.kind === 'groupOutput')
1074
+ // Group-level "in" ports surface as groupInput's OUT handles, and
1075
+ // group-level "out" ports surface as groupOutput's IN handles. We pass
1076
+ // invertDirection=true and target the right sentinel per direction.
1077
+ const inRenames = renames.filter((r) => r.direction === 'in')
1078
+ const outRenames = renames.filter((r) => r.direction === 'out')
1079
+ if (gi && inRenames.length > 0) {
1080
+ applyPortRenamesToEdges(
1081
+ clonedGroup.subgraph.triggerEdges,
1082
+ clonedGroup.subgraph.dataEdges,
1083
+ gi.id,
1084
+ inRenames,
1085
+ true,
1086
+ )
1087
+ }
1088
+ if (go && outRenames.length > 0) {
1089
+ applyPortRenamesToEdges(
1090
+ clonedGroup.subgraph.triggerEdges,
1091
+ clonedGroup.subgraph.dataEdges,
1092
+ go.id,
1093
+ outRenames,
1094
+ true,
1095
+ )
1096
+ }
1097
+ }
1098
+
1099
+ syncSentinels(clonedGroup)
1100
+
1101
+ // Cascade renames into the OUTER graph's edges (where this group node is
1102
+ // an endpoint). We do this by walking the current graph, cloning the
1103
+ // triggerEdges / dataEdges arrays, applying the renames, then going
1104
+ // through onGraphUpdate so history + dirty tracking pick it up. We can't
1105
+ // rely on onUpdateNode for outer-edge changes because it only replaces
1106
+ // the node, not the edges.
1107
+ if (renames.length > 0) {
1108
+ const nextOuter: ProcedureGraph = {
1109
+ ...g,
1110
+ nodes: g.nodes.map((n, i) => (i === idx ? clonedGroup : n)),
1111
+ triggerEdges: g.triggerEdges.map((e) => ({
1112
+ ...e,
1113
+ from: { ...e.from },
1114
+ to: { ...e.to },
1115
+ })),
1116
+ dataEdges: g.dataEdges.map((e) => ({
1117
+ ...e,
1118
+ from: { ...e.from },
1119
+ to: { ...e.to },
1120
+ })),
1121
+ }
1122
+ applyPortRenamesToEdges(
1123
+ nextOuter.triggerEdges,
1124
+ nextOuter.dataEdges,
1125
+ payload.nodeId,
1126
+ renames,
1127
+ false,
1128
+ )
1129
+ onGraphUpdate(nextOuter)
1130
+ return
1131
+ }
1132
+
1133
+ onUpdateNode(clonedGroup)
1134
+ }
1135
+
1136
+ function onUnwireField(payload: { field: string }) {
1137
+ const g = currentGraph.value
1138
+ if (!g || !selectedNode.value) return
1139
+ const nodeId = selectedNode.value.id
1140
+ const nextGraph: ProcedureGraph = {
1141
+ ...g,
1142
+ dataEdges: g.dataEdges.filter(
1143
+ (e) => !(e.to.node === nodeId && e.to.field === payload.field),
1144
+ ),
1145
+ }
1146
+ onGraphUpdate(nextGraph)
1147
+ }
1148
+
1149
+ // Inspector "Delete node" button — same outcome as pressing Delete/Backspace
1150
+ // on the canvas: drop the node plus every trigger/data edge that touches it.
1151
+ function onDeleteNode(payload: { id: string }) {
1152
+ const g = currentGraph.value
1153
+ if (!g || isCurrentGraphReadOnly.value) return
1154
+ const id = payload.id
1155
+ const nextGraph: ProcedureGraph = {
1156
+ ...g,
1157
+ nodes: g.nodes.filter((n) => n.id !== id),
1158
+ triggerEdges: g.triggerEdges.filter(
1159
+ (e) => e.from.node !== id && e.to.node !== id,
1160
+ ),
1161
+ dataEdges: g.dataEdges.filter(
1162
+ (e) => e.from.node !== id && e.to.node !== id,
1163
+ ),
1164
+ }
1165
+ if (selectedNodeId.value === id) selectedNodeId.value = null
1166
+ onGraphUpdate(nextGraph)
1167
+ }
1168
+
1169
+ // ---------------------------------------------------------------------------
1170
+ // Create-group-from-selection
1171
+ // ---------------------------------------------------------------------------
1172
+ // Visible when the user has selected one or more eligible nodes in the
1173
+ // current canvas. "Eligible" rules out kinds that can't legally live inside
1174
+ // a group's subgraph in v1:
1175
+ // • `trigger` — triggers must live at the top level of a procedure.
1176
+ // • `groupInput` / `groupOutput` — sentinels are auto-managed and only
1177
+ // valid inside their owning group.
1178
+ // Selection containing multiple groups IS allowed: nesting an existing
1179
+ // group inside a new one is just a regular subgraph node move; the v1
1180
+ // validator doesn't forbid it, and the executor's depth cap catches
1181
+ // runaway recursion.
1182
+ //
1183
+ // Also gated on:
1184
+ // • An editable graph (not the read-only template-instance view).
1185
+ // • A real procedure being open (selected.value).
1186
+ const canGroupSelection = computed<boolean>(() => {
1187
+ if (!selected.value) return false
1188
+ if (isCurrentGraphReadOnly.value) return false
1189
+ const ids = selectedNodeIds.value
1190
+ if (ids.length === 0) return false
1191
+ const g = currentGraph.value
1192
+ if (!g) return false
1193
+ const idSet = new Set(ids)
1194
+ for (const n of g.nodes) {
1195
+ if (!idSet.has(n.id)) continue
1196
+ if (n.kind === 'trigger') return false
1197
+ if (n.kind === 'groupInput' || n.kind === 'groupOutput') return false
1198
+ }
1199
+ return true
1200
+ })
1201
+
1202
+ // Friendly default port names. Each cut edge maps to a desired name based on
1203
+ // the user-facing label on the cut side; if two edges land on the same name
1204
+ // (e.g. two inside actions both have an `input` field), we suffix `-2`, `-3`
1205
+ // per (kind, direction). The user can rename later in the GroupPortEditor.
1206
+ //
1207
+ // Port name picks:
1208
+ // data-in → target node's input field name
1209
+ // data-out → source node's output port name
1210
+ // trigger-in → source branch label if present, else 'main'
1211
+ // trigger-out → source branch label if present, else 'main'
1212
+ //
1213
+ // The "main" default for non-branching trigger cuts is intentional: the
1214
+ // GroupNode / sentinel renderers place a trigger port named "main" on the
1215
+ // title bar (mirroring ordinary trigger/action nodes), so a typical group
1216
+ // wrapping linear flow looks just like a single action — title-bar in/out.
1217
+ // Branch-labeled ports (if.then, switch.<case>, …) render in the body.
1218
+ function desiredPortNameForTrigger(branch: string | undefined): string {
1219
+ return branch && branch.length > 0 ? branch : 'main'
1220
+ }
1221
+
1222
+ // Dedup against an already-declared set scoped to (kind, direction). The set
1223
+ // is mutated as new names are reserved.
1224
+ function uniqueName(desired: string, taken: Set<string>): string {
1225
+ if (!taken.has(desired)) {
1226
+ taken.add(desired)
1227
+ return desired
1228
+ }
1229
+ let i = 2
1230
+ while (taken.has(`${desired}-${i}`)) i++
1231
+ const final = `${desired}-${i}`
1232
+ taken.add(final)
1233
+ return final
1234
+ }
1235
+
1236
+ interface NewGroupPlan {
1237
+ groupId: string
1238
+ groupPorts: GroupPort[]
1239
+ // Sentinel node ids — the planner generates them up front so its inner
1240
+ // edges reference real ids; the caller installs the sentinel nodes
1241
+ // under these ids.
1242
+ groupInputId: string
1243
+ groupOutputId: string
1244
+ // Edges to add to the outer graph (replacements for cut edges).
1245
+ outerTriggerEdges: TriggerEdge[]
1246
+ outerDataEdges: DataEdge[]
1247
+ // Edges to add inside the new subgraph (connecting sentinels to the
1248
+ // inner endpoints of the cut edges).
1249
+ innerTriggerEdges: TriggerEdge[]
1250
+ innerDataEdges: DataEdge[]
1251
+ }
1252
+
1253
+ // Build a JsonSchema property bag from a kind's declared static port
1254
+ // list — used as the fallback when no type-registry schema applies.
1255
+ function staticPortsToSchema(
1256
+ ports: ReadonlyArray<{ name: string; type?: string }>,
1257
+ ): JsonSchema {
1258
+ const properties: Record<string, JsonSchemaProperty> = {}
1259
+ for (const p of ports) {
1260
+ properties[p.name] = p.type ? { type: p.type } : {}
1261
+ }
1262
+ return { properties }
1263
+ }
1264
+
1265
+ function findSchemaForDataSourcePort(nodeId: string, port: string): JsonSchema | undefined {
1266
+ const g = currentGraph.value
1267
+ if (!g) return undefined
1268
+ const node = g.nodes.find((n) => n.id === nodeId)
1269
+ if (!node) return undefined
1270
+ // `group` carries its ports on the node itself, not in the kind registry.
1271
+ if (node.kind === 'group') {
1272
+ const p = (node.ports ?? []).find(
1273
+ (pp) => pp.kind === 'data' && pp.direction === 'out' && pp.name === port,
1274
+ )
1275
+ return p?.schema as JsonSchema | undefined
1276
+ }
1277
+ const ui = nodeKinds.value.find((k) => k.kind === node.kind)?.meta.ui
1278
+ if (!ui) return undefined
1279
+ let outputSchema: JsonSchema | undefined
1280
+ if (ui.typeRegistry && ui.dataOutsFromTypeSchema) {
1281
+ const key = ui.dataOutsFromTypeSchema
1282
+ if (ui.typeRegistry === 'triggers') {
1283
+ const t = triggerTypes.value.find((tt) => tt.id === node.typeId) as
1284
+ | Record<string, JsonSchema | undefined>
1285
+ | undefined
1286
+ outputSchema = t?.[key]
1287
+ } else if (ui.typeRegistry === 'actions') {
1288
+ const a = actionTypes.value.find((aa) => aa.id === node.typeId) as
1289
+ | Record<string, JsonSchema | undefined>
1290
+ | undefined
1291
+ outputSchema = a?.[key]
1292
+ } else {
1293
+ const d = dataNodeTypes.value.find((dd) => dd.id === node.typeId) as
1294
+ | Record<string, JsonSchema | undefined>
1295
+ | undefined
1296
+ outputSchema = d?.[key]
1297
+ }
1298
+ }
1299
+ if (!outputSchema && ui.staticDataOuts.length > 0) {
1300
+ outputSchema = staticPortsToSchema(ui.staticDataOuts)
1301
+ }
1302
+ const prop = outputSchema?.properties?.[port]
1303
+ if (!prop) return undefined
1304
+ return prop as JsonSchema
1305
+ }
1306
+
1307
+ function findSchemaForDataTargetField(nodeId: string, field: string): JsonSchema | undefined {
1308
+ const g = currentGraph.value
1309
+ if (!g) return undefined
1310
+ const node = g.nodes.find((n) => n.id === nodeId)
1311
+ if (!node) return undefined
1312
+ if (node.kind === 'group') {
1313
+ const p = (node.ports ?? []).find(
1314
+ (pp) => pp.kind === 'data' && pp.direction === 'in' && pp.name === field,
1315
+ )
1316
+ return p?.schema as JsonSchema | undefined
1317
+ }
1318
+ const ui = nodeKinds.value.find((k) => k.kind === node.kind)?.meta.ui
1319
+ if (!ui) return undefined
1320
+ let inputSchema: JsonSchema | undefined
1321
+ if (ui.typeRegistry && ui.dataInsFromTypeSchema) {
1322
+ const key = ui.dataInsFromTypeSchema
1323
+ if (ui.typeRegistry === 'actions') {
1324
+ const a = actionTypes.value.find((aa) => aa.id === node.typeId) as
1325
+ | Record<string, JsonSchema | undefined>
1326
+ | undefined
1327
+ inputSchema = a?.[key]
1328
+ } else if (ui.typeRegistry === 'dataNodes') {
1329
+ const d = dataNodeTypes.value.find((dd) => dd.id === node.typeId) as
1330
+ | Record<string, JsonSchema | undefined>
1331
+ | undefined
1332
+ inputSchema = d?.[key]
1333
+ } else {
1334
+ const t = triggerTypes.value.find((tt) => tt.id === node.typeId) as
1335
+ | Record<string, JsonSchema | undefined>
1336
+ | undefined
1337
+ inputSchema = t?.[key]
1338
+ }
1339
+ }
1340
+ if (!inputSchema && ui.staticDataIns.length > 0) {
1341
+ inputSchema = staticPortsToSchema(ui.staticDataIns)
1342
+ }
1343
+ const prop = inputSchema?.properties?.[field]
1344
+ if (!prop) return undefined
1345
+ return prop as JsonSchema
1346
+ }
1347
+
1348
+ function planNewGroup(
1349
+ g: ProcedureGraph,
1350
+ selection: Set<string>,
1351
+ groupId: string,
1352
+ ): NewGroupPlan {
1353
+ const ports: GroupPort[] = []
1354
+ const outerTriggerEdges: TriggerEdge[] = []
1355
+ const outerDataEdges: DataEdge[] = []
1356
+ const innerTriggerEdges: TriggerEdge[] = []
1357
+ const innerDataEdges: DataEdge[] = []
1358
+
1359
+ // Per (kind, direction) name reservations — keeps dedup scoped so a
1360
+ // data-in "input" and a data-out "output" can coexist even if a
1361
+ // hypothetical trigger-in shared the same desired label.
1362
+ const takenDataIn = new Set<string>()
1363
+ const takenDataOut = new Set<string>()
1364
+ const takenTriggerIn = new Set<string>()
1365
+ const takenTriggerOut = new Set<string>()
1366
+
1367
+ // We need sentinel ids that match the ones syncSentinels will (re)use.
1368
+ // Generate them up front so the inner edges below can reference them;
1369
+ // then we splat them into the subgraph and let syncSentinels see them
1370
+ // already in place.
1371
+ const groupInputId = newId()
1372
+ const groupOutputId = newId()
1373
+
1374
+ for (const e of g.triggerEdges) {
1375
+ const fromIn = selection.has(e.from.node)
1376
+ const toIn = selection.has(e.to.node)
1377
+ if (fromIn === toIn) continue // both inside or both outside → not cut
1378
+ if (fromIn && !toIn) {
1379
+ // Cut: source inside, target outside → declare trigger-out port.
1380
+ const name = uniqueName(
1381
+ desiredPortNameForTrigger(e.from.branch),
1382
+ takenTriggerOut,
1383
+ )
1384
+ ports.push({ kind: 'trigger', direction: 'out', name })
1385
+ // Outer: new edge from the OUTER group node's trigger-out handle
1386
+ // to the original target node. `from.branch` IS the port name —
1387
+ // matches how connect parses `group-trigger-out-<port>`.
1388
+ outerTriggerEdges.push({
1389
+ id: newId(),
1390
+ from: { node: groupId, branch: name },
1391
+ to: { node: e.to.node },
1392
+ })
1393
+ // Inner: original source node fires into groupOutput. Source-side
1394
+ // branch is preserved verbatim (for if/switch routing); target
1395
+ // port name lives on `to.branch` so the renderer can pick the
1396
+ // right `groupOutput-trigger-in-<port>` handle.
1397
+ innerTriggerEdges.push({
1398
+ id: newId(),
1399
+ from: { node: e.from.node, branch: e.from.branch },
1400
+ to: { node: groupOutputId, branch: name },
1401
+ })
1402
+ } else {
1403
+ // Cut: source outside, target inside → declare trigger-in port.
1404
+ const name = uniqueName(
1405
+ desiredPortNameForTrigger(e.from.branch),
1406
+ takenTriggerIn,
1407
+ )
1408
+ ports.push({ kind: 'trigger', direction: 'in', name })
1409
+ // Outer: original source node fires into OUTER group node's
1410
+ // trigger-in handle. Port name lives on `to.branch` (rendered as
1411
+ // `group-trigger-in-<port>` via the renderer).
1412
+ outerTriggerEdges.push({
1413
+ id: newId(),
1414
+ from: { node: e.from.node, branch: e.from.branch },
1415
+ to: { node: groupId, branch: name },
1416
+ })
1417
+ // Inner: groupInput fires into original target. Port name lives
1418
+ // on `from.branch` — matches the syncSentinels filter for
1419
+ // trigger edges out of groupInput.
1420
+ innerTriggerEdges.push({
1421
+ id: newId(),
1422
+ from: { node: groupInputId, branch: name },
1423
+ to: { node: e.to.node },
1424
+ })
1425
+ }
1426
+ }
1427
+
1428
+ for (const e of g.dataEdges) {
1429
+ const fromIn = selection.has(e.from.node)
1430
+ const toIn = selection.has(e.to.node)
1431
+ if (fromIn === toIn) continue
1432
+ if (fromIn && !toIn) {
1433
+ // Cut: source inside, target outside → declare data-out port.
1434
+ const name = uniqueName(e.from.port, takenDataOut)
1435
+ ports.push({
1436
+ kind: 'data',
1437
+ direction: 'out',
1438
+ name,
1439
+ schema: findSchemaForDataSourcePort(e.from.node, e.from.port),
1440
+ })
1441
+ // Outer: group node's data-out → original target field.
1442
+ outerDataEdges.push({
1443
+ id: newId(),
1444
+ from: { node: groupId, port: name },
1445
+ to: { node: e.to.node, field: e.to.field },
1446
+ })
1447
+ // Inner: original source → groupOutput's data-in port (name).
1448
+ innerDataEdges.push({
1449
+ id: newId(),
1450
+ from: { node: e.from.node, port: e.from.port },
1451
+ to: { node: groupOutputId, field: name },
1452
+ })
1453
+ } else {
1454
+ // Cut: source outside, target inside → declare data-in port.
1455
+ const name = uniqueName(e.to.field, takenDataIn)
1456
+ ports.push({
1457
+ kind: 'data',
1458
+ direction: 'in',
1459
+ name,
1460
+ schema: findSchemaForDataTargetField(e.to.node, e.to.field),
1461
+ })
1462
+ // Outer: original source → group node's data-in port.
1463
+ outerDataEdges.push({
1464
+ id: newId(),
1465
+ from: { node: e.from.node, port: e.from.port },
1466
+ to: { node: groupId, field: name },
1467
+ })
1468
+ // Inner: groupInput's data-out → original target field.
1469
+ innerDataEdges.push({
1470
+ id: newId(),
1471
+ from: { node: groupInputId, port: name },
1472
+ to: { node: e.to.node, field: e.to.field },
1473
+ })
1474
+ }
1475
+ }
1476
+
1477
+ return {
1478
+ groupId,
1479
+ groupPorts: ports,
1480
+ groupInputId,
1481
+ groupOutputId,
1482
+ outerTriggerEdges,
1483
+ outerDataEdges,
1484
+ innerTriggerEdges,
1485
+ innerDataEdges,
1486
+ }
1487
+ }
1488
+
1489
+ function createGroupFromSelection() {
1490
+ if (!canGroupSelection.value) return
1491
+ const g = currentGraph.value
1492
+ if (!g) return
1493
+ const ids = selectedNodeIds.value.slice()
1494
+ if (ids.length === 0) return
1495
+
1496
+ // Default name; window.prompt is intentionally lo-fi for v1. A modal
1497
+ // could replace this later, but it keeps the change scope tight.
1498
+ const inputName =
1499
+ typeof window !== 'undefined'
1500
+ ? (window.prompt('Group name', 'Group') ?? '').trim() || 'Group'
1501
+ : 'Group'
1502
+
1503
+ const selection = new Set(ids)
1504
+ const groupId = newId()
1505
+
1506
+ const plan = planNewGroup(g, selection, groupId)
1507
+
1508
+ // Position the new group at the centroid of the selection. We also
1509
+ // shift the selected nodes' positions into the subgraph's coordinate
1510
+ // space: subtract the selection min, then add a left-margin so the
1511
+ // groupInput sentinel can sit at x ≈ 0.
1512
+ const SUBGRAPH_LEFT_MARGIN = 220 // groupInput sits at x = 0 with room
1513
+ const SUBGRAPH_RIGHT_MARGIN = 220
1514
+ const selectedNodes = g.nodes.filter((n) => selection.has(n.id))
1515
+ const xs = selectedNodes.map((n) => n.position.x)
1516
+ const ys = selectedNodes.map((n) => n.position.y)
1517
+ const minX = xs.length ? Math.min(...xs) : 0
1518
+ const maxX = xs.length ? Math.max(...xs) : 0
1519
+ const minY = ys.length ? Math.min(...ys) : 0
1520
+ const maxY = ys.length ? Math.max(...ys) : 0
1521
+ const centerX = xs.length ? (minX + maxX) / 2 : 200
1522
+ const centerY = ys.length ? (minY + maxY) / 2 : 200
1523
+
1524
+ const innerNodes: GraphNode[] = selectedNodes.map((n) => ({
1525
+ ...JSON.parse(JSON.stringify(n)),
1526
+ position: {
1527
+ x: n.position.x - minX + SUBGRAPH_LEFT_MARGIN,
1528
+ y: n.position.y - minY,
1529
+ },
1530
+ }))
1531
+
1532
+ const innerHeight = Math.max(maxY - minY, 0)
1533
+ const innerWidth = Math.max(maxX - minX, 0)
1534
+
1535
+ const groupInputNode: GraphNode = {
1536
+ id: plan.groupInputId,
1537
+ kind: 'groupInput',
1538
+ config: {},
1539
+ position: { x: 0, y: innerHeight / 2 },
1540
+ }
1541
+ const groupOutputNode: GraphNode = {
1542
+ id: plan.groupOutputId,
1543
+ kind: 'groupOutput',
1544
+ config: {},
1545
+ position: {
1546
+ x: innerWidth + SUBGRAPH_LEFT_MARGIN + SUBGRAPH_RIGHT_MARGIN,
1547
+ y: innerHeight / 2,
1548
+ },
1549
+ }
1550
+
1551
+ // Inner edges fully inside the selection — keep them verbatim (re-id
1552
+ // not required since we keep node ids stable; ids only need to be
1553
+ // unique within their containing graph and these are all from the
1554
+ // outer graph we just stripped them from).
1555
+ const innerOriginalTriggerEdges = g.triggerEdges.filter(
1556
+ (e) => selection.has(e.from.node) && selection.has(e.to.node),
1557
+ )
1558
+ const innerOriginalDataEdges = g.dataEdges.filter(
1559
+ (e) => selection.has(e.from.node) && selection.has(e.to.node),
1560
+ )
1561
+
1562
+ const newGroupNode: GraphNode = {
1563
+ id: groupId,
1564
+ kind: 'group',
1565
+ config: { name: inputName },
1566
+ position: { x: centerX, y: centerY },
1567
+ ports: plan.groupPorts,
1568
+ subgraph: {
1569
+ nodes: [...innerNodes, groupInputNode, groupOutputNode],
1570
+ triggerEdges: [...innerOriginalTriggerEdges, ...plan.innerTriggerEdges],
1571
+ dataEdges: [...innerOriginalDataEdges, ...plan.innerDataEdges],
1572
+ },
1573
+ }
1574
+
1575
+ // Build the new outer graph: drop the selected nodes + every edge that
1576
+ // touches them (cut OR fully-inside), then add the new group + the
1577
+ // replacement outer edges.
1578
+ const remainingOuterNodes = g.nodes.filter((n) => !selection.has(n.id))
1579
+ const remainingOuterTriggerEdges = g.triggerEdges.filter(
1580
+ (e) => !selection.has(e.from.node) && !selection.has(e.to.node),
1581
+ )
1582
+ const remainingOuterDataEdges = g.dataEdges.filter(
1583
+ (e) => !selection.has(e.from.node) && !selection.has(e.to.node),
1584
+ )
1585
+
1586
+ const nextGraph: ProcedureGraph = {
1587
+ nodes: [...remainingOuterNodes, newGroupNode],
1588
+ triggerEdges: [...remainingOuterTriggerEdges, ...plan.outerTriggerEdges],
1589
+ dataEdges: [...remainingOuterDataEdges, ...plan.outerDataEdges],
1590
+ }
1591
+
1592
+ // Clear stale selection — the original ids no longer exist in the
1593
+ // outer graph. Select the newly-created group.
1594
+ selectedNodeIds.value = []
1595
+ selectedNodeId.value = groupId
1596
+
1597
+ onGraphUpdate(nextGraph)
1598
+ }
1599
+
1600
+ const canRun = computed<boolean>(() => {
1601
+ const g = selected.value?.graph
1602
+ if (!g) return false
1603
+ return g.nodes.some(
1604
+ (n) => n.kind === 'trigger' && n.typeId === 'manual',
1605
+ )
1606
+ })
1607
+
1608
+ const procedureName = computed<string>({
1609
+ get: () => selected.value?.name ?? '',
1610
+ set: (v: string) => {
1611
+ if (selected.value) selected.value.name = v
1612
+ },
1613
+ })
1614
+
1615
+ const procedureEnabled = computed<boolean>({
1616
+ get: () => selected.value?.enabled ?? false,
1617
+ set: (v: boolean) => {
1618
+ if (selected.value) selected.value.enabled = v
1619
+ },
1620
+ })
1621
+
1622
+ // Triggers other than `manual` only fire when the procedure is enabled and
1623
+ // saved (reconcile activates them); manual still works via Run-now regardless.
1624
+ const hasInactiveTriggers = computed<boolean>(() => {
1625
+ const g = selected.value?.graph
1626
+ if (!g || selected.value?.enabled) return false
1627
+ return g.nodes.some(
1628
+ (n) => n.kind === 'trigger' && n.typeId && n.typeId !== 'manual',
1629
+ )
1630
+ })
1631
+
1632
+ async function onCreate() {
1633
+ try {
1634
+ const body = {
1635
+ name: 'New procedure',
1636
+ description: '',
1637
+ enabled: false,
1638
+ graph: emptyGraph(),
1639
+ }
1640
+ const res = await $authFetch<{ _id: string }>(
1641
+ '/api/automation/procedures',
1642
+ { method: 'POST', body },
1643
+ )
1644
+ const created: ProcedureRecord = {
1645
+ _id: res._id,
1646
+ name: body.name,
1647
+ description: body.description,
1648
+ enabled: body.enabled,
1649
+ graph: body.graph,
1650
+ }
1651
+ procedures.value = [created, ...procedures.value]
1652
+ selectedId.value = created._id
1653
+ toast.add({
1654
+ title: 'Procedure created',
1655
+ color: 'success',
1656
+ icon: 'i-ph-check-circle',
1657
+ })
1658
+ } catch (e) {
1659
+ toast.add({
1660
+ title: 'Failed to create procedure',
1661
+ description: (e as Error).message,
1662
+ color: 'error',
1663
+ icon: 'i-ph-warning',
1664
+ })
1665
+ }
1666
+ }
1667
+
1668
+ async function onDelete(id: string) {
1669
+ try {
1670
+ await $authFetch(`/api/automation/procedures/${id}`, {
1671
+ method: 'DELETE',
1672
+ })
1673
+ procedures.value = procedures.value.filter((p) => p._id !== id)
1674
+ if (selectedId.value === id) {
1675
+ const first = procedures.value[0]
1676
+ selectedId.value = first ? first._id : null
1677
+ }
1678
+ toast.add({
1679
+ title: 'Procedure deleted',
1680
+ color: 'success',
1681
+ icon: 'i-ph-check-circle',
1682
+ })
1683
+ } catch (e) {
1684
+ toast.add({
1685
+ title: 'Failed to delete procedure',
1686
+ description: (e as Error).message,
1687
+ color: 'error',
1688
+ icon: 'i-ph-warning',
1689
+ })
1690
+ }
1691
+ }
1692
+
1693
+ async function onSave() {
1694
+ if (!selected.value) return
1695
+ saving.value = true
1696
+ const current = selected.value
1697
+ try {
1698
+ if (editorMode.value === 'template') {
1699
+ // Template save — the PUT route's Zod requires name + ports +
1700
+ // subgraph and re-runs validateGraph + cycle detection. We send
1701
+ // the in-memory state verbatim; description/icon are not edited
1702
+ // from the procedure-style editor chrome yet (catalog page owns
1703
+ // metadata edits) so we preserve whatever was loaded.
1704
+ const body = {
1705
+ name: current.name,
1706
+ description: current.description,
1707
+ icon: 'i-ph-package',
1708
+ ports: templatePorts.value.map((p) => ({ ...p })),
1709
+ subgraph: current.graph,
1710
+ }
1711
+ await $authFetch(`/api/automation/templates/${current._id}`, {
1712
+ method: 'PUT',
1713
+ body,
1714
+ })
1715
+ savedSnapshot.value = takeSnapshot(current)
1716
+ toast.add({
1717
+ title: 'Template saved',
1718
+ description: current.name,
1719
+ color: 'success',
1720
+ icon: 'i-ph-check-circle',
1721
+ })
1722
+ return
1723
+ }
1724
+ const body = {
1725
+ name: current.name,
1726
+ description: current.description,
1727
+ enabled: current.enabled,
1728
+ graph: current.graph,
1729
+ }
1730
+ await $authFetch(`/api/automation/procedures/${current._id}`, {
1731
+ method: 'PUT',
1732
+ body,
1733
+ })
1734
+ savedSnapshot.value = takeSnapshot(current)
1735
+ toast.add({
1736
+ title: 'Procedure saved',
1737
+ description: current.name,
1738
+ color: 'success',
1739
+ icon: 'i-ph-check-circle',
1740
+ })
1741
+ } catch (e) {
1742
+ toast.add({
1743
+ title:
1744
+ editorMode.value === 'template'
1745
+ ? 'Failed to save template'
1746
+ : 'Failed to save procedure',
1747
+ description: (e as Error).message,
1748
+ color: 'error',
1749
+ icon: 'i-ph-warning',
1750
+ })
1751
+ } finally {
1752
+ saving.value = false
1753
+ }
1754
+ }
1755
+
1756
+ // ---------------------------------------------------------------------------
1757
+ // Save-as-template
1758
+ // ---------------------------------------------------------------------------
1759
+ // Visible when the user has selected a single local group (kind === 'group',
1760
+ // no templateId). On submit:
1761
+ // 1. POST /api/automation/templates with { name, description, icon, ports,
1762
+ // subgraph } from the local group.
1763
+ // 2. On success: mutate the in-graph node — set templateId = res._id, drop
1764
+ // subgraph. The save flow (onGraphUpdate → onSave by user) propagates.
1765
+ // 3. On failure (Zod or template cycle): render the error body inline in
1766
+ // the modal; the graph is NOT touched.
1767
+ const selectedLocalGroupNode = computed<GraphNode | null>(() => {
1768
+ const n = selectedNode.value
1769
+ if (!n || n.kind !== 'group') return null
1770
+ if (n.templateId) return null
1771
+ return n
1772
+ })
1773
+
1774
+ const canSaveAsTemplate = computed<boolean>(() => {
1775
+ if (!selected.value) return false
1776
+ if (isCurrentGraphReadOnly.value) return false
1777
+ return selectedLocalGroupNode.value !== null
1778
+ })
1779
+
1780
+ const saveAsTemplateOpen = ref<boolean>(false)
1781
+ const saveAsTemplateName = ref<string>('')
1782
+ const saveAsTemplateDescription = ref<string>('')
1783
+ const saveAsTemplateIcon = ref<string>('i-ph-package')
1784
+ const saveAsTemplateSubmitting = ref<boolean>(false)
1785
+ const saveAsTemplateError = ref<string | null>(null)
1786
+
1787
+ function openSaveAsTemplate() {
1788
+ const n = selectedLocalGroupNode.value
1789
+ if (!n) return
1790
+ saveAsTemplateName.value =
1791
+ (n.config?.name as string | undefined) ?? 'Template'
1792
+ saveAsTemplateDescription.value = ''
1793
+ saveAsTemplateIcon.value = 'i-ph-package'
1794
+ saveAsTemplateError.value = null
1795
+ saveAsTemplateSubmitting.value = false
1796
+ saveAsTemplateOpen.value = true
1797
+ }
1798
+
1799
+ // Render the 400 body from src/routes/templates.ts:
1800
+ // - Zod failure → { error: 'invalid body', issues: ZodIssue[] }
1801
+ // - validateGraph failure → { error: 'invalid graph', issues: string[] }
1802
+ // - cycle → { error: 'template reference cycle', cycle: string[] }
1803
+ // Other errors (network, 401, 500) fall back to the message string.
1804
+ function formatTemplateError(err: unknown): string {
1805
+ const data = (err as { data?: unknown; message?: string } | undefined)?.data as
1806
+ | {
1807
+ error?: string
1808
+ issues?: Array<{ path?: Array<string | number>; message?: string } | string>
1809
+ cycle?: string[]
1810
+ }
1811
+ | undefined
1812
+ if (data && typeof data === 'object') {
1813
+ const head = data.error ?? 'Request failed'
1814
+ if (Array.isArray(data.issues) && data.issues.length > 0) {
1815
+ const lines = data.issues.map((iss) => {
1816
+ if (typeof iss === 'string') return iss
1817
+ const path = Array.isArray(iss.path) ? iss.path.join('.') : ''
1818
+ const msg = iss.message ?? 'invalid'
1819
+ return path ? `${path}: ${msg}` : msg
1820
+ })
1821
+ return `${head}\n${lines.join('\n')}`
1822
+ }
1823
+ if (Array.isArray(data.cycle) && data.cycle.length > 0) {
1824
+ return `${head}: ${data.cycle.join(' → ')}`
1825
+ }
1826
+ return head
1827
+ }
1828
+ return (err as Error).message ?? 'Failed to save template'
1829
+ }
1830
+
1831
+ function convertLocalGroupToTemplateInstance(
1832
+ nodeId: string,
1833
+ templateId: string,
1834
+ ): void {
1835
+ const g = currentGraph.value
1836
+ if (!g) return
1837
+ const existing = g.nodes.find((n) => n.id === nodeId && n.kind === 'group')
1838
+ if (!existing) return
1839
+ const updated: GraphNode = { ...existing, templateId }
1840
+ delete updated.subgraph
1841
+ onUpdateNode(updated)
1842
+ }
1843
+
1844
+ async function submitSaveAsTemplate() {
1845
+ const n = selectedLocalGroupNode.value
1846
+ if (!n) return
1847
+ const name = saveAsTemplateName.value.trim()
1848
+ if (!name) {
1849
+ saveAsTemplateError.value = 'Name is required'
1850
+ return
1851
+ }
1852
+ if (!n.subgraph) {
1853
+ saveAsTemplateError.value = 'Group has no subgraph to save'
1854
+ return
1855
+ }
1856
+ saveAsTemplateSubmitting.value = true
1857
+ saveAsTemplateError.value = null
1858
+ try {
1859
+ const body = {
1860
+ name,
1861
+ description: saveAsTemplateDescription.value.trim(),
1862
+ icon: saveAsTemplateIcon.value.trim() || 'i-ph-package',
1863
+ ports: Array.isArray(n.ports) ? n.ports : [],
1864
+ subgraph: n.subgraph,
1865
+ }
1866
+ const created = await $authFetch<{ _id: string }>(
1867
+ '/api/automation/templates',
1868
+ { method: 'POST', body },
1869
+ )
1870
+ convertLocalGroupToTemplateInstance(n.id, created._id)
1871
+ saveAsTemplateOpen.value = false
1872
+ toast.add({
1873
+ title: 'Template saved',
1874
+ description: name,
1875
+ color: 'success',
1876
+ icon: 'i-ph-check-circle',
1877
+ })
1878
+ } catch (e) {
1879
+ saveAsTemplateError.value = formatTemplateError(e)
1880
+ } finally {
1881
+ saveAsTemplateSubmitting.value = false
1882
+ }
1883
+ }
1884
+
1885
+ // ---------------------------------------------------------------------------
1886
+ // Fork-to-local-group
1887
+ // ---------------------------------------------------------------------------
1888
+ // Visible when a template-instance group node is selected — i.e. kind === 'group'
1889
+ // AND templateId is set. The fork is the escape hatch from the live-link
1890
+ // model: it converts the instance back to a local group by deep-cloning the
1891
+ // template's current subgraph into the node and dropping templateId.
1892
+ //
1893
+ // Selection rules:
1894
+ // • Allowed even from the read-only inner-subgraph view of a template
1895
+ // instance — this is the "Fork to edit" CTA flow. The action mutates
1896
+ // the OUTER graph (the procedure root or whichever container holds
1897
+ // the template instance), not the inner subgraph being viewed.
1898
+ // • To make that work, the selection target for the fork is the deepest
1899
+ // currentParentGroup when we're inside a template-instance subgraph,
1900
+ // OR the selectedNode otherwise.
1901
+ //
1902
+ // The deep-clone uses a JSON parse/stringify round-trip: structuredClone
1903
+ // throws on Vue's reactive proxies (see the inline note at the clone site).
1904
+ //
1905
+ // Edge case: if the template was deleted between selection and confirm,
1906
+ // GET /:id returns 404 → we surface that as an error in the modal so the
1907
+ // user can dismiss and stop trying.
1908
+ interface ForkTarget {
1909
+ // The group node to convert (the template instance).
1910
+ node: GraphNode
1911
+ // The graph in which that node lives. May be the procedure root or any
1912
+ // outer subgraph (when the user is viewing the inner read-only graph of
1913
+ // an instance, the instance node lives one frame up).
1914
+ containerGraph: ProcedureGraph
1915
+ }
1916
+
1917
+ const forkTarget = computed<ForkTarget | null>(() => {
1918
+ if (!selected.value) return null
1919
+ const frames = subgraphFrames.value
1920
+ // Case A: user is INSIDE a template-instance subgraph (read-only view).
1921
+ // The instance node is the parent group; its container is the previous
1922
+ // frame's graph.
1923
+ if (isCurrentGraphReadOnly.value && frames.length >= 2) {
1924
+ const parent = frames[frames.length - 1]!.groupNode
1925
+ const container = frames[frames.length - 2]!.graph
1926
+ if (parent && parent.kind === 'group' && parent.templateId) {
1927
+ return { node: parent, containerGraph: container }
1928
+ }
1929
+ }
1930
+ // Case B: user has a template-instance node selected in the current
1931
+ // (editable) graph.
1932
+ const sel = selectedNode.value
1933
+ const g = currentGraph.value
1934
+ if (sel && g && sel.kind === 'group' && sel.templateId) {
1935
+ return { node: sel, containerGraph: g }
1936
+ }
1937
+ return null
1938
+ })
1939
+
1940
+ const canForkToLocal = computed<boolean>(() => forkTarget.value !== null)
1941
+
1942
+ const forkOpen = ref<boolean>(false)
1943
+ const forkSubmitting = ref<boolean>(false)
1944
+ const forkError = ref<string | null>(null)
1945
+
1946
+ function openFork() {
1947
+ if (!canForkToLocal.value) return
1948
+ forkError.value = null
1949
+ forkSubmitting.value = false
1950
+ forkOpen.value = true
1951
+ }
1952
+
1953
+ interface FetchedTemplate {
1954
+ _id: string
1955
+ name: string
1956
+ description: string
1957
+ icon: string
1958
+ ports: GroupPort[]
1959
+ subgraph: ProcedureGraph
1960
+ }
1961
+
1962
+ // Replace the template instance node with a forked local copy. Mirrors the
1963
+ // shape of convertLocalGroupToTemplateInstance but in reverse: drops
1964
+ // templateId, installs subgraph from the fetched template, and writes back
1965
+ // through onUpdateNode / onGraphUpdate (so history + dirty-snapshot stay
1966
+ // consistent). The replacement uses a fresh node object so Vue reactivity
1967
+ // sees a real change.
1968
+ function convertTemplateInstanceToLocal(
1969
+ target: ForkTarget,
1970
+ template: FetchedTemplate,
1971
+ ): void {
1972
+ const { node, containerGraph } = target
1973
+ const idx = containerGraph.nodes.findIndex(
1974
+ (n) => n.id === node.id && n.kind === 'group',
1975
+ )
1976
+ if (idx === -1) return
1977
+ const updated: GraphNode = {
1978
+ ...node,
1979
+ // Deep-clone the template's subgraph so subsequent edits to the
1980
+ // template (or to this fork) don't alias each other. JSON round-trip
1981
+ // strips Vue's reactive proxy — structuredClone throws on proxies.
1982
+ subgraph: JSON.parse(JSON.stringify(template.subgraph)) as ProcedureGraph,
1983
+ // Preserve the local ports if the instance carries them (it should
1984
+ // — palette drops clone template.ports onto the instance); fall back
1985
+ // to the template's port list otherwise.
1986
+ ports: Array.isArray(node.ports) && node.ports.length > 0
1987
+ ? node.ports.map((p) => ({ ...p }))
1988
+ : template.ports.map((p) => ({ ...p })),
1989
+ }
1990
+ delete updated.templateId
1991
+
1992
+ // If the user was inside the template instance's subgraph (Case A in
1993
+ // forkTarget), pop the read-only path segment first so the canvas
1994
+ // re-binds to the (now editable) outer graph. The user can re-enter
1995
+ // the local subgraph fresh via dblclick if they want.
1996
+ if (isCurrentGraphReadOnly.value && subgraphPath.value.length > 1) {
1997
+ subgraphPath.value = subgraphPath.value.slice(0, -1)
1998
+ selectedNodeId.value = updated.id
1999
+ }
2000
+
2001
+ // Use onUpdateNode — it walks subgraphPath and rewrites the correct
2002
+ // container graph (root or any depth). Because we just popped above
2003
+ // when needed, currentGraph now points at containerGraph.
2004
+ onUpdateNode(updated)
2005
+ }
2006
+
2007
+ function formatForkError(err: unknown): string {
2008
+ const status = (err as { statusCode?: number; status?: number } | undefined)
2009
+ const code = status?.statusCode ?? status?.status
2010
+ if (code === 404) {
2011
+ return 'This template no longer exists. It may have been deleted by another user.'
2012
+ }
2013
+ const data = (err as { data?: { error?: string } } | undefined)?.data
2014
+ if (data?.error) return data.error
2015
+ return (err as Error).message ?? 'Failed to fork template instance'
2016
+ }
2017
+
2018
+ async function submitFork() {
2019
+ const target = forkTarget.value
2020
+ if (!target) return
2021
+ const templateId = target.node.templateId
2022
+ if (!templateId) return
2023
+ forkSubmitting.value = true
2024
+ forkError.value = null
2025
+ try {
2026
+ const tpl = await $authFetch<FetchedTemplate>(
2027
+ `/api/automation/templates/${templateId}`,
2028
+ )
2029
+ convertTemplateInstanceToLocal(target, tpl)
2030
+ forkOpen.value = false
2031
+ toast.add({
2032
+ title: 'Forked to local group',
2033
+ description:
2034
+ (target.node.config?.name as string | undefined) ?? tpl.name,
2035
+ color: 'success',
2036
+ icon: 'i-ph-check-circle',
2037
+ })
2038
+ } catch (e) {
2039
+ forkError.value = formatForkError(e)
2040
+ } finally {
2041
+ forkSubmitting.value = false
2042
+ }
2043
+ }
2044
+
2045
+ // Tab / Shift+Tab / Escape keybindings for subgraph navigation. Tab enters
2046
+ // the selected group (if eligible); Shift+Tab and Escape pop one level out.
2047
+ // We ignore the key when focus is in an editable field (input, textarea,
2048
+ // contenteditable, USelect popovers) so typing a literal Tab in the
2049
+ // inspector still works.
2050
+ function isEditableTarget(target: EventTarget | null): boolean {
2051
+ if (!(target instanceof HTMLElement)) return false
2052
+ const tag = target.tagName
2053
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
2054
+ if (target.isContentEditable) return true
2055
+ return false
2056
+ }
2057
+
2058
+ function onKeyDown(ev: KeyboardEvent) {
2059
+ if (isEditableTarget(ev.target)) return
2060
+ // Modal / drawer open → don't hijack keys.
2061
+ if (saveAsTemplateOpen.value || forkOpen.value || showProcedures.value) return
2062
+ if (ev.key === 'Tab' && !ev.shiftKey) {
2063
+ if (!canEnterGroup.value) return
2064
+ ev.preventDefault()
2065
+ enterSelectedGroup()
2066
+ return
2067
+ }
2068
+ if ((ev.key === 'Tab' && ev.shiftKey) || ev.key === 'Escape') {
2069
+ if (subgraphPath.value.length <= 1) return
2070
+ ev.preventDefault()
2071
+ exitSubgraph()
2072
+ }
2073
+ }
2074
+
2075
+ onMounted(() => {
2076
+ if (typeof window === 'undefined') return
2077
+ window.addEventListener('keydown', onKeyDown)
2078
+ })
2079
+ onBeforeUnmount(() => {
2080
+ if (typeof window === 'undefined') return
2081
+ window.removeEventListener('keydown', onKeyDown)
2082
+ })
2083
+
2084
+ async function onRun() {
2085
+ if (!selected.value) return
2086
+ running.value = true
2087
+ const id = selected.value._id
2088
+ try {
2089
+ const res = await $authFetch<{ runId: string }>(
2090
+ `/api/automation/procedures/${id}/run`,
2091
+ { method: 'POST', body: {} },
2092
+ )
2093
+ toast.add({
2094
+ title: 'Run started',
2095
+ description: res.runId ? `runId: ${res.runId}` : id,
2096
+ color: 'success',
2097
+ icon: 'i-ph-play-circle',
2098
+ })
2099
+ // Reflect the freshly-queued run in the runs panel and its trigger badge.
2100
+ runsDrawerRef.value?.reload()
2101
+ } catch (e) {
2102
+ toast.add({
2103
+ title: 'Failed to start run',
2104
+ description: (e as Error).message,
2105
+ color: 'error',
2106
+ icon: 'i-ph-warning',
2107
+ })
2108
+ } finally {
2109
+ running.value = false
2110
+ }
2111
+ }
2112
+ </script>
2113
+
2114
+ <template>
2115
+ <!--
2116
+ The page (src/pages/builder.ts) renders the native dms header
2117
+ (icon / title / description), so the editor doesn't repeat one here.
2118
+ Height comes from `editorHeight` (see above); the `min-h-0` / `flex-1`
2119
+ chain below passes it down to the canvas, and `min-h-96` keeps the
2120
+ canvas usable on short viewports at the cost of scrolling there.
2121
+ -->
2122
+ <div ref="editorRoot" class="flex min-h-96 flex-col gap-4" :style="{ height: editorHeight }">
2123
+ <UAlert
2124
+ v-if="loadError"
2125
+ color="error"
2126
+ variant="subtle"
2127
+ icon="i-ph-warning"
2128
+ :title="
2129
+ editorMode === 'template'
2130
+ ? $t('dms_automation.editor.loadTemplateError')
2131
+ : $t('dms_automation.editor.loadProceduresError')
2132
+ "
2133
+ :description="loadError"
2134
+ />
2135
+
2136
+ <DmsCard :padded="false">
2137
+ <div class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
2138
+ <UButton
2139
+ v-if="editorMode === 'procedure'"
2140
+ icon="i-ph-list-bullets"
2141
+ variant="outline"
2142
+ color="neutral"
2143
+ @click="showProcedures = true"
2144
+ >
2145
+ {{ $t('dms_automation.editor.procedures') }}
2146
+ </UButton>
2147
+ <UInput
2148
+ v-if="selected"
2149
+ v-model="procedureName"
2150
+ :placeholder="
2151
+ editorMode === 'template'
2152
+ ? $t('dms_automation.editor.templateName')
2153
+ : $t('dms_automation.editor.procedureName')
2154
+ "
2155
+ class="min-w-[200px] flex-1"
2156
+ />
2157
+ <div
2158
+ v-if="selected && editorMode === 'procedure'"
2159
+ class="flex items-center gap-2"
2160
+ >
2161
+ <span class="text-sm text-muted">{{ $t('dms_automation.editor.enabled') }}</span>
2162
+ <USwitch v-model="procedureEnabled" />
2163
+ </div>
2164
+ <UButton
2165
+ v-if="selected && editorMode === 'procedure'"
2166
+ color="success"
2167
+ icon="i-ph-play"
2168
+ variant="solid"
2169
+ :disabled="!canRun"
2170
+ :loading="running"
2171
+ @click="onRun"
2172
+ >
2173
+ {{ $t('dms_automation.editor.runNow') }}
2174
+ </UButton>
2175
+ <DmsAutomationRunsDrawer
2176
+ v-if="selected && editorMode === 'procedure'"
2177
+ ref="runsDrawerRef"
2178
+ :procedure-id="selectedId"
2179
+ :procedure-name="procedureName"
2180
+ />
2181
+ <div
2182
+ v-if="!selected && editorMode === 'procedure'"
2183
+ class="text-sm text-dimmed"
2184
+ >
2185
+ {{ $t('dms_automation.editor.noProcedure') }}
2186
+ </div>
2187
+ <div
2188
+ v-if="!selected && editorMode === 'template'"
2189
+ class="text-sm text-dimmed"
2190
+ >
2191
+ {{ $t('dms_automation.editor.templateNotLoaded') }}
2192
+ </div>
2193
+ <UButton
2194
+ class="ml-auto"
2195
+ color="neutral"
2196
+ variant="outline"
2197
+ icon="i-ph-arrows-clockwise"
2198
+ :loading="loading"
2199
+ @click="
2200
+ editorMode === 'template' && editorTemplateId
2201
+ ? loadTemplate(editorTemplateId)
2202
+ : loadList()
2203
+ "
2204
+ >
2205
+ {{ $t('dms_automation.common.refresh') }}
2206
+ </UButton>
2207
+ </div>
2208
+ </DmsCard>
2209
+
2210
+ <div v-if="selected" class="flex min-h-0 flex-1 flex-col gap-4">
2211
+ <UAlert
2212
+ v-if="hasInactiveTriggers && editorMode === 'procedure'"
2213
+ color="warning"
2214
+ variant="subtle"
2215
+ icon="i-ph-warning"
2216
+ :title="$t('dms_automation.editor.disabledTitle')"
2217
+ :description="$t('dms_automation.editor.disabledDescription')"
2218
+ :actions="[
2219
+ {
2220
+ label: $t('dms_automation.editor.enableAndSave'),
2221
+ color: 'warning',
2222
+ variant: 'solid',
2223
+ loading: saving,
2224
+ onClick: () => {
2225
+ procedureEnabled = true
2226
+ onSave()
2227
+ },
2228
+ },
2229
+ ]"
2230
+ />
2231
+ <div
2232
+ v-if="subgraphPath.length > 1"
2233
+ class="flex items-center justify-between gap-3 rounded-lg border border-default bg-elevated px-3 py-2"
2234
+ >
2235
+ <UBreadcrumb
2236
+ :items="breadcrumbItems"
2237
+ :ui="{ linkLabel: 'first-letter:uppercase' }"
2238
+ />
2239
+ <UButton
2240
+ v-if="subgraphPath.length > 1"
2241
+ size="xs"
2242
+ variant="ghost"
2243
+ color="neutral"
2244
+ icon="i-ph-arrow-up-left"
2245
+ @click="exitSubgraph"
2246
+ >
2247
+ {{ $t('dms_automation.editor.exitSubgraph') }}
2248
+ </UButton>
2249
+ </div>
2250
+ <UAlert
2251
+ v-if="isCurrentGraphReadOnly"
2252
+ color="info"
2253
+ variant="subtle"
2254
+ icon="i-ph-link"
2255
+ :title="$t('dms_automation.editor.linkedTitle')"
2256
+ :description="$t('dms_automation.editor.linkedDescription')"
2257
+ :actions="canForkToLocal ? [
2258
+ {
2259
+ label: $t('dms_automation.editor.forkToEdit'),
2260
+ color: 'info',
2261
+ variant: 'solid',
2262
+ icon: 'i-ph-git-fork',
2263
+ onClick: openFork,
2264
+ },
2265
+ ] : []"
2266
+ />
2267
+ <div class="flex min-h-0 flex-1 gap-4">
2268
+ <DmsAutomationBuilderPalette
2269
+ v-if="currentGraph && !isCurrentGraphReadOnly"
2270
+ :node-kinds="nodeKinds"
2271
+ :trigger-types="triggerTypes"
2272
+ :action-types="actionTypes"
2273
+ :data-node-types="dataNodeTypes"
2274
+ :templates="palettesTemplates"
2275
+ class="w-60 shrink-0"
2276
+ @add-node="onPaletteAddNode"
2277
+ @add-template="onPaletteAddTemplate"
2278
+ />
2279
+ <div class="h-full min-w-0 flex-1">
2280
+ <ClientOnly>
2281
+ <DmsAutomationGraphCanvas
2282
+ v-if="currentGraph"
2283
+ ref="graphCanvasRef"
2284
+ :graph="currentGraph"
2285
+ :trigger-types="triggerTypes"
2286
+ :action-types="actionTypes"
2287
+ :data-node-types="dataNodeTypes"
2288
+ :node-kinds="nodeKinds"
2289
+ :can-undo="canUndo"
2290
+ :can-redo="canRedo"
2291
+ :is-dirty="isDirty"
2292
+ :applying="saving"
2293
+ :parent-group-ports="currentParentGroupPorts"
2294
+ :read-only="isCurrentGraphReadOnly"
2295
+ @update:graph="onGraphUpdate"
2296
+ @select:node="onSelectNode"
2297
+ @selection-change="onSelectionChange"
2298
+ @undo="onUndo"
2299
+ @redo="onRedo"
2300
+ @cancel="onCancel"
2301
+ @apply="onSave"
2302
+ @group:enter="onGroupEnter"
2303
+ >
2304
+ <template
2305
+ v-if="canGroupSelection || canSaveAsTemplate || canForkToLocal || canEnterGroup"
2306
+ #groupActions
2307
+ >
2308
+ <UButton
2309
+ v-if="canEnterGroup"
2310
+ size="sm"
2311
+ color="primary"
2312
+ icon="i-ph-arrow-square-in"
2313
+ variant="ghost"
2314
+ @click="enterSelectedGroup"
2315
+ >
2316
+ {{ $t('dms_automation.editor.enterGroup') }}
2317
+ </UButton>
2318
+ <UButton
2319
+ v-if="canGroupSelection"
2320
+ size="sm"
2321
+ color="primary"
2322
+ icon="i-ph-package"
2323
+ variant="ghost"
2324
+ @click="createGroupFromSelection"
2325
+ >
2326
+ {{ $t('dms_automation.editor.groupSelection', { n: selectedNodeIds.length }) }}
2327
+ </UButton>
2328
+ <UButton
2329
+ v-if="canSaveAsTemplate"
2330
+ size="sm"
2331
+ color="primary"
2332
+ icon="i-ph-floppy-disk"
2333
+ variant="ghost"
2334
+ @click="openSaveAsTemplate"
2335
+ >
2336
+ {{ $t('dms_automation.editor.saveAsTemplate') }}
2337
+ </UButton>
2338
+ <UButton
2339
+ v-if="canForkToLocal"
2340
+ size="sm"
2341
+ color="primary"
2342
+ icon="i-ph-git-fork"
2343
+ variant="ghost"
2344
+ @click="openFork"
2345
+ >
2346
+ {{ $t('dms_automation.editor.forkToLocal') }}
2347
+ </UButton>
2348
+ </template>
2349
+ <template v-if="selectedNode" #inspector>
2350
+ <DmsAutomationNodeInspector
2351
+ :node="selectedNode"
2352
+ :data-edges="selected.graph.dataEdges"
2353
+ :trigger-type="selectedNodeTriggerType"
2354
+ :action-type="selectedNodeActionType"
2355
+ :trigger-types="triggerTypes"
2356
+ :action-types="actionTypes"
2357
+ :data-node-types="dataNodeTypes"
2358
+ :node-kinds="nodeKinds"
2359
+ :read-only="isCurrentGraphReadOnly"
2360
+ @update:node="onUpdateNode"
2361
+ @unwire="onUnwireField"
2362
+ @ports:update="onPortsUpdate"
2363
+ @group:enter="onGroupEnter"
2364
+ @delete:node="onDeleteNode"
2365
+ />
2366
+ </template>
2367
+ </DmsAutomationGraphCanvas>
2368
+ <template #fallback>
2369
+ <div
2370
+ class="flex h-full items-center justify-center rounded-lg border border-default text-sm text-dimmed"
2371
+ >
2372
+ {{ $t('dms_automation.editor.loading') }}
2373
+ </div>
2374
+ </template>
2375
+ </ClientOnly>
2376
+ </div>
2377
+ </div>
2378
+ </div>
2379
+
2380
+ <USlideover
2381
+ v-if="editorMode === 'procedure'"
2382
+ v-model:open="showProcedures"
2383
+ side="left"
2384
+ :title="$t('dms_automation.editor.procedures')"
2385
+ >
2386
+ <template #body>
2387
+ <DmsAutomationProcedureList
2388
+ :procedures="procedures"
2389
+ :model-value="selectedId"
2390
+ @update:model-value="onProcedurePicked"
2391
+ @create="onCreate"
2392
+ @delete="onDelete"
2393
+ />
2394
+ </template>
2395
+ </USlideover>
2396
+
2397
+ <UModal
2398
+ v-model:open="saveAsTemplateOpen"
2399
+ :title="$t('dms_automation.editor.saveTemplateModal.title')"
2400
+ >
2401
+ <template #body>
2402
+ <div class="flex flex-col gap-3">
2403
+ <UFormField
2404
+ :label="$t('dms_automation.editor.saveTemplateModal.name')"
2405
+ required
2406
+ >
2407
+ <UInput
2408
+ v-model="saveAsTemplateName"
2409
+ :placeholder="$t('dms_automation.editor.saveTemplateModal.namePlaceholder')"
2410
+ class="w-full"
2411
+ :disabled="saveAsTemplateSubmitting"
2412
+ />
2413
+ </UFormField>
2414
+ <UFormField
2415
+ :label="$t('dms_automation.editor.saveTemplateModal.description')"
2416
+ >
2417
+ <UTextarea
2418
+ v-model="saveAsTemplateDescription"
2419
+ :placeholder="$t('dms_automation.editor.saveTemplateModal.descriptionPlaceholder')"
2420
+ class="w-full"
2421
+ :rows="3"
2422
+ :disabled="saveAsTemplateSubmitting"
2423
+ />
2424
+ </UFormField>
2425
+ <UFormField
2426
+ :label="$t('dms_automation.editor.saveTemplateModal.icon')"
2427
+ :help="$t('dms_automation.editor.saveTemplateModal.iconHelp')"
2428
+ >
2429
+ <UInput
2430
+ v-model="saveAsTemplateIcon"
2431
+ placeholder="i-ph-package"
2432
+ class="w-full"
2433
+ :disabled="saveAsTemplateSubmitting"
2434
+ />
2435
+ </UFormField>
2436
+ <UAlert
2437
+ v-if="saveAsTemplateError"
2438
+ color="error"
2439
+ variant="subtle"
2440
+ icon="i-ph-warning"
2441
+ :title="$t('dms_automation.editor.saveTemplateModal.error')"
2442
+ :description="saveAsTemplateError"
2443
+ />
2444
+ </div>
2445
+ </template>
2446
+ <template #footer>
2447
+ <div class="flex w-full justify-end gap-2">
2448
+ <UButton
2449
+ :label="$t('dms_automation.editor.saveTemplateModal.cancel')"
2450
+ variant="outline"
2451
+ color="neutral"
2452
+ :disabled="saveAsTemplateSubmitting"
2453
+ @click="saveAsTemplateOpen = false"
2454
+ />
2455
+ <UButton
2456
+ :label="$t('dms_automation.editor.saveTemplateModal.submit')"
2457
+ color="primary"
2458
+ :loading="saveAsTemplateSubmitting"
2459
+ @click="submitSaveAsTemplate"
2460
+ />
2461
+ </div>
2462
+ </template>
2463
+ </UModal>
2464
+
2465
+ <UModal
2466
+ v-model:open="forkOpen"
2467
+ :title="$t('dms_automation.editor.forkModal.title')"
2468
+ >
2469
+ <template #body>
2470
+ <div class="flex flex-col gap-3">
2471
+ <p class="text-sm text-toned">
2472
+ {{ $t('dms_automation.editor.forkModal.body') }}
2473
+ </p>
2474
+ <UAlert
2475
+ v-if="forkError"
2476
+ color="error"
2477
+ variant="subtle"
2478
+ icon="i-ph-warning"
2479
+ :title="$t('dms_automation.editor.forkModal.error')"
2480
+ :description="forkError"
2481
+ />
2482
+ </div>
2483
+ </template>
2484
+ <template #footer>
2485
+ <div class="flex w-full justify-end gap-2">
2486
+ <UButton
2487
+ :label="$t('dms_automation.editor.forkModal.cancel')"
2488
+ variant="outline"
2489
+ color="neutral"
2490
+ :disabled="forkSubmitting"
2491
+ @click="forkOpen = false"
2492
+ />
2493
+ <UButton
2494
+ :label="$t('dms_automation.editor.forkModal.submit')"
2495
+ color="primary"
2496
+ icon="i-ph-git-fork"
2497
+ :loading="forkSubmitting"
2498
+ @click="submitFork"
2499
+ />
2500
+ </div>
2501
+ </template>
2502
+ </UModal>
2503
+ </div>
2504
+ </template>