devglide 0.1.2 → 0.1.4
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/package.json +5 -1
- package/src/apps/kanban/src/index.ts +1 -1
- package/src/apps/log/.turbo/turbo-lint.log +2 -2
- package/src/apps/log/src/index.ts +1 -1
- package/src/apps/prompts/.turbo/turbo-lint.log +3 -4
- package/src/apps/prompts/src/index.ts +1 -1
- package/src/apps/shell/.turbo/turbo-lint.log +5 -5
- package/src/apps/shell/src/index.ts +1 -1
- package/src/apps/test/.turbo/turbo-lint.log +2 -2
- package/src/apps/test/src/index.ts +1 -1
- package/src/apps/vocabulary/.turbo/turbo-lint.log +3 -4
- package/src/apps/vocabulary/src/index.ts +1 -1
- package/src/apps/voice/.turbo/turbo-lint.log +2 -2
- package/src/apps/voice/src/index.ts +1 -1
- package/src/apps/workflow/.turbo/turbo-lint.log +3 -4
- package/src/apps/workflow/src/index.ts +1 -1
- package/src/project-context.ts +36 -0
- package/src/public/app.js +701 -0
- package/src/public/favicon.svg +7 -0
- package/src/public/index.html +78 -0
- package/src/public/state.js +84 -0
- package/src/public/style.css +1213 -0
- package/src/routers/coder.ts +157 -0
- package/src/routers/dashboard.ts +158 -0
- package/src/routers/kanban.ts +38 -0
- package/src/routers/log.ts +42 -0
- package/src/routers/prompts.ts +134 -0
- package/src/routers/shell/index.ts +47 -0
- package/src/routers/shell/pty-manager.ts +107 -0
- package/src/routers/shell/shell-config.ts +38 -0
- package/src/routers/shell/shell-routes.ts +108 -0
- package/src/routers/shell/shell-socket.ts +321 -0
- package/src/routers/shell/shell-state.ts +59 -0
- package/src/routers/test.ts +254 -0
- package/src/routers/vocabulary.ts +149 -0
- package/src/routers/voice.ts +10 -0
- package/src/routers/workflow.ts +243 -0
- package/src/server.ts +325 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import type { Request, Response } from 'express';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ScenarioManager } from '../apps/test/src/services/scenario-manager.js';
|
|
6
|
+
import { ScenarioStore } from '../apps/test/src/services/scenario-store.js';
|
|
7
|
+
import { ScenarioBroadcaster } from '../apps/test/src/services/scenario-broadcaster.js';
|
|
8
|
+
import { getActiveProject } from '../project-context.js';
|
|
9
|
+
|
|
10
|
+
// ── Zod schemas for HTTP input validation ────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const scenarioStepSchema = z.object({
|
|
13
|
+
command: z.string(),
|
|
14
|
+
selector: z.string().optional(),
|
|
15
|
+
text: z.string().optional(),
|
|
16
|
+
value: z.string().optional(),
|
|
17
|
+
timeout: z.number().optional(),
|
|
18
|
+
ms: z.number().optional(),
|
|
19
|
+
clear: z.boolean().optional(),
|
|
20
|
+
contains: z.boolean().optional(),
|
|
21
|
+
path: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const submitScenarioSchema = z.object({
|
|
25
|
+
name: z.string().optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
steps: z.array(scenarioStepSchema).min(1, 'At least one step is required'),
|
|
28
|
+
target: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const saveScenarioSchema = z.object({
|
|
32
|
+
name: z.string().min(1, 'name is required'),
|
|
33
|
+
description: z.string().optional(),
|
|
34
|
+
steps: z.array(scenarioStepSchema).min(1, 'At least one step is required'),
|
|
35
|
+
target: z.string().min(1, 'target is required'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const scenarioResultSchema = z.object({
|
|
39
|
+
status: z.enum(['passed', 'failed']),
|
|
40
|
+
failedStep: z.number().optional(),
|
|
41
|
+
error: z.string().optional(),
|
|
42
|
+
duration: z.number().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export { createTestMcpServer } from '../apps/test/src/mcp.js';
|
|
46
|
+
|
|
47
|
+
export const router: Router = Router();
|
|
48
|
+
|
|
49
|
+
const scenarioManager = ScenarioManager.getInstance();
|
|
50
|
+
const scenarioStore = ScenarioStore.getInstance();
|
|
51
|
+
const broadcaster = ScenarioBroadcaster.getInstance();
|
|
52
|
+
|
|
53
|
+
// ── Trigger routes (mounted under /trigger) ──────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const triggerRouter = Router();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET /api/test/trigger/status — Return pending scenario count.
|
|
59
|
+
*/
|
|
60
|
+
triggerRouter.get('/status', (req: Request, res: Response) => {
|
|
61
|
+
const projectPath = (req.query.projectPath as string) || getActiveProject()?.path || null;
|
|
62
|
+
res.json({ pendingScenarios: scenarioManager.getPendingCountForProject(projectPath) });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /api/test/trigger/commands — Return the command catalog.
|
|
67
|
+
*/
|
|
68
|
+
triggerRouter.get('/commands', (_req: Request, res: Response) => {
|
|
69
|
+
res.json(scenarioManager.getCommandsCatalog());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST /api/test/trigger/scenarios — Submit a scenario for browser execution.
|
|
74
|
+
*/
|
|
75
|
+
triggerRouter.post('/scenarios', (req: Request, res: Response) => {
|
|
76
|
+
const parsed = submitScenarioSchema.safeParse(req.body);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const saved = scenarioManager.submitScenario(parsed.data);
|
|
83
|
+
if (saved.target) {
|
|
84
|
+
broadcaster.broadcast(scenarioManager.resolveTargetKey(saved.target), saved);
|
|
85
|
+
}
|
|
86
|
+
res.status(201).json(saved);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* GET /api/test/trigger/scenarios/results — List all recent results.
|
|
91
|
+
* Static paths must be registered before :id param routes to avoid ambiguity.
|
|
92
|
+
*/
|
|
93
|
+
triggerRouter.get('/scenarios/results', (req: Request, res: Response) => {
|
|
94
|
+
const projectPath = (req.query.projectPath as string) || getActiveProject()?.path || null;
|
|
95
|
+
res.json(scenarioManager.listResults(projectPath));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* GET /api/test/trigger/scenarios/stream?target=... — SSE stream for scenario delivery.
|
|
100
|
+
*/
|
|
101
|
+
triggerRouter.get('/scenarios/stream', (req: Request, res: Response) => {
|
|
102
|
+
const target = (req.query.target as string) || '';
|
|
103
|
+
|
|
104
|
+
scenarioManager.registerTarget(target);
|
|
105
|
+
|
|
106
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
107
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
108
|
+
res.setHeader('Connection', 'keep-alive');
|
|
109
|
+
res.flushHeaders();
|
|
110
|
+
|
|
111
|
+
const pending = scenarioManager.dequeueScenario(target);
|
|
112
|
+
if (pending) {
|
|
113
|
+
res.write(`data: ${JSON.stringify(pending)}\n\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const key = scenarioManager.resolveTargetKey(target);
|
|
117
|
+
const removeClient = broadcaster.addClient(key, res);
|
|
118
|
+
req.on('close', removeClient);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* GET /api/test/trigger/scenarios/poll?target=... — Check for a queued scenario.
|
|
123
|
+
*/
|
|
124
|
+
triggerRouter.get('/scenarios/poll', (req: Request, res: Response) => {
|
|
125
|
+
const target = (req.query.target as string) || '';
|
|
126
|
+
|
|
127
|
+
const queued = scenarioManager.dequeueScenario(target);
|
|
128
|
+
if (queued) {
|
|
129
|
+
res.status(200).json(queued);
|
|
130
|
+
} else {
|
|
131
|
+
res.status(204).end();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* POST /api/test/trigger/scenarios/:id/result — Receive result from browser.
|
|
137
|
+
*/
|
|
138
|
+
triggerRouter.post('/scenarios/:id/result', (req: Request, res: Response) => {
|
|
139
|
+
const id = req.params.id as string;
|
|
140
|
+
const parsed = scenarioResultSchema.safeParse(req.body);
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = scenarioManager.setResult(id, parsed.data);
|
|
147
|
+
res.status(201).json(result);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* GET /api/test/trigger/scenarios/:id/result — Retrieve result for a scenario.
|
|
152
|
+
*/
|
|
153
|
+
triggerRouter.get('/scenarios/:id/result', (req: Request, res: Response) => {
|
|
154
|
+
const id = req.params.id as string;
|
|
155
|
+
const result = scenarioManager.getResult(id);
|
|
156
|
+
if (!result) {
|
|
157
|
+
res.status(404).end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
res.json(result);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* GET /api/test/trigger/scenarios/saved — List saved scenarios scoped to a target.
|
|
165
|
+
* Accepts ?target= (exact match) or ?projectPath= (matches exact path or basename).
|
|
166
|
+
* Returns empty array if neither is provided.
|
|
167
|
+
*/
|
|
168
|
+
triggerRouter.get('/scenarios/saved', async (req: Request, res: Response) => {
|
|
169
|
+
const target = req.query.target as string | undefined;
|
|
170
|
+
const projectPath = req.query.projectPath as string | undefined;
|
|
171
|
+
|
|
172
|
+
if (target) {
|
|
173
|
+
res.json(await scenarioStore.list(target));
|
|
174
|
+
} else if (projectPath) {
|
|
175
|
+
const basename = path.basename(projectPath);
|
|
176
|
+
const byPath = await scenarioStore.list(projectPath);
|
|
177
|
+
const byName = basename !== projectPath ? await scenarioStore.list(basename) : [];
|
|
178
|
+
// Dedupe in case both match the same scenarios
|
|
179
|
+
const seen = new Set(byPath.map((s) => s.id));
|
|
180
|
+
res.json([...byPath, ...byName.filter((s) => !seen.has(s.id))]);
|
|
181
|
+
} else {
|
|
182
|
+
res.json([]);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* POST /api/test/trigger/scenarios/save — Save a new scenario.
|
|
188
|
+
*/
|
|
189
|
+
triggerRouter.post('/scenarios/save', async (req: Request, res: Response) => {
|
|
190
|
+
const parsed = saveScenarioSchema.safeParse(req.body);
|
|
191
|
+
if (!parsed.success) {
|
|
192
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const saved = await scenarioStore.save({
|
|
197
|
+
name: parsed.data.name,
|
|
198
|
+
description: parsed.data.description,
|
|
199
|
+
target: parsed.data.target,
|
|
200
|
+
steps: parsed.data.steps,
|
|
201
|
+
});
|
|
202
|
+
res.status(201).json(saved);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* DELETE /api/test/trigger/scenarios/saved/:id — Delete a saved scenario by id.
|
|
207
|
+
*/
|
|
208
|
+
triggerRouter.delete('/scenarios/saved/:id', async (req: Request, res: Response) => {
|
|
209
|
+
const id = req.params.id as string;
|
|
210
|
+
const deleted = await scenarioStore.delete(id);
|
|
211
|
+
if (!deleted) {
|
|
212
|
+
res.status(404).end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
res.status(204).end();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* POST /api/test/trigger/scenarios/saved/:id/run — Re-run a saved scenario.
|
|
220
|
+
*/
|
|
221
|
+
triggerRouter.post('/scenarios/saved/:id/run', async (req: Request, res: Response) => {
|
|
222
|
+
const id = req.params.id as string;
|
|
223
|
+
const scenario = await scenarioStore.get(id);
|
|
224
|
+
if (!scenario) {
|
|
225
|
+
res.status(404).end();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await scenarioStore.markRun(id);
|
|
230
|
+
|
|
231
|
+
const queued = scenarioManager.submitScenario({
|
|
232
|
+
name: scenario.name,
|
|
233
|
+
steps: scenario.steps,
|
|
234
|
+
target: scenario.target,
|
|
235
|
+
});
|
|
236
|
+
if (queued.target) {
|
|
237
|
+
broadcaster.broadcast(scenarioManager.resolveTargetKey(queued.target), queued);
|
|
238
|
+
}
|
|
239
|
+
res.status(201).json(queued);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
router.use('/trigger', triggerRouter);
|
|
243
|
+
|
|
244
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
export async function initTest(): Promise<void> {
|
|
247
|
+
scenarioManager.startCleanup();
|
|
248
|
+
await scenarioStore.load();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function shutdownTest(): void {
|
|
252
|
+
scenarioManager.stopCleanup();
|
|
253
|
+
broadcaster.shutdown();
|
|
254
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { VocabularyStore } from '../apps/vocabulary/services/vocabulary-store.js';
|
|
5
|
+
|
|
6
|
+
// ── Zod schemas for HTTP input validation ────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const createEntrySchema = z.object({
|
|
9
|
+
term: z.string().min(1, 'term is required'),
|
|
10
|
+
definition: z.string().min(1, 'definition is required'),
|
|
11
|
+
aliases: z.array(z.string()).optional(),
|
|
12
|
+
category: z.string().optional(),
|
|
13
|
+
tags: z.array(z.string()).optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const updateEntrySchema = z.object({
|
|
17
|
+
term: z.string().optional(),
|
|
18
|
+
definition: z.string().optional(),
|
|
19
|
+
aliases: z.array(z.string()).optional(),
|
|
20
|
+
category: z.string().optional(),
|
|
21
|
+
tags: z.array(z.string()).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export { createVocabularyMcpServer } from '../apps/vocabulary/mcp.js';
|
|
25
|
+
|
|
26
|
+
export const router: Router = Router();
|
|
27
|
+
|
|
28
|
+
const store = VocabularyStore.getInstance();
|
|
29
|
+
|
|
30
|
+
// GET /entries — list all vocabulary entries
|
|
31
|
+
router.get('/entries', async (req: Request, res: Response) => {
|
|
32
|
+
try {
|
|
33
|
+
const category = req.query.category as string | undefined;
|
|
34
|
+
const tag = req.query.tag as string | undefined;
|
|
35
|
+
const entries = await store.list({ category, tag });
|
|
36
|
+
res.json(entries);
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// GET /entries/lookup — lookup a term by name or alias
|
|
43
|
+
router.get('/entries/lookup', async (req: Request, res: Response) => {
|
|
44
|
+
try {
|
|
45
|
+
const term = req.query.term as string;
|
|
46
|
+
if (!term) { res.status(400).json({ error: 'term query parameter is required' }); return; }
|
|
47
|
+
|
|
48
|
+
const entry = await store.lookup(term);
|
|
49
|
+
if (!entry) { res.status(404).json({ error: `Term "${term}" not found` }); return; }
|
|
50
|
+
res.json(entry);
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// GET /entries/:id — get a single entry by ID
|
|
57
|
+
router.get('/entries/:id', async (req: Request, res: Response) => {
|
|
58
|
+
try {
|
|
59
|
+
const entry = await store.get(req.params.id);
|
|
60
|
+
if (!entry) { res.status(404).json({ error: 'Entry not found' }); return; }
|
|
61
|
+
res.json(entry);
|
|
62
|
+
} catch (err: unknown) {
|
|
63
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// POST /entries — create a new entry
|
|
68
|
+
router.post('/entries', async (req: Request, res: Response) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = createEntrySchema.safeParse(req.body);
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { term, definition, aliases, category, tags } = parsed.data;
|
|
77
|
+
|
|
78
|
+
const existing = await store.lookup(term);
|
|
79
|
+
if (existing) {
|
|
80
|
+
res.status(409).json({ error: `Term "${term}" already exists`, id: existing.id });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const entry = await store.save({
|
|
85
|
+
term,
|
|
86
|
+
definition,
|
|
87
|
+
aliases,
|
|
88
|
+
category,
|
|
89
|
+
tags: tags ?? [],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
res.status(201).json(entry);
|
|
93
|
+
} catch (err: unknown) {
|
|
94
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// PUT /entries/:id — update an existing entry
|
|
99
|
+
router.put('/entries/:id', async (req: Request, res: Response) => {
|
|
100
|
+
try {
|
|
101
|
+
const existing = await store.get(req.params.id);
|
|
102
|
+
if (!existing) { res.status(404).json({ error: 'Entry not found' }); return; }
|
|
103
|
+
|
|
104
|
+
const parsed = updateEntrySchema.safeParse(req.body);
|
|
105
|
+
if (!parsed.success) {
|
|
106
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { term, definition, aliases, category, tags } = parsed.data;
|
|
111
|
+
|
|
112
|
+
const entry = await store.save({
|
|
113
|
+
id: req.params.id,
|
|
114
|
+
term: term ?? existing.term,
|
|
115
|
+
definition: definition ?? existing.definition,
|
|
116
|
+
aliases: aliases ?? existing.aliases,
|
|
117
|
+
category: category ?? existing.category,
|
|
118
|
+
tags: tags ?? existing.tags,
|
|
119
|
+
projectId: existing.projectId,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
res.json(entry);
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// DELETE /entries/:id — remove an entry
|
|
129
|
+
router.delete('/entries/:id', async (req: Request, res: Response) => {
|
|
130
|
+
try {
|
|
131
|
+
const deleted = await store.delete(req.params.id);
|
|
132
|
+
if (deleted) { res.json({ ok: true }); return; }
|
|
133
|
+
res.status(404).json({ error: 'Entry not found' });
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// GET /context — get compiled vocabulary as markdown
|
|
140
|
+
router.get('/context', async (req: Request, res: Response) => {
|
|
141
|
+
try {
|
|
142
|
+
const projectId = req.query.projectId as string | undefined;
|
|
143
|
+
const markdown = await store.getCompiledContext(projectId);
|
|
144
|
+
res.setHeader('Content-Type', 'text/markdown');
|
|
145
|
+
res.send(markdown || 'No vocabulary entries defined.');
|
|
146
|
+
} catch (err: unknown) {
|
|
147
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { transcribeRouter } from '../apps/voice/src/routes/transcribe.js';
|
|
3
|
+
import { configRouter } from '../apps/voice/src/routes/config.js';
|
|
4
|
+
|
|
5
|
+
export const router: Router = Router();
|
|
6
|
+
|
|
7
|
+
router.use('/transcribe', transcribeRouter);
|
|
8
|
+
router.use('/config', configRouter);
|
|
9
|
+
|
|
10
|
+
export { createVoiceMcpServer } from '../apps/voice/src/mcp.js';
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
// Workflow imports
|
|
6
|
+
import { WorkflowStore } from '../apps/workflow/services/workflow-store.js';
|
|
7
|
+
import { RunManager } from '../apps/workflow/services/run-manager.js';
|
|
8
|
+
import { getRegisteredTypes } from '../apps/workflow/engine/node-registry.js';
|
|
9
|
+
import { registerAllExecutors } from '../apps/workflow/engine/executors/index.js';
|
|
10
|
+
import { validateWorkflowGraph } from '../apps/workflow/services/workflow-validator.js';
|
|
11
|
+
import { getActiveProject } from '../project-context.js';
|
|
12
|
+
import type { WorkflowNode, WorkflowEdge, ExecutorServices } from '../apps/workflow/types.js';
|
|
13
|
+
import { ScenarioManager } from '../apps/test/src/services/scenario-manager.js';
|
|
14
|
+
import { ScenarioStore } from '../apps/test/src/services/scenario-store.js';
|
|
15
|
+
|
|
16
|
+
// ── Zod schemas for HTTP input validation ────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const createWorkflowSchema = z.object({
|
|
19
|
+
name: z.string().min(1, 'name is required'),
|
|
20
|
+
description: z.string().optional(),
|
|
21
|
+
nodes: z.array(z.object({ id: z.string(), type: z.string(), label: z.string(), config: z.record(z.unknown()), position: z.object({ x: z.number(), y: z.number() }) }).passthrough()),
|
|
22
|
+
edges: z.array(z.object({ id: z.string(), source: z.string(), target: z.string() }).passthrough()),
|
|
23
|
+
variables: z.array(z.unknown()).optional(),
|
|
24
|
+
tags: z.array(z.string()).optional(),
|
|
25
|
+
scope: z.enum(['project', 'global']).optional(),
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
global: z.boolean().optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export { createWorkflowMcpServer } from '../apps/workflow/mcp.js';
|
|
31
|
+
|
|
32
|
+
export const router: Router = Router();
|
|
33
|
+
|
|
34
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const builderStore = WorkflowStore.getInstance();
|
|
37
|
+
const runManager = RunManager.getInstance();
|
|
38
|
+
|
|
39
|
+
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// GET /workflows — returns saved graph workflows scoped to the active project
|
|
42
|
+
router.get('/workflows', async (req: Request, res: Response) => {
|
|
43
|
+
try {
|
|
44
|
+
const projectId = (req.query.projectId as string | undefined) ?? getActiveProject()?.id;
|
|
45
|
+
const workflows = await builderStore.list(projectId).catch(() => []);
|
|
46
|
+
res.json(workflows);
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// GET /workflows/:id — get full graph workflow by ID
|
|
53
|
+
router.get('/workflows/:id', async (req: Request, res: Response) => {
|
|
54
|
+
try {
|
|
55
|
+
const workflow = await builderStore.get(req.params.id);
|
|
56
|
+
if (!workflow) { res.status(404).json({ error: 'Workflow not found' }); return; }
|
|
57
|
+
res.json(workflow);
|
|
58
|
+
} catch (err: unknown) {
|
|
59
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// POST /workflows — create new graph workflow
|
|
64
|
+
router.post('/workflows', async (req: Request, res: Response) => {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = createWorkflowSchema.safeParse(req.body);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { name, description, nodes, edges, variables, tags, scope, enabled, global: isGlobal } = parsed.data;
|
|
73
|
+
|
|
74
|
+
const workflow = await builderStore.save({
|
|
75
|
+
name,
|
|
76
|
+
description,
|
|
77
|
+
version: 1,
|
|
78
|
+
nodes: nodes as WorkflowNode[],
|
|
79
|
+
edges: edges as WorkflowEdge[],
|
|
80
|
+
variables: (variables ?? []) as any,
|
|
81
|
+
tags: tags ?? [],
|
|
82
|
+
scope,
|
|
83
|
+
enabled,
|
|
84
|
+
global: isGlobal,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
res.status(201).json(workflow);
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// PUT /workflows/:id — update graph workflow
|
|
94
|
+
router.put('/workflows/:id', async (req: Request, res: Response) => {
|
|
95
|
+
try {
|
|
96
|
+
const existing = await builderStore.get(req.params.id);
|
|
97
|
+
if (!existing) { res.status(404).json({ error: 'Workflow not found' }); return; }
|
|
98
|
+
|
|
99
|
+
const { name, description, nodes, edges, variables, tags, scope, enabled } = req.body;
|
|
100
|
+
const isGlobal: boolean | undefined = req.body.global;
|
|
101
|
+
|
|
102
|
+
const workflow = await builderStore.save({
|
|
103
|
+
id: req.params.id,
|
|
104
|
+
name: name ?? existing.name,
|
|
105
|
+
description: description ?? existing.description,
|
|
106
|
+
version: (existing.version ?? 0) + 1,
|
|
107
|
+
projectId: existing.projectId,
|
|
108
|
+
nodes: nodes ?? existing.nodes,
|
|
109
|
+
edges: edges ?? existing.edges,
|
|
110
|
+
variables: variables ?? existing.variables,
|
|
111
|
+
tags: tags ?? existing.tags,
|
|
112
|
+
scope,
|
|
113
|
+
enabled: enabled ?? existing.enabled,
|
|
114
|
+
global: isGlobal ?? existing.global,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
res.json(workflow);
|
|
118
|
+
} catch (err: unknown) {
|
|
119
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// DELETE /workflows/:id — delete graph workflow
|
|
124
|
+
router.delete('/workflows/:id', async (req: Request, res: Response) => {
|
|
125
|
+
try {
|
|
126
|
+
const deleted = await builderStore.delete(req.params.id);
|
|
127
|
+
if (deleted) { res.json({ ok: true }); return; }
|
|
128
|
+
res.status(404).json({ error: 'Workflow not found' });
|
|
129
|
+
} catch (err: unknown) {
|
|
130
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// GET /instructions — get compiled workflow instructions as markdown
|
|
135
|
+
router.get('/instructions', async (req: Request, res: Response) => {
|
|
136
|
+
try {
|
|
137
|
+
const projectId = req.query.projectId as string | undefined;
|
|
138
|
+
const markdown = await builderStore.getCompiledInstructions(projectId);
|
|
139
|
+
res.setHeader('Content-Type', 'text/markdown');
|
|
140
|
+
res.send(markdown);
|
|
141
|
+
} catch (err: unknown) {
|
|
142
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// POST /workflows/:id/run — run a workflow
|
|
147
|
+
router.post('/workflows/:id/run', async (req: Request, res: Response) => {
|
|
148
|
+
try {
|
|
149
|
+
const workflow = await builderStore.get(req.params.id);
|
|
150
|
+
if (!workflow) { res.status(404).json({ error: 'Workflow not found' }); return; }
|
|
151
|
+
const { triggerPayload } = req.body ?? {};
|
|
152
|
+
const runId = runManager.startRun(workflow, triggerPayload);
|
|
153
|
+
res.json({ runId });
|
|
154
|
+
} catch (err: unknown) {
|
|
155
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// GET /runs — list all runs
|
|
160
|
+
router.get('/runs', (_req: Request, res: Response) => {
|
|
161
|
+
const runs = runManager.listRuns().map((r) => ({
|
|
162
|
+
id: r.id,
|
|
163
|
+
workflowId: r.workflowId,
|
|
164
|
+
workflowName: r.workflowName,
|
|
165
|
+
startedAt: r.startedAt,
|
|
166
|
+
status: r.status,
|
|
167
|
+
}));
|
|
168
|
+
res.json(runs);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// GET /runs/:id/stream — SSE stream for a run
|
|
172
|
+
router.get('/runs/:id/stream', (req: Request, res: Response) => {
|
|
173
|
+
const run = runManager.getRun(req.params.id);
|
|
174
|
+
if (!run) { res.status(404).json({ error: 'Run not found' }); return; }
|
|
175
|
+
|
|
176
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
177
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
178
|
+
res.setHeader('Connection', 'keep-alive');
|
|
179
|
+
res.flushHeaders();
|
|
180
|
+
|
|
181
|
+
res.write(`data: ${JSON.stringify({ type: 'snapshot', run: { id: run.id, workflowId: run.workflowId, workflowName: run.workflowName, startedAt: run.startedAt, status: run.status, events: run.events } })}\n\n`);
|
|
182
|
+
|
|
183
|
+
if (run.status !== 'running') { res.end(); return; }
|
|
184
|
+
|
|
185
|
+
runManager.addClient(req.params.id, res);
|
|
186
|
+
req.on('close', () => { runManager.removeClient(req.params.id, res); });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// POST /runs/:id/cancel — cancel a run
|
|
190
|
+
router.post('/runs/:id/cancel', (req: Request, res: Response) => {
|
|
191
|
+
const cancelled = runManager.cancelRun(req.params.id);
|
|
192
|
+
if (!cancelled) { res.status(404).json({ error: 'Run not found' }); return; }
|
|
193
|
+
res.json({ ok: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// GET /node-types — registered node type names
|
|
197
|
+
router.get('/node-types', (_req: Request, res: Response) => {
|
|
198
|
+
res.json(getRegisteredTypes());
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// POST /validate — validate a workflow graph
|
|
202
|
+
router.post('/validate', (req: Request, res: Response) => {
|
|
203
|
+
const { nodes, edges } = req.body as { nodes?: WorkflowNode[]; edges?: WorkflowEdge[] };
|
|
204
|
+
|
|
205
|
+
if (!nodes || !Array.isArray(nodes)) {
|
|
206
|
+
res.status(400).json({ valid: false, errors: ['nodes must be an array'] });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!edges || !Array.isArray(edges)) {
|
|
210
|
+
res.status(400).json({ valid: false, errors: ['edges must be an array'] });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
res.json(validateWorkflowGraph(nodes, edges));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
export function initWorkflow(): void {
|
|
220
|
+
registerAllExecutors();
|
|
221
|
+
|
|
222
|
+
// Wire concrete service implementations for executor dependency injection.
|
|
223
|
+
// This centralizes cross-app wiring in one place instead of having executors
|
|
224
|
+
// directly import singletons from other apps.
|
|
225
|
+
const services: ExecutorServices = {
|
|
226
|
+
test: {
|
|
227
|
+
submitScenario: (data) => ScenarioManager.getInstance().submitScenario(data),
|
|
228
|
+
getSavedScenario: (id) => ScenarioStore.getInstance().get(id),
|
|
229
|
+
markRun: (id) => ScenarioStore.getInstance().markRun(id),
|
|
230
|
+
saveScenario: (data) => ScenarioStore.getInstance().save(data),
|
|
231
|
+
listSaved: () => ScenarioStore.getInstance().list(),
|
|
232
|
+
},
|
|
233
|
+
workflow: {
|
|
234
|
+
getWorkflow: (id) => builderStore.get(id),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
runManager.setServices(services);
|
|
238
|
+
runManager.startCleanup();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function shutdownWorkflow(): void {
|
|
242
|
+
runManager.shutdown();
|
|
243
|
+
}
|