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