@actiondock/core 2.0.1 → 2.0.2

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.
@@ -1,15 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { filterByIntent } from "../filter";
4
+ import { runDoctorChecks } from "../doctor/doctor";
5
+ import { filterByIntent, filterWithFallbackInfo } from "../filter";
5
6
  import {
6
7
  findProjectRoot,
7
8
  loadActions,
8
9
  loadPlaybooks,
9
10
  loadProjectConfig,
10
11
  } from "../project/loader";
11
- import { listLinkedPackages, resolveActionProject } from "../registry/registry";
12
+ import {
13
+ getRegistryStatus,
14
+ listLinkedPackages,
15
+ resolveActionProject,
16
+ resolvePackageRoot,
17
+ resolvePlaybookProject,
18
+ } from "../registry/registry";
12
19
  import { ActionRunner } from "../runtime/runner";
20
+ import type { RuntimeStorage } from "../storage/types";
13
21
  import { InvalidJsonError, readJsonBody, RequestTooLargeError } from "./body";
14
22
  import { ServerRuntimeRegistry } from "./runtime-registry";
15
23
  import { isLoopbackHost, resolveCorsHeaders, verifyBearerToken } from "./security";
@@ -70,20 +78,83 @@ function findRunAcrossStorages(
70
78
  return null;
71
79
  }
72
80
 
81
+ /**
82
+ * 辅助函数:依据 package 参数或项目上下文解析目标 Storage 实例与根目录。
83
+ */
84
+ function resolveStorageForPackage(
85
+ packageIdOrPath: string | undefined,
86
+ runtimeRegistry: ServerRuntimeRegistry,
87
+ projectRoot?: string | null,
88
+ customHome?: string
89
+ ): { packageId: string; storage: RuntimeStorage; projectRoot?: string } {
90
+ if (packageIdOrPath) {
91
+ const root = resolvePackageRoot(packageIdOrPath, customHome);
92
+ if (root) {
93
+ const config = loadProjectConfig(root);
94
+ return {
95
+ packageId: config.id,
96
+ storage: runtimeRegistry.getStorage(config.id, root),
97
+ projectRoot: root,
98
+ };
99
+ }
100
+ return {
101
+ packageId: packageIdOrPath,
102
+ storage: runtimeRegistry.getStorage(packageIdOrPath),
103
+ };
104
+ }
105
+
106
+ if (projectRoot) {
107
+ const config = loadProjectConfig(projectRoot);
108
+ return {
109
+ packageId: config.id,
110
+ storage: runtimeRegistry.getStorage(config.id, projectRoot),
111
+ projectRoot,
112
+ };
113
+ }
114
+
115
+ const linked = listLinkedPackages(customHome);
116
+ if (linked.length > 0) {
117
+ const first = linked[0];
118
+ return {
119
+ packageId: first.id,
120
+ storage: runtimeRegistry.getStorage(first.id, first.path),
121
+ projectRoot: first.path,
122
+ };
123
+ }
124
+
125
+ return {
126
+ packageId: "default",
127
+ storage: runtimeRegistry.getStorage("default"),
128
+ };
129
+ }
130
+
73
131
  /**
74
132
  * 启动 ActionDock 2.0 原生轻量级 HTTP Runner 服务端。
75
133
  *
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 服务端实例句柄
134
+ * 全面暴露 RESTful 调度与运维接口:
135
+ * - GET /api/v1/health : 健康检查与就绪状态
136
+ * - GET /api/v1/info : 统一自省探索(支持 ?intent= 智能决议、?package= 详情下钻、?tree=true 工作区拓扑)
137
+ * - GET /api/v1/doctor : 深度环境与依赖诊断
138
+ * - GET /api/v1/actions : 列出可用 Actions(支持 ?intent= ?package=)
139
+ * - GET /api/v1/actions/:id : 查看单个 Action 的 Schema 详情
140
+ * - POST /api/v1/actions/:id/run : 同步或异步执行 Action
141
+ * - GET /api/v1/playbooks : 列出可用 Playbook 工作流
142
+ * - GET /api/v1/playbooks/:id : 查看 Playbook 步骤流程与 SOP 规程正文
143
+ * - GET /api/v1/runs : 多维检索历史运行记录
144
+ * - POST /api/v1/runs/clear : 批量清理历史运行记录
145
+ * - GET /api/v1/runs/:runId : 查询历史或异步任务运行状态
146
+ * - POST /api/v1/runs/:runId/cancel : 中断在途任务
147
+ * - GET /api/v1/runs/:runId/stream : 基于 SSE 实时长任务日志与进度流推送
148
+ * - GET /api/v1/state : 检索状态键名列表
149
+ * - GET /api/v1/state/:key : 获取指定状态值
150
+ * - PUT /api/v1/state/:key : 写入持久化状态(支持 TTL)
151
+ * - DELETE /api/v1/state/:key : 删除指定状态
152
+ * - POST /api/v1/state/clear : 清空状态数据
153
+ * - GET /api/v1/config : 获取配置项规格与当前值(敏感凭证脱敏)
154
+ * - PUT /api/v1/config : 持久化更新配置
155
+ * - DELETE /api/v1/config/:key : 删除配置项
156
+ * - GET /api/v1/config/env : 检查环境变量满足率
157
+ * - ALL /mcp : 一体化 MCP Streamable HTTP 协议端点(可选)
87
158
  */
88
159
  export function startActionDockServer(
89
160
  options: ServerOptions = {}
@@ -122,7 +193,7 @@ export function startActionDockServer(
122
193
  const url = new URL(req.url);
123
194
  const pathname = url.pathname;
124
195
 
125
- // 1. Health Check (supports /api/v1/health and /health)
196
+ // 0. Health Check (supports /api/v1/health and /health)
126
197
  if (pathname === "/api/v1/health" || pathname === "/health") {
127
198
  if (!verifyBearerToken(req, token)) {
128
199
  return jsonResponse(
@@ -149,7 +220,30 @@ export function startActionDockServer(
149
220
  return jsonResponse(healthData, 200, corsHeaders);
150
221
  }
151
222
 
152
- // Check authentication for remaining endpoints
223
+ // 1. MCP Unified Gateway: /mcp
224
+ if (
225
+ options.enableMcp !== false &&
226
+ options.mcpHandler &&
227
+ (pathname === "/mcp" || pathname.startsWith("/mcp/"))
228
+ ) {
229
+ if (!verifyBearerToken(req, token)) {
230
+ return jsonResponse(
231
+ {
232
+ ok: false,
233
+ error: {
234
+ code: "UNAUTHORIZED",
235
+ message: "Invalid or missing Bearer token",
236
+ },
237
+ },
238
+ 401,
239
+ corsHeaders
240
+ );
241
+ }
242
+ const mcpRes = await options.mcpHandler(req);
243
+ if (mcpRes) return mcpRes;
244
+ }
245
+
246
+ // 鉴权检查(后续所有 /api/v1/* 均要求鉴权)
153
247
  if (!verifyBearerToken(req, token)) {
154
248
  return jsonResponse(
155
249
  {
@@ -164,40 +258,202 @@ export function startActionDockServer(
164
258
  );
165
259
  }
166
260
 
167
- // 2. Info: GET /api/v1/info
261
+ // 2. Info 自省: GET /api/v1/info
168
262
  if (pathname === "/api/v1/info" && req.method === "GET") {
169
263
  try {
264
+ const isTree = url.searchParams.get("tree") === "true";
265
+ if (isTree) {
266
+ const status = getRegistryStatus(customHome);
267
+ return jsonResponse(
268
+ { ok: true, type: "tree", ...status },
269
+ 200,
270
+ corsHeaders
271
+ );
272
+ }
273
+
274
+ const intent = url.searchParams.get("intent") || undefined;
275
+ const targetPkg = url.searchParams.get("package") || undefined;
276
+
277
+ // 收集所有候选包全量元数据
278
+ const aggregatedPackages: any[] = [];
279
+
170
280
  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;
281
+ try {
282
+ const config = loadProjectConfig(projectRoot);
283
+ const actions = await loadActions(projectRoot, config.actionsDir);
284
+ const playbooks = loadPlaybooks(projectRoot, config.playbooksDir);
285
+ aggregatedPackages.push({
286
+ id: config.id,
287
+ name: config.name,
288
+ version: config.version,
289
+ description: config.description || "",
290
+ path: projectRoot,
291
+ actionsCount: actions.size,
292
+ playbooksCount: playbooks.size,
293
+ actions: Array.from(actions.entries()).map(([id, a]) => ({
294
+ id,
295
+ description: a.description || "",
296
+ inputSchema: a.inputSchema || null,
297
+ outputSchema: a.outputSchema || null,
298
+ })),
299
+ playbooks: Array.from(playbooks.entries()).map(([id, p]) => ({
300
+ id,
301
+ description: p.description || "",
302
+ actions: p.actions || [],
303
+ })),
304
+ configDeclared: config.config || {},
305
+ });
306
+ } catch {
307
+ // 忽略解析异常
187
308
  }
188
- return jsonResponse(infoData, 200, corsHeaders);
189
309
  } else {
190
310
  const linked = listLinkedPackages(customHome);
311
+ for (const pkg of linked) {
312
+ if (!existsSync(pkg.path)) continue;
313
+ try {
314
+ const config = loadProjectConfig(pkg.path);
315
+ const actions = await loadActions(pkg.path, config.actionsDir);
316
+ const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
317
+ aggregatedPackages.push({
318
+ id: config.id,
319
+ name: config.name,
320
+ version: config.version,
321
+ description: config.description || "",
322
+ path: pkg.path,
323
+ actionsCount: actions.size,
324
+ playbooksCount: playbooks.size,
325
+ actions: Array.from(actions.entries()).map(([id, a]) => ({
326
+ id,
327
+ description: a.description || "",
328
+ inputSchema: a.inputSchema || null,
329
+ outputSchema: a.outputSchema || null,
330
+ })),
331
+ playbooks: Array.from(playbooks.entries()).map(([id, p]) => ({
332
+ id,
333
+ description: p.description || "",
334
+ actions: p.actions || [],
335
+ })),
336
+ configDeclared: config.config || {},
337
+ });
338
+ } catch {
339
+ // 忽略故障包
340
+ }
341
+ }
342
+ }
343
+
344
+ // 显式指定 package 详情下钻
345
+ if (targetPkg) {
346
+ const matched = aggregatedPackages.find(
347
+ (p) => p.id === targetPkg || p.path === targetPkg
348
+ );
349
+ if (!matched) {
350
+ return jsonResponse(
351
+ {
352
+ ok: false,
353
+ error: {
354
+ code: "PACKAGE_NOT_FOUND",
355
+ message: `Package '${targetPkg}' not found on remote server`,
356
+ },
357
+ },
358
+ 404,
359
+ corsHeaders
360
+ );
361
+ }
191
362
  return jsonResponse(
192
363
  {
193
364
  ok: true,
194
- version: "2.0.0",
195
- linkedPackages: linked,
365
+ type: "package_detail",
366
+ ...matched,
367
+ ...(options.exposeDebugInfo ? { projectRoot: matched.path } : {}),
196
368
  },
197
369
  200,
198
370
  corsHeaders
199
371
  );
200
372
  }
373
+
374
+ // 意图过滤与决议
375
+ if (intent) {
376
+ const filterRes = filterWithFallbackInfo(
377
+ aggregatedPackages,
378
+ intent,
379
+ [
380
+ (p) => p.id,
381
+ (p) => p.name,
382
+ (p) => p.description,
383
+ (p) => p.actions.map((a: any) => a.id),
384
+ (p) => p.actions.map((a: any) => a.description),
385
+ (p) => p.playbooks.map((pb: any) => pb.id),
386
+ (p) => p.playbooks.map((pb: any) => pb.description),
387
+ ],
388
+ true
389
+ );
390
+
391
+ // 唯一命中智能展开详情
392
+ if (filterRes.matchedCount === 1) {
393
+ const single = filterRes.items[0];
394
+ return jsonResponse(
395
+ {
396
+ ok: true,
397
+ type: "package_detail",
398
+ isSingleMatch: true,
399
+ ...single,
400
+ ...(options.exposeDebugInfo ? { projectRoot: single.path } : {}),
401
+ },
402
+ 200,
403
+ corsHeaders
404
+ );
405
+ }
406
+
407
+ return jsonResponse(
408
+ {
409
+ ok: true,
410
+ type: "package_list",
411
+ isFallback: filterRes.isFallback,
412
+ packages: filterRes.items,
413
+ },
414
+ 200,
415
+ corsHeaders
416
+ );
417
+ }
418
+
419
+ // 单包模式直接展开详情
420
+ if (projectRoot && aggregatedPackages.length === 1) {
421
+ const p = aggregatedPackages[0];
422
+ return jsonResponse(
423
+ {
424
+ ok: true,
425
+ type: "package_detail",
426
+ id: p.id,
427
+ name: p.name,
428
+ version: p.version,
429
+ description: p.description,
430
+ actionsCount: p.actionsCount,
431
+ playbooksCount: p.playbooksCount,
432
+ actions: p.actions.map((a: any) => a.id),
433
+ actionsDetail: p.actions,
434
+ playbooks: p.playbooks.map((pb: any) => pb.id),
435
+ playbooksDetail: p.playbooks,
436
+ configDeclared: p.configDeclared,
437
+ linkedPackages: listLinkedPackages(customHome),
438
+ ...(options.exposeDebugInfo ? { projectRoot } : {}),
439
+ },
440
+ 200,
441
+ corsHeaders
442
+ );
443
+ }
444
+
445
+ // 全局注册表概览
446
+ return jsonResponse(
447
+ {
448
+ ok: true,
449
+ type: "package_list",
450
+ version: "2.0.0",
451
+ packages: aggregatedPackages,
452
+ linkedPackages: listLinkedPackages(customHome),
453
+ },
454
+ 200,
455
+ corsHeaders
456
+ );
201
457
  } catch (err: any) {
202
458
  return jsonResponse(
203
459
  {
@@ -210,7 +466,26 @@ export function startActionDockServer(
210
466
  }
211
467
  }
212
468
 
213
- // 3. Actions List: GET /api/v1/actions
469
+ // 3. Doctor 深度体检: GET /api/v1/doctor
470
+ if (pathname === "/api/v1/doctor" && req.method === "GET") {
471
+ try {
472
+ const targetPkg = url.searchParams.get("package") || undefined;
473
+ const report = await runDoctorChecks({
474
+ cwd: projectRoot || process.cwd(),
475
+ packageIdOrPath: targetPkg,
476
+ customHome,
477
+ });
478
+ return jsonResponse({ ok: true, report }, 200, corsHeaders);
479
+ } catch (err: any) {
480
+ return jsonResponse(
481
+ { ok: false, error: { code: "DOCTOR_ERROR", message: err.message } },
482
+ 500,
483
+ corsHeaders
484
+ );
485
+ }
486
+ }
487
+
488
+ // 4. Actions List: GET /api/v1/actions
214
489
  if (pathname === "/api/v1/actions" && req.method === "GET") {
215
490
  try {
216
491
  const actionList: Array<{
@@ -251,15 +526,21 @@ export function startActionDockServer(
251
526
  }
252
527
 
253
528
  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
- )
529
+ const targetPkg = url.searchParams.get("package");
530
+
531
+ let filtered = targetPkg
532
+ ? actionList.filter((a) => a.packageId === targetPkg)
261
533
  : actionList;
262
534
 
535
+ if (intent) {
536
+ filtered = filterByIntent(
537
+ filtered,
538
+ intent,
539
+ [(a) => a.id, (a) => a.description, (a) => a.packageId],
540
+ false
541
+ );
542
+ }
543
+
263
544
  return jsonResponse(filtered, 200, corsHeaders);
264
545
  } catch (err: any) {
265
546
  return jsonResponse(
@@ -273,7 +554,7 @@ export function startActionDockServer(
273
554
  }
274
555
  }
275
556
 
276
- // 4. Action Show: GET /api/v1/actions/:id
557
+ // 5. Action Show: GET /api/v1/actions/:id
277
558
  const actionShowMatch = pathname.match(/^\/api\/v1\/actions\/([^/]+)$/);
278
559
  if (actionShowMatch && req.method === "GET") {
279
560
  const actionId = decodeURIComponent(actionShowMatch[1]);
@@ -323,7 +604,7 @@ export function startActionDockServer(
323
604
  }
324
605
  }
325
606
 
326
- // 5. Action Run: POST /api/v1/actions/:id/run
607
+ // 6. Action Run: POST /api/v1/actions/:id/run
327
608
  const actionRunMatch = pathname.match(/^\/api\/v1\/actions\/([^/]+)\/run$/);
328
609
  if (actionRunMatch && req.method === "POST") {
329
610
  const actionId = decodeURIComponent(actionRunMatch[1]);
@@ -336,10 +617,7 @@ export function startActionDockServer(
336
617
  {
337
618
  ok: false,
338
619
  runId: randomUUID(),
339
- error: {
340
- code: "REQUEST_TOO_LARGE",
341
- message: err.message,
342
- },
620
+ error: { code: "REQUEST_TOO_LARGE", message: err.message },
343
621
  },
344
622
  413,
345
623
  corsHeaders
@@ -350,10 +628,7 @@ export function startActionDockServer(
350
628
  {
351
629
  ok: false,
352
630
  runId: randomUUID(),
353
- error: {
354
- code: "INVALID_JSON",
355
- message: err.message,
356
- },
631
+ error: { code: "INVALID_JSON", message: err.message },
357
632
  },
358
633
  400,
359
634
  corsHeaders
@@ -363,10 +638,7 @@ export function startActionDockServer(
363
638
  {
364
639
  ok: false,
365
640
  runId: randomUUID(),
366
- error: {
367
- code: "INVALID_JSON",
368
- message: `Failed to parse request body: ${err.message}`,
369
- },
641
+ error: { code: "INVALID_JSON", message: `Failed to parse request body: ${err.message}` },
370
642
  },
371
643
  400,
372
644
  corsHeaders
@@ -391,30 +663,49 @@ export function startActionDockServer(
391
663
  actions,
392
664
  });
393
665
 
394
- const executionMode = body?.execution?.mode || "sync";
666
+ const isAsync = body?.execution?.mode === "async" || body?.async === true;
395
667
  const timeoutMs =
396
668
  typeof body?.execution?.timeoutMs === "number" && body.execution.timeoutMs > 0
397
669
  ? body.execution.timeoutMs
398
670
  : undefined;
399
671
 
400
- if (executionMode === "async") {
672
+ if (isAsync) {
401
673
  const handle = runner.start(resolved.actionId, body?.input || {}, {
402
674
  timeoutMs,
403
675
  });
404
676
  runtimeRegistry.executionManager.register(handle);
405
677
 
678
+ // 监听结算以广播 SSE 完成事件
679
+ handle.result
680
+ .then((res) => {
681
+ runtimeRegistry.emit(handle.runId, { type: "finish", data: res });
682
+ })
683
+ .catch((err) => {
684
+ runtimeRegistry.emit(handle.runId, {
685
+ type: "finish",
686
+ data: {
687
+ ok: false,
688
+ error: {
689
+ code: "ACTION_EXECUTION_ERROR",
690
+ message: err?.message || String(err),
691
+ },
692
+ },
693
+ });
694
+ });
695
+
406
696
  return jsonResponse(
407
697
  {
408
698
  ok: true,
409
699
  runId: handle.runId,
410
700
  status: "running",
701
+ streamUrl: `/api/v1/runs/${handle.runId}/stream`,
411
702
  },
412
703
  202,
413
704
  corsHeaders
414
705
  );
415
706
  }
416
707
 
417
- // Sync execution mode
708
+ // 同步执行模式
418
709
  const handle = runner.start(resolved.actionId, body?.input || {}, {
419
710
  signal: req.signal,
420
711
  timeoutMs,
@@ -439,7 +730,289 @@ export function startActionDockServer(
439
730
  }
440
731
  }
441
732
 
442
- // 6. Run Show: GET /api/v1/runs/:runId
733
+ // 7. Playbooks List: GET /api/v1/playbooks
734
+ if (pathname === "/api/v1/playbooks" && req.method === "GET") {
735
+ try {
736
+ const pbList: Array<{
737
+ id: string;
738
+ description: string;
739
+ actions: string[];
740
+ packageId: string;
741
+ filePath: string;
742
+ }> = [];
743
+
744
+ const targetPkg = url.searchParams.get("package");
745
+ const intent = url.searchParams.get("intent");
746
+
747
+ const roots: Array<{ root: string; packageId: string }> = [];
748
+ if (projectRoot) {
749
+ const cfg = loadProjectConfig(projectRoot);
750
+ roots.push({ root: projectRoot, packageId: cfg.id });
751
+ }
752
+ const linked = listLinkedPackages(customHome);
753
+ for (const pkg of linked) {
754
+ if (projectRoot && pkg.path === projectRoot) continue;
755
+ if (!existsSync(pkg.path)) continue;
756
+ roots.push({ root: pkg.path, packageId: pkg.id });
757
+ }
758
+
759
+ for (const item of roots) {
760
+ if (targetPkg && item.packageId !== targetPkg && item.root !== targetPkg) continue;
761
+ try {
762
+ const cfg = loadProjectConfig(item.root);
763
+ const pbs = loadPlaybooks(item.root, cfg.playbooksDir);
764
+ for (const [id, pb] of pbs.entries()) {
765
+ pbList.push({
766
+ id,
767
+ description: pb.description || "",
768
+ actions: pb.actions || [],
769
+ packageId: item.packageId,
770
+ filePath: pb.filePath,
771
+ });
772
+ }
773
+ } catch {}
774
+ }
775
+
776
+ const filtered = intent
777
+ ? filterByIntent(
778
+ pbList,
779
+ intent,
780
+ [(p) => p.id, (p) => p.description, (p) => p.packageId, (p) => p.actions],
781
+ false
782
+ )
783
+ : pbList;
784
+
785
+ return jsonResponse(filtered, 200, corsHeaders);
786
+ } catch (err: any) {
787
+ return jsonResponse(
788
+ { ok: false, error: { code: "PLAYBOOKS_LIST_ERROR", message: err.message } },
789
+ 500,
790
+ corsHeaders
791
+ );
792
+ }
793
+ }
794
+
795
+ // 8. Playbook Show: GET /api/v1/playbooks/:id
796
+ const pbShowMatch = pathname.match(/^\/api\/v1\/playbooks\/([^/]+)$/);
797
+ if (pbShowMatch && req.method === "GET") {
798
+ const pbId = decodeURIComponent(pbShowMatch[1]);
799
+ try {
800
+ const resolved = resolvePlaybookProject(
801
+ pbId,
802
+ projectRoot || process.cwd(),
803
+ customHome
804
+ );
805
+ const pb = resolved.playbook;
806
+ return jsonResponse(
807
+ {
808
+ ok: true,
809
+ id: pb.id,
810
+ packageId: resolved.packageId,
811
+ description: pb.description || "",
812
+ actions: pb.actions || [],
813
+ filePath: pb.filePath,
814
+ content: pb.content,
815
+ },
816
+ 200,
817
+ corsHeaders
818
+ );
819
+ } catch (err: any) {
820
+ return jsonResponse(
821
+ { ok: false, error: { code: "PLAYBOOK_NOT_FOUND", message: err.message } },
822
+ 404,
823
+ corsHeaders
824
+ );
825
+ }
826
+ }
827
+
828
+ // 9. Runs List: GET /api/v1/runs
829
+ if (pathname === "/api/v1/runs" && req.method === "GET") {
830
+ try {
831
+ const status = url.searchParams.get("status") || undefined;
832
+ const actionId = url.searchParams.get("actionId") || undefined;
833
+ const packageId = url.searchParams.get("packageId") || undefined;
834
+ const intent = url.searchParams.get("intent") || undefined;
835
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
836
+
837
+ const allRuns: any[] = [];
838
+ const seenRunIds = new Set<string>();
839
+
840
+ const candidateStorages: Array<{ packageId: string; storage: RuntimeStorage }> = [];
841
+ if (projectRoot) {
842
+ try {
843
+ const cfg = loadProjectConfig(projectRoot);
844
+ candidateStorages.push({
845
+ packageId: cfg.id,
846
+ storage: runtimeRegistry.getStorage(cfg.id, projectRoot),
847
+ });
848
+ } catch {}
849
+ }
850
+ const linked = listLinkedPackages(customHome);
851
+ for (const pkg of linked) {
852
+ if (projectRoot && pkg.path === projectRoot) continue;
853
+ if (!existsSync(pkg.path)) continue;
854
+ candidateStorages.push({
855
+ packageId: pkg.id,
856
+ storage: runtimeRegistry.getStorage(pkg.id, pkg.path),
857
+ });
858
+ }
859
+
860
+ for (const item of candidateStorages) {
861
+ if (packageId && item.packageId !== packageId) continue;
862
+ try {
863
+ const records = item.storage.listRuns({ actionId, limit });
864
+ for (const r of records) {
865
+ if (!seenRunIds.has(r.id)) {
866
+ seenRunIds.add(r.id);
867
+ if (status && r.status !== status) continue;
868
+ allRuns.push(r);
869
+ }
870
+ }
871
+ } catch {}
872
+ }
873
+
874
+ allRuns.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || ""));
875
+
876
+ const filtered = intent
877
+ ? filterByIntent(
878
+ allRuns,
879
+ intent,
880
+ [(r) => r.id, (r) => r.actionId, (r) => r.status, (r) => r.packageId],
881
+ false
882
+ )
883
+ : allRuns;
884
+
885
+ const sliced = filtered.slice(0, limit);
886
+ return jsonResponse(
887
+ { ok: true, total: filtered.length, items: sliced },
888
+ 200,
889
+ corsHeaders
890
+ );
891
+ } catch (err: any) {
892
+ return jsonResponse(
893
+ { ok: false, error: { code: "RUNS_LIST_ERROR", message: err.message } },
894
+ 500,
895
+ corsHeaders
896
+ );
897
+ }
898
+ }
899
+
900
+ // 10. Runs Clear: POST /api/v1/runs/clear or DELETE /api/v1/runs
901
+ if (
902
+ (pathname === "/api/v1/runs/clear" && req.method === "POST") ||
903
+ (pathname === "/api/v1/runs" && req.method === "DELETE")
904
+ ) {
905
+ try {
906
+ let body: any = {};
907
+ if (req.method === "POST" || req.headers.get("content-type")?.includes("json")) {
908
+ body = await readJsonBody(req, { maxBytes: options.maxBodyBytes }).catch(() => ({}));
909
+ }
910
+ const packageId = url.searchParams.get("packageId") || body.packageId || undefined;
911
+ const actionId = url.searchParams.get("actionId") || body.actionId || undefined;
912
+ const status = url.searchParams.get("status") || body.status || undefined;
913
+
914
+ let clearedCount = 0;
915
+ const candidateStorages: RuntimeStorage[] = [];
916
+ if (projectRoot) {
917
+ try {
918
+ const cfg = loadProjectConfig(projectRoot);
919
+ candidateStorages.push(runtimeRegistry.getStorage(cfg.id, projectRoot));
920
+ } catch {}
921
+ }
922
+ const linked = listLinkedPackages(customHome);
923
+ for (const pkg of linked) {
924
+ if (projectRoot && pkg.path === projectRoot) continue;
925
+ if (!existsSync(pkg.path)) continue;
926
+ if (packageId && pkg.id !== packageId) continue;
927
+ candidateStorages.push(runtimeRegistry.getStorage(pkg.id, pkg.path));
928
+ }
929
+
930
+ for (const storage of candidateStorages) {
931
+ clearedCount += storage.clearRuns({ actionId, status });
932
+ }
933
+
934
+ return jsonResponse({ ok: true, clearedCount }, 200, corsHeaders);
935
+ } catch (err: any) {
936
+ return jsonResponse(
937
+ { ok: false, error: { code: "RUNS_CLEAR_ERROR", message: err.message } },
938
+ 500,
939
+ corsHeaders
940
+ );
941
+ }
942
+ }
943
+
944
+ // 11. Run Stream (SSE): GET /api/v1/runs/:runId/stream
945
+ const runStreamMatch = pathname.match(/^\/api\/v1\/runs\/([^/]+)\/stream$/);
946
+ if (runStreamMatch && req.method === "GET") {
947
+ const runId = decodeURIComponent(runStreamMatch[1]);
948
+ const found = findRunAcrossStorages(runId, runtimeRegistry, projectRoot, customHome);
949
+ const activeHandle = runtimeRegistry.executionManager.get(runId);
950
+
951
+ if (!found && !activeHandle) {
952
+ return jsonResponse(
953
+ { ok: false, error: { code: "RUN_NOT_FOUND", message: `Run '${runId}' not found` } },
954
+ 404,
955
+ corsHeaders
956
+ );
957
+ }
958
+
959
+ const stream = new ReadableStream({
960
+ start(controller) {
961
+ const encoder = new TextEncoder();
962
+ const sendEvent = (event: string, data: any) => {
963
+ try {
964
+ controller.enqueue(
965
+ encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
966
+ );
967
+ } catch {}
968
+ };
969
+
970
+ if (activeHandle) {
971
+ sendEvent("status", { runId, status: "running" });
972
+ const unsubscribe = runtimeRegistry.subscribe(runId, (evt) => {
973
+ sendEvent(evt.type, evt.data);
974
+ if (evt.type === "finish") {
975
+ try { controller.close(); } catch {}
976
+ }
977
+ });
978
+
979
+ activeHandle.result.then(
980
+ (res) => {
981
+ sendEvent("finish", res);
982
+ try { controller.close(); } catch {}
983
+ },
984
+ (err) => {
985
+ sendEvent("finish", {
986
+ ok: false,
987
+ error: { message: err?.message || String(err) },
988
+ });
989
+ try { controller.close(); } catch {}
990
+ }
991
+ );
992
+
993
+ req.signal.addEventListener("abort", () => {
994
+ unsubscribe();
995
+ try { controller.close(); } catch {}
996
+ });
997
+ } else if (found) {
998
+ sendEvent("finish", found.run);
999
+ controller.close();
1000
+ }
1001
+ },
1002
+ });
1003
+
1004
+ return new Response(stream, {
1005
+ status: 200,
1006
+ headers: {
1007
+ "Content-Type": "text/event-stream",
1008
+ "Cache-Control": "no-cache",
1009
+ "Connection": "keep-alive",
1010
+ ...corsHeaders,
1011
+ },
1012
+ });
1013
+ }
1014
+
1015
+ // 12. Run Show: GET /api/v1/runs/:runId
443
1016
  const runShowMatch = pathname.match(/^\/api\/v1\/runs\/([^/]+)$/);
444
1017
  if (runShowMatch && req.method === "GET") {
445
1018
  const runId = decodeURIComponent(runShowMatch[1]);
@@ -462,7 +1035,7 @@ export function startActionDockServer(
462
1035
  return jsonResponse(found.run, 200, corsHeaders);
463
1036
  }
464
1037
 
465
- // 7. Run Cancel: POST /api/v1/runs/:runId/cancel
1038
+ // 13. Run Cancel: POST /api/v1/runs/:runId/cancel
466
1039
  const runCancelMatch = pathname.match(/^\/api\/v1\/runs\/([^/]+)\/cancel$/);
467
1040
  if (runCancelMatch && req.method === "POST") {
468
1041
  const runId = decodeURIComponent(runCancelMatch[1]);
@@ -475,7 +1048,6 @@ export function startActionDockServer(
475
1048
 
476
1049
  const reason = body?.reason || "Cancelled by client request";
477
1050
 
478
- // 1. Try cancelling in-memory active handle
479
1051
  const activeHandle = runtimeRegistry.executionManager.get(runId);
480
1052
  if (activeHandle) {
481
1053
  const cancelled = runtimeRegistry.executionManager.cancel(runId, reason);
@@ -492,9 +1064,7 @@ export function startActionDockServer(
492
1064
  }
493
1065
  }
494
1066
 
495
- // 2. Check storage for run status
496
1067
  const found = findRunAcrossStorages(runId, runtimeRegistry, projectRoot, customHome);
497
-
498
1068
  if (!found) {
499
1069
  return jsonResponse(
500
1070
  {
@@ -524,7 +1094,6 @@ export function startActionDockServer(
524
1094
  );
525
1095
  }
526
1096
 
527
- // Running in storage but no longer in memory
528
1097
  storage.updateRun(runId, "cancelled", undefined, {
529
1098
  code: "ACTION_CANCELLED",
530
1099
  message: reason,
@@ -541,6 +1110,263 @@ export function startActionDockServer(
541
1110
  );
542
1111
  }
543
1112
 
1113
+ // 14. State List: GET /api/v1/state
1114
+ if (pathname === "/api/v1/state" && req.method === "GET") {
1115
+ try {
1116
+ const pkgParam = url.searchParams.get("package") || undefined;
1117
+ const nsParam = url.searchParams.get("namespace") ?? undefined;
1118
+ const prefix = url.searchParams.get("prefix") || "";
1119
+
1120
+ const { packageId, storage } = resolveStorageForPackage(
1121
+ pkgParam,
1122
+ runtimeRegistry,
1123
+ projectRoot,
1124
+ customHome
1125
+ );
1126
+ const keys = await storage.listStateKeys(nsParam !== undefined ? nsParam : null, prefix);
1127
+ return jsonResponse({ ok: true, packageId, keys }, 200, corsHeaders);
1128
+ } catch (err: any) {
1129
+ return jsonResponse(
1130
+ { ok: false, error: { code: "STATE_LIST_ERROR", message: err.message } },
1131
+ 500,
1132
+ corsHeaders
1133
+ );
1134
+ }
1135
+ }
1136
+
1137
+ // 15. State Clear: POST /api/v1/state/clear
1138
+ if (pathname === "/api/v1/state/clear" && req.method === "POST") {
1139
+ try {
1140
+ const body = await readJsonBody(req, { maxBytes: options.maxBodyBytes }).catch(() => ({}));
1141
+ const pkgParam = url.searchParams.get("package") || body.package || undefined;
1142
+ const { packageId, storage } = resolveStorageForPackage(
1143
+ pkgParam,
1144
+ runtimeRegistry,
1145
+ projectRoot,
1146
+ customHome
1147
+ );
1148
+ const clearedCount = await storage.clearState({
1149
+ namespace: body.namespace ?? (url.searchParams.get("namespace") || undefined),
1150
+ all: Boolean(body.all ?? url.searchParams.get("all") === "true"),
1151
+ prefix: body.prefix ?? (url.searchParams.get("prefix") || undefined),
1152
+ });
1153
+ return jsonResponse({ ok: true, packageId, clearedCount }, 200, corsHeaders);
1154
+ } catch (err: any) {
1155
+ return jsonResponse(
1156
+ { ok: false, error: { code: "STATE_CLEAR_ERROR", message: err.message } },
1157
+ 500,
1158
+ corsHeaders
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ // 16. State Key CRUD: GET / PUT / POST / DELETE /api/v1/state/:key
1164
+ const stateKeyMatch = pathname.match(/^\/api\/v1\/state\/([^/]+)$/);
1165
+ if (stateKeyMatch) {
1166
+ const key = decodeURIComponent(stateKeyMatch[1]);
1167
+ const pkgParam = url.searchParams.get("package") || undefined;
1168
+ const nsParam = url.searchParams.get("namespace") || undefined;
1169
+
1170
+ const { packageId, storage } = resolveStorageForPackage(
1171
+ pkgParam,
1172
+ runtimeRegistry,
1173
+ projectRoot,
1174
+ customHome
1175
+ );
1176
+
1177
+ if (req.method === "GET") {
1178
+ const entry = await storage.findState(key, nsParam);
1179
+ if (!entry || entry.value === undefined) {
1180
+ return jsonResponse(
1181
+ { ok: false, error: { code: "STATE_KEY_NOT_FOUND", message: `State key '${key}' not found` } },
1182
+ 404,
1183
+ corsHeaders
1184
+ );
1185
+ }
1186
+ return jsonResponse(
1187
+ {
1188
+ ok: true,
1189
+ packageId,
1190
+ key: entry.key,
1191
+ namespace: entry.namespace,
1192
+ value: entry.value,
1193
+ expiresAt: entry.expiresAt,
1194
+ },
1195
+ 200,
1196
+ corsHeaders
1197
+ );
1198
+ }
1199
+
1200
+ if (req.method === "PUT" || req.method === "POST") {
1201
+ const body = await readJsonBody(req, { maxBytes: options.maxBodyBytes });
1202
+ const val = body.value !== undefined ? body.value : body;
1203
+ const ttl = typeof body.ttl === "number" ? body.ttl : undefined;
1204
+ const namespace = body.namespace || nsParam || "";
1205
+
1206
+ let actualKey = key;
1207
+ let ns = namespace;
1208
+ if (!ns && key.includes(":")) {
1209
+ const idx = key.indexOf(":");
1210
+ ns = key.slice(0, idx);
1211
+ actualKey = key.slice(idx + 1);
1212
+ }
1213
+
1214
+ await storage.setState(ns, actualKey, val, ttl);
1215
+ return jsonResponse(
1216
+ { ok: true, packageId, key: actualKey, namespace: ns, message: "updated" },
1217
+ 200,
1218
+ corsHeaders
1219
+ );
1220
+ }
1221
+
1222
+ if (req.method === "DELETE") {
1223
+ const deleted = await storage.deleteStateSmart(key, nsParam);
1224
+ if (!deleted) {
1225
+ return jsonResponse(
1226
+ { ok: false, error: { code: "STATE_KEY_NOT_FOUND", message: `State key '${key}' not found` } },
1227
+ 404,
1228
+ corsHeaders
1229
+ );
1230
+ }
1231
+ return jsonResponse({ ok: true, packageId, key, deleted: true }, 200, corsHeaders);
1232
+ }
1233
+ }
1234
+
1235
+ // 17. Config Env Check: GET /api/v1/config/env
1236
+ if (pathname === "/api/v1/config/env" && req.method === "GET") {
1237
+ try {
1238
+ const pkgParam = url.searchParams.get("package") || undefined;
1239
+ const { packageId, projectRoot: root } = resolveStorageForPackage(
1240
+ pkgParam,
1241
+ runtimeRegistry,
1242
+ projectRoot,
1243
+ customHome
1244
+ );
1245
+ if (!root) {
1246
+ return jsonResponse({ ok: true, packageId, envChecks: [] }, 200, corsHeaders);
1247
+ }
1248
+ const cfg = loadProjectConfig(root);
1249
+ const declared = cfg.config || {};
1250
+ const envChecks: any[] = [];
1251
+ for (const [k, def] of Object.entries(declared)) {
1252
+ const envKeys = [
1253
+ k,
1254
+ `ACTIONDOCK_${k}`,
1255
+ `${packageId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_${k}`,
1256
+ ];
1257
+ const foundEnv = envKeys.find((ek) => process.env[ek] !== undefined);
1258
+ envChecks.push({
1259
+ key: k,
1260
+ required: def.default === undefined,
1261
+ satisfied: Boolean(foundEnv || def.default !== undefined),
1262
+ matchedEnv: foundEnv || null,
1263
+ hasDefault: def.default !== undefined,
1264
+ secret: Boolean(def.secret),
1265
+ });
1266
+ }
1267
+ return jsonResponse({ ok: true, packageId, envChecks }, 200, corsHeaders);
1268
+ } catch (err: any) {
1269
+ return jsonResponse(
1270
+ { ok: false, error: { code: "CONFIG_ENV_ERROR", message: err.message } },
1271
+ 500,
1272
+ corsHeaders
1273
+ );
1274
+ }
1275
+ }
1276
+
1277
+ // 18. Config Query: GET /api/v1/config
1278
+ if (pathname === "/api/v1/config" && req.method === "GET") {
1279
+ try {
1280
+ const pkgParam = url.searchParams.get("package") || undefined;
1281
+ const { packageId, storage, projectRoot: root } = resolveStorageForPackage(
1282
+ pkgParam,
1283
+ runtimeRegistry,
1284
+ projectRoot,
1285
+ customHome
1286
+ );
1287
+ const stored = storage.listConfig();
1288
+ let declared: Record<string, any> = {};
1289
+ if (root) {
1290
+ try {
1291
+ const cfg = loadProjectConfig(root);
1292
+ declared = cfg.config || {};
1293
+ } catch {}
1294
+ }
1295
+ const maskedValues: Record<string, any> = {};
1296
+ for (const [k, v] of Object.entries(stored)) {
1297
+ if (declared[k]?.secret) {
1298
+ maskedValues[k] = "******";
1299
+ } else {
1300
+ maskedValues[k] = v;
1301
+ }
1302
+ }
1303
+ return jsonResponse(
1304
+ { ok: true, packageId, declared, values: maskedValues },
1305
+ 200,
1306
+ corsHeaders
1307
+ );
1308
+ } catch (err: any) {
1309
+ return jsonResponse(
1310
+ { ok: false, error: { code: "CONFIG_LIST_ERROR", message: err.message } },
1311
+ 500,
1312
+ corsHeaders
1313
+ );
1314
+ }
1315
+ }
1316
+
1317
+ // 19. Config Update: PUT / POST /api/v1/config
1318
+ if (pathname === "/api/v1/config" && (req.method === "PUT" || req.method === "POST")) {
1319
+ try {
1320
+ const body = await readJsonBody(req, { maxBytes: options.maxBodyBytes });
1321
+ const pkgParam = url.searchParams.get("package") || body.package || undefined;
1322
+ const { packageId, storage } = resolveStorageForPackage(
1323
+ pkgParam,
1324
+ runtimeRegistry,
1325
+ projectRoot,
1326
+ customHome
1327
+ );
1328
+ const key = body.key;
1329
+ if (!key) {
1330
+ return jsonResponse(
1331
+ { ok: false, error: { code: "INVALID_ARGUMENT", message: "Config 'key' is required" } },
1332
+ 400,
1333
+ corsHeaders
1334
+ );
1335
+ }
1336
+ storage.setConfig(key, body.value);
1337
+ return jsonResponse({ ok: true, packageId, key, message: "updated" }, 200, corsHeaders);
1338
+ } catch (err: any) {
1339
+ return jsonResponse(
1340
+ { ok: false, error: { code: "CONFIG_SET_ERROR", message: err.message } },
1341
+ 500,
1342
+ corsHeaders
1343
+ );
1344
+ }
1345
+ }
1346
+
1347
+ // 20. Config Delete: DELETE /api/v1/config/:key
1348
+ const configKeyMatch = pathname.match(/^\/api\/v1\/config\/([^/]+)$/);
1349
+ if (configKeyMatch && req.method === "DELETE") {
1350
+ try {
1351
+ const key = decodeURIComponent(configKeyMatch[1]);
1352
+ const pkgParam = url.searchParams.get("package") || undefined;
1353
+ const { packageId, storage } = resolveStorageForPackage(
1354
+ pkgParam,
1355
+ runtimeRegistry,
1356
+ projectRoot,
1357
+ customHome
1358
+ );
1359
+ const deleted = storage.deleteConfig(key);
1360
+ return jsonResponse({ ok: true, packageId, key, deleted }, 200, corsHeaders);
1361
+ } catch (err: any) {
1362
+ return jsonResponse(
1363
+ { ok: false, error: { code: "CONFIG_DELETE_ERROR", message: err.message } },
1364
+ 500,
1365
+ corsHeaders
1366
+ );
1367
+ }
1368
+ }
1369
+
544
1370
  // 404 Not Found
545
1371
  return jsonResponse(
546
1372
  {