@assistkick/create 1.7.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.
- package/dist/bin/create.js +0 -0
- package/package.json +9 -7
- package/templates/assistkick-product-system/.env.example +1 -0
- package/templates/assistkick-product-system/local.db +0 -0
- package/templates/assistkick-product-system/package.json +4 -2
- package/templates/assistkick-product-system/packages/backend/package.json +2 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
- package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
- package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
- package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
- package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
- package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +202 -171
- package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
- package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
- package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
- package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
- package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
- package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
- package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
- package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
- package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
- package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
- package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
- package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
- package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
- package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
- package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
- package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
- package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
- package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
- package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
- package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
- package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
- package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
- package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
- package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
- package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
- package/templates/assistkick-product-system/packages/video/index.ts +4 -0
- package/templates/assistkick-product-system/packages/video/package.json +28 -0
- package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
- package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
- package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
- package/templates/assistkick-product-system/packages/video/style.css +1 -0
- package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
- package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
- package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
- package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
- package/templates/skills/assistkick-developer/SKILL.md +3 -0
- package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
- package/templates/skills/product-system/graph.json +1890 -0
- package/templates/skills/product-system/kanban.json +304 -0
- package/templates/skills/product-system/nodes/comp_001.md +56 -0
- package/templates/skills/product-system/nodes/comp_002.md +57 -0
- package/templates/skills/product-system/nodes/data_001.md +51 -0
- package/templates/skills/product-system/nodes/data_002.md +40 -0
- package/templates/skills/product-system/nodes/data_004.md +38 -0
- package/templates/skills/product-system/nodes/dec_001.md +34 -0
- package/templates/skills/product-system/nodes/dec_016.md +32 -0
- package/templates/skills/product-system/nodes/feat_008.md +30 -0
- package/templates/skills/video-composition-agent/SKILL.md +232 -0
- 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
|
+
}
|