@marktoflow/gui 2.0.0-alpha.4 → 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.
- package/.turbo/turbo-build.log +24 -8
- package/README.md +11 -1
- package/dist/client/assets/index-CM44OayM.js +704 -0
- package/dist/client/assets/index-CM44OayM.js.map +1 -0
- package/dist/client/assets/index-Dru63gi6.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/{server/index.js → index.js} +22 -1
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/executions.js +125 -0
- package/dist/server/routes/executions.js.map +1 -0
- package/dist/server/{server/routes → routes}/workflows.js +37 -1
- package/dist/server/routes/workflows.js.map +1 -0
- package/dist/server/{server/services → services}/WorkflowService.js +158 -15
- package/dist/server/services/WorkflowService.js.map +1 -0
- package/dist/server/{server/websocket → websocket}/index.js +12 -0
- package/dist/server/{server/websocket → websocket}/index.js.map +1 -1
- package/marktoflow-gui-2.0.0-alpha.5.tgz +0 -0
- package/package.json +19 -5
- package/scripts/flatten-dist.js +69 -0
- package/src/client/components/Canvas/Canvas.tsx +3 -1
- package/src/client/components/Canvas/ExecutionOverlay.tsx +120 -32
- package/src/client/components/Canvas/ForEachNode.tsx +27 -3
- package/src/client/components/Canvas/IfElseNode.tsx +22 -7
- package/src/client/components/Canvas/NodeContextMenu.tsx +8 -4
- package/src/client/components/Canvas/ParallelNode.tsx +25 -8
- package/src/client/components/Canvas/SwitchNode.tsx +41 -20
- package/src/client/components/Canvas/Toolbar.tsx +59 -21
- package/src/client/components/Canvas/TransformNode.tsx +9 -0
- package/src/client/components/Canvas/WhileNode.tsx +35 -3
- package/src/client/components/Debug/VariableInspector.tsx +148 -0
- package/src/client/components/Prompt/PromptInput.tsx +3 -1
- package/src/client/components/Settings/ProviderSwitcher.tsx +228 -0
- package/src/client/components/Sidebar/ImportDialog.tsx +257 -0
- package/src/client/components/Sidebar/Sidebar.tsx +21 -2
- package/src/client/components/common/KeyboardShortcuts.tsx +8 -2
- package/src/client/stores/agentStore.ts +109 -0
- package/src/client/stores/executionStore.ts +64 -2
- package/src/client/stores/workflowStore.ts +10 -2
- package/src/client/styles/globals.css +106 -0
- package/src/client/utils/platform.ts +46 -0
- package/src/client/utils/workflowToGraph.ts +245 -21
- package/src/server/index.ts +24 -1
- package/src/server/routes/executions.ts +136 -0
- package/src/server/routes/workflows.ts +42 -1
- package/src/server/services/WorkflowService.ts +176 -16
- package/src/server/websocket/index.ts +13 -0
- package/tests/unit/ForEachNode.test.tsx +96 -6
- package/tests/unit/IfElseNode.test.tsx +47 -0
- package/tests/unit/ParallelNode.test.tsx +80 -0
- package/tests/unit/SwitchNode.test.tsx +75 -0
- package/tests/unit/WhileNode.test.tsx +12 -8
- package/tests/unit/agentStore.test.ts +218 -0
- package/tests/unit/executionStore.test.ts +40 -0
- package/tests/unit/platform.test.ts +118 -0
- package/tests/unit/workflowToGraph.test.ts +22 -0
- package/dist/client/assets/index-C90Y_aBX.js +0 -678
- package/dist/client/assets/index-C90Y_aBX.js.map +0 -1
- package/dist/client/assets/index-CRWeQ3NN.css +0 -1
- package/dist/server/server/index.js.map +0 -1
- package/dist/server/server/routes/workflows.js.map +0 -1
- package/dist/server/server/services/WorkflowService.js.map +0 -1
- /package/dist/server/{server/routes → routes}/ai.js +0 -0
- /package/dist/server/{server/routes → routes}/ai.js.map +0 -0
- /package/dist/server/{server/routes → routes}/execute.js +0 -0
- /package/dist/server/{server/routes → routes}/execute.js.map +0 -0
- /package/dist/server/{server/routes → routes}/tools.js +0 -0
- /package/dist/server/{server/routes → routes}/tools.js.map +0 -0
- /package/dist/server/{server/services → services}/AIService.js +0 -0
- /package/dist/server/{server/services → services}/AIService.js.map +0 -0
- /package/dist/server/{server/services → services}/FileWatcher.js +0 -0
- /package/dist/server/{server/services → services}/FileWatcher.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/claude-code-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/claude-code-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/claude-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/claude-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/codex-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/codex-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/copilot-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/copilot-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/demo-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/demo-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/index.js +0 -0
- /package/dist/server/{server/services → services}/agents/index.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/ollama-provider.js +0 -0
- /package/dist/server/{server/services → services}/agents/ollama-provider.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/prompts.js +0 -0
- /package/dist/server/{server/services → services}/agents/prompts.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/registry.js +0 -0
- /package/dist/server/{server/services → services}/agents/registry.js.map +0 -0
- /package/dist/server/{server/services → services}/agents/types.js +0 -0
- /package/dist/server/{server/services → services}/agents/types.js.map +0 -0
- /package/dist/{server/shared → shared}/constants.js +0 -0
- /package/dist/{server/shared → shared}/constants.js.map +0 -0
- /package/dist/{server/shared → shared}/types.js +0 -0
- /package/dist/{server/shared → shared}/types.js.map +0 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
});
|