@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.
- package/README.md +50 -0
- package/package.json +51 -0
- package/src/build/builder.ts +205 -0
- package/src/build/index.ts +2 -0
- package/src/build/templates.ts +59 -0
- package/src/doctor/doctor.ts +332 -0
- package/src/doctor/index.ts +2 -0
- package/src/doctor/types.ts +25 -0
- package/src/export/index.ts +2 -0
- package/src/export/skill.ts +349 -0
- package/src/export/templates.ts +258 -0
- package/src/filter/index.ts +1 -0
- package/src/filter/intent.ts +154 -0
- package/src/index.ts +13 -0
- package/src/profile/client.ts +302 -0
- package/src/profile/index.ts +3 -0
- package/src/profile/manager.ts +341 -0
- package/src/profile/types.ts +71 -0
- package/src/project/index.ts +3 -0
- package/src/project/init.ts +194 -0
- package/src/project/loader.ts +382 -0
- package/src/project/types.ts +62 -0
- package/src/registry/index.ts +2 -0
- package/src/registry/registry.ts +703 -0
- package/src/registry/types.ts +127 -0
- package/src/runtime/context.ts +232 -0
- package/src/runtime/env.ts +172 -0
- package/src/runtime/execution-manager.ts +74 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/runner.ts +368 -0
- package/src/runtime/standalone.ts +429 -0
- package/src/schema/validator.ts +61 -0
- package/src/server/body.ts +112 -0
- package/src/server/index.ts +6 -0
- package/src/server/runtime-registry.ts +80 -0
- package/src/server/security.ts +115 -0
- package/src/server/server.ts +572 -0
- package/src/server/types.ts +42 -0
- package/src/storage/index.ts +64 -0
- package/src/storage/mask.ts +34 -0
- package/src/storage/sqlite.ts +578 -0
- package/src/storage/types.ts +111 -0
- 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
|
+
}
|