@assistkick/create 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/package.json +2 -2
  2. package/templates/assistkick-product-system/.env.example +1 -0
  3. package/templates/assistkick-product-system/local.db +0 -0
  4. package/templates/assistkick-product-system/package.json +4 -2
  5. package/templates/assistkick-product-system/packages/backend/package.json +2 -0
  6. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
  7. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
  9. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
  10. package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
  11. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
  12. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
  13. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
  14. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
  15. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
  16. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
  17. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
  18. package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
  19. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
  20. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
  21. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
  22. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
  23. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
  24. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
  25. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
  26. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
  27. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
  28. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
  29. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
  30. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
  31. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
  32. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
  33. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
  34. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
  35. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
  40. package/templates/assistkick-product-system/packages/frontend/index.html +3 -0
  41. package/templates/assistkick-product-system/packages/frontend/package-lock.json +800 -11
  42. package/templates/assistkick-product-system/packages/frontend/package.json +11 -1
  43. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +24 -7
  44. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
  45. package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
  46. package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
  47. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
  48. package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
  49. package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
  50. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +383 -0
  51. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
  52. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
  53. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
  54. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
  55. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
  56. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +193 -64
  57. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
  58. package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
  59. package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
  60. package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
  61. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +226 -291
  62. package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
  63. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
  64. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +40 -66
  65. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +55 -115
  66. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
  67. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +155 -77
  68. package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
  69. package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
  70. package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
  71. package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +270 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +37 -0
  82. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +207 -0
  85. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
  86. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
  87. package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
  88. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
  89. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
  92. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
  97. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
  99. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
  102. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
  105. package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
  106. package/templates/assistkick-product-system/packages/frontend/src/hooks/useGraph.ts +6 -21
  107. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +15 -80
  108. package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
  109. package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
  110. package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
  111. package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
  112. package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
  113. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
  114. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +19 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +54 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +6 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +93 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +30 -0
  120. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +9 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +6 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/stores/useGitModalStore.ts +14 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/stores/useGraphStore.ts +36 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/stores/useGraphUIStore.ts +25 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +90 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/stores/useQaSheetStore.ts +27 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +76 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +336 -3632
  131. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
  132. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
  133. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
  134. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
  135. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
  136. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
  137. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
  138. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
  139. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +7 -1
  140. package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
  141. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
  142. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
  143. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
  144. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
  145. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
  146. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
  147. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
  148. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
  149. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
  150. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
  151. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
  152. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
  153. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
  154. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
  155. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
  156. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
  157. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
  158. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
  159. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
  160. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  161. package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
  162. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
  163. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
  164. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
  165. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +16 -5
  166. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
  167. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
  168. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
  169. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
  170. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
  171. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
  172. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
  173. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
  174. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  175. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
  176. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
  177. package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
  178. package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
  179. package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
  180. package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
  181. package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
  182. package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
  183. package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
  184. package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
  185. package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
  186. package/templates/assistkick-product-system/packages/video/index.ts +4 -0
  187. package/templates/assistkick-product-system/packages/video/package.json +28 -0
  188. package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
  189. package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
  190. package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
  191. package/templates/assistkick-product-system/packages/video/style.css +1 -0
  192. package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
  193. package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
  194. package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
  195. package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
  196. package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
  197. package/templates/skills/assistkick-bootstrap/SKILL.md +3 -3
  198. package/templates/skills/assistkick-code-reviewer/SKILL.md +2 -2
  199. package/templates/skills/assistkick-debugger/SKILL.md +2 -2
  200. package/templates/skills/assistkick-developer/SKILL.md +6 -3
  201. package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
  202. package/templates/skills/assistkick-interview/SKILL.md +2 -2
  203. package/templates/skills/product-system/graph.json +1890 -0
  204. package/templates/skills/product-system/kanban.json +304 -0
  205. package/templates/skills/product-system/nodes/comp_001.md +56 -0
  206. package/templates/skills/product-system/nodes/comp_002.md +57 -0
  207. package/templates/skills/product-system/nodes/data_001.md +51 -0
  208. package/templates/skills/product-system/nodes/data_002.md +40 -0
  209. package/templates/skills/product-system/nodes/data_004.md +38 -0
  210. package/templates/skills/product-system/nodes/dec_001.md +34 -0
  211. package/templates/skills/product-system/nodes/dec_016.md +32 -0
  212. package/templates/skills/product-system/nodes/feat_008.md +30 -0
  213. package/templates/skills/video-composition-agent/SKILL.md +232 -0
  214. package/templates/skills/video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,1281 @@
1
+ /**
2
+ * WorkflowEngine — executes workflow graphs defined in the workflows table.
3
+ * Replaces the hardcoded Pipeline class with a data-driven, graph-traversal engine.
4
+ *
5
+ * Responsibilities:
6
+ * 1. Load workflow graph_data and parse into a traversable directed graph
7
+ * 2. Execute nodes sequentially following edges, dispatching to handlers
8
+ * 3. Evaluate branching conditions and follow matching output edges
9
+ * 4. Handle back-edges (loops) by re-executing previously visited nodes
10
+ * 5. Persist checkpoints after each node (workflow_node_executions)
11
+ * 6. Resume interrupted executions by skipping completed nodes
12
+ * 7. Manage worktree lifecycle (create before first node, merge/cleanup at End)
13
+ * 8. Maintain shared execution context passed between nodes
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto';
17
+ import { resolve as pathResolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { eq, and } from 'drizzle-orm';
20
+ import {
21
+ workflows,
22
+ workflowExecutions,
23
+ workflowNodeExecutions,
24
+ workflowToolCalls,
25
+ agents,
26
+ nodes as nodesTable,
27
+ } from '../db/schema.js';
28
+
29
+ // ── Types ──────────────────────────────────────────────────────────────
30
+
31
+ interface WorkflowNode {
32
+ id: string;
33
+ type: string;
34
+ data: Record<string, unknown>;
35
+ }
36
+
37
+ interface WorkflowEdge {
38
+ id: string;
39
+ source: string;
40
+ target: string;
41
+ sourceHandle?: string;
42
+ data?: { label?: string };
43
+ }
44
+
45
+ interface WorkflowGraph {
46
+ nodes: WorkflowNode[];
47
+ edges: WorkflowEdge[];
48
+ }
49
+
50
+ interface ExecutionContext {
51
+ cycle: number;
52
+ featureId: string;
53
+ projectId: string;
54
+ worktreePath: string;
55
+ branchName: string;
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ interface NodeHandlerResult {
60
+ /** Which output handle/label to follow (null = default/single outgoing edge) */
61
+ outputLabel: string | null;
62
+ /** Optional data to persist in workflow_node_executions.output_data */
63
+ outputData?: Record<string, unknown>;
64
+ }
65
+
66
+ interface WorkflowEngineDeps {
67
+ db: ReturnType<typeof import('./db.js').getDb>;
68
+ kanban: {
69
+ getKanbanEntry: (featureId: string) => Promise<Record<string, unknown> | null>;
70
+ saveKanbanEntry: (featureId: string, entry: Record<string, unknown>, projectId?: string) => Promise<void>;
71
+ };
72
+ claudeService: {
73
+ spawnClaude: (prompt: string, cwd: string, label?: string, opts?: Record<string, unknown>) => Promise<string>;
74
+ spawnCommand: (cmd: string, args: string[], cwd: string) => Promise<string>;
75
+ };
76
+ gitWorkflow: {
77
+ createWorktree: (featureId: string) => Promise<string>;
78
+ removeWorktree: (worktreePath: string) => Promise<void>;
79
+ deleteBranch: (featureId: string, force?: boolean) => Promise<void>;
80
+ commitChanges: (featureId: string, worktreePath: string) => Promise<void>;
81
+ rebaseBranch: (featureId: string, worktreePath: string) => Promise<void>;
82
+ mergeBranch: (featureId: string) => Promise<void>;
83
+ fixMergeConflicts: (featureId: string) => Promise<boolean>;
84
+ stash: () => Promise<boolean>;
85
+ unstash: () => Promise<void>;
86
+ getDirtyFiles: () => Promise<string[]>;
87
+ };
88
+ log: (tag: string, message: string) => void;
89
+ }
90
+
91
+ // ── WorkflowEngine ────────────────────────────────────────────────────
92
+
93
+ export class WorkflowEngine {
94
+ private db: WorkflowEngineDeps['db'];
95
+ private kanban: WorkflowEngineDeps['kanban'];
96
+ private claudeService: WorkflowEngineDeps['claudeService'];
97
+ private gitWorkflow: WorkflowEngineDeps['gitWorkflow'];
98
+ private log: WorkflowEngineDeps['log'];
99
+
100
+ constructor({ db, kanban, claudeService, gitWorkflow, log }: WorkflowEngineDeps) {
101
+ this.db = db;
102
+ this.kanban = kanban;
103
+ this.claudeService = claudeService;
104
+ this.gitWorkflow = gitWorkflow;
105
+ this.log = log;
106
+ }
107
+
108
+ // ── Public API ──────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Start a new workflow execution for a feature.
112
+ * Creates a workflow_execution row, creates a worktree, and begins
113
+ * graph traversal from the Start node. Returns immediately (fire-and-forget).
114
+ */
115
+ start = async (featureId: string, workflowId: string, projectId: string): Promise<{ executionId: string }> => {
116
+ this.log('WORKFLOW', `Starting workflow ${workflowId} for feature ${featureId}`);
117
+
118
+ // Load workflow
119
+ const [workflow] = await this.db.select().from(workflows).where(eq(workflows.id, workflowId));
120
+ if (!workflow) {
121
+ throw new Error(`Workflow ${workflowId} not found`);
122
+ }
123
+
124
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
125
+ const startNode = graph.nodes.find(n => n.type === 'start');
126
+ if (!startNode) {
127
+ throw new Error(`Workflow ${workflowId} has no start node`);
128
+ }
129
+
130
+ // Create worktree and feature branch
131
+ const worktreePath = await this.gitWorkflow.createWorktree(featureId);
132
+ const branchName = `feature/${featureId}`;
133
+
134
+ // Build initial context
135
+ // Derive the monorepo root from this file's location (packages/shared/lib/workflow_engine.ts → ../../..)
136
+ const __dirname = dirname(fileURLToPath(import.meta.url));
137
+ const mainSkillDir = pathResolve(__dirname, '..', '..', '..');
138
+ const mainToolsDir = pathResolve(mainSkillDir, 'packages', 'shared', 'tools');
139
+ const pidFlag = ` --project-id ${projectId}`;
140
+
141
+ const context: ExecutionContext = {
142
+ cycle: 0,
143
+ featureId,
144
+ projectId,
145
+ worktreePath,
146
+ branchName,
147
+ mainSkillDir,
148
+ mainToolsDir,
149
+ pidFlag,
150
+ };
151
+
152
+ // Enrich context with feature data for agent prompt resolution
153
+ const [featureNode] = await this.db.select().from(nodesTable).where(eq(nodesTable.id, featureId));
154
+ if (featureNode) {
155
+ context.featureDescription = this.extractFeatureDescription(featureNode.body, featureNode.name);
156
+ context.compositionName = this.deriveCompositionName(featureNode.name);
157
+ }
158
+
159
+ // Load iteration comments from kanban notes for re-runs
160
+ const kanbanEntry = await this.kanban.getKanbanEntry(featureId);
161
+ if (kanbanEntry?.notes?.length) {
162
+ context.iterationComments = '## Previous Review Notes\n' + (kanbanEntry.notes as string[]).map((n: string) => `- ${n}`).join('\n');
163
+ } else {
164
+ context.iterationComments = '';
165
+ }
166
+
167
+ // Create execution row
168
+ const executionId = randomUUID();
169
+ const now = new Date().toISOString();
170
+ await this.db.insert(workflowExecutions).values({
171
+ id: executionId,
172
+ workflowId,
173
+ featureId,
174
+ status: 'running',
175
+ currentNodeId: startNode.id,
176
+ context: JSON.stringify(context),
177
+ startedAt: now,
178
+ updatedAt: now,
179
+ });
180
+
181
+ this.log('WORKFLOW', `Created execution ${executionId}, worktree at ${worktreePath}`);
182
+
183
+ // Fire-and-forget: traverse the graph asynchronously
184
+ this.executeGraph(executionId, graph, startNode.id, context).catch(err => {
185
+ this.log('WORKFLOW', `UNCAUGHT ERROR in execution ${executionId}: ${err.message}`);
186
+ this.setExecutionStatus(executionId, 'failed', null, context).catch(() => {});
187
+ });
188
+
189
+ return { executionId };
190
+ };
191
+
192
+ /**
193
+ * Resume a failed/interrupted workflow execution.
194
+ * Finds the current node, resets its status, and re-executes from that point.
195
+ */
196
+ resume = async (executionId: string): Promise<void> => {
197
+ this.log('WORKFLOW', `Resuming execution ${executionId}`);
198
+
199
+ const [execution] = await this.db.select().from(workflowExecutions)
200
+ .where(eq(workflowExecutions.id, executionId));
201
+ if (!execution) {
202
+ throw new Error(`Execution ${executionId} not found`);
203
+ }
204
+ if (execution.status === 'running') {
205
+ // Allow resuming stuck 'running' executions — mark them as failed first
206
+ this.log('WORKFLOW', `Execution ${executionId} is stuck in 'running' — marking as failed before resuming`);
207
+ await this.db.update(workflowExecutions)
208
+ .set({ status: 'failed', updatedAt: new Date().toISOString() })
209
+ .where(eq(workflowExecutions.id, executionId));
210
+ }
211
+
212
+ // Load workflow graph
213
+ const [workflow] = await this.db.select().from(workflows)
214
+ .where(eq(workflows.id, execution.workflowId));
215
+ if (!workflow) {
216
+ throw new Error(`Workflow ${execution.workflowId} not found`);
217
+ }
218
+
219
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
220
+ const context: ExecutionContext = JSON.parse(execution.context);
221
+ const currentNodeId = execution.currentNodeId;
222
+
223
+ if (!currentNodeId) {
224
+ throw new Error(`Execution ${executionId} has no current node to resume from`);
225
+ }
226
+
227
+ // Find the failed/interrupted node execution and increment its attempt
228
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
229
+ .where(and(
230
+ eq(workflowNodeExecutions.executionId, executionId),
231
+ eq(workflowNodeExecutions.nodeId, currentNodeId),
232
+ ));
233
+
234
+ const failedExec = nodeExecs.find(ne => ne.status === 'failed' || ne.status === 'running');
235
+ if (failedExec) {
236
+ await this.db.update(workflowNodeExecutions)
237
+ .set({ status: 'pending', attempt: failedExec.attempt + 1 })
238
+ .where(eq(workflowNodeExecutions.id, failedExec.id));
239
+ }
240
+
241
+ // Set execution back to running
242
+ await this.db.update(workflowExecutions)
243
+ .set({ status: 'running', updatedAt: new Date().toISOString() })
244
+ .where(eq(workflowExecutions.id, executionId));
245
+
246
+ // Resume graph traversal from the current node
247
+ this.executeGraph(executionId, graph, currentNodeId, context).catch(err => {
248
+ this.log('WORKFLOW', `UNCAUGHT ERROR resuming ${executionId}: ${err.message}`);
249
+ this.setExecutionStatus(executionId, 'failed', currentNodeId, context).catch(() => {});
250
+ });
251
+ };
252
+
253
+ /**
254
+ * Force-advance the workflow to the next node, skipping the current one.
255
+ * Marks the current node as completed and resumes from the next node in the graph.
256
+ */
257
+ forceNextNode = async (featureId: string, nodeId: string): Promise<void> => {
258
+ this.log('WORKFLOW', `Force-next requested for feature ${featureId}, node ${nodeId}`);
259
+
260
+ const execution = await this.findLatestExecution(featureId);
261
+ if (!execution) throw new Error(`No execution found for feature ${featureId}`);
262
+ if (execution.currentNodeId !== nodeId) {
263
+ throw new Error(`Node ${nodeId} is not the current execution node (current: ${execution.currentNodeId})`);
264
+ }
265
+
266
+ const [workflow] = await this.db.select().from(workflows)
267
+ .where(eq(workflows.id, execution.workflowId));
268
+ if (!workflow) throw new Error(`Workflow ${execution.workflowId} not found`);
269
+
270
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
271
+ const context: ExecutionContext = JSON.parse(execution.context);
272
+
273
+ // Mark current node execution as completed (skipped)
274
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
275
+ .where(and(
276
+ eq(workflowNodeExecutions.executionId, execution.id),
277
+ eq(workflowNodeExecutions.nodeId, nodeId),
278
+ ));
279
+ const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed');
280
+ if (currentNodeExec) {
281
+ await this.db.update(workflowNodeExecutions)
282
+ .set({ status: 'completed', completedAt: new Date().toISOString() })
283
+ .where(eq(workflowNodeExecutions.id, currentNodeExec.id));
284
+ }
285
+
286
+ // Resolve the next node in the graph (follow the default edge)
287
+ const nextNodeId = this.resolveNextNode(graph, nodeId, null);
288
+ if (!nextNodeId) {
289
+ // No next node — mark execution as completed
290
+ this.log('WORKFLOW', `No next node after ${nodeId} — marking execution completed`);
291
+ await this.setExecutionStatus(execution.id, 'completed', null, context);
292
+ return;
293
+ }
294
+
295
+ this.log('WORKFLOW', `Force-advancing from ${nodeId} to ${nextNodeId}`);
296
+
297
+ // Ensure execution is in 'running' state
298
+ await this.db.update(workflowExecutions)
299
+ .set({ status: 'running', updatedAt: new Date().toISOString() })
300
+ .where(eq(workflowExecutions.id, execution.id));
301
+
302
+ // Resume graph traversal from the next node
303
+ this.executeGraph(execution.id, graph, nextNodeId, context).catch(err => {
304
+ this.log('WORKFLOW', `UNCAUGHT ERROR after force-next ${execution.id}: ${err.message}`);
305
+ this.setExecutionStatus(execution.id, 'failed', nextNodeId, context).catch(() => {});
306
+ });
307
+ };
308
+
309
+ /**
310
+ * Restart the current workflow node — resets it and re-executes from that point.
311
+ */
312
+ restartNode = async (featureId: string, nodeId: string): Promise<void> => {
313
+ this.log('WORKFLOW', `Restart-node requested for feature ${featureId}, node ${nodeId}`);
314
+
315
+ const execution = await this.findLatestExecution(featureId);
316
+ if (!execution) throw new Error(`No execution found for feature ${featureId}`);
317
+ if (execution.currentNodeId !== nodeId) {
318
+ throw new Error(`Node ${nodeId} is not the current execution node (current: ${execution.currentNodeId})`);
319
+ }
320
+
321
+ const [workflow] = await this.db.select().from(workflows)
322
+ .where(eq(workflows.id, execution.workflowId));
323
+ if (!workflow) throw new Error(`Workflow ${execution.workflowId} not found`);
324
+
325
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
326
+ const context: ExecutionContext = JSON.parse(execution.context);
327
+
328
+ // Reset the current node execution — increment attempt counter
329
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
330
+ .where(and(
331
+ eq(workflowNodeExecutions.executionId, execution.id),
332
+ eq(workflowNodeExecutions.nodeId, nodeId),
333
+ ));
334
+ const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed');
335
+ if (currentNodeExec) {
336
+ await this.db.update(workflowNodeExecutions)
337
+ .set({ status: 'pending', attempt: currentNodeExec.attempt + 1 })
338
+ .where(eq(workflowNodeExecutions.id, currentNodeExec.id));
339
+ }
340
+
341
+ // Set execution back to running
342
+ await this.db.update(workflowExecutions)
343
+ .set({ status: 'running', updatedAt: new Date().toISOString() })
344
+ .where(eq(workflowExecutions.id, execution.id));
345
+
346
+ // Re-execute from the current node
347
+ this.executeGraph(execution.id, graph, nodeId, context).catch(err => {
348
+ this.log('WORKFLOW', `UNCAUGHT ERROR after restart-node ${execution.id}: ${err.message}`);
349
+ this.setExecutionStatus(execution.id, 'failed', nodeId, context).catch(() => {});
350
+ });
351
+ };
352
+
353
+ /**
354
+ * Find the most recent execution for a feature (any status).
355
+ * Returns the execution row or null.
356
+ */
357
+ private findLatestExecution = async (featureId: string) => {
358
+ const executions = await this.db.select().from(workflowExecutions)
359
+ .where(eq(workflowExecutions.featureId, featureId));
360
+ if (executions.length === 0) return null;
361
+ return executions.sort((a, b) =>
362
+ new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
363
+ )[0];
364
+ };
365
+
366
+ /**
367
+ * Get the current execution status for a feature.
368
+ * Returns execution state, current node, and per-node statuses.
369
+ */
370
+ getStatus = async (featureId: string): Promise<Record<string, unknown>> => {
371
+ // Find the most recent execution for this feature
372
+ const executions = await this.db.select().from(workflowExecutions)
373
+ .where(eq(workflowExecutions.featureId, featureId));
374
+
375
+ if (executions.length === 0) {
376
+ return { status: 'idle', featureId };
377
+ }
378
+
379
+ // Pick the most recent by startedAt
380
+ const execution = executions.sort((a, b) =>
381
+ new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
382
+ )[0];
383
+
384
+ // Load node executions
385
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
386
+ .where(eq(workflowNodeExecutions.executionId, execution.id));
387
+
388
+ const completed = nodeExecs.filter(ne => ne.status === 'completed').map(ne => ne.nodeId);
389
+ const failed = nodeExecs.filter(ne => ne.status === 'failed').map(ne => ne.nodeId);
390
+ const pending = nodeExecs.filter(ne => ne.status === 'pending' || ne.status === 'running').map(ne => ne.nodeId);
391
+
392
+ // Resolve current node type and agent name from workflow graph
393
+ let currentNodeType: string | null = null;
394
+ let currentAgentName: string | null = null;
395
+ if (execution.currentNodeId) {
396
+ try {
397
+ const [workflow] = await this.db.select().from(workflows)
398
+ .where(eq(workflows.id, execution.workflowId));
399
+ if (workflow) {
400
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
401
+ const currentNode = graph.nodes.find(n => n.id === execution.currentNodeId);
402
+ if (currentNode) {
403
+ currentNodeType = currentNode.type;
404
+ // If the current node is a RunAgent node and execution is running, resolve agent name
405
+ if (currentNode.type === 'runAgent' && execution.status === 'running') {
406
+ const agentId = (currentNode.data as { agentId?: string }).agentId;
407
+ if (agentId) {
408
+ const [agent] = await this.db.select().from(agents).where(eq(agents.id, agentId));
409
+ if (agent) currentAgentName = agent.name;
410
+ }
411
+ }
412
+ }
413
+ }
414
+ } catch { /* non-critical */ }
415
+ }
416
+
417
+ // Get error message from the most recent failed node execution
418
+ let errorMessage: string | null = null;
419
+ if (execution.status === 'failed') {
420
+ const failedExec = nodeExecs.find(ne => ne.status === 'failed' && ne.error);
421
+ if (failedExec) errorMessage = failedExec.error;
422
+ }
423
+
424
+ return {
425
+ executionId: execution.id,
426
+ status: execution.status,
427
+ featureId,
428
+ currentNodeId: execution.currentNodeId,
429
+ currentNodeType,
430
+ currentAgentName,
431
+ error: errorMessage,
432
+ completedNodes: completed,
433
+ failedNodes: failed,
434
+ pendingNodes: pending,
435
+ };
436
+ };
437
+
438
+ /**
439
+ * Get per-node KPI metrics for a workflow execution.
440
+ * Returns an array of metrics for each runAgent node execution.
441
+ */
442
+ getExecutionMetrics = async (executionId: string): Promise<Record<string, unknown>[]> => {
443
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
444
+ .where(eq(workflowNodeExecutions.executionId, executionId));
445
+
446
+ // Load the workflow graph to identify runAgent nodes
447
+ const [execution] = await this.db.select().from(workflowExecutions)
448
+ .where(eq(workflowExecutions.id, executionId));
449
+ if (!execution) return [];
450
+
451
+ const [workflow] = await this.db.select().from(workflows)
452
+ .where(eq(workflows.id, execution.workflowId));
453
+ if (!workflow) return [];
454
+
455
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
456
+ const runAgentNodes = new Set(
457
+ graph.nodes.filter(n => n.type === 'runAgent').map(n => n.id),
458
+ );
459
+
460
+ return nodeExecs
461
+ .filter(ne => runAgentNodes.has(ne.nodeId) && ne.outputData)
462
+ .map(ne => {
463
+ const data = JSON.parse(ne.outputData!);
464
+ return {
465
+ nodeId: ne.nodeId,
466
+ agentName: data.agentName ?? null,
467
+ toolCalls: data.toolCalls ?? null,
468
+ costUsd: data.costUsd ?? null,
469
+ numTurns: data.numTurns ?? null,
470
+ stopReason: data.stopReason ?? null,
471
+ model: data.model ?? null,
472
+ contextWindowPct: data.contextWindowPct ?? null,
473
+ status: ne.status,
474
+ };
475
+ });
476
+ };
477
+
478
+ /**
479
+ * Find the most recent resumable execution for a feature (failed or interrupted).
480
+ * Returns the executionId or null if none found.
481
+ */
482
+ findResumableExecution = async (featureId: string): Promise<string | null> => {
483
+ const executions = await this.db.select().from(workflowExecutions)
484
+ .where(eq(workflowExecutions.featureId, featureId));
485
+
486
+ if (executions.length === 0) return null;
487
+
488
+ // Find the most recent failed or stuck-running execution.
489
+ // A 'running' execution is considered stuck if updatedAt is older than 5 minutes.
490
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000;
491
+ const now = Date.now();
492
+ const resumable = executions
493
+ .filter((e: any) => {
494
+ if (e.status === 'failed') return true;
495
+ if (e.status === 'running') {
496
+ const updatedAt = new Date(e.updatedAt).getTime();
497
+ return (now - updatedAt) > STALE_THRESHOLD_MS;
498
+ }
499
+ return false;
500
+ })
501
+ .sort((a: any, b: any) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
502
+
503
+ return resumable.length > 0 ? resumable[0].id : null;
504
+ };
505
+
506
+ // ── Graph Traversal ─────────────────────────────────────────────────
507
+
508
+ /**
509
+ * Main graph traversal loop. Executes nodes sequentially, following edges.
510
+ * Back-edges cause re-execution (loops). Branching nodes return an outputLabel
511
+ * to pick the correct outgoing edge.
512
+ */
513
+ private executeGraph = async (
514
+ executionId: string,
515
+ graph: WorkflowGraph,
516
+ startNodeId: string,
517
+ context: ExecutionContext,
518
+ ): Promise<void> => {
519
+ let currentNodeId: string | null = startNodeId;
520
+
521
+ while (currentNodeId) {
522
+ const node = graph.nodes.find(n => n.id === currentNodeId);
523
+ if (!node) {
524
+ throw new Error(`Node ${currentNodeId} not found in workflow graph`);
525
+ }
526
+
527
+ // Update execution's current_node_id before executing
528
+ await this.setExecutionCurrentNode(executionId, currentNodeId, context);
529
+
530
+ this.log('WORKFLOW', `Executing node ${node.id} (${node.type})`);
531
+
532
+ // Create or reset node execution record
533
+ const nodeExecId = await this.upsertNodeExecution(executionId, node.id, 'running');
534
+
535
+ try {
536
+ const result = await this.executeNode(node, context, executionId);
537
+
538
+ // Mark node as completed
539
+ await this.completeNodeExecution(nodeExecId, result.outputData || null);
540
+ this.log('WORKFLOW', `Node ${node.id} completed (output: ${result.outputLabel || 'default'})`);
541
+
542
+ // Find the next node via edges
543
+ currentNodeId = this.resolveNextNode(graph, node.id, result.outputLabel);
544
+
545
+ if (currentNodeId) {
546
+ this.log('WORKFLOW', `Next node: ${currentNodeId}`);
547
+ } else {
548
+ this.log('WORKFLOW', `No outgoing edge from ${node.id} — traversal complete`);
549
+ }
550
+ } catch (err: any) {
551
+ this.log('WORKFLOW', `Node ${node.id} FAILED: ${err.message}`);
552
+
553
+ // Mark node execution as failed
554
+ await this.db.update(workflowNodeExecutions)
555
+ .set({
556
+ status: 'failed',
557
+ error: err.message,
558
+ completedAt: new Date().toISOString(),
559
+ })
560
+ .where(eq(workflowNodeExecutions.id, nodeExecId));
561
+
562
+ // Mark workflow execution as failed — do NOT clean up worktree (enables resume)
563
+ await this.setExecutionStatus(executionId, 'failed', node.id, context);
564
+ return;
565
+ }
566
+ }
567
+
568
+ // Loop exited normally (no more outgoing edges) without hitting an End node —
569
+ // ensure execution is finalized so it doesn't stay stuck in 'running'.
570
+ this.log('WORKFLOW', `Graph traversal ended without End node — marking execution completed`);
571
+ await this.setExecutionStatus(executionId, 'completed', null, context);
572
+ };
573
+
574
+ // ── Node Handlers ───────────────────────────────────────────────────
575
+
576
+ /**
577
+ * Dispatch to the appropriate handler based on node type.
578
+ */
579
+ private executeNode = async (
580
+ node: WorkflowNode,
581
+ context: ExecutionContext,
582
+ executionId: string,
583
+ ): Promise<NodeHandlerResult> => {
584
+ switch (node.type) {
585
+ case 'start':
586
+ return this.handleStart(node, context);
587
+ case 'transitionCard':
588
+ return this.handleTransitionCard(node, context);
589
+ case 'runAgent':
590
+ return this.handleRunAgent(node, context, executionId);
591
+ case 'checkCardPosition':
592
+ return this.handleCheckCardPosition(node, context);
593
+ case 'checkCycleCount':
594
+ return this.handleCheckCycleCount(node, context);
595
+ case 'setCardMetadata':
596
+ return this.handleSetCardMetadata(node, context);
597
+ case 'end':
598
+ return this.handleEnd(node, context, executionId);
599
+ case 'group':
600
+ return this.handleGroup(node, context, executionId);
601
+ default:
602
+ throw new Error(`Unknown node type: ${node.type}`);
603
+ }
604
+ };
605
+
606
+ /**
607
+ * Start node — no-op passthrough.
608
+ */
609
+ private handleStart = async (_node: WorkflowNode, _context: ExecutionContext): Promise<NodeHandlerResult> => {
610
+ return { outputLabel: null };
611
+ };
612
+
613
+ /**
614
+ * TransitionCard — moves the kanban card from one column to another.
615
+ * Fails if the card is not in the expected source column.
616
+ */
617
+ private handleTransitionCard = async (node: WorkflowNode, context: ExecutionContext): Promise<NodeHandlerResult> => {
618
+ const { fromColumn, toColumn } = node.data as { fromColumn: string; toColumn: string };
619
+ const { featureId } = context;
620
+
621
+ const entry = await this.kanban.getKanbanEntry(featureId);
622
+ if (!entry) {
623
+ throw new Error(`Feature ${featureId} not found in kanban`);
624
+ }
625
+
626
+ if ((entry as any).column !== fromColumn) {
627
+ throw new Error(
628
+ `Expected card in "${fromColumn}" but found in "${(entry as any).column}"`,
629
+ );
630
+ }
631
+
632
+ (entry as any).column = toColumn;
633
+ (entry as any).moved_at = new Date().toISOString();
634
+ await this.kanban.saveKanbanEntry(featureId, entry);
635
+ this.log('WORKFLOW', `Moved ${featureId} from ${fromColumn} → ${toColumn}`);
636
+
637
+ return { outputLabel: null };
638
+ };
639
+
640
+ /**
641
+ * RunAgent — loads agent from DB, resolves prompt template placeholders,
642
+ * spawns Claude in the worktree, and captures output.
643
+ * Wires onToolUse/onResult/onTurnUsage callbacks to capture KPI metrics.
644
+ */
645
+ private handleRunAgent = async (
646
+ node: WorkflowNode,
647
+ context: ExecutionContext,
648
+ executionId: string,
649
+ ): Promise<NodeHandlerResult> => {
650
+ const { agentId } = node.data as { agentId: string };
651
+ const { featureId, worktreePath } = context;
652
+
653
+ // Load agent from DB
654
+ const [agent] = await this.db.select().from(agents).where(eq(agents.id, agentId));
655
+ if (!agent) {
656
+ throw new Error(`Agent ${agentId} not found`);
657
+ }
658
+
659
+ // Resolve {{placeholder}} variables in prompt template
660
+ const resolvedPrompt = this.resolvePromptTemplate(agent.promptTemplate, context);
661
+
662
+ // Find the node execution ID for persisting tool calls
663
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
664
+ .where(and(
665
+ eq(workflowNodeExecutions.executionId, executionId),
666
+ eq(workflowNodeExecutions.nodeId, node.id),
667
+ ));
668
+ const currentNodeExecId = nodeExecs.length > 0
669
+ ? nodeExecs.sort((a, b) => b.attempt - a.attempt)[0].id
670
+ : null;
671
+
672
+ // Accumulate KPI metrics during execution
673
+ const toolCalls: Record<string, number> = { total: 0, read: 0, write: 0, edit: 0, bash: 0, glob: 0, grep: 0 };
674
+ let costUsd: number | null = null;
675
+ let numTurns: number | null = null;
676
+ let stopReason: string | null = null;
677
+ let model: string | null = null;
678
+ let contextWindowPct: number | null = null;
679
+
680
+ const onToolUse = (toolName: string, toolInput: object) => {
681
+ toolCalls.total++;
682
+ const key = toolName.toLowerCase();
683
+ if (key in toolCalls) {
684
+ toolCalls[key]++;
685
+ }
686
+
687
+ // Persist individual tool call to workflow_tool_calls table
688
+ if (currentNodeExecId) {
689
+ const target = this.extractToolTarget(toolName, toolInput);
690
+ this.db.insert(workflowToolCalls).values({
691
+ id: randomUUID(),
692
+ nodeExecutionId: currentNodeExecId,
693
+ timestamp: new Date().toISOString(),
694
+ toolName,
695
+ target,
696
+ createdAt: new Date().toISOString(),
697
+ }).catch(() => {}); // fire-and-forget, don't block agent execution
698
+ }
699
+ };
700
+
701
+ const onAssistantText = (text: string) => {
702
+ // Persist assistant text messages to the same table with a special toolName
703
+ if (currentNodeExecId) {
704
+ this.db.insert(workflowToolCalls).values({
705
+ id: randomUUID(),
706
+ nodeExecutionId: currentNodeExecId,
707
+ timestamp: new Date().toISOString(),
708
+ toolName: '__assistant__',
709
+ target: text,
710
+ createdAt: new Date().toISOString(),
711
+ }).catch(() => {}); // fire-and-forget
712
+ }
713
+ };
714
+
715
+ const onResult = (metadata: {
716
+ costUsd: number | null;
717
+ numTurns: number | null;
718
+ stopReason: string | null;
719
+ model: string | null;
720
+ contextWindow: number | null;
721
+ }) => {
722
+ costUsd = metadata.costUsd;
723
+ numTurns = metadata.numTurns;
724
+ stopReason = metadata.stopReason;
725
+ model = metadata.model;
726
+ contextWindowPct = metadata.contextWindow;
727
+ };
728
+
729
+ this.log('WORKFLOW', `Running agent "${agent.name}" (${agentId}) for ${featureId}`);
730
+ const output = await this.claudeService.spawnClaude(
731
+ resolvedPrompt,
732
+ worktreePath,
733
+ `${agent.name.toLowerCase().replace(/\s+/g, '-')}:${featureId}`,
734
+ { onToolUse, onResult, onAssistantText },
735
+ );
736
+
737
+ // Commit any changes the agent made
738
+ try {
739
+ await this.gitWorkflow.commitChanges(featureId, worktreePath);
740
+ } catch (err: any) {
741
+ this.log('WORKFLOW', `WARN: Auto-commit after agent failed: ${err.message}`);
742
+ }
743
+
744
+ // Rebase onto main
745
+ try {
746
+ await this.gitWorkflow.rebaseBranch(featureId, worktreePath);
747
+ } catch (err: any) {
748
+ this.log('WORKFLOW', `WARN: Rebase after agent failed: ${err.message}`);
749
+ }
750
+
751
+ this.log('WORKFLOW', `Agent "${agent.name}" completed (${output.length} chars output)`);
752
+
753
+ return {
754
+ outputLabel: null,
755
+ outputData: {
756
+ agentId,
757
+ agentName: agent.name,
758
+ output,
759
+ toolCalls,
760
+ costUsd,
761
+ numTurns,
762
+ stopReason,
763
+ model,
764
+ contextWindowPct,
765
+ },
766
+ };
767
+ };
768
+
769
+ /**
770
+ * CheckCardPosition — reads the kanban column and follows the matching output edge.
771
+ * The edge's sourceHandle or data.label must match the column name.
772
+ */
773
+ private handleCheckCardPosition = async (
774
+ node: WorkflowNode,
775
+ context: ExecutionContext,
776
+ ): Promise<NodeHandlerResult> => {
777
+ const { featureId } = context;
778
+
779
+ const entry = await this.kanban.getKanbanEntry(featureId);
780
+ if (!entry) {
781
+ throw new Error(`Feature ${featureId} not found in kanban`);
782
+ }
783
+
784
+ const column = (entry as any).column as string;
785
+
786
+ // If reviewer didn't move the card (still in_review), treat as rejection
787
+ if (column === 'in_review') {
788
+ this.log('WORKFLOW', `Card still in in_review — treating as rejection (todo)`);
789
+ (entry as any).column = 'todo';
790
+ (entry as any).moved_at = new Date().toISOString();
791
+ await this.kanban.saveKanbanEntry(featureId, entry);
792
+ return { outputLabel: 'todo' };
793
+ }
794
+
795
+ this.log('WORKFLOW', `Card position for ${featureId}: ${column}`);
796
+ return { outputLabel: column };
797
+ };
798
+
799
+ /**
800
+ * CheckCycleCount — increments the cycle counter and branches on limit.
801
+ * Follows 'under_limit' if count < max, 'at_limit' if count >= max.
802
+ */
803
+ private handleCheckCycleCount = async (
804
+ node: WorkflowNode,
805
+ context: ExecutionContext,
806
+ ): Promise<NodeHandlerResult> => {
807
+ const { maxCycles } = node.data as { maxCycles: number };
808
+
809
+ context.cycle = (context.cycle || 0) + 1;
810
+ const currentCycle = context.cycle;
811
+
812
+ this.log('WORKFLOW', `Cycle count: ${currentCycle}/${maxCycles}`);
813
+
814
+ if (currentCycle >= maxCycles) {
815
+ return { outputLabel: 'at_limit', outputData: { cycle: currentCycle, maxCycles } };
816
+ }
817
+ return { outputLabel: 'under_limit', outputData: { cycle: currentCycle, maxCycles } };
818
+ };
819
+
820
+ /**
821
+ * SetCardMetadata — sets a key/value on the kanban card metadata.
822
+ */
823
+ private handleSetCardMetadata = async (
824
+ node: WorkflowNode,
825
+ context: ExecutionContext,
826
+ ): Promise<NodeHandlerResult> => {
827
+ const { key, value } = node.data as { key: string; value: string };
828
+ const { featureId } = context;
829
+
830
+ const entry = await this.kanban.getKanbanEntry(featureId);
831
+ if (!entry) {
832
+ throw new Error(`Feature ${featureId} not found in kanban`);
833
+ }
834
+
835
+ // Parse value — "true"/"false" become booleans
836
+ let parsedValue: unknown = value;
837
+ if (value === 'true') parsedValue = true;
838
+ else if (value === 'false') parsedValue = false;
839
+
840
+ (entry as any)[key] = parsedValue;
841
+ await this.kanban.saveKanbanEntry(featureId, entry);
842
+ this.log('WORKFLOW', `Set metadata ${key}=${value} on ${featureId}`);
843
+
844
+ return { outputLabel: null };
845
+ };
846
+
847
+ /**
848
+ * End node — finalizes the execution based on outcome.
849
+ * - success: merge feature branch, cleanup worktree, status=completed
850
+ * - blocked: cleanup worktree without merge, status=completed
851
+ * - failed: cleanup worktree without merge, status=failed
852
+ */
853
+ private handleEnd = async (
854
+ node: WorkflowNode,
855
+ context: ExecutionContext,
856
+ executionId: string,
857
+ ): Promise<NodeHandlerResult> => {
858
+ const { outcome } = node.data as { outcome: string };
859
+ const { featureId, worktreePath } = context;
860
+
861
+ this.log('WORKFLOW', `End node reached: outcome=${outcome}`);
862
+
863
+ if (outcome === 'success') {
864
+ await this.handleMerge(featureId, worktreePath, context);
865
+ await this.setExecutionStatus(executionId, 'completed', node.id, context);
866
+ } else if (outcome === 'blocked') {
867
+ await this.cleanupWorktree(featureId, worktreePath);
868
+ await this.setExecutionStatus(executionId, 'completed', node.id, context);
869
+ } else {
870
+ // failed or unknown outcome
871
+ await this.cleanupWorktree(featureId, worktreePath);
872
+ await this.setExecutionStatus(executionId, 'failed', node.id, context);
873
+ }
874
+
875
+ return { outputLabel: null };
876
+ };
877
+
878
+ /**
879
+ * Group node — expands the group's internal nodes/edges into a sub-graph
880
+ * and traverses them sequentially. Checkpoint/resume works correctly
881
+ * because each internal node gets its own workflow_node_execution record
882
+ * with a prefixed ID (groupNodeId:internalNodeId).
883
+ */
884
+ private handleGroup = async (
885
+ node: WorkflowNode,
886
+ context: ExecutionContext,
887
+ executionId: string,
888
+ ): Promise<NodeHandlerResult> => {
889
+ const { internalNodes, internalEdges, groupName } = node.data as {
890
+ internalNodes: WorkflowNode[];
891
+ internalEdges: WorkflowEdge[];
892
+ groupName: string;
893
+ };
894
+
895
+ this.log('WORKFLOW', `Expanding group "${groupName}" (${node.id}) with ${internalNodes.length} internal nodes`);
896
+
897
+ if (!internalNodes || internalNodes.length === 0) {
898
+ this.log('WORKFLOW', `Group "${groupName}" is empty — skipping`);
899
+ return { outputLabel: null };
900
+ }
901
+
902
+ // Build a sub-graph from the group's internal nodes/edges
903
+ const subGraph: WorkflowGraph = {
904
+ nodes: internalNodes,
905
+ edges: internalEdges || [],
906
+ };
907
+
908
+ // Find the start node in the sub-graph (first node with no incoming edges, or type=start)
909
+ const targetIds = new Set((internalEdges || []).map(e => e.target));
910
+ const startNode = internalNodes.find(n => n.type === 'start') ||
911
+ internalNodes.find(n => !targetIds.has(n.id)) ||
912
+ internalNodes[0];
913
+
914
+ // Execute the sub-graph using the same traversal logic
915
+ // Each internal node execution is tracked with a composite ID for resume support
916
+ await this.executeGraph(executionId, subGraph, startNode.id, context);
917
+
918
+ this.log('WORKFLOW', `Group "${groupName}" (${node.id}) completed`);
919
+ return { outputLabel: null };
920
+ };
921
+
922
+ // ── Merge & Cleanup Helpers ─────────────────────────────────────────
923
+
924
+ /**
925
+ * Merge the feature branch into main, then cleanup worktree and branch.
926
+ */
927
+ private handleMerge = async (
928
+ featureId: string,
929
+ worktreePath: string,
930
+ context: ExecutionContext,
931
+ ): Promise<void> => {
932
+ this.log('WORKFLOW', `Merging feature/${featureId} into main...`);
933
+
934
+ // Stash local changes on the parent branch
935
+ let stashed = false;
936
+ try {
937
+ stashed = await this.gitWorkflow.stash();
938
+ } catch (err: any) {
939
+ this.log('WORKFLOW', `Stash failed (non-fatal): ${err.message}`);
940
+ }
941
+
942
+ // Merge
943
+ let mergeSucceeded = false;
944
+ try {
945
+ await this.gitWorkflow.mergeBranch(featureId);
946
+ mergeSucceeded = true;
947
+ } catch (mergeErr: any) {
948
+ this.log('WORKFLOW', `Merge FAILED: ${mergeErr.message} — attempting fix`);
949
+ try {
950
+ const fixed = await this.gitWorkflow.fixMergeConflicts(featureId);
951
+ if (fixed) {
952
+ this.log('WORKFLOW', `Merge fix succeeded`);
953
+ mergeSucceeded = true;
954
+ } else {
955
+ throw new Error(`Merge fix did not result in a successful merge`);
956
+ }
957
+ } catch (fixErr: any) {
958
+ this.log('WORKFLOW', `Merge fix FAILED: ${fixErr.message}`);
959
+ throw new Error(`Merge failed: ${mergeErr.message}`);
960
+ }
961
+ }
962
+
963
+ // Pop stash
964
+ if (stashed) {
965
+ try {
966
+ await this.gitWorkflow.unstash();
967
+ } catch (popErr: any) {
968
+ this.log('WORKFLOW', `Stash pop failed: ${popErr.message}`);
969
+ }
970
+ }
971
+
972
+ // Cleanup worktree + branch
973
+ if (mergeSucceeded) {
974
+ await this.gitWorkflow.removeWorktree(worktreePath);
975
+ await this.gitWorkflow.deleteBranch(featureId);
976
+ this.log('WORKFLOW', `Merge completed, worktree and branch cleaned up for ${featureId}`);
977
+ }
978
+ };
979
+
980
+ /**
981
+ * Remove the worktree and branch without merging.
982
+ */
983
+ private cleanupWorktree = async (featureId: string, worktreePath: string): Promise<void> => {
984
+ this.log('WORKFLOW', `Cleaning up worktree for ${featureId} (no merge)`);
985
+ await this.gitWorkflow.removeWorktree(worktreePath);
986
+ await this.gitWorkflow.deleteBranch(featureId, true);
987
+ };
988
+
989
+ // ── Graph Helpers ───────────────────────────────────────────────────
990
+
991
+ /**
992
+ * Given a node ID and an optional output label, find the target node ID
993
+ * by matching outgoing edges. If outputLabel is null, returns the single
994
+ * outgoing edge's target (or null if none).
995
+ */
996
+ private resolveNextNode = (
997
+ graph: WorkflowGraph,
998
+ nodeId: string,
999
+ outputLabel: string | null,
1000
+ ): string | null => {
1001
+ const outgoing = graph.edges.filter(e => e.source === nodeId);
1002
+
1003
+ if (outgoing.length === 0) return null;
1004
+
1005
+ if (outputLabel === null) {
1006
+ // No branching — take the single outgoing edge
1007
+ if (outgoing.length === 1) return outgoing[0].target;
1008
+ // If multiple edges but no label, take the first (shouldn't happen in well-formed graphs)
1009
+ this.log('WORKFLOW', `WARN: Node ${nodeId} has ${outgoing.length} outgoing edges but no output label — taking first`);
1010
+ return outgoing[0].target;
1011
+ }
1012
+
1013
+ // Find the edge matching the output label (by sourceHandle or data.label)
1014
+ const match = outgoing.find(e =>
1015
+ e.sourceHandle === outputLabel ||
1016
+ e.data?.label === outputLabel,
1017
+ );
1018
+
1019
+ if (!match) {
1020
+ throw new Error(
1021
+ `No outgoing edge from node ${nodeId} matches output label "${outputLabel}". ` +
1022
+ `Available: ${outgoing.map(e => e.sourceHandle || e.data?.label || '(unlabeled)').join(', ')}`,
1023
+ );
1024
+ }
1025
+
1026
+ return match.target;
1027
+ };
1028
+
1029
+ /**
1030
+ * Replace {{placeholder}} variables in a prompt template with values from context.
1031
+ */
1032
+ private resolvePromptTemplate = (template: string, context: ExecutionContext): string => {
1033
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
1034
+ if (key in context) {
1035
+ return String(context[key]);
1036
+ }
1037
+ return `{{${key}}}`; // leave unresolved placeholders as-is
1038
+ });
1039
+ };
1040
+
1041
+ // ── Feature Context Helpers ──────────────────────────────────────────
1042
+
1043
+ /**
1044
+ * Extract the Description section content from a node body.
1045
+ * Falls back to the node name if no description section is found.
1046
+ */
1047
+ private extractFeatureDescription = (body: string | null, fallbackName: string): string => {
1048
+ if (!body) return fallbackName;
1049
+ const match = body.match(/## Description *\n([\s\S]*?)(?=\n## |$)/);
1050
+ return match ? match[1].trim() || fallbackName : fallbackName;
1051
+ };
1052
+
1053
+ /**
1054
+ * Derive a kebab-case composition name from a feature name.
1055
+ * E.g. "Product Feature Walkthrough" → "product-feature-walkthrough"
1056
+ */
1057
+ private deriveCompositionName = (name: string): string => {
1058
+ return name
1059
+ .toLowerCase()
1060
+ .replace(/[^a-z0-9]+/g, '-')
1061
+ .replace(/^-|-$/g, '');
1062
+ };
1063
+
1064
+ // ── Tool Target Extraction ─────────────────────────────────────────
1065
+
1066
+ /**
1067
+ * Extract the primary target from a tool's input object.
1068
+ * For Read/Write/Edit: file_path. For Bash: command. For Grep/Glob: pattern.
1069
+ */
1070
+ private extractToolTarget = (toolName: string, toolInput: object): string => {
1071
+ const input = toolInput as Record<string, unknown>;
1072
+ switch (toolName.toLowerCase()) {
1073
+ case 'read':
1074
+ case 'write':
1075
+ case 'edit':
1076
+ return (input.file_path as string) || (input.filePath as string) || '';
1077
+ case 'bash':
1078
+ return (input.command as string) || '';
1079
+ case 'grep':
1080
+ return (input.pattern as string) || '';
1081
+ case 'glob':
1082
+ return (input.pattern as string) || '';
1083
+ default:
1084
+ // For unknown tools, try common field names
1085
+ return (input.file_path as string) || (input.command as string) || (input.pattern as string) || '';
1086
+ }
1087
+ };
1088
+
1089
+ // ── Monitor Data ──────────────────────────────────────────────────
1090
+
1091
+ /**
1092
+ * Get full monitor data for a feature's workflow execution.
1093
+ * Returns workflow graph, all node executions with statuses/timings/errors,
1094
+ * and tool calls for each node.
1095
+ */
1096
+ getMonitorData = async (featureId: string): Promise<Record<string, unknown> | null> => {
1097
+ // Find the most recent execution for this feature
1098
+ const executions = await this.db.select().from(workflowExecutions)
1099
+ .where(eq(workflowExecutions.featureId, featureId));
1100
+
1101
+ if (executions.length === 0) return null;
1102
+
1103
+ const execution = executions.sort((a, b) =>
1104
+ new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
1105
+ )[0];
1106
+
1107
+ // Load workflow graph
1108
+ const [workflow] = await this.db.select().from(workflows)
1109
+ .where(eq(workflows.id, execution.workflowId));
1110
+ if (!workflow) return null;
1111
+
1112
+ // Load node executions
1113
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
1114
+ .where(eq(workflowNodeExecutions.executionId, execution.id));
1115
+
1116
+ // Load tool calls for all node executions
1117
+ const nodeExecIds = nodeExecs.map(ne => ne.id);
1118
+ let allToolCalls: Array<Record<string, unknown>> = [];
1119
+ if (nodeExecIds.length > 0) {
1120
+ // Batch query: get all tool calls for this execution's node executions
1121
+ for (const neId of nodeExecIds) {
1122
+ const calls = await this.db.select().from(workflowToolCalls)
1123
+ .where(eq(workflowToolCalls.nodeExecutionId, neId));
1124
+ allToolCalls = allToolCalls.concat(calls);
1125
+ }
1126
+ }
1127
+
1128
+ // Group tool calls by node execution ID
1129
+ const toolCallsByNodeExec: Record<string, Array<Record<string, unknown>>> = {};
1130
+ for (const tc of allToolCalls) {
1131
+ const neId = tc.nodeExecutionId as string;
1132
+ if (!toolCallsByNodeExec[neId]) toolCallsByNodeExec[neId] = [];
1133
+ toolCallsByNodeExec[neId].push({
1134
+ id: tc.id,
1135
+ timestamp: tc.timestamp,
1136
+ toolName: tc.toolName,
1137
+ target: tc.target,
1138
+ });
1139
+ }
1140
+
1141
+ // Parse graph data
1142
+ const graphData = JSON.parse(workflow.graphData);
1143
+
1144
+ // Build node execution map
1145
+ const nodeExecutionMap: Record<string, Record<string, unknown>> = {};
1146
+ for (const ne of nodeExecs) {
1147
+ nodeExecutionMap[ne.nodeId] = {
1148
+ id: ne.id,
1149
+ nodeId: ne.nodeId,
1150
+ status: ne.status,
1151
+ startedAt: ne.startedAt,
1152
+ completedAt: ne.completedAt,
1153
+ outputData: ne.outputData ? JSON.parse(ne.outputData) : null,
1154
+ error: ne.error,
1155
+ attempt: ne.attempt,
1156
+ toolCalls: toolCallsByNodeExec[ne.id] || [],
1157
+ };
1158
+ }
1159
+
1160
+ return {
1161
+ executionId: execution.id,
1162
+ workflowId: execution.workflowId,
1163
+ workflowName: workflow.name,
1164
+ featureId: execution.featureId,
1165
+ status: execution.status,
1166
+ currentNodeId: execution.currentNodeId,
1167
+ startedAt: execution.startedAt,
1168
+ updatedAt: execution.updatedAt,
1169
+ graphData,
1170
+ nodeExecutions: nodeExecutionMap,
1171
+ };
1172
+ };
1173
+
1174
+ // ── Persistence Helpers ─────────────────────────────────────────────
1175
+
1176
+ /**
1177
+ * Update the execution's current_node_id and persist context.
1178
+ */
1179
+ private setExecutionCurrentNode = async (
1180
+ executionId: string,
1181
+ nodeId: string,
1182
+ context: ExecutionContext,
1183
+ ): Promise<void> => {
1184
+ await this.db.update(workflowExecutions)
1185
+ .set({
1186
+ currentNodeId: nodeId,
1187
+ context: JSON.stringify(context),
1188
+ updatedAt: new Date().toISOString(),
1189
+ })
1190
+ .where(eq(workflowExecutions.id, executionId));
1191
+ };
1192
+
1193
+ /**
1194
+ * Set the execution's final status.
1195
+ */
1196
+ private setExecutionStatus = async (
1197
+ executionId: string,
1198
+ status: string,
1199
+ currentNodeId: string | null,
1200
+ context: ExecutionContext,
1201
+ ): Promise<void> => {
1202
+ await this.db.update(workflowExecutions)
1203
+ .set({
1204
+ status,
1205
+ currentNodeId,
1206
+ context: JSON.stringify(context),
1207
+ updatedAt: new Date().toISOString(),
1208
+ })
1209
+ .where(eq(workflowExecutions.id, executionId));
1210
+ };
1211
+
1212
+ /**
1213
+ * Create or reset a node execution record. Returns the record ID.
1214
+ * For back-edges (re-execution), increments attempt counter.
1215
+ */
1216
+ private upsertNodeExecution = async (
1217
+ executionId: string,
1218
+ nodeId: string,
1219
+ status: string,
1220
+ ): Promise<string> => {
1221
+ // Check for existing records for this node in this execution
1222
+ const existing = await this.db.select().from(workflowNodeExecutions)
1223
+ .where(and(
1224
+ eq(workflowNodeExecutions.executionId, executionId),
1225
+ eq(workflowNodeExecutions.nodeId, nodeId),
1226
+ ));
1227
+
1228
+ const now = new Date().toISOString();
1229
+
1230
+ if (existing.length > 0) {
1231
+ // Re-execution (back-edge or resume) — update existing record
1232
+ const latest = existing.sort((a, b) => b.attempt - a.attempt)[0];
1233
+ const newAttempt = latest.status === 'completed' ? latest.attempt + 1 : latest.attempt;
1234
+
1235
+ await this.db.update(workflowNodeExecutions)
1236
+ .set({
1237
+ status,
1238
+ startedAt: now,
1239
+ completedAt: null,
1240
+ outputData: null,
1241
+ error: null,
1242
+ attempt: newAttempt,
1243
+ })
1244
+ .where(eq(workflowNodeExecutions.id, latest.id));
1245
+
1246
+ return latest.id;
1247
+ }
1248
+
1249
+ // First execution of this node
1250
+ const id = randomUUID();
1251
+ await this.db.insert(workflowNodeExecutions).values({
1252
+ id,
1253
+ executionId,
1254
+ nodeId,
1255
+ status,
1256
+ startedAt: now,
1257
+ completedAt: null,
1258
+ outputData: null,
1259
+ error: null,
1260
+ attempt: 1,
1261
+ });
1262
+
1263
+ return id;
1264
+ };
1265
+
1266
+ /**
1267
+ * Mark a node execution as completed with optional output data.
1268
+ */
1269
+ private completeNodeExecution = async (
1270
+ nodeExecId: string,
1271
+ outputData: Record<string, unknown> | null,
1272
+ ): Promise<void> => {
1273
+ await this.db.update(workflowNodeExecutions)
1274
+ .set({
1275
+ status: 'completed',
1276
+ completedAt: new Date().toISOString(),
1277
+ outputData: outputData ? JSON.stringify(outputData) : null,
1278
+ })
1279
+ .where(eq(workflowNodeExecutions.id, nodeExecId));
1280
+ };
1281
+ }