@marktoflow/gui 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +24 -8
  2. package/README.md +11 -1
  3. package/dist/client/assets/index-CM44OayM.js +704 -0
  4. package/dist/client/assets/index-CM44OayM.js.map +1 -0
  5. package/dist/client/assets/index-Dru63gi6.css +1 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/{server/index.js → index.js} +22 -1
  8. package/dist/server/index.js.map +1 -0
  9. package/dist/server/routes/executions.js +125 -0
  10. package/dist/server/routes/executions.js.map +1 -0
  11. package/dist/server/{server/routes → routes}/workflows.js +37 -1
  12. package/dist/server/routes/workflows.js.map +1 -0
  13. package/dist/server/{server/services → services}/WorkflowService.js +158 -15
  14. package/dist/server/services/WorkflowService.js.map +1 -0
  15. package/dist/server/{server/websocket → websocket}/index.js +12 -0
  16. package/dist/server/{server/websocket → websocket}/index.js.map +1 -1
  17. package/marktoflow-gui-2.0.0-alpha.5.tgz +0 -0
  18. package/package.json +20 -6
  19. package/scripts/flatten-dist.js +69 -0
  20. package/src/client/components/Canvas/Canvas.tsx +3 -1
  21. package/src/client/components/Canvas/ExecutionOverlay.tsx +120 -32
  22. package/src/client/components/Canvas/ForEachNode.tsx +27 -3
  23. package/src/client/components/Canvas/IfElseNode.tsx +22 -7
  24. package/src/client/components/Canvas/NodeContextMenu.tsx +8 -4
  25. package/src/client/components/Canvas/ParallelNode.tsx +25 -8
  26. package/src/client/components/Canvas/SwitchNode.tsx +41 -20
  27. package/src/client/components/Canvas/Toolbar.tsx +59 -21
  28. package/src/client/components/Canvas/TransformNode.tsx +9 -0
  29. package/src/client/components/Canvas/WhileNode.tsx +35 -3
  30. package/src/client/components/Debug/VariableInspector.tsx +148 -0
  31. package/src/client/components/Prompt/PromptInput.tsx +3 -1
  32. package/src/client/components/Settings/ProviderSwitcher.tsx +228 -0
  33. package/src/client/components/Sidebar/ImportDialog.tsx +257 -0
  34. package/src/client/components/Sidebar/Sidebar.tsx +21 -2
  35. package/src/client/components/common/KeyboardShortcuts.tsx +8 -2
  36. package/src/client/stores/agentStore.ts +109 -0
  37. package/src/client/stores/executionStore.ts +64 -2
  38. package/src/client/stores/workflowStore.ts +10 -2
  39. package/src/client/styles/globals.css +106 -0
  40. package/src/client/utils/platform.ts +46 -0
  41. package/src/client/utils/workflowToGraph.ts +245 -21
  42. package/src/server/index.ts +24 -1
  43. package/src/server/routes/executions.ts +136 -0
  44. package/src/server/routes/workflows.ts +42 -1
  45. package/src/server/services/WorkflowService.ts +176 -16
  46. package/src/server/websocket/index.ts +13 -0
  47. package/tests/unit/ForEachNode.test.tsx +96 -6
  48. package/tests/unit/IfElseNode.test.tsx +47 -0
  49. package/tests/unit/ParallelNode.test.tsx +80 -0
  50. package/tests/unit/SwitchNode.test.tsx +75 -0
  51. package/tests/unit/WhileNode.test.tsx +12 -8
  52. package/tests/unit/agentStore.test.ts +218 -0
  53. package/tests/unit/executionStore.test.ts +40 -0
  54. package/tests/unit/platform.test.ts +118 -0
  55. package/tests/unit/workflowToGraph.test.ts +22 -0
  56. package/dist/client/assets/index-C90Y_aBX.js +0 -678
  57. package/dist/client/assets/index-C90Y_aBX.js.map +0 -1
  58. package/dist/client/assets/index-CRWeQ3NN.css +0 -1
  59. package/dist/server/server/index.js.map +0 -1
  60. package/dist/server/server/routes/workflows.js.map +0 -1
  61. package/dist/server/server/services/WorkflowService.js.map +0 -1
  62. /package/dist/server/{server/routes → routes}/ai.js +0 -0
  63. /package/dist/server/{server/routes → routes}/ai.js.map +0 -0
  64. /package/dist/server/{server/routes → routes}/execute.js +0 -0
  65. /package/dist/server/{server/routes → routes}/execute.js.map +0 -0
  66. /package/dist/server/{server/routes → routes}/tools.js +0 -0
  67. /package/dist/server/{server/routes → routes}/tools.js.map +0 -0
  68. /package/dist/server/{server/services → services}/AIService.js +0 -0
  69. /package/dist/server/{server/services → services}/AIService.js.map +0 -0
  70. /package/dist/server/{server/services → services}/FileWatcher.js +0 -0
  71. /package/dist/server/{server/services → services}/FileWatcher.js.map +0 -0
  72. /package/dist/server/{server/services → services}/agents/claude-code-provider.js +0 -0
  73. /package/dist/server/{server/services → services}/agents/claude-code-provider.js.map +0 -0
  74. /package/dist/server/{server/services → services}/agents/claude-provider.js +0 -0
  75. /package/dist/server/{server/services → services}/agents/claude-provider.js.map +0 -0
  76. /package/dist/server/{server/services → services}/agents/codex-provider.js +0 -0
  77. /package/dist/server/{server/services → services}/agents/codex-provider.js.map +0 -0
  78. /package/dist/server/{server/services → services}/agents/copilot-provider.js +0 -0
  79. /package/dist/server/{server/services → services}/agents/copilot-provider.js.map +0 -0
  80. /package/dist/server/{server/services → services}/agents/demo-provider.js +0 -0
  81. /package/dist/server/{server/services → services}/agents/demo-provider.js.map +0 -0
  82. /package/dist/server/{server/services → services}/agents/index.js +0 -0
  83. /package/dist/server/{server/services → services}/agents/index.js.map +0 -0
  84. /package/dist/server/{server/services → services}/agents/ollama-provider.js +0 -0
  85. /package/dist/server/{server/services → services}/agents/ollama-provider.js.map +0 -0
  86. /package/dist/server/{server/services → services}/agents/prompts.js +0 -0
  87. /package/dist/server/{server/services → services}/agents/prompts.js.map +0 -0
  88. /package/dist/server/{server/services → services}/agents/registry.js +0 -0
  89. /package/dist/server/{server/services → services}/agents/registry.js.map +0 -0
  90. /package/dist/server/{server/services → services}/agents/types.js +0 -0
  91. /package/dist/server/{server/services → services}/agents/types.js.map +0 -0
  92. /package/dist/{server/shared → shared}/constants.js +0 -0
  93. /package/dist/{server/shared → shared}/constants.js.map +0 -0
  94. /package/dist/{server/shared → shared}/types.js +0 -0
  95. /package/dist/{server/shared → shared}/types.js.map +0 -0
@@ -5,11 +5,13 @@ import cors from 'cors';
5
5
  import { createServer, type Server } from 'http';
6
6
  import { Server as SocketIOServer } from 'socket.io';
7
7
  import { join } from 'path';
8
- import { existsSync } from 'fs';
8
+ import { existsSync, mkdirSync } from 'fs';
9
+ import { StateStore } from '@marktoflow/core';
9
10
  import { workflowRoutes } from './routes/workflows.js';
10
11
  import { aiRoutes } from './routes/ai.js';
11
12
  import { executeRoutes } from './routes/execute.js';
12
13
  import { toolsRoutes } from './routes/tools.js';
14
+ import { executionRoutes } from './routes/executions.js';
13
15
  import { setupWebSocket } from './websocket/index.js';
14
16
  import { FileWatcher } from './services/FileWatcher.js';
15
17
 
@@ -21,6 +23,17 @@ export interface ServerOptions {
21
23
 
22
24
  let httpServer: Server | null = null;
23
25
  let fileWatcher: FileWatcher | null = null;
26
+ let stateStore: StateStore | null = null;
27
+
28
+ /**
29
+ * Get the StateStore instance
30
+ */
31
+ export function getStateStore(): StateStore {
32
+ if (!stateStore) {
33
+ throw new Error('StateStore not initialized. Call startServer() first.');
34
+ }
35
+ return stateStore;
36
+ }
24
37
 
25
38
  /**
26
39
  * Start the GUI server programmatically
@@ -30,6 +43,11 @@ export async function startServer(options: ServerOptions = {}): Promise<Server>
30
43
  const WORKFLOW_DIR = options.workflowDir || process.env.WORKFLOW_DIR || process.cwd();
31
44
  const STATIC_DIR = options.staticDir || process.env.STATIC_DIR;
32
45
 
46
+ // Initialize StateStore
47
+ const stateDir = join(WORKFLOW_DIR, '.marktoflow', 'state');
48
+ mkdirSync(stateDir, { recursive: true });
49
+ stateStore = new StateStore(join(stateDir, 'workflow-state.db'));
50
+
33
51
  const app = express();
34
52
  httpServer = createServer(app);
35
53
  const io = new SocketIOServer(httpServer, {
@@ -47,6 +65,7 @@ export async function startServer(options: ServerOptions = {}): Promise<Server>
47
65
  app.use('/api/workflows', workflowRoutes);
48
66
  app.use('/api/ai', aiRoutes);
49
67
  app.use('/api/execute', executeRoutes);
68
+ app.use('/api/executions', executionRoutes);
50
69
  app.use('/api/tools', toolsRoutes);
51
70
 
52
71
  // Health check
@@ -94,6 +113,10 @@ export function stopServer(): void {
94
113
  fileWatcher.stop();
95
114
  fileWatcher = null;
96
115
  }
116
+ if (stateStore) {
117
+ stateStore.close();
118
+ stateStore = null;
119
+ }
97
120
  if (httpServer) {
98
121
  httpServer.close();
99
122
  httpServer = null;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * API routes for execution history
3
+ */
4
+
5
+ import { Router, type Request, type Response } from 'express';
6
+ import { getStateStore } from '../index.js';
7
+
8
+ export const executionRoutes = Router();
9
+
10
+ /**
11
+ * GET /api/executions
12
+ * List all executions with optional filtering
13
+ */
14
+ executionRoutes.get('/', (req: Request, res: Response) => {
15
+ try {
16
+ const stateStore = getStateStore();
17
+ const { workflowId, status, limit, offset } = req.query;
18
+
19
+ // Helper to extract string from query param
20
+ const getString = (val: unknown): string | undefined => {
21
+ if (typeof val === 'string') return val;
22
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') return val[0];
23
+ return undefined;
24
+ };
25
+
26
+ const workflowIdStr = getString(workflowId);
27
+ const statusStr = getString(status);
28
+ const limitNum = parseInt(getString(limit) || '50', 10);
29
+ const offsetNum = getString(offset) ? parseInt(getString(offset)!, 10) : undefined;
30
+
31
+ const executions = stateStore.listExecutions({
32
+ workflowId: workflowIdStr,
33
+ status: statusStr as any,
34
+ limit: limitNum,
35
+ offset: offsetNum,
36
+ });
37
+
38
+ res.json(executions);
39
+ } catch (error) {
40
+ console.error('Error listing executions:', error);
41
+ res.status(500).json({
42
+ error: 'Failed to list executions',
43
+ message: error instanceof Error ? error.message : 'Unknown error',
44
+ });
45
+ }
46
+ });
47
+
48
+ /**
49
+ * GET /api/executions/:runId
50
+ * Get details for a specific execution
51
+ */
52
+ executionRoutes.get('/:runId', (req: Request, res: Response) => {
53
+ try {
54
+ const stateStore = getStateStore();
55
+ const runId = Array.isArray(req.params.runId) ? req.params.runId[0] : req.params.runId;
56
+
57
+ const execution = stateStore.getExecution(runId);
58
+ if (!execution) {
59
+ res.status(404).json({ error: 'Execution not found' });
60
+ return;
61
+ }
62
+
63
+ res.json(execution);
64
+ } catch (error) {
65
+ console.error('Error getting execution:', error);
66
+ res.status(500).json({
67
+ error: 'Failed to get execution',
68
+ message: error instanceof Error ? error.message : 'Unknown error',
69
+ });
70
+ }
71
+ });
72
+
73
+ /**
74
+ * GET /api/executions/:runId/checkpoints
75
+ * Get checkpoints for a specific execution
76
+ */
77
+ executionRoutes.get('/:runId/checkpoints', (req: Request, res: Response) => {
78
+ try {
79
+ const stateStore = getStateStore();
80
+ const runId = Array.isArray(req.params.runId) ? req.params.runId[0] : req.params.runId;
81
+
82
+ const checkpoints = stateStore.getCheckpoints(runId);
83
+ res.json(checkpoints);
84
+ } catch (error) {
85
+ console.error('Error getting checkpoints:', error);
86
+ res.status(500).json({
87
+ error: 'Failed to get checkpoints',
88
+ message: error instanceof Error ? error.message : 'Unknown error',
89
+ });
90
+ }
91
+ });
92
+
93
+ /**
94
+ * GET /api/executions/:runId/stats
95
+ * Get execution statistics
96
+ */
97
+ executionRoutes.get('/:runId/stats', (req: Request, res: Response) => {
98
+ try {
99
+ const stateStore = getStateStore();
100
+ const runId = Array.isArray(req.params.runId) ? req.params.runId[0] : req.params.runId;
101
+
102
+ const execution = stateStore.getExecution(runId);
103
+ if (!execution) {
104
+ res.status(404).json({ error: 'Execution not found' });
105
+ return;
106
+ }
107
+
108
+ const checkpoints = stateStore.getCheckpoints(runId);
109
+ const completedSteps = checkpoints.filter((c) => c.status === 'completed').length;
110
+ const failedSteps = checkpoints.filter((c) => c.status === 'failed').length;
111
+
112
+ const stats = {
113
+ runId,
114
+ workflowId: execution.workflowId,
115
+ status: execution.status,
116
+ totalSteps: execution.totalSteps,
117
+ completedSteps,
118
+ failedSteps,
119
+ currentStep: execution.currentStep,
120
+ duration:
121
+ execution.completedAt && execution.startedAt
122
+ ? execution.completedAt.getTime() - execution.startedAt.getTime()
123
+ : null,
124
+ startedAt: execution.startedAt,
125
+ completedAt: execution.completedAt,
126
+ };
127
+
128
+ res.json(stats);
129
+ } catch (error) {
130
+ console.error('Error getting execution stats:', error);
131
+ res.status(500).json({
132
+ error: 'Failed to get execution stats',
133
+ message: error instanceof Error ? error.message : 'Unknown error',
134
+ });
135
+ }
136
+ });
@@ -1,9 +1,27 @@
1
1
  import { Router, type Router as RouterType } from 'express';
2
+ import multer from 'multer';
2
3
  import { WorkflowService } from '../services/WorkflowService.js';
3
4
 
4
5
  const router: RouterType = Router();
5
6
  const workflowService = new WorkflowService();
6
7
 
8
+ // Configure multer for file uploads (in-memory storage)
9
+ const upload = multer({
10
+ storage: multer.memoryStorage(),
11
+ limits: {
12
+ fileSize: 10 * 1024 * 1024, // 10MB max file size
13
+ },
14
+ fileFilter: (_req, file, cb) => {
15
+ const allowedExtensions = ['.md', '.yaml', '.yml', '.zip'];
16
+ const ext = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.'));
17
+ if (allowedExtensions.includes(ext)) {
18
+ cb(null, true);
19
+ } else {
20
+ cb(new Error('Invalid file type. Only .md, .yaml, .yml, and .zip files are allowed.'));
21
+ }
22
+ },
23
+ });
24
+
7
25
  // List all workflows
8
26
  router.get('/', async (_req, res) => {
9
27
  try {
@@ -20,7 +38,8 @@ router.get('/', async (_req, res) => {
20
38
  // Get a specific workflow
21
39
  router.get('/:path(*)', async (req, res) => {
22
40
  try {
23
- const workflowPath = decodeURIComponent((req.params as Record<string, string>)['path(*)']);
41
+ // Express captures wildcard routes in params[0]
42
+ const workflowPath = decodeURIComponent((req.params as any)[0] || '');
24
43
  const workflow = await workflowService.getWorkflow(workflowPath);
25
44
 
26
45
  if (!workflow) {
@@ -103,4 +122,26 @@ router.get('/:path(*)/runs', async (req, res) => {
103
122
  }
104
123
  });
105
124
 
125
+ // Import workflow from file upload
126
+ router.post('/import', upload.single('file'), async (req, res) => {
127
+ try {
128
+ if (!req.file) {
129
+ return res.status(400).json({ error: 'No file uploaded' });
130
+ }
131
+
132
+ const result = await workflowService.importWorkflow(
133
+ req.file.buffer,
134
+ req.file.originalname
135
+ );
136
+
137
+ res.json(result);
138
+ } catch (error) {
139
+ console.error('Import error:', error);
140
+ res.status(500).json({
141
+ error: 'Failed to import workflow',
142
+ message: error instanceof Error ? error.message : 'Unknown error',
143
+ });
144
+ }
145
+ });
146
+
106
147
  export { router as workflowRoutes };
@@ -1,9 +1,11 @@
1
1
  import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
2
- import { join, relative, dirname } from 'path';
2
+ import { join, relative, dirname, basename, extname } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { stringify as yamlStringify } from 'yaml';
4
+ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
5
+ import AdmZip from 'adm-zip';
5
6
  // Import from @marktoflow/core for proper parsing
6
- import { parseFile as coreParseFile } from '@marktoflow/core';
7
+ import { parseFile as coreParseFile, type ExecutionRecord } from '@marktoflow/core';
8
+ import { getStateStore } from '../index.js';
7
9
 
8
10
  interface WorkflowListItem {
9
11
  path: string;
@@ -44,6 +46,7 @@ interface Workflow {
44
46
  tools?: Record<string, unknown>;
45
47
  inputs?: Record<string, unknown>;
46
48
  triggers?: unknown[];
49
+ markdown?: string;
47
50
  }
48
51
 
49
52
  export class WorkflowService {
@@ -75,17 +78,41 @@ export class WorkflowService {
75
78
  entry.isFile() &&
76
79
  (entry.name.endsWith('.md') || entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))
77
80
  ) {
78
- // Check if it's a workflow file by looking for frontmatter
81
+ // Skip README files (they're documentation, not workflows)
82
+ if (entry.name.toLowerCase() === 'readme.md') {
83
+ continue;
84
+ }
85
+
86
+ // Check if it's a workflow file by verifying YAML frontmatter
79
87
  try {
80
88
  const content = await readFile(fullPath, 'utf-8');
81
- if (content.includes('workflow:') || content.includes('steps:')) {
82
- const relativePath = relative(baseDir, fullPath);
83
- const workflowInfo = extractWorkflowInfo(content, entry.name);
84
- workflows.push({
85
- path: relativePath,
86
- ...workflowInfo,
87
- });
89
+
90
+ // For markdown files, verify it has YAML frontmatter
91
+ if (entry.name.endsWith('.md')) {
92
+ if (!content.trimStart().startsWith('---\n')) {
93
+ continue;
94
+ }
95
+ const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---/);
96
+ if (!frontmatterMatch) {
97
+ continue;
98
+ }
99
+ const frontmatter = frontmatterMatch[0];
100
+ if (!frontmatter.includes('workflow:') && !frontmatter.includes('steps:')) {
101
+ continue;
102
+ }
103
+ } else {
104
+ // For YAML files, check if they contain workflow structure
105
+ if (!content.includes('workflow:') && !content.includes('steps:')) {
106
+ continue;
107
+ }
88
108
  }
109
+
110
+ const relativePath = relative(baseDir, fullPath);
111
+ const workflowInfo = extractWorkflowInfo(content, entry.name);
112
+ workflows.push({
113
+ path: relativePath,
114
+ ...workflowInfo,
115
+ });
89
116
  } catch {
90
117
  // Skip files that can't be read
91
118
  }
@@ -108,6 +135,9 @@ export class WorkflowService {
108
135
  }
109
136
 
110
137
  try {
138
+ // Read the raw markdown content for GUI parser
139
+ const rawContent = await readFile(fullPath, 'utf-8');
140
+
111
141
  // Use core parser for proper workflow parsing
112
142
  const result = await coreParseFile(fullPath);
113
143
  if (result.warnings && result.warnings.length > 0) {
@@ -115,7 +145,13 @@ export class WorkflowService {
115
145
  }
116
146
 
117
147
  // Convert core Workflow type to GUI Workflow type
118
- return this.convertCoreWorkflow(result.workflow, workflowPath);
148
+ const workflow = this.convertCoreWorkflow(result.workflow, workflowPath);
149
+
150
+ // Add raw markdown for GUI control flow parsing
151
+ return {
152
+ ...workflow,
153
+ markdown: rawContent,
154
+ };
119
155
  } catch (error) {
120
156
  console.error(`Error parsing workflow ${workflowPath}:`, error);
121
157
  // Fallback to local parsing if core parser fails
@@ -197,10 +233,134 @@ export class WorkflowService {
197
233
  }
198
234
  }
199
235
 
200
- async getExecutionHistory(_workflowPath: string): Promise<unknown[]> {
201
- // TODO: Query state store for execution history
202
- // This will integrate with @marktoflow/core StateStore
203
- return [];
236
+ async getExecutionHistory(workflowPath: string): Promise<ExecutionRecord[]> {
237
+ try {
238
+ const stateStore = getStateStore();
239
+ // Use the workflow path as the workflow ID for querying
240
+ const executions = stateStore.listExecutions({
241
+ workflowId: workflowPath,
242
+ limit: 50,
243
+ });
244
+ return executions;
245
+ } catch (error) {
246
+ console.error('Error getting execution history:', error);
247
+ return [];
248
+ }
249
+ }
250
+
251
+ async importWorkflow(
252
+ fileBuffer: Buffer,
253
+ originalFilename: string
254
+ ): Promise<{ success: boolean; results?: Array<{ success: boolean; filename: string; message?: string }>; filename?: string; message?: string }> {
255
+ const ext = extname(originalFilename).toLowerCase();
256
+
257
+ try {
258
+ // Handle ZIP files - extract and import all workflow files
259
+ if (ext === '.zip') {
260
+ return await this.importZipBundle(fileBuffer);
261
+ }
262
+
263
+ // Handle single workflow files (.md, .yaml, .yml)
264
+ if (['.md', '.yaml', '.yml'].includes(ext)) {
265
+ const content = fileBuffer.toString('utf-8');
266
+ await this.importSingleWorkflow(content, originalFilename);
267
+
268
+ return {
269
+ success: true,
270
+ filename: originalFilename,
271
+ message: 'Workflow imported successfully',
272
+ };
273
+ }
274
+
275
+ throw new Error('Unsupported file type');
276
+ } catch (error) {
277
+ console.error('Import error:', error);
278
+ throw error;
279
+ }
280
+ }
281
+
282
+ private async importSingleWorkflow(content: string, filename: string): Promise<void> {
283
+ // Validate workflow content
284
+ try {
285
+ // Basic validation - try to parse YAML frontmatter or direct YAML
286
+ if (filename.endsWith('.md')) {
287
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
288
+ if (frontmatterMatch) {
289
+ yamlParse(frontmatterMatch[1]);
290
+ } else {
291
+ throw new Error('Invalid markdown workflow: missing YAML frontmatter');
292
+ }
293
+ } else {
294
+ yamlParse(content);
295
+ }
296
+ } catch (error) {
297
+ throw new Error(`Invalid workflow format: ${error instanceof Error ? error.message : 'Unknown error'}`);
298
+ }
299
+
300
+ // Check for filename conflicts
301
+ let targetFilename = filename;
302
+ let fullPath = this.resolvePath(targetFilename);
303
+ let counter = 1;
304
+
305
+ while (existsSync(fullPath)) {
306
+ const nameWithoutExt = basename(filename, extname(filename));
307
+ targetFilename = `${nameWithoutExt}-${counter}${extname(filename)}`;
308
+ fullPath = this.resolvePath(targetFilename);
309
+ counter++;
310
+ }
311
+
312
+ // Write file
313
+ await writeFile(fullPath, content, 'utf-8');
314
+ }
315
+
316
+ private async importZipBundle(zipBuffer: Buffer): Promise<{ success: boolean; results: Array<{ success: boolean; filename: string; message?: string }> }> {
317
+ const results: Array<{ success: boolean; filename: string; message?: string }> = [];
318
+
319
+ try {
320
+ const zip = new AdmZip(zipBuffer);
321
+ const zipEntries = zip.getEntries();
322
+
323
+ for (const entry of zipEntries) {
324
+ // Skip directories and non-workflow files
325
+ if (entry.isDirectory) continue;
326
+
327
+ const filename = basename(entry.entryName);
328
+ const ext = extname(filename).toLowerCase();
329
+
330
+ if (!['.md', '.yaml', '.yml'].includes(ext)) {
331
+ results.push({
332
+ success: false,
333
+ filename,
334
+ message: 'Skipped: not a workflow file',
335
+ });
336
+ continue;
337
+ }
338
+
339
+ try {
340
+ const content = entry.getData().toString('utf-8');
341
+ await this.importSingleWorkflow(content, filename);
342
+
343
+ results.push({
344
+ success: true,
345
+ filename,
346
+ message: 'Imported successfully',
347
+ });
348
+ } catch (error) {
349
+ results.push({
350
+ success: false,
351
+ filename,
352
+ message: error instanceof Error ? error.message : 'Unknown error',
353
+ });
354
+ }
355
+ }
356
+
357
+ return {
358
+ success: results.some(r => r.success),
359
+ results,
360
+ };
361
+ } catch (error) {
362
+ throw new Error(`Failed to extract ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
363
+ }
204
364
  }
205
365
 
206
366
  private resolvePath(workflowPath: string): string {
@@ -70,6 +70,19 @@ export function setupWebSocket(io: SocketIOServer) {
70
70
  });
71
71
  },
72
72
 
73
+ // Emit execution started
74
+ emitExecutionStarted(runId: string, data: any) {
75
+ io.to(`execution:${runId}`).emit('execution:started', {
76
+ runId,
77
+ ...data,
78
+ });
79
+ // Also broadcast to all clients for global execution history updates
80
+ io.emit('execution:new', {
81
+ runId,
82
+ ...data,
83
+ });
84
+ },
85
+
73
86
  // Emit execution step update
74
87
  emitExecutionStep(runId: string, data: any) {
75
88
  io.to(`execution:${runId}`).emit('execution:step', {
@@ -112,8 +112,9 @@ describe('ForEachNode', () => {
112
112
  totalIterations: 10,
113
113
  });
114
114
 
115
- const progressBar = container.querySelector('.bg-orange-400');
116
- expect(progressBar).toHaveStyle({ width: '50%' });
115
+ const progressBar = container.querySelector('.bg-pink-400, .bg-orange-400');
116
+ expect(progressBar).toBeInTheDocument();
117
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('50%'));
117
118
  });
118
119
 
119
120
  it('should display 0% progress when currentIteration is 0', () => {
@@ -122,8 +123,9 @@ describe('ForEachNode', () => {
122
123
  totalIterations: 10,
123
124
  });
124
125
 
125
- const progressBar = container.querySelector('.bg-orange-400');
126
- expect(progressBar).toHaveStyle({ width: '0%' });
126
+ const progressBar = container.querySelector('.bg-pink-400, .bg-orange-400');
127
+ expect(progressBar).toBeInTheDocument();
128
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('0%'));
127
129
  });
128
130
 
129
131
  it('should display 100% progress when completed', () => {
@@ -132,8 +134,9 @@ describe('ForEachNode', () => {
132
134
  totalIterations: 10,
133
135
  });
134
136
 
135
- const progressBar = container.querySelector('.bg-orange-400');
136
- expect(progressBar).toHaveStyle({ width: '100%' });
137
+ const progressBar = container.querySelector('.bg-pink-400, .bg-orange-400');
138
+ expect(progressBar).toBeInTheDocument();
139
+ expect(progressBar).toHaveAttribute('style', expect.stringContaining('100%'));
137
140
  });
138
141
 
139
142
  it('should not display progress when totalIterations is undefined', () => {
@@ -215,4 +218,91 @@ describe('ForEachNode', () => {
215
218
  expect(screen.getByText(complexItems)).toBeInTheDocument();
216
219
  });
217
220
  });
221
+
222
+ describe('early exit indicators', () => {
223
+ it('should show early exit warning when loop exited with break', () => {
224
+ renderNode({
225
+ earlyExit: true,
226
+ exitReason: 'break',
227
+ currentIteration: 3,
228
+ totalIterations: 10
229
+ });
230
+
231
+ expect(screen.getByText('Loop exited early (break)')).toBeInTheDocument();
232
+ });
233
+
234
+ it('should show error message when loop stopped on error', () => {
235
+ renderNode({
236
+ earlyExit: true,
237
+ exitReason: 'error',
238
+ currentIteration: 5,
239
+ totalIterations: 10
240
+ });
241
+
242
+ expect(screen.getByText('Loop stopped on error')).toBeInTheDocument();
243
+ });
244
+
245
+ it('should show (stopped) indicator in progress text', () => {
246
+ renderNode({
247
+ earlyExit: true,
248
+ exitReason: 'break',
249
+ currentIteration: 5,
250
+ totalIterations: 10
251
+ });
252
+
253
+ expect(screen.getByText('(stopped)')).toBeInTheDocument();
254
+ expect(screen.getByText('5 / 10')).toBeInTheDocument();
255
+ });
256
+
257
+ it('should change progress bar color to orange when early exit', () => {
258
+ const { container } = renderNode({
259
+ earlyExit: true,
260
+ exitReason: 'break',
261
+ currentIteration: 5,
262
+ totalIterations: 10
263
+ });
264
+
265
+ const progressBar = container.querySelector('.bg-orange-400');
266
+ expect(progressBar).toBeInTheDocument();
267
+ expect(progressBar).toHaveStyle({ width: '50%' });
268
+ });
269
+
270
+ it('should use pink color for progress bar when no early exit', () => {
271
+ const { container } = renderNode({
272
+ earlyExit: false,
273
+ currentIteration: 5,
274
+ totalIterations: 10
275
+ });
276
+
277
+ const progressBar = container.querySelector('.bg-pink-400');
278
+ expect(progressBar).toBeInTheDocument();
279
+ });
280
+
281
+ it('should not show early exit warning when earlyExit is false', () => {
282
+ renderNode({
283
+ earlyExit: false,
284
+ currentIteration: 10,
285
+ totalIterations: 10
286
+ });
287
+
288
+ expect(screen.queryByText('Loop exited early (break)')).not.toBeInTheDocument();
289
+ expect(screen.queryByText('Loop stopped on error')).not.toBeInTheDocument();
290
+ });
291
+ });
292
+
293
+ describe('completed and failed states', () => {
294
+ it('should show completed class on node', () => {
295
+ renderNode({ status: 'completed' });
296
+
297
+ const node = screen.getByText('Process Orders').closest('.control-flow-node');
298
+ expect(node).toHaveClass('completed');
299
+ });
300
+
301
+ it('should show failed class on node', () => {
302
+ renderNode({ status: 'failed' });
303
+
304
+ const node = screen.getByText('Process Orders').closest('.control-flow-node');
305
+ expect(node).toHaveClass('failed');
306
+ });
307
+ });
218
308
  });