@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,994 @@
1
+ <script setup lang="ts">
2
+ import { computed, markRaw, nextTick, ref, watch } from 'vue'
3
+ import { newId } from '../../utils/automation'
4
+ // The canvas chrome (dotted DMS background, zoom controls, themed
5
+ // node/handle/edge styling, pan/zoom + fit-view) now comes from the shared
6
+ // DmsFlowCanvas wrapper (dms ≥0.0.25). We only keep the @vue-flow/core bits
7
+ // our business renderers need: `Position` for node source/target anchors and
8
+ // the connection / change / edge types for our wiring handlers. The imperative
9
+ // flow controller (zoom/fit/updateNodeInternals/viewport) is reached through
10
+ // DmsFlowCanvas's exposed store via a template ref (see `flowRef`).
11
+ import { Position } from '@vue-flow/core'
12
+ import type {
13
+ Connection,
14
+ NodeChange,
15
+ EdgeChange,
16
+ Edge as VfEdge,
17
+ } from '@vue-flow/core'
18
+ // Most node kinds share a single port-spec-driven renderer. Group and the
19
+ // two group sentinels stay custom because they have cross-graph port
20
+ // declarations and special handle naming.
21
+ import GenericNode from './nodes/GenericNode.vue'
22
+ import GroupNode from './nodes/GroupNode.vue'
23
+ import GroupInputNode from './nodes/GroupInputNode.vue'
24
+ import GroupOutputNode from './nodes/GroupOutputNode.vue'
25
+ import {
26
+ canConnect,
27
+ connectionKind,
28
+ parseSourceBranch,
29
+ parseTargetBranch,
30
+ parseDataPort,
31
+ parseDataField,
32
+ } from '../../composables/useGraphConnect'
33
+ import type {
34
+ ProcedureGraph,
35
+ GraphNode as AppGraphNode,
36
+ TriggerEdge,
37
+ DataEdge,
38
+ NodeKind,
39
+ TriggerType,
40
+ ActionType,
41
+ DataNodeType,
42
+ JsonSchema,
43
+ GroupPort,
44
+ } from '../../composables/useGraphConnect'
45
+ import type {
46
+ NodeKindEntry,
47
+ NodeKindUI,
48
+ } from '../../composables/useAutomationNodeKinds'
49
+
50
+ // Minimal shape of a workspace template surfaced in the palette. Mirrors the
51
+ // `TemplateRow` interface in Templates.vue but only the fields the palette
52
+ // actually needs (id, name, icon, ports). `ports` is deep-cloned (JSON
53
+ // round-trip) onto each new template-instance group node so future template
54
+ // edits don't retroactively mutate the instance's port list (the resolved
55
+ // subgraph still updates live — that's the live-link model).
56
+ interface TemplateSummary {
57
+ _id: string
58
+ name: string
59
+ icon?: string
60
+ ports: GroupPort[]
61
+ }
62
+
63
+ const props = defineProps<{
64
+ graph: ProcedureGraph
65
+ triggerTypes: TriggerType[]
66
+ actionTypes: ActionType[]
67
+ dataNodeTypes: DataNodeType[]
68
+ /**
69
+ * Per-NodeKind metadata served by `/api/automation/node-kinds`. The
70
+ * `ui` block on each entry tells `dataFor` how to render the node —
71
+ * label, icon, port lists, type-registry / dynamic-config hooks. The
72
+ * frontend has zero kind-specific code beyond group + sentinels.
73
+ */
74
+ nodeKinds: NodeKindEntry[]
75
+ canUndo?: boolean
76
+ canRedo?: boolean
77
+ isDirty?: boolean
78
+ applying?: boolean
79
+ // When the canvas is showing a group's inner subgraph, these are the
80
+ // parent group's declared ports — the sentinel nodes (groupInput /
81
+ // groupOutput) render handles derived from this list rather than
82
+ // reading from their own node data. (Sentinel nodes themselves don't
83
+ // carry ports; their parent group does.) Undefined at the procedure
84
+ // root.
85
+ parentGroupPorts?: GroupPort[]
86
+ // When true, the canvas suppresses graph mutations from user input —
87
+ // used for the read-only view of template-instance inner subgraphs.
88
+ // User can still pan / zoom / select / inspect, just not connect,
89
+ // move, delete, or edit. The "Apply / Cancel" buttons are hidden too.
90
+ readOnly?: boolean
91
+ }>()
92
+
93
+ const emit = defineEmits<{
94
+ (e: 'update:graph', g: ProcedureGraph): void
95
+ (e: 'select:node', id: string | null): void
96
+ // Fires whenever the selection set changes (single or multi). `ids` is
97
+ // the full set of currently selected node ids in this canvas — used by
98
+ // Editor.vue to drive the "Group selection" toolbar action. Sentinel
99
+ // nodes (groupInput / groupOutput) are filtered out before emitting.
100
+ (e: 'selection-change', payload: { ids: string[] }): void
101
+ (e: 'undo'): void
102
+ (e: 'redo'): void
103
+ (e: 'cancel'): void
104
+ (e: 'apply'): void
105
+ // Bubbles up GroupNode's @dblclick — Editor.vue uses this to push the
106
+ // group onto the subgraph navigation path. Filtered by kind here so
107
+ // the parent doesn't need to look the node up itself.
108
+ (e: 'group:enter', payload: { id: string }): void
109
+ }>()
110
+
111
+ const toast = useToast()
112
+
113
+ const nodeTypes = markRaw({
114
+ genericNode: GenericNode,
115
+ group: GroupNode,
116
+ groupInput: GroupInputNode,
117
+ groupOutput: GroupOutputNode,
118
+ })
119
+
120
+ // Sentinel node kinds — only valid inside a group's subgraph and never
121
+ // user-deletable. We filter them out of keyboard/right-click delete in
122
+ // onNodesChange below.
123
+ const SENTINEL_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
124
+ 'groupInput',
125
+ 'groupOutput',
126
+ ])
127
+
128
+ function vfTypeFor(kind: NodeKind): string {
129
+ if (kind === 'group') return 'group'
130
+ if (kind === 'groupInput') return 'groupInput'
131
+ if (kind === 'groupOutput') return 'groupOutput'
132
+ return 'genericNode'
133
+ }
134
+
135
+ function propsOf(schema: JsonSchema | undefined) {
136
+ if (!schema?.properties) return []
137
+ return Object.entries(schema.properties).map(([name, p]) => ({
138
+ name,
139
+ type: Array.isArray(p?.type) ? p?.type[0] : p?.type,
140
+ }))
141
+ }
142
+
143
+ // Index node-kind metadata by kind for O(1) lookup.
144
+ const nodeKindUiByKind = computed<Map<string, NodeKindUI>>(() => {
145
+ const m = new Map<string, NodeKindUI>()
146
+ for (const entry of props.nodeKinds ?? []) {
147
+ if (entry.meta?.ui) m.set(entry.kind, entry.meta.ui)
148
+ }
149
+ return m
150
+ })
151
+
152
+ // Pull a registered TriggerType / ActionType / DataNodeType from the
153
+ // type-registry prop indicated by a NodeKindUI.typeRegistry hint.
154
+ function lookupTypeBySpec(spec: NodeKindUI, typeId: string | undefined) {
155
+ if (!spec.typeRegistry || !typeId) return null
156
+ if (spec.typeRegistry === 'triggers')
157
+ return props.triggerTypes.find((t) => t.id === typeId) ?? null
158
+ if (spec.typeRegistry === 'actions')
159
+ return props.actionTypes.find((a) => a.id === typeId) ?? null
160
+ if (spec.typeRegistry === 'dataNodes')
161
+ return props.dataNodeTypes.find((d) => d.id === typeId) ?? null
162
+ return null
163
+ }
164
+
165
+ // Build the GenericNode `data` payload from a graph node. For every kind
166
+ // with `ui` metadata (every kind except group + sentinels), one
167
+ // data-driven resolver applies: combine `spec` from the backend with the
168
+ // node's `typeId` (for type-registered kinds) and `config` (for dynamic
169
+ // trigger-out ports).
170
+ function dataFor(node: AppGraphNode) {
171
+ const spec = nodeKindUiByKind.value.get(node.kind)
172
+ if (spec) {
173
+ let label = spec.label
174
+ let icon = spec.icon
175
+ let category: string | undefined
176
+ let dataIns = [...spec.staticDataIns]
177
+ let dataOuts = [...spec.staticDataOuts]
178
+
179
+ const type = lookupTypeBySpec(spec, node.typeId) as
180
+ | {
181
+ name?: string
182
+ icon?: string
183
+ category?: string
184
+ inputSchema?: JsonSchema
185
+ outputSchema?: JsonSchema
186
+ configSchema?: JsonSchema
187
+ }
188
+ | null
189
+ if (type) {
190
+ if (spec.labelFromType) label = type.name ?? label
191
+ if (spec.iconFromType) icon = type.icon ?? icon
192
+ if (spec.categoryFromType) category = type.category
193
+ if (spec.dataInsFromTypeSchema)
194
+ dataIns = propsOf(type[spec.dataInsFromTypeSchema])
195
+ if (spec.dataOutsFromTypeSchema)
196
+ dataOuts = propsOf(type[spec.dataOutsFromTypeSchema])
197
+ }
198
+
199
+ const dynPorts = spec.dynamicTriggerOutsFromConfig
200
+ ? ((node.config?.[spec.dynamicTriggerOutsFromConfig] as
201
+ | string[]
202
+ | undefined) ?? [])
203
+ : []
204
+ const triggerOuts = [...dynPorts, ...spec.staticTriggerOuts]
205
+
206
+ return {
207
+ label,
208
+ icon,
209
+ category,
210
+ typeId: node.typeId,
211
+ config: node.config,
212
+ hasTriggerIn: spec.hasTriggerIn,
213
+ hasMainTriggerOut: spec.hasMainTriggerOut,
214
+ triggerOuts,
215
+ dataIns,
216
+ dataOuts,
217
+ }
218
+ }
219
+
220
+ if (node.kind === 'group') {
221
+ // Group node carries its own port list + (optional) inline subgraph
222
+ // or templateId. The fields live on the node itself (per backend
223
+ // GroupNode interface) — surface them so the renderer can display
224
+ // handles, linked badges, etc.
225
+ const g = node as AppGraphNode & {
226
+ ports?: unknown
227
+ templateId?: string
228
+ subgraph?: unknown
229
+ }
230
+ return {
231
+ label: node.config?.name ?? 'Group',
232
+ ports: Array.isArray(g.ports) ? g.ports : [],
233
+ templateId: g.templateId,
234
+ subgraph: g.subgraph,
235
+ config: node.config,
236
+ }
237
+ }
238
+ if (node.kind === 'groupInput' || node.kind === 'groupOutput') {
239
+ // Sentinels render the parent group's ports. In subgraph-editor
240
+ // mode (C5.3) Editor.vue passes `parentGroupPorts` so both
241
+ // sentinels share one source of truth — the outer group node.
242
+ // At the procedure-graph root there's no parent group, so we
243
+ // fall back to whatever ports the sentinel carries (which
244
+ // should normally be none — a stray sentinel renders empty).
245
+ if (Array.isArray(props.parentGroupPorts)) {
246
+ return { ports: props.parentGroupPorts }
247
+ }
248
+ const s = node as AppGraphNode & { ports?: unknown }
249
+ return { ports: Array.isArray(s.ports) ? s.ports : [] }
250
+ }
251
+ return {}
252
+ }
253
+
254
+ const vfNodes = computed(() =>
255
+ props.graph.nodes.map((n) => ({
256
+ id: n.id,
257
+ type: vfTypeFor(n.kind),
258
+ position: n.position,
259
+ data: dataFor(n),
260
+ sourcePosition: Position.Bottom,
261
+ targetPosition: Position.Top,
262
+ })),
263
+ )
264
+
265
+ // Look up the kind of an edge endpoint by node id — needed for the group
266
+ // handle-id derivation below. Sentinels and groups have richer handle
267
+ // naming than regular nodes (`group-trigger-in-<port>` vs `trigger-in`),
268
+ // so the edge mapping needs to know which target/source it's wiring to.
269
+ const nodeKindById = computed<Map<string, NodeKind>>(() => {
270
+ const m = new Map<string, NodeKind>()
271
+ for (const n of props.graph.nodes) m.set(n.id, n.kind)
272
+ return m
273
+ })
274
+
275
+ // Trigger edges into a group node or groupOutput sentinel target a specific
276
+ // trigger-in port — the port name lives on `e.to.branch`. Ordinary target
277
+ // nodes have a single fixed `trigger-in` handle.
278
+ function triggerTargetHandle(e: TriggerEdge, targetKind: NodeKind | undefined): string {
279
+ if (targetKind === 'group') {
280
+ return `group-trigger-in-${e.to.branch}`
281
+ }
282
+ if (targetKind === 'groupOutput') {
283
+ // Trigger edge from an inner node into the groupOutput sentinel —
284
+ // the sentinel exposes a `groupOutput-trigger-in-<port>` handle per
285
+ // declared trigger-OUT port (since trigger-OUT on the OUTER group is
286
+ // trigger-IN on the sentinel side).
287
+ return `groupOutput-trigger-in-${e.to.branch}`
288
+ }
289
+ return 'trigger-in'
290
+ }
291
+
292
+ function triggerSourceHandle(e: TriggerEdge, sourceKind: NodeKind | undefined): string {
293
+ if (sourceKind === 'group') {
294
+ // Source is the OUTER group node firing into an outer downstream
295
+ // node — the branch name IS the port name (see planNewGroup's
296
+ // outerTriggerEdges where `from.branch = portName`).
297
+ return `group-trigger-out-${e.from.branch ?? 'main'}`
298
+ }
299
+ if (sourceKind === 'groupInput') {
300
+ // Inner edge out of groupInput into an inner node — handle id is
301
+ // `groupInput-trigger-out-<port>` and the port name lives on
302
+ // `from.branch` (per planNewGroup's innerTriggerEdges).
303
+ return `groupInput-trigger-out-${e.from.branch ?? 'main'}`
304
+ }
305
+ return `trigger-out-${e.from.branch ?? 'main'}`
306
+ }
307
+
308
+ function dataSourceHandle(e: DataEdge, sourceKind: NodeKind | undefined): string {
309
+ if (sourceKind === 'group') return `group-data-out-${e.from.port}`
310
+ if (sourceKind === 'groupInput') return `groupInput-data-out-${e.from.port}`
311
+ return `data-out-${e.from.port}`
312
+ }
313
+
314
+ function dataTargetHandle(e: DataEdge, targetKind: NodeKind | undefined): string {
315
+ if (targetKind === 'group') return `group-data-in-${e.to.field}`
316
+ if (targetKind === 'groupOutput') return `groupOutput-data-in-${e.to.field}`
317
+ return `data-in-${e.to.field}`
318
+ }
319
+
320
+ // Edge styling lives in <style> (.edge-trigger / .edge-data) so the
321
+ // `.selected` highlight can win without `!important`. Inline `style`
322
+ // on a vue-flow edge defeats the .selected rule — documented gotcha.
323
+ const vfEdges = computed(() => {
324
+ const kinds = nodeKindById.value
325
+ const triggerEdges = props.graph.triggerEdges.map((e) => ({
326
+ id: e.id,
327
+ source: e.from.node,
328
+ target: e.to.node,
329
+ sourceHandle: triggerSourceHandle(e, kinds.get(e.from.node)),
330
+ targetHandle: triggerTargetHandle(e, kinds.get(e.to.node)),
331
+ data: { kind: 'trigger' as const },
332
+ class: 'edge-trigger',
333
+ }))
334
+ const dataEdges = props.graph.dataEdges.map((e) => ({
335
+ id: e.id,
336
+ source: e.from.node,
337
+ target: e.to.node,
338
+ sourceHandle: dataSourceHandle(e, kinds.get(e.from.node)),
339
+ targetHandle: dataTargetHandle(e, kinds.get(e.to.node)),
340
+ data: { kind: 'data' as const },
341
+ class: 'edge-data',
342
+ }))
343
+ return [...triggerEdges, ...dataEdges]
344
+ })
345
+
346
+
347
+ function emitGraph(g: ProcedureGraph) {
348
+ emit('update:graph', g)
349
+ }
350
+
351
+ function defaultPos() {
352
+ // Stagger new nodes a bit so they don't all stack.
353
+ const n = props.graph.nodes.length
354
+ return { x: 120 + (n % 5) * 40, y: 120 + n * 40 }
355
+ }
356
+
357
+ function defaultsFromSchema(schema: JsonSchema | undefined): Record<string, unknown> {
358
+ if (!schema?.properties) return {}
359
+ const out: Record<string, unknown> = {}
360
+ for (const [name, prop] of Object.entries(schema.properties)) {
361
+ if (prop && prop.default !== undefined) out[name] = prop.default
362
+ }
363
+ return out
364
+ }
365
+
366
+ function defaultConfigFor(kind: NodeKind, typeId?: string): Record<string, unknown> {
367
+ const ui = nodeKindUiByKind.value.get(kind)
368
+ if (ui?.typeRegistry === 'triggers') {
369
+ const t = props.triggerTypes.find((tt) => tt.id === typeId)
370
+ return defaultsFromSchema(t?.configSchema)
371
+ }
372
+ if (ui?.typeRegistry === 'actions') {
373
+ const a = props.actionTypes.find((aa) => aa.id === typeId)
374
+ return defaultsFromSchema(a?.inputSchema)
375
+ }
376
+ if (ui?.typeRegistry === 'dataNodes') {
377
+ const d = props.dataNodeTypes.find((dd) => dd.id === typeId)
378
+ // Constant nodes seed from `configSchema` (their value lives there);
379
+ // wired-port data nodes seed any `inputSchema` field defaults.
380
+ return { ...defaultsFromSchema(d?.inputSchema), ...defaultsFromSchema(d?.configSchema) }
381
+ }
382
+ // Static-config kinds carry their seed config in metadata; deep-clone
383
+ // so each instance gets its own mutable copy. JSON round-trip strips
384
+ // the Vue reactive proxy that wraps `props.nodeKinds` — structuredClone
385
+ // would throw on the proxy.
386
+ return ui?.defaultConfig
387
+ ? (JSON.parse(JSON.stringify(ui.defaultConfig)) as Record<string, unknown>)
388
+ : {}
389
+ }
390
+
391
+ function addNode(kind: NodeKind, typeId?: string) {
392
+ const node: AppGraphNode = {
393
+ id: newId(),
394
+ kind,
395
+ typeId,
396
+ config: defaultConfigFor(kind, typeId),
397
+ position: defaultPos(),
398
+ }
399
+ emitGraph({
400
+ ...props.graph,
401
+ nodes: [...props.graph.nodes, node],
402
+ })
403
+ }
404
+
405
+ // Drop a template-instance group node into the current graph. The instance
406
+ // carries `templateId` and a deep clone (JSON round-trip) of the template's
407
+ // port list;
408
+ // it has NO inline `subgraph` — the executor resolves the live subgraph
409
+ // from the template cache at run time (C3.2 / C4.4). Editing the source
410
+ // template later will retroactively change what this instance executes
411
+ // (the "live link" model); the port list is cloned so a port rename on
412
+ // the template doesn't silently invalidate the outer wiring on this
413
+ // instance (the user can manually re-sync by forking + re-saving).
414
+ function addTemplateInstance(template: TemplateSummary) {
415
+ const node: AppGraphNode & {
416
+ ports: GroupPort[]
417
+ templateId: string
418
+ } = {
419
+ id: newId(),
420
+ kind: 'group',
421
+ config: { name: template.name },
422
+ position: defaultPos(),
423
+ templateId: template._id,
424
+ // JSON round-trip strips Vue's reactive proxy that wraps the template
425
+ // passed from the palette; structuredClone throws on proxies.
426
+ ports: JSON.parse(JSON.stringify(template.ports ?? [])) as GroupPort[],
427
+ }
428
+ emitGraph({
429
+ ...props.graph,
430
+ nodes: [...props.graph.nodes, node],
431
+ })
432
+ }
433
+
434
+ // Node creation is driven from the left BuilderPalette (in Editor.vue) — expose
435
+ // the two add helpers so the palette's events can reach the graph mutation.
436
+ defineExpose({ addNode, addTemplateInstance })
437
+
438
+ // DmsFlowCanvas owns the VueFlow store (it calls `useVueFlow(flowId)` with a
439
+ // private id and `defineExpose`s the resulting controller). We grab that
440
+ // controller through a template ref instead of calling `useVueFlow()` here —
441
+ // a bare `useVueFlow()` would resolve to the DEFAULT store, which is NOT the
442
+ // one DmsFlowCanvas drives, so zoom/fit/updateNodeInternals would silently
443
+ // no-op. Only the handful of imperative methods we actually use are typed.
444
+ interface FlowController {
445
+ zoomIn: () => void
446
+ zoomOut: () => void
447
+ fitView: () => void
448
+ zoomTo: (zoom: number) => void
449
+ updateNodeInternals: (ids?: string | string[]) => void
450
+ // DmsFlowCanvas `defineExpose`s the VueFlow controller; Vue's expose proxy
451
+ // unwraps the `viewport` ref, so consumers read `.viewport.zoom` directly
452
+ // (NOT `.viewport.value.zoom` — that extra `.value` is undefined and pinned
453
+ // the zoom readout at 100%).
454
+ viewport: { zoom: number }
455
+ }
456
+ const flowRef = ref<FlowController | null>(null)
457
+
458
+ // Vue Flow caches each node's handle bounding boxes in its internal store and
459
+ // only re-measures when `updateNodeInternals(id)` is called. When a port is
460
+ // renamed or moved between the title bar and body (e.g. renaming a port to
461
+ // "main" moves the handle from a body row into the header), the Handle DOM
462
+ // element ends up in a different on-screen position but the cached bbox stays
463
+ // stale — connected edges keep drawing to the OLD coordinates until the next
464
+ // re-measure.
465
+ //
466
+ // To keep edges glued to handles, watch a signature derived from the per-node
467
+ // port lists / kinds and force a re-measure of every node after the DOM has
468
+ // applied the new template. Watching the full `props.graph` would fire on
469
+ // every drag (each emitGraph returns a fresh object) — costly and pointless,
470
+ // since node positions trigger their own internal updates already. The
471
+ // signature only changes when port shape changes, which is exactly when we
472
+ // need to re-measure.
473
+ const portsSignature = computed(() => {
474
+ const own = props.graph.nodes
475
+ .map((n) => {
476
+ const ports = (n as AppGraphNode & { ports?: unknown }).ports
477
+ const portsKey = Array.isArray(ports) ? JSON.stringify(ports) : ''
478
+ return `${n.id}:${n.kind}:${portsKey}`
479
+ })
480
+ .join('|')
481
+ // Sentinel nodes (groupInput / groupOutput) inside this canvas render
482
+ // handles derived from `parentGroupPorts`, not from their own node data.
483
+ // A rename on the outer group's port list re-renders the sentinel but
484
+ // doesn't show up in `props.graph.nodes` above, so we must also fold
485
+ // the parent ports into the signature.
486
+ const parent = Array.isArray(props.parentGroupPorts)
487
+ ? JSON.stringify(props.parentGroupPorts)
488
+ : ''
489
+ return `${own}#${parent}`
490
+ })
491
+ watch(portsSignature, () => {
492
+ nextTick(() => {
493
+ for (const node of props.graph.nodes) {
494
+ flowRef.value?.updateNodeInternals(node.id)
495
+ }
496
+ })
497
+ })
498
+
499
+ const zoomPercent = computed(() => {
500
+ const zoom = flowRef.value?.viewport.zoom ?? 1
501
+ return `${Math.round(zoom * 100)}%`
502
+ })
503
+
504
+ function resetZoom() {
505
+ flowRef.value?.zoomTo(1)
506
+ }
507
+
508
+ function onConnect(conn: Connection) {
509
+ if (props.readOnly) return
510
+ const kind = connectionKind(conn.sourceHandle)
511
+ const result = canConnect(
512
+ props.graph,
513
+ props.nodeKinds,
514
+ props.triggerTypes,
515
+ props.actionTypes,
516
+ props.dataNodeTypes,
517
+ conn,
518
+ { parentGroupPorts: props.parentGroupPorts },
519
+ )
520
+ if (!result.ok) {
521
+ toast.add({
522
+ title: 'Cannot connect',
523
+ description: result.reason,
524
+ color: 'warning',
525
+ icon: 'i-ph-warning',
526
+ })
527
+ return
528
+ }
529
+ if (kind === 'trigger') {
530
+ const branch = parseSourceBranch(conn.sourceHandle)
531
+ const toBranch = parseTargetBranch(conn.targetHandle)
532
+ const newEdge: TriggerEdge = {
533
+ id: newId(),
534
+ from: { node: conn.source, branch },
535
+ to: toBranch !== undefined
536
+ ? { node: conn.target, branch: toBranch }
537
+ : { node: conn.target },
538
+ }
539
+ // Each source/branch carries exactly one outgoing trigger edge.
540
+ // Connecting again replaces any existing edge from that source/branch.
541
+ // (Branching across if/switch named branches still works because each
542
+ // branch has its own `from.branch` and only conflicts with itself.)
543
+ const remaining = props.graph.triggerEdges.filter(
544
+ (e) =>
545
+ !(
546
+ e.from.node === conn.source &&
547
+ (e.from.branch ?? 'main') === branch
548
+ ),
549
+ )
550
+ emitGraph({
551
+ ...props.graph,
552
+ triggerEdges: [...remaining, newEdge],
553
+ })
554
+ } else if (kind === 'data') {
555
+ const port = parseDataPort(conn.sourceHandle)
556
+ const field = parseDataField(conn.targetHandle)
557
+ if (!port || !field) return
558
+ const newEdge: DataEdge = {
559
+ id: newId(),
560
+ from: { node: conn.source, port },
561
+ to: { node: conn.target, field },
562
+ }
563
+ // Overwrite: if the target field is already wired, drop the existing
564
+ // data edge before adding the new one.
565
+ const remaining = props.graph.dataEdges.filter(
566
+ (e) => !(e.to.node === conn.target && e.to.field === field),
567
+ )
568
+ emitGraph({
569
+ ...props.graph,
570
+ dataEdges: [...remaining, newEdge],
571
+ })
572
+ }
573
+ }
574
+
575
+ function isValidConnection(conn: Connection): boolean {
576
+ return canConnect(
577
+ props.graph,
578
+ props.nodeKinds,
579
+ props.triggerTypes,
580
+ props.actionTypes,
581
+ props.dataNodeTypes,
582
+ conn,
583
+ { parentGroupPorts: props.parentGroupPorts },
584
+ ).ok
585
+ }
586
+
587
+ // Multi-selection tracking — Vue Flow emits one NodeSelectionChange per
588
+ // affected node when the user box-selects or shift-clicks. We accumulate
589
+ // the current set here and emit a single `selection-change` event with
590
+ // the full list (sentinels filtered out — the user shouldn't be able to
591
+ // fold a sentinel into a new group). The single-node `select:node` event
592
+ // is preserved for the inspector (it expects exactly one node id).
593
+ const selectedNodeIds = new Set<string>()
594
+
595
+ // Drop any tracked ids that no longer exist in the current graph (e.g.
596
+ // after navigating to a different procedure / subgraph, or after the
597
+ // "Group selection" action removes the selected nodes). Re-emit if the
598
+ // set shrank. We can't `watch(() => props.graph, ...)` because that
599
+ // fires on every node drag (each emitGraph returns a fresh object).
600
+ watch(
601
+ () => props.graph.nodes,
602
+ (nodes) => {
603
+ if (selectedNodeIds.size === 0) return
604
+ const valid = new Set(nodes.map((n) => n.id))
605
+ let pruned = false
606
+ for (const id of [...selectedNodeIds]) {
607
+ if (!valid.has(id)) {
608
+ selectedNodeIds.delete(id)
609
+ pruned = true
610
+ }
611
+ }
612
+ if (pruned) {
613
+ const ids = [...selectedNodeIds].filter((id) => {
614
+ const n = nodes.find((nn) => nn.id === id)
615
+ return n ? !SENTINEL_KINDS.has(n.kind) : false
616
+ })
617
+ emit('selection-change', { ids })
618
+ }
619
+ },
620
+ )
621
+
622
+ function onNodesChange(changes: NodeChange[]) {
623
+ let nextNodes = props.graph.nodes
624
+ let nextTrigger = props.graph.triggerEdges
625
+ let nextData = props.graph.dataEdges
626
+ let changed = false
627
+ let selectionChanged = false
628
+
629
+ for (const c of changes) {
630
+ // In read-only mode, still surface selection so the inspector
631
+ // can show node details; suppress position drag and delete.
632
+ if (props.readOnly && c.type !== 'select') continue
633
+ if (c.type === 'position' && c.position) {
634
+ const pos = c.position
635
+ nextNodes = nextNodes.map((n) =>
636
+ n.id === c.id ? { ...n, position: { x: pos.x, y: pos.y } } : n,
637
+ )
638
+ changed = true
639
+ } else if (c.type === 'remove') {
640
+ // Sentinel nodes (groupInput / groupOutput) are auto-managed by
641
+ // the editor and never user-deletable. Drop the change silently
642
+ // so keyboard delete + right-click delete both no-op on them.
643
+ const victim = nextNodes.find((n) => n.id === c.id)
644
+ if (victim && SENTINEL_KINDS.has(victim.kind)) continue
645
+ nextNodes = nextNodes.filter((n) => n.id !== c.id)
646
+ nextTrigger = nextTrigger.filter(
647
+ (e) => e.from.node !== c.id && e.to.node !== c.id,
648
+ )
649
+ nextData = nextData.filter(
650
+ (e) => e.from.node !== c.id && e.to.node !== c.id,
651
+ )
652
+ if (selectedNodeIds.delete(c.id)) selectionChanged = true
653
+ changed = true
654
+ } else if (c.type === 'select') {
655
+ if (c.selected) {
656
+ if (!selectedNodeIds.has(c.id)) {
657
+ selectedNodeIds.add(c.id)
658
+ selectionChanged = true
659
+ }
660
+ emit('select:node', c.id)
661
+ } else {
662
+ if (selectedNodeIds.delete(c.id)) selectionChanged = true
663
+ // Re-point the single-node inspector on deselect: fall back to a
664
+ // still-selected node (most-recent), or clear it once the
665
+ // selection is empty. A click-swap fires `select: true` above in
666
+ // the same batch, which then overrides this.
667
+ const remaining = [...selectedNodeIds]
668
+ emit('select:node', remaining[remaining.length - 1] ?? null)
669
+ }
670
+ }
671
+ }
672
+
673
+ if (selectionChanged) {
674
+ // Filter out sentinels — folding them into a new group makes no
675
+ // sense and the toolbar gating depends on a clean set.
676
+ const ids = [...selectedNodeIds].filter((id) => {
677
+ const n = props.graph.nodes.find((nn) => nn.id === id)
678
+ return n ? !SENTINEL_KINDS.has(n.kind) : false
679
+ })
680
+ emit('selection-change', { ids })
681
+ }
682
+
683
+ if (changed) {
684
+ emitGraph({
685
+ ...props.graph,
686
+ nodes: nextNodes,
687
+ triggerEdges: nextTrigger,
688
+ dataEdges: nextData,
689
+ })
690
+ }
691
+ }
692
+
693
+ // DmsFlowCanvas's delete affordance (trash badge on the selected node + the
694
+ // Delete/Backspace key path it now owns) is emit-only: it hands us the ids and
695
+ // we perform the mutation. Route them through onNodesChange's `remove` path so
696
+ // sentinel filtering, edge cleanup, selection sync and emitGraph stay in one
697
+ // place.
698
+ function onDeleteNodes(ids: string[]) {
699
+ if (props.readOnly) return
700
+ onNodesChange(ids.map((id) => ({ type: 'remove', id }) as NodeChange))
701
+ }
702
+
703
+ function onEdgesChange(changes: EdgeChange[]) {
704
+ if (props.readOnly) return
705
+ let nextTrigger = props.graph.triggerEdges
706
+ let nextData = props.graph.dataEdges
707
+ let changed = false
708
+
709
+ for (const c of changes) {
710
+ if (c.type === 'remove') {
711
+ const beforeT = nextTrigger.length
712
+ nextTrigger = nextTrigger.filter((e) => e.id !== c.id)
713
+ if (nextTrigger.length !== beforeT) {
714
+ changed = true
715
+ continue
716
+ }
717
+ const beforeD = nextData.length
718
+ nextData = nextData.filter((e) => e.id !== c.id)
719
+ if (nextData.length !== beforeD) changed = true
720
+ }
721
+ }
722
+
723
+ if (changed) {
724
+ emitGraph({
725
+ ...props.graph,
726
+ triggerEdges: nextTrigger,
727
+ dataEdges: nextData,
728
+ })
729
+ }
730
+ }
731
+
732
+ // Edge-update flow (drag an existing edge's endpoint to reattach or delete):
733
+ // 1. @edge-update-start clears the success flag.
734
+ // 2. @edge-update fires only if the user drops on a valid target — mark
735
+ // successful and rewrite the edge with the new connection.
736
+ // 3. @edge-update-end fires unconditionally; if no successful update
737
+ // happened, the user dropped in empty space → delete the edge.
738
+ let edgeUpdateSuccessful = false
739
+
740
+ function onEdgeUpdateStart() {
741
+ edgeUpdateSuccessful = false
742
+ }
743
+
744
+ function onEdgeUpdate(payload: {
745
+ event: MouseEvent
746
+ edge: VfEdge
747
+ connection: Connection
748
+ }) {
749
+ edgeUpdateSuccessful = true
750
+ const { edge, connection } = payload
751
+ const kind = (edge.data as { kind?: 'trigger' | 'data' } | undefined)?.kind
752
+ if (kind === 'trigger') {
753
+ const branch = parseSourceBranch(connection.sourceHandle)
754
+ const toBranch = parseTargetBranch(connection.targetHandle)
755
+ const nextTrigger = props.graph.triggerEdges.map((e) =>
756
+ e.id === edge.id
757
+ ? {
758
+ ...e,
759
+ from: { node: connection.source, branch },
760
+ to: toBranch !== undefined
761
+ ? { node: connection.target, branch: toBranch }
762
+ : { node: connection.target },
763
+ }
764
+ : e,
765
+ )
766
+ emitGraph({ ...props.graph, triggerEdges: nextTrigger })
767
+ } else if (kind === 'data') {
768
+ const port = parseDataPort(connection.sourceHandle)
769
+ const field = parseDataField(connection.targetHandle)
770
+ if (!port || !field) return
771
+ const nextData = props.graph.dataEdges.map((e) =>
772
+ e.id === edge.id
773
+ ? {
774
+ ...e,
775
+ from: { node: connection.source, port },
776
+ to: { node: connection.target, field },
777
+ }
778
+ : e,
779
+ )
780
+ emitGraph({ ...props.graph, dataEdges: nextData })
781
+ }
782
+ }
783
+
784
+ // Vue Flow fires @node-double-click with a single `{ event, node }` payload.
785
+ // Filter to group nodes so Editor.vue receives a clean group:enter signal —
786
+ // double-clicking any other node kind is reserved for future inline edit
787
+ // affordances.
788
+ function onNodeDoubleClick(payload: {
789
+ event: MouseEvent
790
+ node: { id: string; type?: string }
791
+ }) {
792
+ if (payload.node?.type !== 'group') return
793
+ emit('group:enter', { id: payload.node.id })
794
+ }
795
+
796
+ function onEdgeUpdateEnd(payload: { event: MouseEvent; edge: VfEdge }) {
797
+ if (edgeUpdateSuccessful) return
798
+ const { edge } = payload
799
+ if (!edge) return
800
+ const nextTrigger = props.graph.triggerEdges.filter((e) => e.id !== edge.id)
801
+ const nextData = props.graph.dataEdges.filter((e) => e.id !== edge.id)
802
+ if (
803
+ nextTrigger.length === props.graph.triggerEdges.length &&
804
+ nextData.length === props.graph.dataEdges.length
805
+ ) {
806
+ return
807
+ }
808
+ emitGraph({
809
+ ...props.graph,
810
+ triggerEdges: nextTrigger,
811
+ dataEdges: nextData,
812
+ })
813
+ }
814
+ </script>
815
+
816
+ <template>
817
+ <div
818
+ class="graph-canvas-wrap relative w-full h-full border border-default rounded-lg overflow-hidden"
819
+ >
820
+ <DmsFlowCanvas
821
+ ref="flowRef"
822
+ :nodes="vfNodes"
823
+ :edges="vfEdges"
824
+ :node-types="nodeTypes"
825
+ :controls="false"
826
+ :is-valid-connection="isValidConnection"
827
+ :nodes-draggable="!readOnly"
828
+ :nodes-connectable="!readOnly"
829
+ :elements-selectable="true"
830
+ :edges-updatable="!readOnly"
831
+ :deletable-nodes="!readOnly"
832
+ @connect="onConnect"
833
+ @nodes-change="onNodesChange"
834
+ @delete-nodes="onDeleteNodes"
835
+ @edges-change="onEdgesChange"
836
+ @edge-update-start="onEdgeUpdateStart"
837
+ @edge-update="onEdgeUpdate"
838
+ @edge-update-end="onEdgeUpdateEnd"
839
+ @node-double-click="onNodeDoubleClick"
840
+ >
841
+ <!-- Toolbar. While editable: Add Node / Undo / Redo / group
842
+ actions / Cancel / Apply. Read-only template-instance view:
843
+ only the group actions (Fork) survive, in a bare card. The two
844
+ branches share DmsFlowCanvas's single `top-right` Panel slot. -->
845
+ <template v-if="!readOnly || $slots.groupActions" #top-right>
846
+ <div
847
+ v-if="!readOnly"
848
+ class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md flex-wrap"
849
+ >
850
+ <UButton
851
+ size="sm"
852
+ variant="ghost"
853
+ color="neutral"
854
+ icon="i-ph-arrow-counter-clockwise"
855
+ :aria-label="$t('dms_automation.editor.canvas.undo')"
856
+ :disabled="!canUndo"
857
+ @click="emit('undo')"
858
+ />
859
+ <UButton
860
+ size="sm"
861
+ variant="ghost"
862
+ color="neutral"
863
+ icon="i-ph-arrow-clockwise"
864
+ :aria-label="$t('dms_automation.editor.canvas.redo')"
865
+ :disabled="!canRedo"
866
+ @click="emit('redo')"
867
+ />
868
+ <!-- Group-related actions slotted in by Editor.vue: Group
869
+ selection, Save as template, Fork to local group, Enter
870
+ group. Kept inside the canvas toolbar so they sit next
871
+ to Add Node / Undo / Redo instead of in a separate card. -->
872
+ <template v-if="$slots.groupActions">
873
+ <div class="w-px h-5 bg-default mx-1" />
874
+ <slot name="groupActions" />
875
+ </template>
876
+ <div class="mx-1 h-5 w-px bg-default" />
877
+ <UButton
878
+ size="sm"
879
+ variant="ghost"
880
+ color="neutral"
881
+ :label="$t('dms_automation.editor.canvas.cancel')"
882
+ :disabled="!isDirty || applying"
883
+ @click="emit('cancel')"
884
+ />
885
+ <UButton
886
+ size="sm"
887
+ color="primary"
888
+ :label="$t('dms_automation.editor.canvas.apply')"
889
+ :disabled="!isDirty"
890
+ :loading="applying"
891
+ @click="emit('apply')"
892
+ />
893
+ </div>
894
+ <!-- Read-only fallback: Fork stays reachable from the
895
+ template-instance inner-subgraph view. -->
896
+ <div
897
+ v-else-if="$slots.groupActions"
898
+ class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md flex-wrap"
899
+ >
900
+ <slot name="groupActions" />
901
+ </div>
902
+ </template>
903
+
904
+ <!-- The #top-left slot is provided UNCONDITIONALLY (inner v-if gates
905
+ the content). DmsFlowCanvas computes its active panel positions
906
+ once from a non-reactive useSlots() snapshot, so a panel slot
907
+ added later (e.g. when a node is first selected) would never
908
+ render. Providing it from first render keeps the inspector panel
909
+ alive; it just shows nothing until a node is selected. The real
910
+ fix belongs upstream in DmsFlowCanvas (make panel detection
911
+ reactive); this is a consumer workaround. -->
912
+ <template #top-left>
913
+ <div
914
+ v-if="$slots.inspector"
915
+ class="w-[320px] max-h-[calc(100vh-16rem)] overflow-y-auto shadow-md rounded-lg"
916
+ >
917
+ <slot name="inspector" />
918
+ </div>
919
+ </template>
920
+
921
+ <template #bottom-center>
922
+ <div
923
+ class="flex items-center gap-1 rounded-lg border border-default bg-elevated p-1 shadow-md"
924
+ >
925
+ <UButton
926
+ size="sm"
927
+ variant="ghost"
928
+ color="neutral"
929
+ icon="i-ph-magnifying-glass-minus"
930
+ :aria-label="$t('dms_automation.editor.canvas.zoomOut')"
931
+ @click="flowRef?.zoomOut()"
932
+ />
933
+ <button
934
+ type="button"
935
+ class="px-2 text-sm tabular-nums text-muted transition-colors hover:text-highlighted"
936
+ :aria-label="$t('dms_automation.editor.canvas.resetZoom')"
937
+ @click="resetZoom"
938
+ >
939
+ {{ zoomPercent }}
940
+ </button>
941
+ <UButton
942
+ size="sm"
943
+ variant="ghost"
944
+ color="neutral"
945
+ icon="i-ph-magnifying-glass-plus"
946
+ :aria-label="$t('dms_automation.editor.canvas.zoomIn')"
947
+ @click="flowRef?.zoomIn()"
948
+ />
949
+ <div class="mx-1 h-5 w-px bg-default" />
950
+ <UButton
951
+ size="sm"
952
+ variant="ghost"
953
+ color="neutral"
954
+ icon="i-ph-arrows-out"
955
+ :aria-label="$t('dms_automation.editor.canvas.fitView')"
956
+ @click="flowRef?.fitView()"
957
+ />
958
+ </div>
959
+ </template>
960
+ </DmsFlowCanvas>
961
+ </div>
962
+ </template>
963
+
964
+ <style>
965
+ /* DmsFlowCanvas imports the @vue-flow core/theme/controls/minimap CSS itself,
966
+ so we only ship the editor's business edge palette here: trigger edges
967
+ (control flow) read cyan, data edges (data flow) read green-dashed. The
968
+ `.edge-*` class out-specifies DmsFlowCanvas's generic `.vue-flow__edge-path`
969
+ rule, so these win without `!important`. */
970
+ .vue-flow__edge.edge-trigger .vue-flow__edge-path {
971
+ stroke: #2dc1cf;
972
+ stroke-width: 2;
973
+ }
974
+
975
+ .vue-flow__edge.edge-data .vue-flow__edge-path {
976
+ stroke: #10b981;
977
+ stroke-width: 2;
978
+ stroke-dasharray: 4 4;
979
+ }
980
+
981
+ /* Selected / focused edge highlight (amber). The doubled `.edge-*` class lifts
982
+ specificity above DmsFlowCanvas's own `.dms-flow-canvas .vue-flow__edge.selected`
983
+ rule, which would otherwise repaint the edge in the DMS accent and erase the
984
+ trigger-vs-data distinction. */
985
+ .vue-flow__edge.edge-trigger.selected .vue-flow__edge-path,
986
+ .vue-flow__edge.edge-data.selected .vue-flow__edge-path,
987
+ .vue-flow__edge.edge-trigger:focus .vue-flow__edge-path,
988
+ .vue-flow__edge.edge-data:focus .vue-flow__edge-path,
989
+ .vue-flow__edge.edge-trigger:focus-visible .vue-flow__edge-path,
990
+ .vue-flow__edge.edge-data:focus-visible .vue-flow__edge-path {
991
+ stroke: #f59e0b;
992
+ stroke-width: 3;
993
+ }
994
+ </style>