codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Workflow API route handlers
3
+ */
4
+
5
+ import {
6
+ createWorkflowDb,
7
+ type WorkflowDb,
8
+ type WorkflowExecution,
9
+ type WorkflowExecutionStatus,
10
+ } from "../db/workflowDb";
11
+ import { parseWorkflow } from "../workflows/workflowParser";
12
+ import { WorkflowRunner } from "../workflows/workflowRunner";
13
+ import { validateWorkflow } from "../workflows/workflowValidator";
14
+ import type { RouteContext } from "./routes";
15
+
16
+ const activeWorkflowRunners = new Map<string, WorkflowRunner>();
17
+ const EXECUTION_TRACK_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
18
+ type CancellationMode = "runner" | "fallback";
19
+ const VALID_WORKFLOW_EXECUTION_STATUSES = new Set<WorkflowExecutionStatus>([
20
+ "pending",
21
+ "running",
22
+ "completed",
23
+ "failed",
24
+ "cancelled",
25
+ ]);
26
+
27
+ interface CancellationMetrics {
28
+ runner: number;
29
+ fallback: number;
30
+ runnerErrors: number;
31
+ }
32
+
33
+ const cancellationMetrics: CancellationMetrics = {
34
+ runner: 0,
35
+ fallback: 0,
36
+ runnerErrors: 0,
37
+ };
38
+
39
+ function recordCancellation(mode: CancellationMode): void {
40
+ cancellationMetrics[mode] += 1;
41
+
42
+ if (mode !== "fallback") {
43
+ return;
44
+ }
45
+
46
+ const total = cancellationMetrics.runner + cancellationMetrics.fallback;
47
+ const fallbackRate = total > 0 ? cancellationMetrics.fallback / total : 0;
48
+
49
+ // Emit periodic alert when cancellation relies heavily on DB-only fallback path.
50
+ if (
51
+ cancellationMetrics.fallback === 1 ||
52
+ (fallbackRate >= 0.25 && cancellationMetrics.fallback % 5 === 0)
53
+ ) {
54
+ console.warn(
55
+ `[workflow] cancellation fallback path used ${cancellationMetrics.fallback}/${total} times (${(fallbackRate * 100).toFixed(1)}%). Check runner lifecycle tracking.`
56
+ );
57
+ }
58
+ }
59
+
60
+ function getCancellationMetricsSnapshot(): CancellationMetrics {
61
+ return { ...cancellationMetrics };
62
+ }
63
+
64
+ function withCancellationMeta(context: unknown, mode: CancellationMode): Record<string, any> {
65
+ const base =
66
+ context && typeof context === "object" && !Array.isArray(context)
67
+ ? { ...(context as Record<string, any>) }
68
+ : {};
69
+
70
+ const existingMeta =
71
+ base.__codepiperMeta &&
72
+ typeof base.__codepiperMeta === "object" &&
73
+ !Array.isArray(base.__codepiperMeta)
74
+ ? (base.__codepiperMeta as Record<string, any>)
75
+ : {};
76
+
77
+ base.__codepiperMeta = {
78
+ ...existingMeta,
79
+ cancellation: {
80
+ mode,
81
+ timestamp: new Date().toISOString(),
82
+ },
83
+ };
84
+
85
+ return base;
86
+ }
87
+
88
+ async function trackExecutionLifecycle(executionId: string, runner: WorkflowRunner): Promise<void> {
89
+ try {
90
+ await runner.waitForCompletion(executionId, EXECUTION_TRACK_TIMEOUT_MS);
91
+ } catch (error) {
92
+ // Timeout or unexpected runner error should not crash route handling.
93
+ console.warn(`[workflow] execution tracking ended for ${executionId}:`, error);
94
+ } finally {
95
+ activeWorkflowRunners.delete(executionId);
96
+ }
97
+ }
98
+
99
+ export function clearActiveWorkflowRunnersForTests(): void {
100
+ activeWorkflowRunners.clear();
101
+ cancellationMetrics.runner = 0;
102
+ cancellationMetrics.fallback = 0;
103
+ cancellationMetrics.runnerErrors = 0;
104
+ }
105
+
106
+ export function registerActiveWorkflowRunnerForTests(
107
+ executionId: string,
108
+ runner: WorkflowRunner
109
+ ): void {
110
+ activeWorkflowRunners.set(executionId, runner);
111
+ }
112
+
113
+ export function getWorkflowCancellationMetricsForTests(): CancellationMetrics {
114
+ return getCancellationMetricsSnapshot();
115
+ }
116
+
117
+ function resolveExecutionForRoute(
118
+ workflowDb: WorkflowDb,
119
+ executionId: string,
120
+ workflowId?: string
121
+ ): { execution: WorkflowExecution } | { response: Response } {
122
+ const execution = workflowDb.getExecution(executionId);
123
+ if (!execution) {
124
+ return {
125
+ response: Response.json({ error: `Execution not found: ${executionId}` }, { status: 404 }),
126
+ };
127
+ }
128
+
129
+ if (workflowId && execution.workflowId !== workflowId) {
130
+ return {
131
+ response: Response.json({ error: `Execution not found: ${executionId}` }, { status: 404 }),
132
+ };
133
+ }
134
+
135
+ return { execution };
136
+ }
137
+
138
+ async function getExecutionWithSteps(
139
+ ctx: RouteContext,
140
+ executionId: string,
141
+ workflowId?: string
142
+ ): Promise<Response> {
143
+ try {
144
+ const workflowDb = createWorkflowDb(ctx.db);
145
+ const resolved = resolveExecutionForRoute(workflowDb, executionId, workflowId);
146
+ if ("response" in resolved) {
147
+ return resolved.response;
148
+ }
149
+
150
+ const steps = workflowDb.getSteps(executionId);
151
+ return Response.json({ execution: resolved.execution, steps });
152
+ } catch (error) {
153
+ return Response.json(
154
+ { error: error instanceof Error ? error.message : "Unknown error" },
155
+ { status: 500 }
156
+ );
157
+ }
158
+ }
159
+
160
+ async function cancelExecutionById(
161
+ ctx: RouteContext,
162
+ executionId: string,
163
+ workflowId?: string
164
+ ): Promise<Response> {
165
+ try {
166
+ const workflowDb = createWorkflowDb(ctx.db);
167
+ const resolved = resolveExecutionForRoute(workflowDb, executionId, workflowId);
168
+ if ("response" in resolved) {
169
+ return resolved.response;
170
+ }
171
+ const execution = resolved.execution;
172
+
173
+ // Check if execution can be cancelled
174
+ if (
175
+ execution.status === "completed" ||
176
+ execution.status === "failed" ||
177
+ execution.status === "cancelled"
178
+ ) {
179
+ return Response.json(
180
+ { error: `Cannot cancel execution with status: ${execution.status}` },
181
+ { status: 400 }
182
+ );
183
+ }
184
+
185
+ let cancelMode: CancellationMode;
186
+ const runner = activeWorkflowRunners.get(executionId);
187
+ if (runner) {
188
+ try {
189
+ await runner.cancel(executionId);
190
+ activeWorkflowRunners.delete(executionId);
191
+ } catch (error) {
192
+ cancellationMetrics.runnerErrors += 1;
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ console.error(
195
+ `[workflow] failed to cancel execution ${executionId} via runner path: ${message}`
196
+ );
197
+ return Response.json(
198
+ { error: "Failed to cancel active workflow execution" },
199
+ { status: 500 }
200
+ );
201
+ }
202
+ cancelMode = "runner";
203
+ recordCancellation(cancelMode);
204
+ } else {
205
+ // Fallback for stale/missing in-memory runner references
206
+ // (e.g. daemon restart between execute and cancel).
207
+ workflowDb.updateExecution(executionId, {
208
+ status: "cancelled",
209
+ completedAt: new Date(),
210
+ });
211
+ cancelMode = "fallback";
212
+ recordCancellation(cancelMode);
213
+ }
214
+
215
+ workflowDb.updateExecution(executionId, {
216
+ context: withCancellationMeta(execution.context, cancelMode),
217
+ });
218
+ const metrics = getCancellationMetricsSnapshot();
219
+ console.info(
220
+ `[workflow] cancelled execution ${executionId} via ${cancelMode} path` +
221
+ ` (runner=${metrics.runner}, fallback=${metrics.fallback}, runnerErrors=${metrics.runnerErrors})`
222
+ );
223
+
224
+ return Response.json({ success: true, cancelMode });
225
+ } catch (error) {
226
+ return Response.json(
227
+ { error: error instanceof Error ? error.message : "Unknown error" },
228
+ { status: 500 }
229
+ );
230
+ }
231
+ }
232
+
233
+ /**
234
+ * POST /workflows - Create a new workflow
235
+ */
236
+ export async function handleCreateWorkflow(req: Request, ctx: RouteContext): Promise<Response> {
237
+ let body: any;
238
+ try {
239
+ body = await req.json();
240
+ } catch {
241
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
242
+ }
243
+
244
+ // Validate required fields
245
+ if (!body.id) {
246
+ return Response.json({ error: "Missing required field: id" }, { status: 400 });
247
+ }
248
+ if (!body.name) {
249
+ return Response.json({ error: "Missing required field: name" }, { status: 400 });
250
+ }
251
+ if (!body.definition) {
252
+ return Response.json({ error: "Missing required field: definition" }, { status: 400 });
253
+ }
254
+
255
+ if (
256
+ typeof body.definition !== "object" ||
257
+ body.definition === null ||
258
+ Array.isArray(body.definition)
259
+ ) {
260
+ return Response.json(
261
+ { error: "Invalid workflow definition: expected object" },
262
+ { status: 400 }
263
+ );
264
+ }
265
+
266
+ try {
267
+ const rawDefinition: Record<string, unknown> = {
268
+ ...(body.definition as Record<string, unknown>),
269
+ name: body.name,
270
+ };
271
+ if (body.description !== undefined && rawDefinition.description === undefined) {
272
+ rawDefinition.description = body.description;
273
+ }
274
+
275
+ const parsedDefinition = parseWorkflow(JSON.stringify(rawDefinition));
276
+ if (!parsedDefinition) {
277
+ return Response.json({ error: "Invalid workflow definition format" }, { status: 400 });
278
+ }
279
+
280
+ const validationErrors = validateWorkflow(parsedDefinition);
281
+ if (validationErrors.length > 0) {
282
+ return Response.json(
283
+ {
284
+ error: "Workflow definition validation failed",
285
+ validationErrors,
286
+ },
287
+ { status: 422 }
288
+ );
289
+ }
290
+
291
+ const workflowDb = createWorkflowDb(ctx.db);
292
+
293
+ workflowDb.createWorkflow({
294
+ id: body.id,
295
+ name: body.name,
296
+ description: body.description,
297
+ definition: parsedDefinition,
298
+ });
299
+
300
+ const workflow = workflowDb.getWorkflow(body.id);
301
+
302
+ return Response.json({ workflow }, { status: 201 });
303
+ } catch (error) {
304
+ const message = error instanceof Error ? error.message : "Unknown error";
305
+ if (message.includes("UNIQUE constraint failed")) {
306
+ return Response.json({ error: `Workflow already exists: ${body.id}` }, { status: 409 });
307
+ }
308
+
309
+ return Response.json({ error: message }, { status: 500 });
310
+ }
311
+ }
312
+
313
+ /**
314
+ * GET /workflows - List all workflows
315
+ */
316
+ export async function handleListWorkflows(_req: Request, ctx: RouteContext): Promise<Response> {
317
+ try {
318
+ const workflowDb = createWorkflowDb(ctx.db);
319
+ const workflows = workflowDb.listWorkflows();
320
+
321
+ return Response.json({ workflows });
322
+ } catch (error) {
323
+ return Response.json(
324
+ { error: error instanceof Error ? error.message : "Unknown error" },
325
+ { status: 500 }
326
+ );
327
+ }
328
+ }
329
+
330
+ /**
331
+ * GET /workflows/:id - Get a specific workflow
332
+ */
333
+ export async function handleGetWorkflow(
334
+ _req: Request,
335
+ ctx: RouteContext,
336
+ workflowId: string
337
+ ): Promise<Response> {
338
+ try {
339
+ const workflowDb = createWorkflowDb(ctx.db);
340
+ const workflow = workflowDb.getWorkflow(workflowId);
341
+
342
+ if (!workflow) {
343
+ return Response.json({ error: `Workflow not found: ${workflowId}` }, { status: 404 });
344
+ }
345
+
346
+ return Response.json({ workflow });
347
+ } catch (error) {
348
+ return Response.json(
349
+ { error: error instanceof Error ? error.message : "Unknown error" },
350
+ { status: 500 }
351
+ );
352
+ }
353
+ }
354
+
355
+ /**
356
+ * DELETE /workflows/:id - Delete a workflow
357
+ */
358
+ export async function handleDeleteWorkflow(
359
+ _req: Request,
360
+ ctx: RouteContext,
361
+ workflowId: string
362
+ ): Promise<Response> {
363
+ try {
364
+ const workflowDb = createWorkflowDb(ctx.db);
365
+
366
+ // Check if workflow exists
367
+ const workflow = workflowDb.getWorkflow(workflowId);
368
+ if (!workflow) {
369
+ return Response.json({ error: `Workflow not found: ${workflowId}` }, { status: 404 });
370
+ }
371
+
372
+ workflowDb.deleteWorkflow(workflowId);
373
+
374
+ return Response.json({ success: true });
375
+ } catch (error) {
376
+ return Response.json(
377
+ { error: error instanceof Error ? error.message : "Unknown error" },
378
+ { status: 500 }
379
+ );
380
+ }
381
+ }
382
+
383
+ /**
384
+ * POST /workflows/:id/execute - Start workflow execution
385
+ */
386
+ export async function handleExecuteWorkflow(
387
+ req: Request,
388
+ ctx: RouteContext,
389
+ workflowId: string
390
+ ): Promise<Response> {
391
+ let body: any;
392
+ try {
393
+ body = await req.json();
394
+ } catch {
395
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
396
+ }
397
+
398
+ try {
399
+ const variables = body.variables ?? {};
400
+ if (typeof variables !== "object" || variables === null || Array.isArray(variables)) {
401
+ return Response.json({ error: "Invalid variables: expected object" }, { status: 400 });
402
+ }
403
+
404
+ const workflowDb = createWorkflowDb(ctx.db);
405
+
406
+ // Check if workflow exists
407
+ const workflow = workflowDb.getWorkflow(workflowId);
408
+ if (!workflow) {
409
+ return Response.json({ error: `Workflow not found: ${workflowId}` }, { status: 404 });
410
+ }
411
+
412
+ // Generate execution ID
413
+ const executionId = crypto.randomUUID();
414
+
415
+ // Create execution record with pending status
416
+ workflowDb.createExecution({
417
+ id: executionId,
418
+ workflowId,
419
+ status: "pending",
420
+ context: variables,
421
+ });
422
+
423
+ // Start workflow execution in background
424
+ if (ctx.sessionManager) {
425
+ const runner = new WorkflowRunner({
426
+ sessionManager: ctx.sessionManager,
427
+ database: ctx.db,
428
+ eventBus: ctx.eventBus,
429
+ });
430
+
431
+ activeWorkflowRunners.set(executionId, runner);
432
+
433
+ // Run in background - don't await
434
+ runner
435
+ .start(workflow.definition, executionId, variables as Record<string, any>)
436
+ .then(() => {
437
+ void trackExecutionLifecycle(executionId, runner);
438
+ })
439
+ .catch((error) => {
440
+ console.error(`Workflow execution ${executionId} failed:`, error);
441
+ workflowDb.updateExecution(executionId, {
442
+ status: "failed",
443
+ errorMessage: error instanceof Error ? error.message : String(error),
444
+ });
445
+ activeWorkflowRunners.delete(executionId);
446
+ });
447
+ }
448
+
449
+ return Response.json(
450
+ {
451
+ executionId,
452
+ status: "pending",
453
+ },
454
+ { status: 202 }
455
+ );
456
+ } catch (error) {
457
+ return Response.json(
458
+ { error: error instanceof Error ? error.message : "Unknown error" },
459
+ { status: 500 }
460
+ );
461
+ }
462
+ }
463
+
464
+ /**
465
+ * GET /workflows/:id/executions - List executions for a workflow
466
+ */
467
+ export async function handleListExecutions(
468
+ req: Request,
469
+ ctx: RouteContext,
470
+ workflowId: string
471
+ ): Promise<Response> {
472
+ try {
473
+ const workflowDb = createWorkflowDb(ctx.db);
474
+
475
+ // Parse query parameters
476
+ const url = new URL(req.url);
477
+ const statusParam = url.searchParams.get("status");
478
+ const limitParam = url.searchParams.get("limit");
479
+
480
+ const options: any = {};
481
+
482
+ if (statusParam !== null) {
483
+ if (!VALID_WORKFLOW_EXECUTION_STATUSES.has(statusParam as WorkflowExecutionStatus)) {
484
+ return Response.json(
485
+ {
486
+ error:
487
+ "Invalid status query parameter. Must be one of: pending, running, completed, failed, cancelled",
488
+ },
489
+ { status: 422 }
490
+ );
491
+ }
492
+ options.status = statusParam;
493
+ }
494
+
495
+ if (limitParam !== null) {
496
+ if (!/^\d+$/.test(limitParam)) {
497
+ return Response.json(
498
+ { error: "Invalid limit query parameter. Must be a positive integer" },
499
+ { status: 422 }
500
+ );
501
+ }
502
+ const parsedLimit = Number.parseInt(limitParam, 10);
503
+ if (!Number.isInteger(parsedLimit) || parsedLimit <= 0) {
504
+ return Response.json(
505
+ { error: "Invalid limit query parameter. Must be a positive integer" },
506
+ { status: 422 }
507
+ );
508
+ }
509
+ options.limit = parsedLimit;
510
+ }
511
+
512
+ const executions = workflowDb.listExecutions(workflowId, options);
513
+
514
+ return Response.json({ executions });
515
+ } catch (error) {
516
+ return Response.json(
517
+ { error: error instanceof Error ? error.message : "Unknown error" },
518
+ { status: 500 }
519
+ );
520
+ }
521
+ }
522
+
523
+ /**
524
+ * GET /workflows/:id/executions/:execId - Get execution status
525
+ */
526
+ export async function handleGetExecution(
527
+ _req: Request,
528
+ ctx: RouteContext,
529
+ workflowId: string,
530
+ executionId: string
531
+ ): Promise<Response> {
532
+ return getExecutionWithSteps(ctx, executionId, workflowId);
533
+ }
534
+
535
+ /**
536
+ * GET /workflows/executions/:execId - Get execution status by execution ID
537
+ */
538
+ export async function handleGetExecutionById(
539
+ _req: Request,
540
+ ctx: RouteContext,
541
+ executionId: string
542
+ ): Promise<Response> {
543
+ return getExecutionWithSteps(ctx, executionId);
544
+ }
545
+
546
+ /**
547
+ * POST /workflows/:id/executions/:execId/cancel - Cancel execution
548
+ */
549
+ export async function handleCancelExecution(
550
+ _req: Request,
551
+ ctx: RouteContext,
552
+ workflowId: string,
553
+ executionId: string
554
+ ): Promise<Response> {
555
+ return cancelExecutionById(ctx, executionId, workflowId);
556
+ }
557
+
558
+ /**
559
+ * POST /workflows/executions/:execId/cancel - Cancel execution by execution ID
560
+ */
561
+ export async function handleCancelExecutionById(
562
+ _req: Request,
563
+ ctx: RouteContext,
564
+ executionId: string
565
+ ): Promise<Response> {
566
+ return cancelExecutionById(ctx, executionId);
567
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Workspace API route handlers
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import type { RouteContext } from "./routes";
7
+ import { hasAnyDefinedField, jsonError, parseJsonBody } from "./routeUtils";
8
+ import { getErrorMessage, isSqliteUniqueConstraintError } from "./sqliteErrors";
9
+
10
+ /**
11
+ * GET /workspaces - List all workspaces
12
+ */
13
+ export async function handleListWorkspaces(_req: Request, ctx: RouteContext): Promise<Response> {
14
+ const workspaces = ctx.db.listWorkspaces();
15
+ return Response.json({ workspaces });
16
+ }
17
+
18
+ /**
19
+ * POST /workspaces - Create a new workspace
20
+ */
21
+ export async function handleCreateWorkspace(req: Request, ctx: RouteContext): Promise<Response> {
22
+ const parsed = await parseJsonBody(req);
23
+ if (!parsed.ok) {
24
+ return parsed.response;
25
+ }
26
+ const body = parsed.body as any;
27
+
28
+ if (!body.name || typeof body.name !== "string") {
29
+ return jsonError(400, "Missing required field: name");
30
+ }
31
+ if (body.name.trim().length === 0) {
32
+ return jsonError(400, "Field 'name' must not be empty");
33
+ }
34
+ if (body.name.length > 100) {
35
+ return jsonError(400, "Name too long (max 100 chars)");
36
+ }
37
+ if (!body.path || typeof body.path !== "string") {
38
+ return jsonError(400, "Missing required field: path");
39
+ }
40
+ if (!body.path.startsWith("/")) {
41
+ return jsonError(400, "Path must be an absolute path");
42
+ }
43
+ if (!fs.existsSync(body.path)) {
44
+ return jsonError(400, `Directory does not exist: ${body.path}`);
45
+ }
46
+
47
+ const id = crypto.randomUUID();
48
+
49
+ try {
50
+ ctx.db.createWorkspace({ id, name: body.name, path: body.path });
51
+ const workspace = ctx.db.getWorkspace(id);
52
+ return Response.json({ workspace }, { status: 201 });
53
+ } catch (error) {
54
+ const msg = getErrorMessage(error);
55
+ if (isSqliteUniqueConstraintError(error)) {
56
+ return jsonError(409, `Workspace name already exists: ${body.name}`);
57
+ }
58
+ return jsonError(500, msg);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * GET /workspaces/:id - Get a specific workspace
64
+ */
65
+ export async function handleGetWorkspace(
66
+ _req: Request,
67
+ ctx: RouteContext,
68
+ workspaceId: string
69
+ ): Promise<Response> {
70
+ const workspace = ctx.db.getWorkspace(workspaceId);
71
+ if (!workspace) {
72
+ return jsonError(404, `Workspace not found: ${workspaceId}`);
73
+ }
74
+ return Response.json({ workspace });
75
+ }
76
+
77
+ /**
78
+ * PUT /workspaces/:id - Update a workspace
79
+ */
80
+ export async function handleUpdateWorkspace(
81
+ req: Request,
82
+ ctx: RouteContext,
83
+ workspaceId: string
84
+ ): Promise<Response> {
85
+ const parsed = await parseJsonBody(req);
86
+ if (!parsed.ok) {
87
+ return parsed.response;
88
+ }
89
+ const body = parsed.body as any;
90
+
91
+ const existing = ctx.db.getWorkspace(workspaceId);
92
+ if (!existing) {
93
+ return jsonError(404, `Workspace not found: ${workspaceId}`);
94
+ }
95
+
96
+ if (body.name !== undefined && typeof body.name !== "string") {
97
+ return jsonError(400, "name must be a string");
98
+ }
99
+ if (body.name !== undefined && body.name.trim().length === 0) {
100
+ return jsonError(400, "name must not be empty");
101
+ }
102
+ if (body.name !== undefined && body.name.length > 100) {
103
+ return jsonError(400, "Name too long (max 100 chars)");
104
+ }
105
+ if (body.path !== undefined) {
106
+ if (typeof body.path !== "string") {
107
+ return jsonError(400, "path must be a string");
108
+ }
109
+ if (!body.path.startsWith("/")) {
110
+ return jsonError(400, "Path must be an absolute path");
111
+ }
112
+ if (!fs.existsSync(body.path)) {
113
+ return jsonError(400, `Directory does not exist: ${body.path}`);
114
+ }
115
+ }
116
+ if (!hasAnyDefinedField(body, ["name", "path"])) {
117
+ return jsonError(422, "At least one field must be provided: name or path");
118
+ }
119
+
120
+ try {
121
+ ctx.db.updateWorkspace(workspaceId, {
122
+ name: body.name,
123
+ path: body.path,
124
+ });
125
+ const workspace = ctx.db.getWorkspace(workspaceId);
126
+ return Response.json({ workspace });
127
+ } catch (error) {
128
+ const msg = getErrorMessage(error);
129
+ if (isSqliteUniqueConstraintError(error)) {
130
+ return jsonError(409, `Workspace name already exists: ${body.name}`);
131
+ }
132
+ return jsonError(500, msg);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * DELETE /workspaces/:id - Delete a workspace
138
+ */
139
+ export async function handleDeleteWorkspace(
140
+ _req: Request,
141
+ ctx: RouteContext,
142
+ workspaceId: string
143
+ ): Promise<Response> {
144
+ const existing = ctx.db.getWorkspace(workspaceId);
145
+ if (!existing) {
146
+ return jsonError(404, `Workspace not found: ${workspaceId}`);
147
+ }
148
+
149
+ ctx.db.deleteWorkspace(workspaceId);
150
+ return Response.json({ success: true });
151
+ }