@actiondock/core 2.0.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 (43) hide show
  1. package/README.md +50 -0
  2. package/package.json +51 -0
  3. package/src/build/builder.ts +205 -0
  4. package/src/build/index.ts +2 -0
  5. package/src/build/templates.ts +59 -0
  6. package/src/doctor/doctor.ts +332 -0
  7. package/src/doctor/index.ts +2 -0
  8. package/src/doctor/types.ts +25 -0
  9. package/src/export/index.ts +2 -0
  10. package/src/export/skill.ts +349 -0
  11. package/src/export/templates.ts +258 -0
  12. package/src/filter/index.ts +1 -0
  13. package/src/filter/intent.ts +154 -0
  14. package/src/index.ts +13 -0
  15. package/src/profile/client.ts +302 -0
  16. package/src/profile/index.ts +3 -0
  17. package/src/profile/manager.ts +341 -0
  18. package/src/profile/types.ts +71 -0
  19. package/src/project/index.ts +3 -0
  20. package/src/project/init.ts +194 -0
  21. package/src/project/loader.ts +382 -0
  22. package/src/project/types.ts +62 -0
  23. package/src/registry/index.ts +2 -0
  24. package/src/registry/registry.ts +703 -0
  25. package/src/registry/types.ts +127 -0
  26. package/src/runtime/context.ts +232 -0
  27. package/src/runtime/env.ts +172 -0
  28. package/src/runtime/execution-manager.ts +74 -0
  29. package/src/runtime/index.ts +5 -0
  30. package/src/runtime/runner.ts +368 -0
  31. package/src/runtime/standalone.ts +429 -0
  32. package/src/schema/validator.ts +61 -0
  33. package/src/server/body.ts +112 -0
  34. package/src/server/index.ts +6 -0
  35. package/src/server/runtime-registry.ts +80 -0
  36. package/src/server/security.ts +115 -0
  37. package/src/server/server.ts +572 -0
  38. package/src/server/types.ts +42 -0
  39. package/src/storage/index.ts +64 -0
  40. package/src/storage/mask.ts +34 -0
  41. package/src/storage/sqlite.ts +578 -0
  42. package/src/storage/types.ts +111 -0
  43. package/src/utils/index.ts +60 -0
@@ -0,0 +1,115 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * 恒定时间字符串比较(Constant-time comparison)。
5
+ * 使用底层的 `crypto.timingSafeEqual` 防范时序攻击(Timing Attack)。
6
+ *
7
+ * @param a 字符串 A
8
+ * @param b 字符串 B
9
+ * @returns 两个字符串内容是否完全一致
10
+ */
11
+ export function safeEqual(a: string, b: string): boolean {
12
+ const aa = Buffer.from(a);
13
+ const bb = Buffer.from(b);
14
+
15
+ if (aa.length !== bb.length) {
16
+ return false;
17
+ }
18
+
19
+ return timingSafeEqual(aa, bb);
20
+ }
21
+
22
+ /**
23
+ * 检查指定的主机地址是否为本地回环接口(Loopback Host)。
24
+ * 支持 127.0.0.1, localhost, ::1 等形式。
25
+ *
26
+ * @param host 主机名或 IP 字符串
27
+ */
28
+ export function isLoopbackHost(host: string): boolean {
29
+ const trimmed = host.trim().toLowerCase();
30
+ return (
31
+ trimmed === "127.0.0.1" ||
32
+ trimmed === "::1" ||
33
+ trimmed === "localhost" ||
34
+ trimmed === "0:0:0:0:0:0:0:1"
35
+ );
36
+ }
37
+
38
+ /**
39
+ * 校验 HTTP 请求中的鉴权令牌是否有效。
40
+ *
41
+ * 支持两种凭证携带模式(均通过恒定时间对比):
42
+ * 1. HTTP 请求头: `Authorization: Bearer <token>`
43
+ * 2. URL 查询参数: `?token=<token>`(便于浏览器/Web 调试)
44
+ *
45
+ * @param req 传入的 HTTP Request 对象
46
+ * @param expectedToken 服务端预期的正确 Token(若未配置 Token 则默认放行)
47
+ * @returns 是否鉴权成功
48
+ */
49
+ export function verifyBearerToken(req: Request, expectedToken?: string): boolean {
50
+ if (!expectedToken || !expectedToken.trim()) {
51
+ return true;
52
+ }
53
+
54
+ const trimmedExpected = expectedToken.trim();
55
+
56
+ // 1. 请求头 Authorization: Bearer <token>
57
+ const authHeader = req.headers.get("authorization");
58
+ if (authHeader && authHeader.startsWith("Bearer ")) {
59
+ const token = authHeader.slice(7).trim();
60
+ if (safeEqual(token, trimmedExpected)) {
61
+ return true;
62
+ }
63
+ }
64
+
65
+ // 2. URL 查询参数: ?token=<token>
66
+ try {
67
+ const url = new URL(req.url);
68
+ const tokenParam = url.searchParams.get("token");
69
+ if (tokenParam && safeEqual(tokenParam.trim(), trimmedExpected)) {
70
+ return true;
71
+ }
72
+ } catch {
73
+ // 畸形 URL 视作鉴权失败
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * 根据配置的 CORS 白名单解析并返回跨域响应头。
81
+ * 默认情况下若未配置 corsOrigins,返回空对象(即默认关闭跨域访问,防范 CSRF)。
82
+ *
83
+ * @param origin 请求头中的 Origin 字段
84
+ * @param allowedOrigins 允许跨域的白名单来源数组
85
+ * @returns 包含 Access-Control-* 的响应头对象
86
+ */
87
+ export function resolveCorsHeaders(
88
+ origin: string | null,
89
+ allowedOrigins?: string[]
90
+ ): Record<string, string> {
91
+ if (!allowedOrigins || allowedOrigins.length === 0) {
92
+ return {};
93
+ }
94
+
95
+ const hasWildcard = allowedOrigins.includes("*");
96
+ if (hasWildcard) {
97
+ return {
98
+ "Access-Control-Allow-Origin": "*",
99
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
100
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
101
+ Vary: "Origin",
102
+ };
103
+ }
104
+
105
+ if (origin && allowedOrigins.includes(origin)) {
106
+ return {
107
+ "Access-Control-Allow-Origin": origin,
108
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
109
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
110
+ Vary: "Origin",
111
+ };
112
+ }
113
+
114
+ return {};
115
+ }
@@ -0,0 +1,572 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { filterByIntent } from "../filter";
5
+ import {
6
+ findProjectRoot,
7
+ loadActions,
8
+ loadPlaybooks,
9
+ loadProjectConfig,
10
+ } from "../project/loader";
11
+ import { listLinkedPackages, resolveActionProject } from "../registry/registry";
12
+ import { ActionRunner } from "../runtime/runner";
13
+ import { InvalidJsonError, readJsonBody, RequestTooLargeError } from "./body";
14
+ import { ServerRuntimeRegistry } from "./runtime-registry";
15
+ import { isLoopbackHost, resolveCorsHeaders, verifyBearerToken } from "./security";
16
+ import type { ActionDockServerInstance, ServerOptions } from "./types";
17
+
18
+ /**
19
+ * 辅助函数:快速构造带 CORS 头的 JSON HTTP 响应。
20
+ */
21
+ function jsonResponse(
22
+ data: unknown,
23
+ status = 200,
24
+ corsHeaders: Record<string, string> = {}
25
+ ): Response {
26
+ return new Response(JSON.stringify(data, null, 2), {
27
+ status,
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ ...corsHeaders,
31
+ },
32
+ });
33
+ }
34
+
35
+ /**
36
+ * 跨内存活跃连接与所有已知持久化存储全局检索指定 runId 的运行记录。
37
+ */
38
+ function findRunAcrossStorages(
39
+ runId: string,
40
+ runtimeRegistry: ServerRuntimeRegistry,
41
+ projectRoot?: string | null,
42
+ customHome?: string
43
+ ) {
44
+ const inMemory = runtimeRegistry.findRun(runId);
45
+ if (inMemory) return inMemory;
46
+
47
+ if (projectRoot) {
48
+ try {
49
+ const config = loadProjectConfig(projectRoot);
50
+ const storage = runtimeRegistry.getStorage(config.id, projectRoot);
51
+ const run = storage.getRun(runId);
52
+ if (run) return { storage, run };
53
+ } catch {
54
+ // 忽略读取错误
55
+ }
56
+ }
57
+
58
+ try {
59
+ const linked = listLinkedPackages(customHome);
60
+ for (const pkg of linked) {
61
+ if (!existsSync(pkg.path)) continue;
62
+ const storage = runtimeRegistry.getStorage(pkg.id, pkg.path);
63
+ const run = storage.getRun(runId);
64
+ if (run) return { storage, run };
65
+ }
66
+ } catch {
67
+ // 忽略读取错误
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * 启动 ActionDock 2.0 原生轻量级 HTTP Runner 服务端。
75
+ *
76
+ * 提供 RESTful API 供跨机器或远端 AI Agent 执行 Action:
77
+ * - `GET /api/v1/health` : 健康检查与就绪状态
78
+ * - `GET /api/v1/info` : 项目元数据、Action 与 Playbook 概览
79
+ * - `GET /api/v1/actions`: 列出可用 Actions(支持 ?intent= 过滤)
80
+ * - `GET /api/v1/actions/:id`: 查看单个 Action 的 Schema 详情
81
+ * - `POST /api/v1/actions/:id/run`: 同步或异步(202 Accepted)执行 Action
82
+ * - `GET /api/v1/runs/:runId`: 查询历史或异步任务运行状态
83
+ * - `POST /api/v1/runs/:runId/cancel`: 中断在途任务
84
+ *
85
+ * @param options 服务端启动配置参数
86
+ * @returns ActionDockServerInstance 服务端实例句柄
87
+ */
88
+ export function startActionDockServer(
89
+ options: ServerOptions = {}
90
+ ): ActionDockServerInstance {
91
+ const port = options.port ?? 5177;
92
+ const host = options.host ?? "127.0.0.1";
93
+ const token = options.token;
94
+ const customHome = options.customHome;
95
+ const projectRoot = options.projectRoot
96
+ ? resolve(options.projectRoot)
97
+ : findProjectRoot(process.cwd());
98
+
99
+ // 非回环地址强制要求配置 Token 鉴权(防裸奔)
100
+ if (!isLoopbackHost(host) && !token && !options.allowInsecureNoAuth) {
101
+ throw new Error(
102
+ "Authentication token is required when binding to a non-loopback address. Use --allow-insecure-no-auth to override."
103
+ );
104
+ }
105
+
106
+ const runtimeRegistry = new ServerRuntimeRegistry();
107
+
108
+ const server = Bun.serve({
109
+ port,
110
+ hostname: host,
111
+ async fetch(req) {
112
+ const origin = req.headers.get("origin");
113
+ const corsHeaders = resolveCorsHeaders(origin, options.corsOrigins);
114
+
115
+ if (req.method === "OPTIONS") {
116
+ return new Response(null, {
117
+ status: 204,
118
+ headers: corsHeaders,
119
+ });
120
+ }
121
+
122
+ const url = new URL(req.url);
123
+ const pathname = url.pathname;
124
+
125
+ // 1. Health Check (supports /api/v1/health and /health)
126
+ if (pathname === "/api/v1/health" || pathname === "/health") {
127
+ if (!verifyBearerToken(req, token)) {
128
+ return jsonResponse(
129
+ {
130
+ ok: false,
131
+ error: {
132
+ code: "UNAUTHORIZED",
133
+ message: "Invalid or missing Bearer token",
134
+ },
135
+ },
136
+ 401,
137
+ corsHeaders
138
+ );
139
+ }
140
+ const healthData: Record<string, unknown> = {
141
+ status: "ok",
142
+ version: "2.0.0",
143
+ timestamp: new Date().toISOString(),
144
+ uptime: process.uptime(),
145
+ };
146
+ if (options.exposeDebugInfo && projectRoot) {
147
+ healthData.projectRoot = projectRoot;
148
+ }
149
+ return jsonResponse(healthData, 200, corsHeaders);
150
+ }
151
+
152
+ // Check authentication for remaining endpoints
153
+ if (!verifyBearerToken(req, token)) {
154
+ return jsonResponse(
155
+ {
156
+ ok: false,
157
+ error: {
158
+ code: "UNAUTHORIZED",
159
+ message: "Invalid or missing Bearer token",
160
+ },
161
+ },
162
+ 401,
163
+ corsHeaders
164
+ );
165
+ }
166
+
167
+ // 2. Info: GET /api/v1/info
168
+ if (pathname === "/api/v1/info" && req.method === "GET") {
169
+ try {
170
+ if (projectRoot) {
171
+ const config = loadProjectConfig(projectRoot);
172
+ const actions = await loadActions(projectRoot, config.actionsDir);
173
+ const playbooks = loadPlaybooks(projectRoot, config.playbooksDir);
174
+ const infoData: Record<string, unknown> = {
175
+ ok: true,
176
+ id: config.id,
177
+ name: config.name,
178
+ version: config.version,
179
+ description: config.description,
180
+ actionsCount: actions.size,
181
+ playbooksCount: playbooks.size,
182
+ actions: Array.from(actions.keys()),
183
+ playbooks: Array.from(playbooks.keys()),
184
+ };
185
+ if (options.exposeDebugInfo) {
186
+ infoData.projectRoot = projectRoot;
187
+ }
188
+ return jsonResponse(infoData, 200, corsHeaders);
189
+ } else {
190
+ const linked = listLinkedPackages(customHome);
191
+ return jsonResponse(
192
+ {
193
+ ok: true,
194
+ version: "2.0.0",
195
+ linkedPackages: linked,
196
+ },
197
+ 200,
198
+ corsHeaders
199
+ );
200
+ }
201
+ } catch (err: any) {
202
+ return jsonResponse(
203
+ {
204
+ ok: false,
205
+ error: { code: "INFO_ERROR", message: err.message },
206
+ },
207
+ 500,
208
+ corsHeaders
209
+ );
210
+ }
211
+ }
212
+
213
+ // 3. Actions List: GET /api/v1/actions
214
+ if (pathname === "/api/v1/actions" && req.method === "GET") {
215
+ try {
216
+ const actionList: Array<{
217
+ id: string;
218
+ description: string;
219
+ packageId?: string;
220
+ }> = [];
221
+
222
+ if (projectRoot) {
223
+ const config = loadProjectConfig(projectRoot);
224
+ const actions = await loadActions(projectRoot, config.actionsDir);
225
+ for (const [id, a] of actions.entries()) {
226
+ actionList.push({
227
+ id,
228
+ description: a.description || "",
229
+ packageId: config.id,
230
+ });
231
+ }
232
+ }
233
+
234
+ const linked = listLinkedPackages(customHome);
235
+ for (const pkg of linked) {
236
+ if (projectRoot && pkg.path === projectRoot) continue;
237
+ if (!existsSync(pkg.path)) continue;
238
+ try {
239
+ const config = loadProjectConfig(pkg.path);
240
+ const actions = await loadActions(pkg.path, config.actionsDir);
241
+ for (const [id, a] of actions.entries()) {
242
+ actionList.push({
243
+ id,
244
+ description: a.description || "",
245
+ packageId: pkg.id,
246
+ });
247
+ }
248
+ } catch {
249
+ // Ignore broken package
250
+ }
251
+ }
252
+
253
+ const intent = url.searchParams.get("intent");
254
+ const filtered = intent
255
+ ? filterByIntent(
256
+ actionList,
257
+ intent,
258
+ [(a) => a.id, (a) => a.description, (a) => a.packageId],
259
+ false
260
+ )
261
+ : actionList;
262
+
263
+ return jsonResponse(filtered, 200, corsHeaders);
264
+ } catch (err: any) {
265
+ return jsonResponse(
266
+ {
267
+ ok: false,
268
+ error: { code: "ACTIONS_LIST_ERROR", message: err.message },
269
+ },
270
+ 500,
271
+ corsHeaders
272
+ );
273
+ }
274
+ }
275
+
276
+ // 4. Action Show: GET /api/v1/actions/:id
277
+ const actionShowMatch = pathname.match(/^\/api\/v1\/actions\/([^/]+)$/);
278
+ if (actionShowMatch && req.method === "GET") {
279
+ const actionId = decodeURIComponent(actionShowMatch[1]);
280
+ try {
281
+ const resolved = await resolveActionProject(
282
+ actionId,
283
+ projectRoot || process.cwd(),
284
+ customHome
285
+ );
286
+ const config = loadProjectConfig(resolved.projectRoot);
287
+ const actions = await loadActions(resolved.projectRoot, config.actionsDir);
288
+ const action = actions.get(resolved.actionId);
289
+ if (!action) {
290
+ return jsonResponse(
291
+ {
292
+ ok: false,
293
+ error: {
294
+ code: "ACTION_NOT_FOUND",
295
+ message: `Action '${resolved.actionId}' not found in package '${resolved.packageId}'`,
296
+ },
297
+ },
298
+ 404,
299
+ corsHeaders
300
+ );
301
+ }
302
+
303
+ return jsonResponse(
304
+ {
305
+ id: action.id,
306
+ packageId: resolved.packageId,
307
+ description: action.description || "",
308
+ inputSchema: action.inputSchema || null,
309
+ outputSchema: action.outputSchema || null,
310
+ },
311
+ 200,
312
+ corsHeaders
313
+ );
314
+ } catch (err: any) {
315
+ return jsonResponse(
316
+ {
317
+ ok: false,
318
+ error: { code: "ACTION_NOT_FOUND", message: err.message },
319
+ },
320
+ 404,
321
+ corsHeaders
322
+ );
323
+ }
324
+ }
325
+
326
+ // 5. Action Run: POST /api/v1/actions/:id/run
327
+ const actionRunMatch = pathname.match(/^\/api\/v1\/actions\/([^/]+)\/run$/);
328
+ if (actionRunMatch && req.method === "POST") {
329
+ const actionId = decodeURIComponent(actionRunMatch[1]);
330
+ let body: any = {};
331
+ try {
332
+ body = await readJsonBody(req, { maxBytes: options.maxBodyBytes });
333
+ } catch (err: any) {
334
+ if (err instanceof RequestTooLargeError) {
335
+ return jsonResponse(
336
+ {
337
+ ok: false,
338
+ runId: randomUUID(),
339
+ error: {
340
+ code: "REQUEST_TOO_LARGE",
341
+ message: err.message,
342
+ },
343
+ },
344
+ 413,
345
+ corsHeaders
346
+ );
347
+ }
348
+ if (err instanceof InvalidJsonError) {
349
+ return jsonResponse(
350
+ {
351
+ ok: false,
352
+ runId: randomUUID(),
353
+ error: {
354
+ code: "INVALID_JSON",
355
+ message: err.message,
356
+ },
357
+ },
358
+ 400,
359
+ corsHeaders
360
+ );
361
+ }
362
+ return jsonResponse(
363
+ {
364
+ ok: false,
365
+ runId: randomUUID(),
366
+ error: {
367
+ code: "INVALID_JSON",
368
+ message: `Failed to parse request body: ${err.message}`,
369
+ },
370
+ },
371
+ 400,
372
+ corsHeaders
373
+ );
374
+ }
375
+
376
+ try {
377
+ const resolved = await resolveActionProject(
378
+ actionId,
379
+ projectRoot || process.cwd(),
380
+ customHome
381
+ );
382
+ const config = loadProjectConfig(resolved.projectRoot);
383
+ const actions = await loadActions(resolved.projectRoot, config.actionsDir);
384
+ const storage = runtimeRegistry.getStorage(config.id, resolved.projectRoot);
385
+
386
+ const runner = new ActionRunner({
387
+ packageId: config.id,
388
+ storage,
389
+ projectConfig: config,
390
+ configOverrides: body?.config || {},
391
+ actions,
392
+ });
393
+
394
+ const executionMode = body?.execution?.mode || "sync";
395
+ const timeoutMs =
396
+ typeof body?.execution?.timeoutMs === "number" && body.execution.timeoutMs > 0
397
+ ? body.execution.timeoutMs
398
+ : undefined;
399
+
400
+ if (executionMode === "async") {
401
+ const handle = runner.start(resolved.actionId, body?.input || {}, {
402
+ timeoutMs,
403
+ });
404
+ runtimeRegistry.executionManager.register(handle);
405
+
406
+ return jsonResponse(
407
+ {
408
+ ok: true,
409
+ runId: handle.runId,
410
+ status: "running",
411
+ },
412
+ 202,
413
+ corsHeaders
414
+ );
415
+ }
416
+
417
+ // Sync execution mode
418
+ const handle = runner.start(resolved.actionId, body?.input || {}, {
419
+ signal: req.signal,
420
+ timeoutMs,
421
+ });
422
+ runtimeRegistry.executionManager.register(handle);
423
+ const result = await handle.result;
424
+
425
+ return jsonResponse(result, 200, corsHeaders);
426
+ } catch (err: any) {
427
+ return jsonResponse(
428
+ {
429
+ ok: false,
430
+ runId: randomUUID(),
431
+ error: {
432
+ code: "ACTION_EXECUTION_ERROR",
433
+ message: err.message || String(err),
434
+ },
435
+ },
436
+ 500,
437
+ corsHeaders
438
+ );
439
+ }
440
+ }
441
+
442
+ // 6. Run Show: GET /api/v1/runs/:runId
443
+ const runShowMatch = pathname.match(/^\/api\/v1\/runs\/([^/]+)$/);
444
+ if (runShowMatch && req.method === "GET") {
445
+ const runId = decodeURIComponent(runShowMatch[1]);
446
+ const found = findRunAcrossStorages(runId, runtimeRegistry, projectRoot, customHome);
447
+
448
+ if (!found) {
449
+ return jsonResponse(
450
+ {
451
+ ok: false,
452
+ error: {
453
+ code: "RUN_NOT_FOUND",
454
+ message: `Run '${runId}' not found`,
455
+ },
456
+ },
457
+ 404,
458
+ corsHeaders
459
+ );
460
+ }
461
+
462
+ return jsonResponse(found.run, 200, corsHeaders);
463
+ }
464
+
465
+ // 7. Run Cancel: POST /api/v1/runs/:runId/cancel
466
+ const runCancelMatch = pathname.match(/^\/api\/v1\/runs\/([^/]+)\/cancel$/);
467
+ if (runCancelMatch && req.method === "POST") {
468
+ const runId = decodeURIComponent(runCancelMatch[1]);
469
+ let body: any = {};
470
+ try {
471
+ body = await readJsonBody(req, { maxBytes: options.maxBodyBytes });
472
+ } catch {
473
+ // Body is optional
474
+ }
475
+
476
+ const reason = body?.reason || "Cancelled by client request";
477
+
478
+ // 1. Try cancelling in-memory active handle
479
+ const activeHandle = runtimeRegistry.executionManager.get(runId);
480
+ if (activeHandle) {
481
+ const cancelled = runtimeRegistry.executionManager.cancel(runId, reason);
482
+ if (cancelled) {
483
+ return jsonResponse(
484
+ {
485
+ ok: true,
486
+ runId,
487
+ status: "cancelled",
488
+ },
489
+ 200,
490
+ corsHeaders
491
+ );
492
+ }
493
+ }
494
+
495
+ // 2. Check storage for run status
496
+ const found = findRunAcrossStorages(runId, runtimeRegistry, projectRoot, customHome);
497
+
498
+ if (!found) {
499
+ return jsonResponse(
500
+ {
501
+ ok: false,
502
+ error: {
503
+ code: "RUN_NOT_FOUND",
504
+ message: `Run '${runId}' not found`,
505
+ },
506
+ },
507
+ 404,
508
+ corsHeaders
509
+ );
510
+ }
511
+
512
+ const { storage, run } = found;
513
+ if (run.status === "success" || run.status === "failed" || run.status === "cancelled") {
514
+ return jsonResponse(
515
+ {
516
+ ok: false,
517
+ error: {
518
+ code: "RUN_ALREADY_FINISHED",
519
+ message: `Run '${runId}' has already finished with status '${run.status}'`,
520
+ },
521
+ },
522
+ 409,
523
+ corsHeaders
524
+ );
525
+ }
526
+
527
+ // Running in storage but no longer in memory
528
+ storage.updateRun(runId, "cancelled", undefined, {
529
+ code: "ACTION_CANCELLED",
530
+ message: reason,
531
+ });
532
+
533
+ return jsonResponse(
534
+ {
535
+ ok: true,
536
+ runId,
537
+ status: "cancelled",
538
+ },
539
+ 200,
540
+ corsHeaders
541
+ );
542
+ }
543
+
544
+ // 404 Not Found
545
+ return jsonResponse(
546
+ {
547
+ ok: false,
548
+ error: {
549
+ code: "NOT_FOUND",
550
+ message: `Route not found: ${req.method} ${pathname}`,
551
+ },
552
+ },
553
+ 404,
554
+ corsHeaders
555
+ );
556
+ },
557
+ });
558
+
559
+ const actualHost = host === "0.0.0.0" ? "127.0.0.1" : host;
560
+ const url = `http://${actualHost}:${server.port}`;
561
+
562
+ return {
563
+ port: server.port ?? port,
564
+ host,
565
+ url,
566
+ runtimeRegistry,
567
+ stop: () => {
568
+ runtimeRegistry.close();
569
+ server.stop(true);
570
+ },
571
+ };
572
+ }