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.
Files changed (38) hide show
  1. package/package.json +5 -1
  2. package/src/apps/kanban/src/index.ts +1 -1
  3. package/src/apps/log/.turbo/turbo-lint.log +2 -2
  4. package/src/apps/log/src/index.ts +1 -1
  5. package/src/apps/prompts/.turbo/turbo-lint.log +3 -4
  6. package/src/apps/prompts/src/index.ts +1 -1
  7. package/src/apps/shell/.turbo/turbo-lint.log +5 -5
  8. package/src/apps/shell/src/index.ts +1 -1
  9. package/src/apps/test/.turbo/turbo-lint.log +2 -2
  10. package/src/apps/test/src/index.ts +1 -1
  11. package/src/apps/vocabulary/.turbo/turbo-lint.log +3 -4
  12. package/src/apps/vocabulary/src/index.ts +1 -1
  13. package/src/apps/voice/.turbo/turbo-lint.log +2 -2
  14. package/src/apps/voice/src/index.ts +1 -1
  15. package/src/apps/workflow/.turbo/turbo-lint.log +3 -4
  16. package/src/apps/workflow/src/index.ts +1 -1
  17. package/src/project-context.ts +36 -0
  18. package/src/public/app.js +701 -0
  19. package/src/public/favicon.svg +7 -0
  20. package/src/public/index.html +78 -0
  21. package/src/public/state.js +84 -0
  22. package/src/public/style.css +1213 -0
  23. package/src/routers/coder.ts +157 -0
  24. package/src/routers/dashboard.ts +158 -0
  25. package/src/routers/kanban.ts +38 -0
  26. package/src/routers/log.ts +42 -0
  27. package/src/routers/prompts.ts +134 -0
  28. package/src/routers/shell/index.ts +47 -0
  29. package/src/routers/shell/pty-manager.ts +107 -0
  30. package/src/routers/shell/shell-config.ts +38 -0
  31. package/src/routers/shell/shell-routes.ts +108 -0
  32. package/src/routers/shell/shell-socket.ts +321 -0
  33. package/src/routers/shell/shell-state.ts +59 -0
  34. package/src/routers/test.ts +254 -0
  35. package/src/routers/vocabulary.ts +149 -0
  36. package/src/routers/voice.ts +10 -0
  37. package/src/routers/workflow.ts +243 -0
  38. 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
+ }