@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,1753 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { WorkflowEngine } from './workflow_engine.ts';
|
|
4
|
+
|
|
5
|
+
// ── Default workflow graph (matches seeded data) ──────────────────────
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GRAPH = {
|
|
8
|
+
nodes: [
|
|
9
|
+
{ id: 'start_1', type: 'start', position: { x: 50, y: 250 }, data: { label: 'Start' } },
|
|
10
|
+
{ id: 'transition_1', type: 'transitionCard', position: { x: 250, y: 250 }, data: { label: 'Move to In Progress', fromColumn: 'todo', toColumn: 'in_progress' } },
|
|
11
|
+
{ id: 'agent_dev', type: 'runAgent', position: { x: 500, y: 250 }, data: { label: 'Run Developer', agentId: 'dev-agent-id' } },
|
|
12
|
+
{ id: 'transition_2', type: 'transitionCard', position: { x: 750, y: 250 }, data: { label: 'Move to In Review', fromColumn: 'in_progress', toColumn: 'in_review' } },
|
|
13
|
+
{ id: 'agent_rev', type: 'runAgent', position: { x: 1000, y: 250 }, data: { label: 'Run Reviewer', agentId: 'rev-agent-id' } },
|
|
14
|
+
{ id: 'check_pos', type: 'checkCardPosition', position: { x: 1250, y: 250 }, data: { label: 'Check Card Position' } },
|
|
15
|
+
{ id: 'end_success', type: 'end', position: { x: 1500, y: 100 }, data: { label: 'End (Success)', outcome: 'success' } },
|
|
16
|
+
{ id: 'check_cycle', type: 'checkCycleCount', position: { x: 1500, y: 400 }, data: { label: 'Check Cycle Count', maxCycles: 3 } },
|
|
17
|
+
{ id: 'set_meta', type: 'setCardMetadata', position: { x: 1750, y: 400 }, data: { label: 'Set dev_blocked', key: 'dev_blocked', value: 'true' } },
|
|
18
|
+
{ id: 'end_blocked', type: 'end', position: { x: 2000, y: 400 }, data: { label: 'End (Blocked)', outcome: 'blocked' } },
|
|
19
|
+
],
|
|
20
|
+
edges: [
|
|
21
|
+
{ id: 'e_start_t1', source: 'start_1', target: 'transition_1' },
|
|
22
|
+
{ id: 'e_t1_dev', source: 'transition_1', target: 'agent_dev' },
|
|
23
|
+
{ id: 'e_dev_t2', source: 'agent_dev', target: 'transition_2' },
|
|
24
|
+
{ id: 'e_t2_rev', source: 'transition_2', target: 'agent_rev' },
|
|
25
|
+
{ id: 'e_rev_check', source: 'agent_rev', target: 'check_pos' },
|
|
26
|
+
{ id: 'e_check_success', source: 'check_pos', target: 'end_success', sourceHandle: 'qa', data: { label: 'qa' } },
|
|
27
|
+
{ id: 'e_check_cycle', source: 'check_pos', target: 'check_cycle', sourceHandle: 'todo', data: { label: 'todo' } },
|
|
28
|
+
{ id: 'e_cycle_dev', source: 'check_cycle', target: 'agent_dev', sourceHandle: 'under_limit', data: { label: '< 3' } },
|
|
29
|
+
{ id: 'e_cycle_meta', source: 'check_cycle', target: 'set_meta', sourceHandle: 'at_limit', data: { label: '>= 3' } },
|
|
30
|
+
{ id: 'e_meta_blocked', source: 'set_meta', target: 'end_blocked' },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const WORKFLOW_ID = 'test-workflow-id';
|
|
35
|
+
|
|
36
|
+
// ── Drizzle Condition Extraction ─────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Recursively flatten drizzle SQL queryChunks into a flat array of
|
|
40
|
+
* Column, Param, and StringChunk objects.
|
|
41
|
+
*/
|
|
42
|
+
const flattenChunks = (obj: any): any[] => {
|
|
43
|
+
if (!obj || typeof obj !== 'object') return [];
|
|
44
|
+
if (Array.isArray(obj.queryChunks)) {
|
|
45
|
+
return obj.queryChunks.flatMap((c: any) => flattenChunks(c));
|
|
46
|
+
}
|
|
47
|
+
return [obj];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract {column, value} pairs from a drizzle eq()/and() condition.
|
|
52
|
+
* Column objects have .name (string) and .table (object).
|
|
53
|
+
* Param objects have .value (non-array scalar).
|
|
54
|
+
* StringChunk objects have .value as string[] — skipped.
|
|
55
|
+
*/
|
|
56
|
+
const extractEqPairs = (condition: any): Array<{ column: string; value: any }> => {
|
|
57
|
+
const flat = flattenChunks(condition);
|
|
58
|
+
const pairs: Array<{ column: string; value: any }> = [];
|
|
59
|
+
let lastColumnName: string | null = null;
|
|
60
|
+
|
|
61
|
+
for (const item of flat) {
|
|
62
|
+
if (!item || typeof item !== 'object') continue;
|
|
63
|
+
|
|
64
|
+
// Column: has 'name' (string) and 'table' (object reference)
|
|
65
|
+
if (typeof item.name === 'string' && item.table && typeof item.table === 'object') {
|
|
66
|
+
lastColumnName = item.name;
|
|
67
|
+
}
|
|
68
|
+
// Param: has 'value' that is NOT an array (StringChunks have string[] values)
|
|
69
|
+
else if ('value' in item && !Array.isArray(item.value) && lastColumnName !== null) {
|
|
70
|
+
pairs.push({ column: lastColumnName, value: item.value });
|
|
71
|
+
lastColumnName = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return pairs;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Convert snake_case DB column name to camelCase store key */
|
|
79
|
+
const snakeToCamel = (s: string): string => {
|
|
80
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ── Mock Builders ─────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** In-memory store for workflow_executions and workflow_node_executions */
|
|
86
|
+
const createMockDb = (graphData = DEFAULT_GRAPH) => {
|
|
87
|
+
const executionsStore: Record<string, any> = {};
|
|
88
|
+
const nodeExecutionsStore: Record<string, any> = {};
|
|
89
|
+
const toolCallsStore: Record<string, any> = {};
|
|
90
|
+
const workflowsStore: Record<string, any> = {
|
|
91
|
+
[WORKFLOW_ID]: {
|
|
92
|
+
id: WORKFLOW_ID,
|
|
93
|
+
name: 'Test Workflow',
|
|
94
|
+
description: null,
|
|
95
|
+
projectId: null,
|
|
96
|
+
isDefault: 1,
|
|
97
|
+
graphData: JSON.stringify(graphData),
|
|
98
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
99
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const agentsStore: Record<string, any> = {
|
|
103
|
+
'dev-agent-id': {
|
|
104
|
+
id: 'dev-agent-id',
|
|
105
|
+
name: 'Developer',
|
|
106
|
+
promptTemplate: 'Implement feature {{featureId}} in {{worktreePath}}',
|
|
107
|
+
projectId: null,
|
|
108
|
+
isDefault: 1,
|
|
109
|
+
},
|
|
110
|
+
'rev-agent-id': {
|
|
111
|
+
id: 'rev-agent-id',
|
|
112
|
+
name: 'Reviewer',
|
|
113
|
+
promptTemplate: 'Review feature {{featureId}}',
|
|
114
|
+
projectId: null,
|
|
115
|
+
isDefault: 1,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const graphNodesStore: Record<string, any> = {
|
|
119
|
+
'feat_test': {
|
|
120
|
+
id: 'feat_test',
|
|
121
|
+
type: 'feature',
|
|
122
|
+
name: 'Product Feature Walkthrough',
|
|
123
|
+
status: 'draft',
|
|
124
|
+
priority: 'medium',
|
|
125
|
+
body: '## Description\nA walkthrough video showing the product features.\n\n## Acceptance Criteria\n- AC 1\n',
|
|
126
|
+
projectId: null,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Track all DB operations for assertions
|
|
131
|
+
const operations: Array<{ op: string; table: string; data: any }> = [];
|
|
132
|
+
|
|
133
|
+
// Helper to resolve table name from drizzle table reference
|
|
134
|
+
const resolveTableName = (table: any): string => {
|
|
135
|
+
if (table && typeof table === 'object') {
|
|
136
|
+
const name = table[Symbol.for('drizzle:Name')];
|
|
137
|
+
if (name) return name;
|
|
138
|
+
}
|
|
139
|
+
return 'unknown';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const getStoreForTable = (tableName: string): Record<string, any> | null => {
|
|
143
|
+
switch (tableName) {
|
|
144
|
+
case 'workflow_executions': return executionsStore;
|
|
145
|
+
case 'workflow_node_executions': return nodeExecutionsStore;
|
|
146
|
+
case 'workflow_tool_calls': return toolCallsStore;
|
|
147
|
+
case 'workflows': return workflowsStore;
|
|
148
|
+
case 'agents': return agentsStore;
|
|
149
|
+
case 'nodes': return graphNodesStore;
|
|
150
|
+
default: return null;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Match a row against a drizzle eq()/and() condition */
|
|
155
|
+
const matchesCondition = (row: any, condition: any, _tableName: string): boolean => {
|
|
156
|
+
const pairs = extractEqPairs(condition);
|
|
157
|
+
if (pairs.length === 0) return true;
|
|
158
|
+
return pairs.every(({ column, value }) => {
|
|
159
|
+
const prop = snakeToCamel(column);
|
|
160
|
+
return row[prop] === value;
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Build a chainable mock that handles select/from/where patterns
|
|
165
|
+
const db = {
|
|
166
|
+
_stores: { executionsStore, nodeExecutionsStore, toolCallsStore, workflowsStore, agentsStore, graphNodesStore },
|
|
167
|
+
_operations: operations,
|
|
168
|
+
|
|
169
|
+
select: () => ({
|
|
170
|
+
from: (table: any) => {
|
|
171
|
+
const tableName = resolveTableName(table);
|
|
172
|
+
return {
|
|
173
|
+
where: (condition: any) => {
|
|
174
|
+
const store = getStoreForTable(tableName);
|
|
175
|
+
if (!store) return [];
|
|
176
|
+
return Object.values(store).filter((row: any) => {
|
|
177
|
+
return matchesCondition(row, condition, tableName);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
insert: (table: any) => ({
|
|
185
|
+
values: (data: any) => {
|
|
186
|
+
const tableName = resolveTableName(table);
|
|
187
|
+
const store = getStoreForTable(tableName);
|
|
188
|
+
if (store && data.id) {
|
|
189
|
+
store[data.id] = { ...data };
|
|
190
|
+
}
|
|
191
|
+
operations.push({ op: 'insert', table: tableName, data });
|
|
192
|
+
return Promise.resolve();
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
|
|
196
|
+
update: (table: any) => ({
|
|
197
|
+
set: (data: any) => ({
|
|
198
|
+
where: (condition: any) => {
|
|
199
|
+
const tableName = resolveTableName(table);
|
|
200
|
+
const store = getStoreForTable(tableName);
|
|
201
|
+
if (store) {
|
|
202
|
+
for (const [_key, row] of Object.entries(store)) {
|
|
203
|
+
if (matchesCondition(row, condition, tableName)) {
|
|
204
|
+
Object.assign(row as any, data);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
operations.push({ op: 'update', table: tableName, data });
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return db;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Creates a mock kanban with controllable card state */
|
|
219
|
+
const createMockKanban = (initialColumn = 'todo') => {
|
|
220
|
+
const entries: Record<string, any> = {
|
|
221
|
+
'feat_test': {
|
|
222
|
+
column: initialColumn,
|
|
223
|
+
rejection_count: 0,
|
|
224
|
+
notes: [],
|
|
225
|
+
reviews: [],
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
getKanbanEntry: mock.fn(async (featureId: string) => {
|
|
231
|
+
return entries[featureId] || null;
|
|
232
|
+
}),
|
|
233
|
+
saveKanbanEntry: mock.fn(async (featureId: string, entry: any) => {
|
|
234
|
+
entries[featureId] = { ...entry };
|
|
235
|
+
}),
|
|
236
|
+
_entries: entries,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const createMockClaudeService = () => ({
|
|
241
|
+
spawnClaude: mock.fn(async () => 'Agent output text'),
|
|
242
|
+
spawnCommand: mock.fn(async () => ''),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const createMockGitWorkflow = () => ({
|
|
246
|
+
createWorktree: mock.fn(async (featureId: string) => `/tmp/worktrees/${featureId}`),
|
|
247
|
+
removeWorktree: mock.fn(async () => {}),
|
|
248
|
+
deleteBranch: mock.fn(async () => {}),
|
|
249
|
+
commitChanges: mock.fn(async () => {}),
|
|
250
|
+
rebaseBranch: mock.fn(async () => {}),
|
|
251
|
+
mergeBranch: mock.fn(async () => {}),
|
|
252
|
+
fixMergeConflicts: mock.fn(async () => true),
|
|
253
|
+
stash: mock.fn(async () => false),
|
|
254
|
+
unstash: mock.fn(async () => {}),
|
|
255
|
+
getDirtyFiles: mock.fn(async () => []),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const createMockLog = () => mock.fn((_tag: string, _msg: string) => {});
|
|
259
|
+
|
|
260
|
+
/** Default execution context for handler tests */
|
|
261
|
+
const defaultContext = (overrides?: Record<string, unknown>) => ({
|
|
262
|
+
cycle: 0,
|
|
263
|
+
featureId: 'feat_test',
|
|
264
|
+
projectId: null,
|
|
265
|
+
worktreePath: '/tmp/wt',
|
|
266
|
+
branchName: 'feature/feat_test',
|
|
267
|
+
...overrides,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── Tests ─────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe('WorkflowEngine', () => {
|
|
273
|
+
describe('resolvePromptTemplate', () => {
|
|
274
|
+
it('replaces known placeholders from context', () => {
|
|
275
|
+
const engine = new WorkflowEngine({
|
|
276
|
+
db: createMockDb() as any,
|
|
277
|
+
kanban: createMockKanban(),
|
|
278
|
+
claudeService: createMockClaudeService(),
|
|
279
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
280
|
+
log: createMockLog(),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = (engine as any).resolvePromptTemplate(
|
|
284
|
+
'Feature: {{featureId}}, Path: {{worktreePath}}, Cycle: {{cycle}}',
|
|
285
|
+
{ featureId: 'feat_001', worktreePath: '/tmp/wt', cycle: 2, projectId: null, branchName: 'feature/feat_001' },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
assert.equal(result, 'Feature: feat_001, Path: /tmp/wt, Cycle: 2');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('leaves unknown placeholders as-is', () => {
|
|
292
|
+
const engine = new WorkflowEngine({
|
|
293
|
+
db: createMockDb() as any,
|
|
294
|
+
kanban: createMockKanban(),
|
|
295
|
+
claudeService: createMockClaudeService(),
|
|
296
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
297
|
+
log: createMockLog(),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const result = (engine as any).resolvePromptTemplate(
|
|
301
|
+
'Hello {{featureId}} and {{unknownVar}}',
|
|
302
|
+
{ featureId: 'feat_001', worktreePath: '/tmp', cycle: 1, projectId: null, branchName: 'b' },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
assert.equal(result, 'Hello feat_001 and {{unknownVar}}');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('resolves mainToolsDir, mainSkillDir, and pidFlag from context', () => {
|
|
309
|
+
const engine = new WorkflowEngine({
|
|
310
|
+
db: createMockDb() as any,
|
|
311
|
+
kanban: createMockKanban(),
|
|
312
|
+
claudeService: createMockClaudeService(),
|
|
313
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
314
|
+
log: createMockLog(),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const context = {
|
|
318
|
+
featureId: 'feat_001',
|
|
319
|
+
worktreePath: '/tmp/wt',
|
|
320
|
+
cycle: 0,
|
|
321
|
+
projectId: 'proj_abc',
|
|
322
|
+
branchName: 'feature/feat_001',
|
|
323
|
+
mainSkillDir: '/repo/root',
|
|
324
|
+
mainToolsDir: '/repo/root/packages/shared/tools',
|
|
325
|
+
pidFlag: ' --project-id proj_abc',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = (engine as any).resolvePromptTemplate(
|
|
329
|
+
'cd {{mainSkillDir}} && node {{mainToolsDir}}/get_node.ts {{featureId}}{{pidFlag}}',
|
|
330
|
+
context,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
assert.equal(
|
|
334
|
+
result,
|
|
335
|
+
'cd /repo/root && node /repo/root/packages/shared/tools/get_node.ts feat_001 --project-id proj_abc',
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('resolveNextNode', () => {
|
|
341
|
+
it('follows single outgoing edge when outputLabel is null', () => {
|
|
342
|
+
const engine = new WorkflowEngine({
|
|
343
|
+
db: createMockDb() as any,
|
|
344
|
+
kanban: createMockKanban(),
|
|
345
|
+
claudeService: createMockClaudeService(),
|
|
346
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
347
|
+
log: createMockLog(),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'start_1', null);
|
|
351
|
+
assert.equal(result, 'transition_1');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('follows edge matching sourceHandle', () => {
|
|
355
|
+
const engine = new WorkflowEngine({
|
|
356
|
+
db: createMockDb() as any,
|
|
357
|
+
kanban: createMockKanban(),
|
|
358
|
+
claudeService: createMockClaudeService(),
|
|
359
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
360
|
+
log: createMockLog(),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'check_pos', 'qa');
|
|
364
|
+
assert.equal(result, 'end_success');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('follows todo edge from check_pos', () => {
|
|
368
|
+
const engine = new WorkflowEngine({
|
|
369
|
+
db: createMockDb() as any,
|
|
370
|
+
kanban: createMockKanban(),
|
|
371
|
+
claudeService: createMockClaudeService(),
|
|
372
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
373
|
+
log: createMockLog(),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'check_pos', 'todo');
|
|
377
|
+
assert.equal(result, 'check_cycle');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('follows under_limit edge from check_cycle', () => {
|
|
381
|
+
const engine = new WorkflowEngine({
|
|
382
|
+
db: createMockDb() as any,
|
|
383
|
+
kanban: createMockKanban(),
|
|
384
|
+
claudeService: createMockClaudeService(),
|
|
385
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
386
|
+
log: createMockLog(),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'check_cycle', 'under_limit');
|
|
390
|
+
assert.equal(result, 'agent_dev');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('follows at_limit edge from check_cycle', () => {
|
|
394
|
+
const engine = new WorkflowEngine({
|
|
395
|
+
db: createMockDb() as any,
|
|
396
|
+
kanban: createMockKanban(),
|
|
397
|
+
claudeService: createMockClaudeService(),
|
|
398
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
399
|
+
log: createMockLog(),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'check_cycle', 'at_limit');
|
|
403
|
+
assert.equal(result, 'set_meta');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('returns null when no outgoing edges exist', () => {
|
|
407
|
+
const engine = new WorkflowEngine({
|
|
408
|
+
db: createMockDb() as any,
|
|
409
|
+
kanban: createMockKanban(),
|
|
410
|
+
claudeService: createMockClaudeService(),
|
|
411
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
412
|
+
log: createMockLog(),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const result = (engine as any).resolveNextNode(DEFAULT_GRAPH, 'end_success', null);
|
|
416
|
+
assert.equal(result, null);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('throws when no edge matches the output label', () => {
|
|
420
|
+
const engine = new WorkflowEngine({
|
|
421
|
+
db: createMockDb() as any,
|
|
422
|
+
kanban: createMockKanban(),
|
|
423
|
+
claudeService: createMockClaudeService(),
|
|
424
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
425
|
+
log: createMockLog(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
assert.throws(() => {
|
|
429
|
+
(engine as any).resolveNextNode(DEFAULT_GRAPH, 'check_pos', 'nonexistent');
|
|
430
|
+
}, /No outgoing edge.*matches output label "nonexistent"/);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('handleTransitionCard', () => {
|
|
435
|
+
it('moves card from source to target column', async () => {
|
|
436
|
+
const kanban = createMockKanban('todo');
|
|
437
|
+
const engine = new WorkflowEngine({
|
|
438
|
+
db: createMockDb() as any,
|
|
439
|
+
kanban,
|
|
440
|
+
claudeService: createMockClaudeService(),
|
|
441
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
442
|
+
log: createMockLog(),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const node = { id: 't1', type: 'transitionCard', data: { fromColumn: 'todo', toColumn: 'in_progress' } };
|
|
446
|
+
|
|
447
|
+
const result = await (engine as any).handleTransitionCard(node, defaultContext());
|
|
448
|
+
|
|
449
|
+
assert.equal(result.outputLabel, null);
|
|
450
|
+
assert.equal(kanban._entries['feat_test'].column, 'in_progress');
|
|
451
|
+
assert.equal(kanban.saveKanbanEntry.mock.calls.length, 1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('throws when card is not in expected source column', async () => {
|
|
455
|
+
const kanban = createMockKanban('in_review');
|
|
456
|
+
const engine = new WorkflowEngine({
|
|
457
|
+
db: createMockDb() as any,
|
|
458
|
+
kanban,
|
|
459
|
+
claudeService: createMockClaudeService(),
|
|
460
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
461
|
+
log: createMockLog(),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const node = { id: 't1', type: 'transitionCard', data: { fromColumn: 'todo', toColumn: 'in_progress' } };
|
|
465
|
+
|
|
466
|
+
await assert.rejects(
|
|
467
|
+
() => (engine as any).handleTransitionCard(node, defaultContext()),
|
|
468
|
+
/Expected card in "todo" but found in "in_review"/,
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('throws when feature not found in kanban', async () => {
|
|
473
|
+
const kanban = createMockKanban();
|
|
474
|
+
const engine = new WorkflowEngine({
|
|
475
|
+
db: createMockDb() as any,
|
|
476
|
+
kanban,
|
|
477
|
+
claudeService: createMockClaudeService(),
|
|
478
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
479
|
+
log: createMockLog(),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const node = { id: 't1', type: 'transitionCard', data: { fromColumn: 'todo', toColumn: 'in_progress' } };
|
|
483
|
+
|
|
484
|
+
await assert.rejects(
|
|
485
|
+
() => (engine as any).handleTransitionCard(node, defaultContext({ featureId: 'nonexistent' })),
|
|
486
|
+
/not found in kanban/,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe('handleCheckCardPosition', () => {
|
|
492
|
+
it('returns column name as outputLabel when card is in qa', async () => {
|
|
493
|
+
const kanban = createMockKanban('qa');
|
|
494
|
+
const engine = new WorkflowEngine({
|
|
495
|
+
db: createMockDb() as any,
|
|
496
|
+
kanban,
|
|
497
|
+
claudeService: createMockClaudeService(),
|
|
498
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
499
|
+
log: createMockLog(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const node = { id: 'cp', type: 'checkCardPosition', data: {} };
|
|
503
|
+
|
|
504
|
+
const result = await (engine as any).handleCheckCardPosition(node, defaultContext({ cycle: 1 }));
|
|
505
|
+
assert.equal(result.outputLabel, 'qa');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('treats in_review as rejection and returns todo', async () => {
|
|
509
|
+
const kanban = createMockKanban('in_review');
|
|
510
|
+
const engine = new WorkflowEngine({
|
|
511
|
+
db: createMockDb() as any,
|
|
512
|
+
kanban,
|
|
513
|
+
claudeService: createMockClaudeService(),
|
|
514
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
515
|
+
log: createMockLog(),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const node = { id: 'cp', type: 'checkCardPosition', data: {} };
|
|
519
|
+
|
|
520
|
+
const result = await (engine as any).handleCheckCardPosition(node, defaultContext({ cycle: 1 }));
|
|
521
|
+
assert.equal(result.outputLabel, 'todo');
|
|
522
|
+
assert.equal(kanban._entries['feat_test'].column, 'todo');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('returns todo when card is in todo', async () => {
|
|
526
|
+
const kanban = createMockKanban('todo');
|
|
527
|
+
const engine = new WorkflowEngine({
|
|
528
|
+
db: createMockDb() as any,
|
|
529
|
+
kanban,
|
|
530
|
+
claudeService: createMockClaudeService(),
|
|
531
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
532
|
+
log: createMockLog(),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const node = { id: 'cp', type: 'checkCardPosition', data: {} };
|
|
536
|
+
|
|
537
|
+
const result = await (engine as any).handleCheckCardPosition(node, defaultContext({ cycle: 1 }));
|
|
538
|
+
assert.equal(result.outputLabel, 'todo');
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('handleCheckCycleCount', () => {
|
|
543
|
+
it('returns under_limit when cycle < max', async () => {
|
|
544
|
+
const engine = new WorkflowEngine({
|
|
545
|
+
db: createMockDb() as any,
|
|
546
|
+
kanban: createMockKanban(),
|
|
547
|
+
claudeService: createMockClaudeService(),
|
|
548
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
549
|
+
log: createMockLog(),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const node = { id: 'cc', type: 'checkCycleCount', data: { maxCycles: 3 } };
|
|
553
|
+
const context = defaultContext();
|
|
554
|
+
|
|
555
|
+
const result = await (engine as any).handleCheckCycleCount(node, context);
|
|
556
|
+
assert.equal(result.outputLabel, 'under_limit');
|
|
557
|
+
assert.equal(context.cycle, 1);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('returns at_limit when cycle reaches max', async () => {
|
|
561
|
+
const engine = new WorkflowEngine({
|
|
562
|
+
db: createMockDb() as any,
|
|
563
|
+
kanban: createMockKanban(),
|
|
564
|
+
claudeService: createMockClaudeService(),
|
|
565
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
566
|
+
log: createMockLog(),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const node = { id: 'cc', type: 'checkCycleCount', data: { maxCycles: 3 } };
|
|
570
|
+
const context = defaultContext({ cycle: 2 });
|
|
571
|
+
|
|
572
|
+
const result = await (engine as any).handleCheckCycleCount(node, context);
|
|
573
|
+
assert.equal(result.outputLabel, 'at_limit');
|
|
574
|
+
assert.equal(context.cycle, 3);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('increments cycle counter in context', async () => {
|
|
578
|
+
const engine = new WorkflowEngine({
|
|
579
|
+
db: createMockDb() as any,
|
|
580
|
+
kanban: createMockKanban(),
|
|
581
|
+
claudeService: createMockClaudeService(),
|
|
582
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
583
|
+
log: createMockLog(),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const node = { id: 'cc', type: 'checkCycleCount', data: { maxCycles: 5 } };
|
|
587
|
+
const context = defaultContext({ cycle: 1 });
|
|
588
|
+
|
|
589
|
+
await (engine as any).handleCheckCycleCount(node, context);
|
|
590
|
+
assert.equal(context.cycle, 2);
|
|
591
|
+
await (engine as any).handleCheckCycleCount(node, context);
|
|
592
|
+
assert.equal(context.cycle, 3);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
describe('handleSetCardMetadata', () => {
|
|
597
|
+
it('sets boolean metadata on card', async () => {
|
|
598
|
+
const kanban = createMockKanban('todo');
|
|
599
|
+
const engine = new WorkflowEngine({
|
|
600
|
+
db: createMockDb() as any,
|
|
601
|
+
kanban,
|
|
602
|
+
claudeService: createMockClaudeService(),
|
|
603
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
604
|
+
log: createMockLog(),
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const node = { id: 'sm', type: 'setCardMetadata', data: { key: 'dev_blocked', value: 'true' } };
|
|
608
|
+
|
|
609
|
+
const result = await (engine as any).handleSetCardMetadata(node, defaultContext({ cycle: 3 }));
|
|
610
|
+
assert.equal(result.outputLabel, null);
|
|
611
|
+
assert.equal(kanban._entries['feat_test'].dev_blocked, true);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('sets string metadata on card', async () => {
|
|
615
|
+
const kanban = createMockKanban('todo');
|
|
616
|
+
const engine = new WorkflowEngine({
|
|
617
|
+
db: createMockDb() as any,
|
|
618
|
+
kanban,
|
|
619
|
+
claudeService: createMockClaudeService(),
|
|
620
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
621
|
+
log: createMockLog(),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const node = { id: 'sm', type: 'setCardMetadata', data: { key: 'reason', value: 'too many cycles' } };
|
|
625
|
+
|
|
626
|
+
await (engine as any).handleSetCardMetadata(node, defaultContext({ cycle: 3 }));
|
|
627
|
+
assert.equal(kanban._entries['feat_test'].reason, 'too many cycles');
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe('handleStart', () => {
|
|
632
|
+
it('returns null outputLabel (passthrough)', async () => {
|
|
633
|
+
const engine = new WorkflowEngine({
|
|
634
|
+
db: createMockDb() as any,
|
|
635
|
+
kanban: createMockKanban(),
|
|
636
|
+
claudeService: createMockClaudeService(),
|
|
637
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
638
|
+
log: createMockLog(),
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const node = { id: 's1', type: 'start', data: {} };
|
|
642
|
+
|
|
643
|
+
const result = await (engine as any).handleStart(node, defaultContext());
|
|
644
|
+
assert.equal(result.outputLabel, null);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe('handleEnd', () => {
|
|
649
|
+
it('merges, cleans up worktree, and sets completed on success outcome', async () => {
|
|
650
|
+
const db = createMockDb();
|
|
651
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
652
|
+
const execId = 'test-exec-id';
|
|
653
|
+
|
|
654
|
+
db._stores.executionsStore[execId] = {
|
|
655
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
656
|
+
status: 'running', currentNodeId: 'end_success',
|
|
657
|
+
context: '{}', startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const engine = new WorkflowEngine({
|
|
661
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
662
|
+
gitWorkflow, log: createMockLog(),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const node = { id: 'end_success', type: 'end', data: { outcome: 'success' } };
|
|
666
|
+
const context = defaultContext({ cycle: 1 });
|
|
667
|
+
|
|
668
|
+
await (engine as any).handleEnd(node, context, execId);
|
|
669
|
+
|
|
670
|
+
assert.equal(gitWorkflow.mergeBranch.mock.calls.length, 1);
|
|
671
|
+
assert.equal(gitWorkflow.removeWorktree.mock.calls.length, 1);
|
|
672
|
+
assert.equal(gitWorkflow.deleteBranch.mock.calls.length, 1);
|
|
673
|
+
assert.equal(db._stores.executionsStore[execId].status, 'completed');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('cleans up worktree without merge and sets completed on blocked outcome', async () => {
|
|
677
|
+
const db = createMockDb();
|
|
678
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
679
|
+
const execId = 'test-exec-id';
|
|
680
|
+
|
|
681
|
+
db._stores.executionsStore[execId] = {
|
|
682
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
683
|
+
status: 'running', currentNodeId: 'end_blocked',
|
|
684
|
+
context: '{}', startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const engine = new WorkflowEngine({
|
|
688
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
689
|
+
gitWorkflow, log: createMockLog(),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const node = { id: 'end_blocked', type: 'end', data: { outcome: 'blocked' } };
|
|
693
|
+
const context = defaultContext({ cycle: 3 });
|
|
694
|
+
|
|
695
|
+
await (engine as any).handleEnd(node, context, execId);
|
|
696
|
+
|
|
697
|
+
assert.equal(gitWorkflow.mergeBranch.mock.calls.length, 0);
|
|
698
|
+
assert.equal(gitWorkflow.removeWorktree.mock.calls.length, 1);
|
|
699
|
+
assert.equal(gitWorkflow.deleteBranch.mock.calls.length, 1);
|
|
700
|
+
assert.equal(db._stores.executionsStore[execId].status, 'completed');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('cleans up worktree without merge and sets failed on failed outcome', async () => {
|
|
704
|
+
const db = createMockDb();
|
|
705
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
706
|
+
const execId = 'test-exec-id';
|
|
707
|
+
|
|
708
|
+
db._stores.executionsStore[execId] = {
|
|
709
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
710
|
+
status: 'running', currentNodeId: 'end_fail',
|
|
711
|
+
context: '{}', startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const engine = new WorkflowEngine({
|
|
715
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
716
|
+
gitWorkflow, log: createMockLog(),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const node = { id: 'end_fail', type: 'end', data: { outcome: 'failed' } };
|
|
720
|
+
const context = defaultContext({ cycle: 1 });
|
|
721
|
+
|
|
722
|
+
await (engine as any).handleEnd(node, context, execId);
|
|
723
|
+
|
|
724
|
+
assert.equal(gitWorkflow.mergeBranch.mock.calls.length, 0);
|
|
725
|
+
assert.equal(gitWorkflow.removeWorktree.mock.calls.length, 1);
|
|
726
|
+
assert.equal(db._stores.executionsStore[execId].status, 'failed');
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
describe('handleRunAgent', () => {
|
|
731
|
+
it('loads agent, resolves template, calls spawnClaude, and stores actual output', async () => {
|
|
732
|
+
const db = createMockDb();
|
|
733
|
+
const claudeService = createMockClaudeService();
|
|
734
|
+
claudeService.spawnClaude = mock.fn(async () => 'The agent wrote this output text');
|
|
735
|
+
|
|
736
|
+
const engine = new WorkflowEngine({
|
|
737
|
+
db: db as any, kanban: createMockKanban(), claudeService,
|
|
738
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const node = { id: 'run1', type: 'runAgent', data: { agentId: 'dev-agent-id' } };
|
|
742
|
+
const context = defaultContext({ cycle: 1 });
|
|
743
|
+
|
|
744
|
+
const result = await (engine as any).handleRunAgent(node, context, 'exec-id');
|
|
745
|
+
|
|
746
|
+
assert.equal(result.outputLabel, null);
|
|
747
|
+
// AC: actual output stored in outputData
|
|
748
|
+
assert.equal(result.outputData.output, 'The agent wrote this output text');
|
|
749
|
+
assert.equal(result.outputData.agentId, 'dev-agent-id');
|
|
750
|
+
assert.equal(result.outputData.agentName, 'Developer');
|
|
751
|
+
|
|
752
|
+
// Verify spawnClaude called with resolved prompt and worktree path
|
|
753
|
+
const call = claudeService.spawnClaude.mock.calls[0];
|
|
754
|
+
assert.equal(call.arguments[0], 'Implement feature feat_test in /tmp/wt');
|
|
755
|
+
assert.equal(call.arguments[1], '/tmp/wt');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('passes onToolUse and onResult callbacks to spawnClaude', async () => {
|
|
759
|
+
const db = createMockDb();
|
|
760
|
+
const claudeService = createMockClaudeService();
|
|
761
|
+
claudeService.spawnClaude = mock.fn(async (_prompt: string, _cwd: string, _label: string, opts: any) => {
|
|
762
|
+
// Simulate tool use callbacks
|
|
763
|
+
if (opts?.onToolUse) {
|
|
764
|
+
opts.onToolUse('Read', { file_path: '/tmp/test.ts' });
|
|
765
|
+
opts.onToolUse('Edit', { file_path: '/tmp/test.ts' });
|
|
766
|
+
opts.onToolUse('Bash', { command: 'npm test' });
|
|
767
|
+
opts.onToolUse('Read', { file_path: '/tmp/other.ts' });
|
|
768
|
+
}
|
|
769
|
+
// Simulate result callback
|
|
770
|
+
if (opts?.onResult) {
|
|
771
|
+
opts.onResult({
|
|
772
|
+
costUsd: 0.5432,
|
|
773
|
+
numTurns: 12,
|
|
774
|
+
stopReason: 'end_turn',
|
|
775
|
+
model: 'claude-sonnet-4-6',
|
|
776
|
+
contextWindow: 42,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return 'Agent output';
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const engine = new WorkflowEngine({
|
|
783
|
+
db: db as any, kanban: createMockKanban(), claudeService,
|
|
784
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const node = { id: 'run1', type: 'runAgent', data: { agentId: 'dev-agent-id' } };
|
|
788
|
+
const context = defaultContext({ cycle: 1 });
|
|
789
|
+
|
|
790
|
+
const result = await (engine as any).handleRunAgent(node, context, 'exec-id');
|
|
791
|
+
|
|
792
|
+
// Verify tool calls accumulated correctly
|
|
793
|
+
assert.deepEqual(result.outputData.toolCalls, {
|
|
794
|
+
total: 4, read: 2, write: 0, edit: 1, bash: 1, glob: 0, grep: 0,
|
|
795
|
+
});
|
|
796
|
+
assert.equal(result.outputData.costUsd, 0.5432);
|
|
797
|
+
assert.equal(result.outputData.numTurns, 12);
|
|
798
|
+
assert.equal(result.outputData.stopReason, 'end_turn');
|
|
799
|
+
assert.equal(result.outputData.model, 'claude-sonnet-4-6');
|
|
800
|
+
assert.equal(result.outputData.contextWindowPct, 42);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('stores zero metrics when no callbacks fire', async () => {
|
|
804
|
+
const db = createMockDb();
|
|
805
|
+
const claudeService = createMockClaudeService();
|
|
806
|
+
|
|
807
|
+
const engine = new WorkflowEngine({
|
|
808
|
+
db: db as any, kanban: createMockKanban(), claudeService,
|
|
809
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const node = { id: 'run1', type: 'runAgent', data: { agentId: 'dev-agent-id' } };
|
|
813
|
+
const context = defaultContext({ cycle: 1 });
|
|
814
|
+
|
|
815
|
+
const result = await (engine as any).handleRunAgent(node, context, 'exec-id');
|
|
816
|
+
|
|
817
|
+
// Tool calls should be all zeros
|
|
818
|
+
assert.deepEqual(result.outputData.toolCalls, {
|
|
819
|
+
total: 0, read: 0, write: 0, edit: 0, bash: 0, glob: 0, grep: 0,
|
|
820
|
+
});
|
|
821
|
+
// Result metrics should be null (no callback fired)
|
|
822
|
+
assert.equal(result.outputData.costUsd, null);
|
|
823
|
+
assert.equal(result.outputData.numTurns, null);
|
|
824
|
+
assert.equal(result.outputData.stopReason, null);
|
|
825
|
+
assert.equal(result.outputData.model, null);
|
|
826
|
+
assert.equal(result.outputData.contextWindowPct, null);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('throws when agent is not found in DB', async () => {
|
|
830
|
+
const db = createMockDb();
|
|
831
|
+
|
|
832
|
+
const engine = new WorkflowEngine({
|
|
833
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
834
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const node = { id: 'run1', type: 'runAgent', data: { agentId: 'nonexistent-id' } };
|
|
838
|
+
const context = defaultContext({ cycle: 1 });
|
|
839
|
+
|
|
840
|
+
await assert.rejects(
|
|
841
|
+
() => (engine as any).handleRunAgent(node, context, 'exec-id'),
|
|
842
|
+
/Agent nonexistent-id not found/,
|
|
843
|
+
);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('resolves multiple placeholders in prompt template', async () => {
|
|
847
|
+
const db = createMockDb();
|
|
848
|
+
db._stores.agentsStore['custom-agent'] = {
|
|
849
|
+
id: 'custom-agent',
|
|
850
|
+
name: 'Custom',
|
|
851
|
+
promptTemplate: 'Feature: {{featureId}}, Branch: {{branchName}}, Cycle: {{cycle}}',
|
|
852
|
+
projectId: null,
|
|
853
|
+
isDefault: 0,
|
|
854
|
+
};
|
|
855
|
+
const claudeService = createMockClaudeService();
|
|
856
|
+
|
|
857
|
+
const engine = new WorkflowEngine({
|
|
858
|
+
db: db as any, kanban: createMockKanban(), claudeService,
|
|
859
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
const node = { id: 'run1', type: 'runAgent', data: { agentId: 'custom-agent' } };
|
|
863
|
+
const context = defaultContext({ cycle: 2 });
|
|
864
|
+
|
|
865
|
+
await (engine as any).handleRunAgent(node, context, 'exec-id');
|
|
866
|
+
|
|
867
|
+
const call = claudeService.spawnClaude.mock.calls[0];
|
|
868
|
+
assert.equal(call.arguments[0], 'Feature: feat_test, Branch: feature/feat_test, Cycle: 2');
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
describe('getExecutionMetrics', () => {
|
|
873
|
+
it('returns metrics for runAgent nodes only', async () => {
|
|
874
|
+
const db = createMockDb();
|
|
875
|
+
const execId = 'metrics-exec-id';
|
|
876
|
+
|
|
877
|
+
// Pre-populate execution
|
|
878
|
+
db._stores.executionsStore[execId] = {
|
|
879
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
880
|
+
status: 'completed', currentNodeId: 'end_success',
|
|
881
|
+
context: '{}', startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// Pre-populate node executions — mix of runAgent and non-runAgent
|
|
885
|
+
db._stores.nodeExecutionsStore['ne-start'] = {
|
|
886
|
+
id: 'ne-start', executionId: execId, nodeId: 'start_1',
|
|
887
|
+
status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01',
|
|
888
|
+
outputData: null, error: null, attempt: 1,
|
|
889
|
+
};
|
|
890
|
+
db._stores.nodeExecutionsStore['ne-dev'] = {
|
|
891
|
+
id: 'ne-dev', executionId: execId, nodeId: 'agent_dev',
|
|
892
|
+
status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01',
|
|
893
|
+
outputData: JSON.stringify({
|
|
894
|
+
agentName: 'Developer', toolCalls: { total: 10, read: 5, write: 2, edit: 1, bash: 2, glob: 0, grep: 0 },
|
|
895
|
+
costUsd: 0.25, numTurns: 8, stopReason: 'end_turn', model: 'claude-sonnet-4-6', contextWindowPct: 35,
|
|
896
|
+
}),
|
|
897
|
+
error: null, attempt: 1,
|
|
898
|
+
};
|
|
899
|
+
db._stores.nodeExecutionsStore['ne-rev'] = {
|
|
900
|
+
id: 'ne-rev', executionId: execId, nodeId: 'agent_rev',
|
|
901
|
+
status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01',
|
|
902
|
+
outputData: JSON.stringify({
|
|
903
|
+
agentName: 'Reviewer', toolCalls: { total: 3, read: 2, write: 0, edit: 0, bash: 1, glob: 0, grep: 0 },
|
|
904
|
+
costUsd: 0.10, numTurns: 4, stopReason: 'end_turn', model: 'claude-haiku-4-5', contextWindowPct: 12,
|
|
905
|
+
}),
|
|
906
|
+
error: null, attempt: 1,
|
|
907
|
+
};
|
|
908
|
+
db._stores.nodeExecutionsStore['ne-transition'] = {
|
|
909
|
+
id: 'ne-transition', executionId: execId, nodeId: 'transition_1',
|
|
910
|
+
status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01',
|
|
911
|
+
outputData: null, error: null, attempt: 1,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const engine = new WorkflowEngine({
|
|
915
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
916
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const metrics = await engine.getExecutionMetrics(execId);
|
|
920
|
+
|
|
921
|
+
assert.equal(metrics.length, 2);
|
|
922
|
+
assert.equal(metrics[0].agentName, 'Developer');
|
|
923
|
+
assert.equal(metrics[0].costUsd, 0.25);
|
|
924
|
+
assert.equal(metrics[0].numTurns, 8);
|
|
925
|
+
assert.deepEqual(metrics[0].toolCalls, { total: 10, read: 5, write: 2, edit: 1, bash: 2, glob: 0, grep: 0 });
|
|
926
|
+
assert.equal(metrics[1].agentName, 'Reviewer');
|
|
927
|
+
assert.equal(metrics[1].costUsd, 0.10);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('returns empty array when execution not found', async () => {
|
|
931
|
+
const db = createMockDb();
|
|
932
|
+
const engine = new WorkflowEngine({
|
|
933
|
+
db: db as any, kanban: createMockKanban(), claudeService: createMockClaudeService(),
|
|
934
|
+
gitWorkflow: createMockGitWorkflow(), log: createMockLog(),
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const metrics = await engine.getExecutionMetrics('nonexistent');
|
|
938
|
+
assert.deepEqual(metrics, []);
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
describe('handleGroup', () => {
|
|
943
|
+
it('expands group internal nodes and executes them', async () => {
|
|
944
|
+
// A group containing: start → transitionCard (todo→in_progress)
|
|
945
|
+
const groupGraph = {
|
|
946
|
+
nodes: [
|
|
947
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
948
|
+
{ id: 'group_1', type: 'group', data: {
|
|
949
|
+
groupId: 'grp-1',
|
|
950
|
+
groupName: 'My Group',
|
|
951
|
+
internalNodes: [
|
|
952
|
+
{ id: 'inner_start', type: 'start', data: {} },
|
|
953
|
+
{ id: 'inner_transition', type: 'transitionCard', data: { fromColumn: 'todo', toColumn: 'in_progress' } },
|
|
954
|
+
],
|
|
955
|
+
internalEdges: [
|
|
956
|
+
{ id: 'inner_e1', source: 'inner_start', target: 'inner_transition' },
|
|
957
|
+
],
|
|
958
|
+
inputHandles: ['input'],
|
|
959
|
+
outputHandles: ['output'],
|
|
960
|
+
}},
|
|
961
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
962
|
+
],
|
|
963
|
+
edges: [
|
|
964
|
+
{ id: 'e1', source: 'start_1', target: 'group_1' },
|
|
965
|
+
{ id: 'e2', source: 'group_1', target: 'end_1' },
|
|
966
|
+
],
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const db = createMockDb(groupGraph);
|
|
970
|
+
const kanban = createMockKanban('todo');
|
|
971
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
972
|
+
|
|
973
|
+
const engine = new WorkflowEngine({
|
|
974
|
+
db: db as any, kanban, claudeService: createMockClaudeService(),
|
|
975
|
+
gitWorkflow, log: createMockLog(),
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
979
|
+
|
|
980
|
+
// Wait for async fire-and-forget execution
|
|
981
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
982
|
+
|
|
983
|
+
// Card should have been moved from todo → in_progress by the inner transition node
|
|
984
|
+
assert.equal(kanban._entries['feat_test'].column, 'in_progress');
|
|
985
|
+
// Execution should have completed (success outcome)
|
|
986
|
+
assert.equal(db._stores.executionsStore[executionId].status, 'completed');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('skips empty group and continues traversal', async () => {
|
|
990
|
+
const engine = new WorkflowEngine({
|
|
991
|
+
db: createMockDb() as any,
|
|
992
|
+
kanban: createMockKanban(),
|
|
993
|
+
claudeService: createMockClaudeService(),
|
|
994
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
995
|
+
log: createMockLog(),
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
const emptyGroupNode = {
|
|
999
|
+
id: 'g1', type: 'group', data: {
|
|
1000
|
+
groupId: 'grp-empty',
|
|
1001
|
+
groupName: 'Empty Group',
|
|
1002
|
+
internalNodes: [],
|
|
1003
|
+
internalEdges: [],
|
|
1004
|
+
inputHandles: ['input'],
|
|
1005
|
+
outputHandles: ['output'],
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const result = await (engine as any).handleGroup(emptyGroupNode, defaultContext(), 'exec-id');
|
|
1010
|
+
assert.equal(result.outputLabel, null);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
describe('executeNode', () => {
|
|
1015
|
+
it('dispatches group node type correctly', async () => {
|
|
1016
|
+
const engine = new WorkflowEngine({
|
|
1017
|
+
db: createMockDb() as any,
|
|
1018
|
+
kanban: createMockKanban(),
|
|
1019
|
+
claudeService: createMockClaudeService(),
|
|
1020
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1021
|
+
log: createMockLog(),
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const groupNode = {
|
|
1025
|
+
id: 'g1', type: 'group', data: {
|
|
1026
|
+
groupId: 'grp-1', groupName: 'Test Group',
|
|
1027
|
+
internalNodes: [], internalEdges: [],
|
|
1028
|
+
inputHandles: ['input'], outputHandles: ['output'],
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// Should not throw — dispatches to handleGroup
|
|
1033
|
+
const result = await (engine as any).executeNode(groupNode, defaultContext(), 'exec-id');
|
|
1034
|
+
assert.equal(result.outputLabel, null);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it('throws for unknown node type', async () => {
|
|
1038
|
+
const engine = new WorkflowEngine({
|
|
1039
|
+
db: createMockDb() as any,
|
|
1040
|
+
kanban: createMockKanban(),
|
|
1041
|
+
claudeService: createMockClaudeService(),
|
|
1042
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1043
|
+
log: createMockLog(),
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const node = { id: 'x', type: 'unknownType', data: {} };
|
|
1047
|
+
|
|
1048
|
+
await assert.rejects(
|
|
1049
|
+
() => (engine as any).executeNode(node, defaultContext(), 'exec-id'),
|
|
1050
|
+
/Unknown node type: unknownType/,
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
describe('start (integration)', () => {
|
|
1056
|
+
it('passes projectId into context with computed mainSkillDir, mainToolsDir, and pidFlag', async () => {
|
|
1057
|
+
const simpleGraph = {
|
|
1058
|
+
nodes: [
|
|
1059
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1060
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1061
|
+
],
|
|
1062
|
+
edges: [
|
|
1063
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1064
|
+
],
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const db = createMockDb(simpleGraph);
|
|
1068
|
+
|
|
1069
|
+
const engine = new WorkflowEngine({
|
|
1070
|
+
db: db as any, kanban: createMockKanban(),
|
|
1071
|
+
claudeService: createMockClaudeService(),
|
|
1072
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1073
|
+
log: createMockLog(),
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_abc123');
|
|
1077
|
+
|
|
1078
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1079
|
+
|
|
1080
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1081
|
+
const context = JSON.parse(execution.context);
|
|
1082
|
+
|
|
1083
|
+
assert.equal(context.projectId, 'proj_abc123');
|
|
1084
|
+
assert.equal(context.pidFlag, ' --project-id proj_abc123');
|
|
1085
|
+
assert.equal(context.mainSkillDir, process.cwd());
|
|
1086
|
+
assert.ok(context.mainToolsDir.endsWith('packages/shared/tools'));
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('uses workflow.projectId when no projectId argument is passed', async () => {
|
|
1090
|
+
const simpleGraph = {
|
|
1091
|
+
nodes: [
|
|
1092
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1093
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1094
|
+
],
|
|
1095
|
+
edges: [
|
|
1096
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1097
|
+
],
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const db = createMockDb(simpleGraph);
|
|
1101
|
+
// Set projectId on the workflow itself
|
|
1102
|
+
db._stores.workflowsStore[WORKFLOW_ID].projectId = 'proj_from_workflow';
|
|
1103
|
+
|
|
1104
|
+
const engine = new WorkflowEngine({
|
|
1105
|
+
db: db as any, kanban: createMockKanban(),
|
|
1106
|
+
claudeService: createMockClaudeService(),
|
|
1107
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1108
|
+
log: createMockLog(),
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1112
|
+
|
|
1113
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1114
|
+
|
|
1115
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1116
|
+
const context = JSON.parse(execution.context);
|
|
1117
|
+
|
|
1118
|
+
// projectId passed explicitly always takes precedence
|
|
1119
|
+
assert.equal(context.projectId, 'proj_test');
|
|
1120
|
+
assert.equal(context.pidFlag, ' --project-id proj_test');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('creates execution, worktree, and traverses a simple graph to completion', async () => {
|
|
1124
|
+
const simpleGraph = {
|
|
1125
|
+
nodes: [
|
|
1126
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1127
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1128
|
+
],
|
|
1129
|
+
edges: [
|
|
1130
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1131
|
+
],
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const db = createMockDb(simpleGraph);
|
|
1135
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
1136
|
+
|
|
1137
|
+
const engine = new WorkflowEngine({
|
|
1138
|
+
db: db as any, kanban: createMockKanban(),
|
|
1139
|
+
claudeService: createMockClaudeService(), gitWorkflow,
|
|
1140
|
+
log: createMockLog(),
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1144
|
+
|
|
1145
|
+
// Wait for async fire-and-forget execution to complete
|
|
1146
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1147
|
+
|
|
1148
|
+
// Execution row was created
|
|
1149
|
+
assert.ok(db._stores.executionsStore[executionId]);
|
|
1150
|
+
// Worktree was created
|
|
1151
|
+
assert.equal(gitWorkflow.createWorktree.mock.calls.length, 1);
|
|
1152
|
+
assert.equal(gitWorkflow.createWorktree.mock.calls[0].arguments[0], 'feat_test');
|
|
1153
|
+
// Merge was called (success outcome)
|
|
1154
|
+
assert.equal(gitWorkflow.mergeBranch.mock.calls.length, 1);
|
|
1155
|
+
// Execution completed
|
|
1156
|
+
assert.equal(db._stores.executionsStore[executionId].status, 'completed');
|
|
1157
|
+
// Node executions were created for both nodes
|
|
1158
|
+
const nodeExecs = Object.values(db._stores.nodeExecutionsStore);
|
|
1159
|
+
assert.equal(nodeExecs.length, 2);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('sets execution status to failed when a node throws', async () => {
|
|
1163
|
+
const badGraph = {
|
|
1164
|
+
nodes: [
|
|
1165
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1166
|
+
{ id: 't1', type: 'transitionCard', data: { fromColumn: 'in_progress', toColumn: 'in_review' } },
|
|
1167
|
+
],
|
|
1168
|
+
edges: [
|
|
1169
|
+
{ id: 'e1', source: 'start_1', target: 't1' },
|
|
1170
|
+
],
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
const db = createMockDb(badGraph);
|
|
1174
|
+
const kanban = createMockKanban('todo'); // Card is in 'todo', not 'in_progress'
|
|
1175
|
+
|
|
1176
|
+
const engine = new WorkflowEngine({
|
|
1177
|
+
db: db as any, kanban,
|
|
1178
|
+
claudeService: createMockClaudeService(),
|
|
1179
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1180
|
+
log: createMockLog(),
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1184
|
+
|
|
1185
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1186
|
+
|
|
1187
|
+
// Execution should be failed because transitionCard expected in_progress but got todo
|
|
1188
|
+
assert.equal(db._stores.executionsStore[executionId].status, 'failed');
|
|
1189
|
+
// Node execution for t1 should be failed
|
|
1190
|
+
const failedNodes = Object.values(db._stores.nodeExecutionsStore)
|
|
1191
|
+
.filter((ne: any) => ne.status === 'failed');
|
|
1192
|
+
assert.equal(failedNodes.length, 1);
|
|
1193
|
+
assert.match((failedNodes[0] as any).error, /Expected card in "in_progress"/);
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
it('enriches context with featureDescription from node body', async () => {
|
|
1197
|
+
const simpleGraph = {
|
|
1198
|
+
nodes: [
|
|
1199
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1200
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1201
|
+
],
|
|
1202
|
+
edges: [
|
|
1203
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1204
|
+
],
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
const db = createMockDb(simpleGraph);
|
|
1208
|
+
|
|
1209
|
+
const engine = new WorkflowEngine({
|
|
1210
|
+
db: db as any, kanban: createMockKanban(),
|
|
1211
|
+
claudeService: createMockClaudeService(),
|
|
1212
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1213
|
+
log: createMockLog(),
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1217
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1218
|
+
|
|
1219
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1220
|
+
const context = JSON.parse(execution.context);
|
|
1221
|
+
|
|
1222
|
+
assert.equal(context.featureDescription, 'A walkthrough video showing the product features.');
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it('enriches context with compositionName derived from feature name', async () => {
|
|
1226
|
+
const simpleGraph = {
|
|
1227
|
+
nodes: [
|
|
1228
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1229
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1230
|
+
],
|
|
1231
|
+
edges: [
|
|
1232
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1233
|
+
],
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const db = createMockDb(simpleGraph);
|
|
1237
|
+
|
|
1238
|
+
const engine = new WorkflowEngine({
|
|
1239
|
+
db: db as any, kanban: createMockKanban(),
|
|
1240
|
+
claudeService: createMockClaudeService(),
|
|
1241
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1242
|
+
log: createMockLog(),
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1246
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1247
|
+
|
|
1248
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1249
|
+
const context = JSON.parse(execution.context);
|
|
1250
|
+
|
|
1251
|
+
assert.equal(context.compositionName, 'product-feature-walkthrough');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('enriches context with iterationComments from kanban notes', async () => {
|
|
1255
|
+
const simpleGraph = {
|
|
1256
|
+
nodes: [
|
|
1257
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1258
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1259
|
+
],
|
|
1260
|
+
edges: [
|
|
1261
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1262
|
+
],
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const db = createMockDb(simpleGraph);
|
|
1266
|
+
const kanban = createMockKanban();
|
|
1267
|
+
kanban._entries['feat_test'].notes = ['Fix scene 2 narration', 'Add outro scene'];
|
|
1268
|
+
|
|
1269
|
+
const engine = new WorkflowEngine({
|
|
1270
|
+
db: db as any, kanban,
|
|
1271
|
+
claudeService: createMockClaudeService(),
|
|
1272
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1273
|
+
log: createMockLog(),
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1277
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1278
|
+
|
|
1279
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1280
|
+
const context = JSON.parse(execution.context);
|
|
1281
|
+
|
|
1282
|
+
assert.ok(context.iterationComments.includes('Fix scene 2 narration'));
|
|
1283
|
+
assert.ok(context.iterationComments.includes('Add outro scene'));
|
|
1284
|
+
assert.ok(context.iterationComments.includes('Previous Review Notes'));
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it('sets iterationComments to empty string when no kanban notes exist', async () => {
|
|
1288
|
+
const simpleGraph = {
|
|
1289
|
+
nodes: [
|
|
1290
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1291
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1292
|
+
],
|
|
1293
|
+
edges: [
|
|
1294
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1295
|
+
],
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
const db = createMockDb(simpleGraph);
|
|
1299
|
+
|
|
1300
|
+
const engine = new WorkflowEngine({
|
|
1301
|
+
db: db as any, kanban: createMockKanban(),
|
|
1302
|
+
claudeService: createMockClaudeService(),
|
|
1303
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1304
|
+
log: createMockLog(),
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
const { executionId } = await engine.start('feat_test', WORKFLOW_ID, 'proj_test');
|
|
1308
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1309
|
+
|
|
1310
|
+
const execution = db._stores.executionsStore[executionId];
|
|
1311
|
+
const context = JSON.parse(execution.context);
|
|
1312
|
+
|
|
1313
|
+
assert.equal(context.iterationComments, '');
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
describe('resume (integration)', () => {
|
|
1318
|
+
it('resumes from a failed node, re-executes it, and completes', async () => {
|
|
1319
|
+
const simpleGraph = {
|
|
1320
|
+
nodes: [
|
|
1321
|
+
{ id: 'start_1', type: 'start', data: {} },
|
|
1322
|
+
{ id: 'end_1', type: 'end', data: { outcome: 'success' } },
|
|
1323
|
+
],
|
|
1324
|
+
edges: [
|
|
1325
|
+
{ id: 'e1', source: 'start_1', target: 'end_1' },
|
|
1326
|
+
],
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
const db = createMockDb(simpleGraph);
|
|
1330
|
+
const gitWorkflow = createMockGitWorkflow();
|
|
1331
|
+
const execId = 'resume-exec-id';
|
|
1332
|
+
|
|
1333
|
+
// Pre-populate a failed execution at end_1
|
|
1334
|
+
db._stores.executionsStore[execId] = {
|
|
1335
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
1336
|
+
status: 'failed', currentNodeId: 'end_1',
|
|
1337
|
+
context: JSON.stringify({
|
|
1338
|
+
cycle: 0, featureId: 'feat_test', projectId: null,
|
|
1339
|
+
worktreePath: '/tmp/worktrees/feat_test', branchName: 'feature/feat_test',
|
|
1340
|
+
}),
|
|
1341
|
+
startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
// Pre-populate the failed node execution
|
|
1345
|
+
db._stores.nodeExecutionsStore['ne-1'] = {
|
|
1346
|
+
id: 'ne-1', executionId: execId, nodeId: 'end_1',
|
|
1347
|
+
status: 'failed', startedAt: '2026-01-01', completedAt: null,
|
|
1348
|
+
outputData: null, error: 'Previous error', attempt: 1,
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
const engine = new WorkflowEngine({
|
|
1352
|
+
db: db as any, kanban: createMockKanban(),
|
|
1353
|
+
claudeService: createMockClaudeService(), gitWorkflow,
|
|
1354
|
+
log: createMockLog(),
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
await engine.resume(execId);
|
|
1358
|
+
|
|
1359
|
+
// Wait for async execution
|
|
1360
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1361
|
+
|
|
1362
|
+
// Execution should be completed
|
|
1363
|
+
assert.equal(db._stores.executionsStore[execId].status, 'completed');
|
|
1364
|
+
// The node execution was re-attempted (attempt incremented)
|
|
1365
|
+
assert.ok(db._stores.nodeExecutionsStore['ne-1'].attempt >= 2);
|
|
1366
|
+
// Merge was called (success outcome)
|
|
1367
|
+
assert.equal(gitWorkflow.mergeBranch.mock.calls.length, 1);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it('resumes a stuck running execution by marking it failed first', async () => {
|
|
1371
|
+
const db = createMockDb();
|
|
1372
|
+
const execId = 'running-exec-id';
|
|
1373
|
+
|
|
1374
|
+
db._stores.executionsStore[execId] = {
|
|
1375
|
+
id: execId, workflowId: WORKFLOW_ID, featureId: 'feat_test',
|
|
1376
|
+
status: 'running', currentNodeId: 'start_1',
|
|
1377
|
+
context: '{}', startedAt: '2026-01-01', updatedAt: '2026-01-01',
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
const engine = new WorkflowEngine({
|
|
1381
|
+
db: db as any, kanban: createMockKanban(),
|
|
1382
|
+
claudeService: createMockClaudeService(),
|
|
1383
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1384
|
+
log: createMockLog(),
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
// Should not throw — instead marks as failed then resumes
|
|
1388
|
+
await engine.resume(execId);
|
|
1389
|
+
|
|
1390
|
+
// Wait for async execution
|
|
1391
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1392
|
+
|
|
1393
|
+
// The execution was marked as failed (from 'running') then resumed
|
|
1394
|
+
// It may end up completed, failed (if no matching node in mock graph), or running
|
|
1395
|
+
const finalStatus = db._stores.executionsStore[execId].status;
|
|
1396
|
+
assert.ok(
|
|
1397
|
+
['running', 'completed', 'failed'].includes(finalStatus),
|
|
1398
|
+
`Expected valid status, got: ${finalStatus}`,
|
|
1399
|
+
);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
it('throws when execution not found', async () => {
|
|
1403
|
+
const db = createMockDb();
|
|
1404
|
+
|
|
1405
|
+
const engine = new WorkflowEngine({
|
|
1406
|
+
db: db as any, kanban: createMockKanban(),
|
|
1407
|
+
claudeService: createMockClaudeService(),
|
|
1408
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1409
|
+
log: createMockLog(),
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
await assert.rejects(
|
|
1413
|
+
() => engine.resume('nonexistent-id'),
|
|
1414
|
+
/not found/,
|
|
1415
|
+
);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
describe('constructor', () => {
|
|
1420
|
+
it('accepts all required dependencies', () => {
|
|
1421
|
+
const engine = new WorkflowEngine({
|
|
1422
|
+
db: createMockDb() as any,
|
|
1423
|
+
kanban: createMockKanban(),
|
|
1424
|
+
claudeService: createMockClaudeService(),
|
|
1425
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1426
|
+
log: createMockLog(),
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
assert.ok(engine);
|
|
1430
|
+
assert.equal(typeof engine.start, 'function');
|
|
1431
|
+
assert.equal(typeof engine.resume, 'function');
|
|
1432
|
+
assert.equal(typeof engine.getStatus, 'function');
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
describe('extractToolTarget', () => {
|
|
1437
|
+
let engine: WorkflowEngine;
|
|
1438
|
+
|
|
1439
|
+
beforeEach(() => {
|
|
1440
|
+
engine = new WorkflowEngine({
|
|
1441
|
+
db: createMockDb() as any,
|
|
1442
|
+
kanban: createMockKanban(),
|
|
1443
|
+
claudeService: createMockClaudeService(),
|
|
1444
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1445
|
+
log: createMockLog(),
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it('extracts file_path for Read tool', () => {
|
|
1450
|
+
const result = (engine as any).extractToolTarget('Read', { file_path: '/src/index.ts' });
|
|
1451
|
+
assert.equal(result, '/src/index.ts');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('extracts file_path for Write tool', () => {
|
|
1455
|
+
const result = (engine as any).extractToolTarget('Write', { file_path: '/src/app.ts', content: '...' });
|
|
1456
|
+
assert.equal(result, '/src/app.ts');
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it('extracts file_path for Edit tool', () => {
|
|
1460
|
+
const result = (engine as any).extractToolTarget('Edit', { file_path: '/src/utils.ts', old_string: 'a', new_string: 'b' });
|
|
1461
|
+
assert.equal(result, '/src/utils.ts');
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it('extracts command for Bash tool', () => {
|
|
1465
|
+
const result = (engine as any).extractToolTarget('Bash', { command: 'pnpm test' });
|
|
1466
|
+
assert.equal(result, 'pnpm test');
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it('extracts pattern for Grep tool', () => {
|
|
1470
|
+
const result = (engine as any).extractToolTarget('Grep', { pattern: 'TODO', path: '/src' });
|
|
1471
|
+
assert.equal(result, 'TODO');
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('extracts pattern for Glob tool', () => {
|
|
1475
|
+
const result = (engine as any).extractToolTarget('Glob', { pattern: '**/*.ts' });
|
|
1476
|
+
assert.equal(result, '**/*.ts');
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it('handles case-insensitive tool names', () => {
|
|
1480
|
+
const result = (engine as any).extractToolTarget('read', { file_path: '/foo.ts' });
|
|
1481
|
+
assert.equal(result, '/foo.ts');
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it('falls back to common fields for unknown tools', () => {
|
|
1485
|
+
const result = (engine as any).extractToolTarget('CustomTool', { command: 'run-it' });
|
|
1486
|
+
assert.equal(result, 'run-it');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('returns empty string when no known fields present', () => {
|
|
1490
|
+
const result = (engine as any).extractToolTarget('CustomTool', { foo: 'bar' });
|
|
1491
|
+
assert.equal(result, '');
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it('prefers filePath camelCase variant', () => {
|
|
1495
|
+
const result = (engine as any).extractToolTarget('Read', { filePath: '/alt.ts' });
|
|
1496
|
+
assert.equal(result, '/alt.ts');
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
describe('extractFeatureDescription', () => {
|
|
1501
|
+
let engine: WorkflowEngine;
|
|
1502
|
+
|
|
1503
|
+
beforeEach(() => {
|
|
1504
|
+
engine = new WorkflowEngine({
|
|
1505
|
+
db: createMockDb() as any,
|
|
1506
|
+
kanban: createMockKanban(),
|
|
1507
|
+
claudeService: createMockClaudeService(),
|
|
1508
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1509
|
+
log: createMockLog(),
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('extracts description section from node body', () => {
|
|
1514
|
+
const body = '## Description\nA video about our product.\n\n## Acceptance Criteria\n- AC 1\n';
|
|
1515
|
+
const result = (engine as any).extractFeatureDescription(body, 'Fallback');
|
|
1516
|
+
assert.equal(result, 'A video about our product.');
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
it('returns fallback when body is null', () => {
|
|
1520
|
+
const result = (engine as any).extractFeatureDescription(null, 'Fallback Name');
|
|
1521
|
+
assert.equal(result, 'Fallback Name');
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it('returns fallback when body has no Description section', () => {
|
|
1525
|
+
const body = '## Acceptance Criteria\n- AC 1\n';
|
|
1526
|
+
const result = (engine as any).extractFeatureDescription(body, 'Feature Name');
|
|
1527
|
+
assert.equal(result, 'Feature Name');
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
it('returns fallback when Description section is empty', () => {
|
|
1531
|
+
const body = '## Description\n\n## Acceptance Criteria\n- AC 1\n';
|
|
1532
|
+
const result = (engine as any).extractFeatureDescription(body, 'Feature Name');
|
|
1533
|
+
assert.equal(result, 'Feature Name');
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
it('handles multiline descriptions', () => {
|
|
1537
|
+
const body = '## Description\nLine one.\nLine two.\n\n## Notes\n';
|
|
1538
|
+
const result = (engine as any).extractFeatureDescription(body, 'Fallback');
|
|
1539
|
+
assert.equal(result, 'Line one.\nLine two.');
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
describe('deriveCompositionName', () => {
|
|
1544
|
+
let engine: WorkflowEngine;
|
|
1545
|
+
|
|
1546
|
+
beforeEach(() => {
|
|
1547
|
+
engine = new WorkflowEngine({
|
|
1548
|
+
db: createMockDb() as any,
|
|
1549
|
+
kanban: createMockKanban(),
|
|
1550
|
+
claudeService: createMockClaudeService(),
|
|
1551
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1552
|
+
log: createMockLog(),
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it('converts feature name to kebab-case', () => {
|
|
1557
|
+
const result = (engine as any).deriveCompositionName('Product Feature Walkthrough');
|
|
1558
|
+
assert.equal(result, 'product-feature-walkthrough');
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it('strips special characters', () => {
|
|
1562
|
+
const result = (engine as any).deriveCompositionName('My Video: Part 1 (Draft)');
|
|
1563
|
+
assert.equal(result, 'my-video-part-1-draft');
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it('removes leading and trailing hyphens', () => {
|
|
1567
|
+
const result = (engine as any).deriveCompositionName('--test--');
|
|
1568
|
+
assert.equal(result, 'test');
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
describe('getMonitorData', () => {
|
|
1573
|
+
it('returns null when no execution exists for feature', async () => {
|
|
1574
|
+
const db = createMockDb();
|
|
1575
|
+
const engine = new WorkflowEngine({
|
|
1576
|
+
db: db as any,
|
|
1577
|
+
kanban: createMockKanban(),
|
|
1578
|
+
claudeService: createMockClaudeService(),
|
|
1579
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1580
|
+
log: createMockLog(),
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
const result = await engine.getMonitorData('feat_nonexistent');
|
|
1584
|
+
assert.equal(result, null);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it('returns full monitor data for an existing execution', async () => {
|
|
1588
|
+
const db = createMockDb();
|
|
1589
|
+
const execId = 'exec-1';
|
|
1590
|
+
const nodeExecId = 'ne-1';
|
|
1591
|
+
|
|
1592
|
+
// Seed execution
|
|
1593
|
+
db._stores.executionsStore[execId] = {
|
|
1594
|
+
id: execId,
|
|
1595
|
+
workflowId: WORKFLOW_ID,
|
|
1596
|
+
featureId: 'feat_test',
|
|
1597
|
+
status: 'completed',
|
|
1598
|
+
currentNodeId: 'end_success',
|
|
1599
|
+
startedAt: '2026-01-01T00:01:00.000Z',
|
|
1600
|
+
updatedAt: '2026-01-01T00:05:00.000Z',
|
|
1601
|
+
contextData: null,
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
// Seed node execution
|
|
1605
|
+
db._stores.nodeExecutionsStore[nodeExecId] = {
|
|
1606
|
+
id: nodeExecId,
|
|
1607
|
+
executionId: execId,
|
|
1608
|
+
nodeId: 'agent_dev',
|
|
1609
|
+
status: 'completed',
|
|
1610
|
+
startedAt: '2026-01-01T00:02:00.000Z',
|
|
1611
|
+
completedAt: '2026-01-01T00:04:00.000Z',
|
|
1612
|
+
outputData: JSON.stringify({ result: 'ok' }),
|
|
1613
|
+
error: null,
|
|
1614
|
+
attempt: 1,
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// Seed tool calls
|
|
1618
|
+
db._stores.toolCallsStore['tc-1'] = {
|
|
1619
|
+
id: 'tc-1',
|
|
1620
|
+
nodeExecutionId: nodeExecId,
|
|
1621
|
+
timestamp: '2026-01-01T00:02:30.000Z',
|
|
1622
|
+
toolName: 'Read',
|
|
1623
|
+
target: '/src/index.ts',
|
|
1624
|
+
};
|
|
1625
|
+
db._stores.toolCallsStore['tc-2'] = {
|
|
1626
|
+
id: 'tc-2',
|
|
1627
|
+
nodeExecutionId: nodeExecId,
|
|
1628
|
+
timestamp: '2026-01-01T00:03:00.000Z',
|
|
1629
|
+
toolName: 'Edit',
|
|
1630
|
+
target: '/src/app.ts',
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
const engine = new WorkflowEngine({
|
|
1634
|
+
db: db as any,
|
|
1635
|
+
kanban: createMockKanban(),
|
|
1636
|
+
claudeService: createMockClaudeService(),
|
|
1637
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1638
|
+
log: createMockLog(),
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
const result = await engine.getMonitorData('feat_test');
|
|
1642
|
+
|
|
1643
|
+
assert.ok(result);
|
|
1644
|
+
assert.equal(result.executionId, execId);
|
|
1645
|
+
assert.equal(result.workflowId, WORKFLOW_ID);
|
|
1646
|
+
assert.equal(result.workflowName, 'Test Workflow');
|
|
1647
|
+
assert.equal(result.featureId, 'feat_test');
|
|
1648
|
+
assert.equal(result.status, 'completed');
|
|
1649
|
+
assert.equal(result.currentNodeId, 'end_success');
|
|
1650
|
+
|
|
1651
|
+
// Check graph data is parsed
|
|
1652
|
+
const graphData = result.graphData as { nodes: any[]; edges: any[] };
|
|
1653
|
+
assert.ok(graphData.nodes.length > 0);
|
|
1654
|
+
assert.ok(graphData.edges.length > 0);
|
|
1655
|
+
|
|
1656
|
+
// Check node executions
|
|
1657
|
+
const nodeExecs = result.nodeExecutions as Record<string, any>;
|
|
1658
|
+
assert.ok(nodeExecs['agent_dev']);
|
|
1659
|
+
assert.equal(nodeExecs['agent_dev'].status, 'completed');
|
|
1660
|
+
assert.deepEqual(nodeExecs['agent_dev'].outputData, { result: 'ok' });
|
|
1661
|
+
|
|
1662
|
+
// Check tool calls grouped under node execution
|
|
1663
|
+
const toolCalls = nodeExecs['agent_dev'].toolCalls as any[];
|
|
1664
|
+
assert.equal(toolCalls.length, 2);
|
|
1665
|
+
assert.equal(toolCalls[0].toolName, 'Read');
|
|
1666
|
+
assert.equal(toolCalls[0].target, '/src/index.ts');
|
|
1667
|
+
assert.equal(toolCalls[1].toolName, 'Edit');
|
|
1668
|
+
assert.equal(toolCalls[1].target, '/src/app.ts');
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
it('returns most recent execution when multiple exist', async () => {
|
|
1672
|
+
const db = createMockDb();
|
|
1673
|
+
|
|
1674
|
+
// Older execution
|
|
1675
|
+
db._stores.executionsStore['exec-old'] = {
|
|
1676
|
+
id: 'exec-old',
|
|
1677
|
+
workflowId: WORKFLOW_ID,
|
|
1678
|
+
featureId: 'feat_test',
|
|
1679
|
+
status: 'completed',
|
|
1680
|
+
currentNodeId: 'end_success',
|
|
1681
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
1682
|
+
updatedAt: '2026-01-01T00:01:00.000Z',
|
|
1683
|
+
contextData: null,
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
// Newer execution
|
|
1687
|
+
db._stores.executionsStore['exec-new'] = {
|
|
1688
|
+
id: 'exec-new',
|
|
1689
|
+
workflowId: WORKFLOW_ID,
|
|
1690
|
+
featureId: 'feat_test',
|
|
1691
|
+
status: 'running',
|
|
1692
|
+
currentNodeId: 'agent_dev',
|
|
1693
|
+
startedAt: '2026-01-02T00:00:00.000Z',
|
|
1694
|
+
updatedAt: '2026-01-02T00:01:00.000Z',
|
|
1695
|
+
contextData: null,
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
const engine = new WorkflowEngine({
|
|
1699
|
+
db: db as any,
|
|
1700
|
+
kanban: createMockKanban(),
|
|
1701
|
+
claudeService: createMockClaudeService(),
|
|
1702
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1703
|
+
log: createMockLog(),
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
const result = await engine.getMonitorData('feat_test');
|
|
1707
|
+
assert.ok(result);
|
|
1708
|
+
assert.equal(result.executionId, 'exec-new');
|
|
1709
|
+
assert.equal(result.status, 'running');
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
it('returns empty tool calls array when node has no tool calls', async () => {
|
|
1713
|
+
const db = createMockDb();
|
|
1714
|
+
const execId = 'exec-notc';
|
|
1715
|
+
|
|
1716
|
+
db._stores.executionsStore[execId] = {
|
|
1717
|
+
id: execId,
|
|
1718
|
+
workflowId: WORKFLOW_ID,
|
|
1719
|
+
featureId: 'feat_test',
|
|
1720
|
+
status: 'completed',
|
|
1721
|
+
currentNodeId: 'end_success',
|
|
1722
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
1723
|
+
updatedAt: '2026-01-01T00:01:00.000Z',
|
|
1724
|
+
contextData: null,
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
db._stores.nodeExecutionsStore['ne-notc'] = {
|
|
1728
|
+
id: 'ne-notc',
|
|
1729
|
+
executionId: execId,
|
|
1730
|
+
nodeId: 'transition_1',
|
|
1731
|
+
status: 'completed',
|
|
1732
|
+
startedAt: '2026-01-01T00:00:10.000Z',
|
|
1733
|
+
completedAt: '2026-01-01T00:00:11.000Z',
|
|
1734
|
+
outputData: null,
|
|
1735
|
+
error: null,
|
|
1736
|
+
attempt: 1,
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
const engine = new WorkflowEngine({
|
|
1740
|
+
db: db as any,
|
|
1741
|
+
kanban: createMockKanban(),
|
|
1742
|
+
claudeService: createMockClaudeService(),
|
|
1743
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1744
|
+
log: createMockLog(),
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
const result = await engine.getMonitorData('feat_test');
|
|
1748
|
+
assert.ok(result);
|
|
1749
|
+
const nodeExecs = result.nodeExecutions as Record<string, any>;
|
|
1750
|
+
assert.deepEqual(nodeExecs['transition_1'].toolCalls, []);
|
|
1751
|
+
});
|
|
1752
|
+
});
|
|
1753
|
+
});
|