@actiondock/core 2.0.1 → 2.0.3

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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 统一时间与时钟接口。
3
+ */
4
+ export interface Clock {
5
+ /** 获取当前系统墙上时间 */
6
+ now(): Date;
7
+ /** 获取单调递增时间戳(单位:毫秒) */
8
+ monotonic(): number;
9
+ /** 异步休眠指定毫秒 */
10
+ sleep(ms: number): Promise<void>;
11
+ }
12
+
13
+ /**
14
+ * 生产环境系统时钟实现。
15
+ */
16
+ export class SystemClock implements Clock {
17
+ now(): Date {
18
+ return new Date();
19
+ }
20
+
21
+ monotonic(): number {
22
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
23
+ return performance.now();
24
+ }
25
+ return Date.now();
26
+ }
27
+
28
+ sleep(ms: number): Promise<void> {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+ }
32
+
33
+ let defaultClock: Clock = new SystemClock();
34
+
35
+ export function getSystemClock(): Clock {
36
+ return defaultClock;
37
+ }
38
+
39
+ export function setSystemClock(clock: Clock): void {
40
+ defaultClock = clock;
41
+ }
@@ -1,15 +1,19 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type {
2
3
  ActionContext,
3
4
  ActionDefinition,
4
5
  ActionInvoker,
5
6
  Config,
6
7
  Logger,
8
+ ProcessAPI,
9
+ ProgressReporter,
7
10
  StateStore,
8
11
  } from "@actiondock/sdk";
9
12
  import type { ProjectConfig } from "../project/types";
10
13
  import { createGlobalStorage } from "../storage";
11
14
  import type { RuntimeStorage } from "../storage/types";
12
15
  import { resolveEnvValue } from "./env";
16
+ import { getProcessExecutor } from "./process";
13
17
 
14
18
  /**
15
19
  * 生产级配置解析器实现。
@@ -184,8 +188,12 @@ export interface ContextOptions {
184
188
  overrides?: Record<string, unknown>;
185
189
  projectConfig?: ProjectConfig;
186
190
  parentRunId?: string;
191
+ runId?: string;
192
+ rootRunId?: string;
187
193
  callStack?: string[];
188
194
  signal?: AbortSignal;
195
+ process?: ProcessAPI;
196
+ progress?: ProgressReporter;
189
197
  onActionInvoke?: (
190
198
  action: ActionDefinition,
191
199
  input: unknown,
@@ -196,7 +204,7 @@ export interface ContextOptions {
196
204
  /**
197
205
  * 构建并装配传递给 Action 的完整 ActionContext 运行时上下文。
198
206
  *
199
- * @param options 上下文构建参数(包含存储连接、配置覆盖、项目元数据、取消信号与互调委托)
207
+ * @param options 上下文构建参数
200
208
  * @returns 组装完毕的 ActionContext 实例
201
209
  */
202
210
  export function createActionContext(options: ContextOptions): ActionContext {
@@ -208,6 +216,8 @@ export function createActionContext(options: ContextOptions): ActionContext {
208
216
  const state = new RuntimeStateStore(options.storage);
209
217
  const log = new StderrLogger();
210
218
  const signal = options.signal ?? new AbortController().signal;
219
+ const currentRunId = options.runId || randomUUID();
220
+ const currentRootRunId = options.rootRunId || options.parentRunId || currentRunId;
211
221
 
212
222
  const invoker: ActionInvoker = {
213
223
  async invoke<I, O>(action: ActionDefinition<I, O>, input: I): Promise<O> {
@@ -215,18 +225,30 @@ export function createActionContext(options: ContextOptions): ActionContext {
215
225
  return (await options.onActionInvoke(
216
226
  action as any,
217
227
  input,
218
- options.parentRunId
228
+ currentRunId
219
229
  )) as O;
220
230
  }
221
231
  throw new Error("ActionInvoker not configured with an invocation delegate");
222
232
  },
223
233
  };
224
234
 
235
+ const processApi = options.process || getProcessExecutor();
236
+ const progressApi: ProgressReporter = options.progress || {
237
+ report() {},
238
+ };
239
+
225
240
  return {
226
241
  config,
227
242
  state,
228
243
  actions: invoker,
244
+ process: processApi,
229
245
  log,
246
+ progress: progressApi,
230
247
  signal,
248
+ run: {
249
+ id: currentRunId,
250
+ rootId: currentRootRunId,
251
+ parentId: options.parentRunId,
252
+ },
231
253
  };
232
254
  }
@@ -0,0 +1,142 @@
1
+ import type { ExecutionEvent } from "@actiondock/sdk";
2
+
3
+ export interface EventSink {
4
+ emit(event: ExecutionEvent): void;
5
+ subscribe(
6
+ runId: string,
7
+ options?: { after?: number; signal?: AbortSignal }
8
+ ): AsyncIterable<ExecutionEvent>;
9
+ clear(runId: string): void;
10
+ }
11
+
12
+ /**
13
+ * 进程内有界事件缓冲区实现。
14
+ * 遵循设计文档:每个运行最多保留 1024 条或 1MB 事件,支持单调序号。
15
+ */
16
+ export class InMemoryEventSink implements EventSink {
17
+ private eventsByRun = new Map<string, ExecutionEvent[]>();
18
+ private listeners = new Map<string, Set<(event: ExecutionEvent) => void>>();
19
+ private maxEventsPerRun = 1024;
20
+
21
+ emit(event: ExecutionEvent): void {
22
+ let list = this.eventsByRun.get(event.runId);
23
+ if (!list) {
24
+ list = [];
25
+ this.eventsByRun.set(event.runId, list);
26
+ }
27
+
28
+ if (list.length >= this.maxEventsPerRun) {
29
+ // 淘汰旧日志和进度事件,保留状态和结果
30
+ const nonEssentialIndex = list.findIndex((e) => e.type === "log" || e.type === "progress");
31
+ if (nonEssentialIndex >= 0) {
32
+ list.splice(nonEssentialIndex, 1);
33
+ } else {
34
+ list.shift();
35
+ }
36
+ }
37
+ list.push(event);
38
+
39
+ const subs = this.listeners.get(event.runId);
40
+ if (subs) {
41
+ for (const listener of subs) {
42
+ try {
43
+ listener(event);
44
+ } catch {
45
+ // 忽略单个监听器内部异常
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ async *subscribe(
52
+ runId: string,
53
+ options: { after?: number; signal?: AbortSignal } = {}
54
+ ): AsyncIterable<ExecutionEvent> {
55
+ const after = options.after ?? -1;
56
+ const history = this.eventsByRun.get(runId) || [];
57
+
58
+ for (const evt of history) {
59
+ if (evt.sequence > after) {
60
+ yield evt;
61
+ }
62
+ }
63
+
64
+ const lastEvt = history[history.length - 1];
65
+ if (lastEvt && (lastEvt.type === "finish" || lastEvt.type === "status" && lastEvt.status !== "running")) {
66
+ return;
67
+ }
68
+
69
+ const queue: ExecutionEvent[] = [];
70
+ let notify: (() => void) | null = null;
71
+ let done = false;
72
+
73
+ const listener = (evt: ExecutionEvent) => {
74
+ if (evt.sequence > after) {
75
+ queue.push(evt);
76
+ if (notify) {
77
+ notify();
78
+ notify = null;
79
+ }
80
+ if (evt.type === "finish") {
81
+ done = true;
82
+ }
83
+ }
84
+ };
85
+
86
+ let subs = this.listeners.get(runId);
87
+ if (!subs) {
88
+ subs = new Set();
89
+ this.listeners.set(runId, subs);
90
+ }
91
+ subs.add(listener);
92
+
93
+ const cleanup = () => {
94
+ subs?.delete(listener);
95
+ if (subs && subs.size === 0) {
96
+ this.listeners.delete(runId);
97
+ }
98
+ };
99
+
100
+ if (options.signal) {
101
+ options.signal.addEventListener("abort", () => {
102
+ done = true;
103
+ if (notify) {
104
+ notify();
105
+ notify = null;
106
+ }
107
+ }, { once: true });
108
+ }
109
+
110
+ try {
111
+ while (!done && !options.signal?.aborted) {
112
+ if (queue.length > 0) {
113
+ yield queue.shift()!;
114
+ } else {
115
+ await new Promise<void>((resolve) => {
116
+ notify = resolve;
117
+ });
118
+ }
119
+ }
120
+ while (queue.length > 0) {
121
+ yield queue.shift()!;
122
+ }
123
+ } finally {
124
+ cleanup();
125
+ }
126
+ }
127
+
128
+ clear(runId: string): void {
129
+ this.eventsByRun.delete(runId);
130
+ this.listeners.delete(runId);
131
+ }
132
+ }
133
+
134
+ let defaultEventSink: EventSink = new InMemoryEventSink();
135
+
136
+ export function getDefaultEventSink(): EventSink {
137
+ return defaultEventSink;
138
+ }
139
+
140
+ export function setDefaultEventSink(sink: EventSink): void {
141
+ defaultEventSink = sink;
142
+ }
@@ -3,3 +3,7 @@ export * from "./runner";
3
3
  export * from "./execution-manager";
4
4
  export * from "./standalone";
5
5
  export * from "./env";
6
+ export * from "./clock";
7
+ export * from "./process";
8
+ export * from "./events";
9
+
@@ -0,0 +1,244 @@
1
+ import { spawn } from "node:child_process";
2
+ import type {
3
+ DetachedProcessOptions,
4
+ DetachedProcessResult,
5
+ ProcessAPI,
6
+ ProcessExecOptions,
7
+ ProcessResult,
8
+ RuntimeError,
9
+ } from "@actiondock/sdk";
10
+
11
+ export type ProcessExecutor = ProcessAPI;
12
+
13
+ let globalProcessExecutor: ProcessExecutor | undefined;
14
+
15
+ export function setProcessExecutor(executor: ProcessExecutor): void {
16
+ globalProcessExecutor = executor;
17
+ }
18
+
19
+ export function getProcessExecutor(): ProcessExecutor {
20
+ if (!globalProcessExecutor) {
21
+ globalProcessExecutor = new DefaultProcessExecutor();
22
+ }
23
+ return globalProcessExecutor;
24
+ }
25
+
26
+ /**
27
+ * 基于 Node.js 标准 child_process 实现的基础进程执行器。
28
+ */
29
+ export class DefaultProcessExecutor implements ProcessExecutor {
30
+ async exec(
31
+ command: string,
32
+ args: string[] = [],
33
+ options: ProcessExecOptions = {}
34
+ ): Promise<ProcessResult> {
35
+ const startTime = Date.now();
36
+ const maxOutputBytes = options.maxOutputBytes ?? 10 * 1024 * 1024;
37
+
38
+ return new Promise<ProcessResult>((resolve, reject) => {
39
+ let stdoutBuf = "";
40
+ let stderrBuf = "";
41
+ let totalBytes = 0;
42
+ let timedOut = false;
43
+ let cancelled = false;
44
+ let error: RuntimeError | undefined;
45
+
46
+ const cp = spawn(command, args, {
47
+ cwd: options.cwd,
48
+ env: options.env ? { ...process.env, ...options.env } : process.env,
49
+ stdio: ["pipe", "pipe", "pipe"],
50
+ });
51
+
52
+ if (options.input) {
53
+ cp.stdin.write(options.input);
54
+ cp.stdin.end();
55
+ } else {
56
+ cp.stdin.end();
57
+ }
58
+
59
+ let timer: ReturnType<typeof setTimeout> | undefined;
60
+ if (options.timeoutMs && options.timeoutMs > 0) {
61
+ timer = setTimeout(() => {
62
+ timedOut = true;
63
+ cp.kill("SIGTERM");
64
+ setTimeout(() => {
65
+ if (!cp.killed) cp.kill("SIGKILL");
66
+ }, 1000);
67
+ }, options.timeoutMs);
68
+ }
69
+
70
+ const onAbort = () => {
71
+ cancelled = true;
72
+ cp.kill("SIGTERM");
73
+ setTimeout(() => {
74
+ if (!cp.killed) cp.kill("SIGKILL");
75
+ }, 1000);
76
+ };
77
+
78
+ if (options.signal) {
79
+ if (options.signal.aborted) {
80
+ onAbort();
81
+ } else {
82
+ options.signal.addEventListener("abort", onAbort, { once: true });
83
+ }
84
+ }
85
+
86
+ cp.stdout?.on("data", (chunk: Buffer) => {
87
+ totalBytes += chunk.length;
88
+ if (totalBytes > maxOutputBytes) {
89
+ error = {
90
+ code: "PROCESS_OUTPUT_LIMIT",
91
+ message: `Process output exceeded limit of ${maxOutputBytes} bytes`,
92
+ };
93
+ cp.kill("SIGKILL");
94
+ return;
95
+ }
96
+ stdoutBuf += chunk.toString("utf-8");
97
+ });
98
+
99
+ cp.stderr?.on("data", (chunk: Buffer) => {
100
+ totalBytes += chunk.length;
101
+ if (totalBytes > maxOutputBytes) {
102
+ error = {
103
+ code: "PROCESS_OUTPUT_LIMIT",
104
+ message: `Process output exceeded limit of ${maxOutputBytes} bytes`,
105
+ };
106
+ cp.kill("SIGKILL");
107
+ return;
108
+ }
109
+ stderrBuf += chunk.toString("utf-8");
110
+ });
111
+
112
+ cp.on("error", (err) => {
113
+ if (timer) clearTimeout(timer);
114
+ const durationMs = Date.now() - startTime;
115
+ const res: ProcessResult = {
116
+ ok: false,
117
+ exitCode: null,
118
+ stdout: stdoutBuf.trim(),
119
+ stderr: stderrBuf.trim() || err.message,
120
+ raw: new TextEncoder().encode(stdoutBuf),
121
+ timedOut,
122
+ cancelled,
123
+ durationMs,
124
+ error: error || {
125
+ code: "PROCESS_SPAWN_ERROR",
126
+ message: err.message,
127
+ },
128
+ };
129
+ if (options.throwOnError) {
130
+ reject(new Error(err.message));
131
+ } else {
132
+ resolve(res);
133
+ }
134
+ });
135
+
136
+ cp.on("close", (exitCode, signal) => {
137
+ if (timer) clearTimeout(timer);
138
+ const durationMs = Date.now() - startTime;
139
+ const ok = exitCode === 0 && !timedOut && !cancelled && !error;
140
+
141
+ const res: ProcessResult = {
142
+ ok,
143
+ exitCode,
144
+ signal: signal || undefined,
145
+ stdout: stdoutBuf.trim(),
146
+ stderr: stderrBuf.trim(),
147
+ raw: new TextEncoder().encode(stdoutBuf),
148
+ timedOut,
149
+ cancelled,
150
+ durationMs,
151
+ error,
152
+ };
153
+
154
+ if (!ok && options.throwOnError) {
155
+ reject(new Error(stderrBuf.trim() || `Process exited with code ${exitCode}`));
156
+ } else {
157
+ resolve(res);
158
+ }
159
+ });
160
+ });
161
+ }
162
+
163
+ async spawnDetached(options: DetachedProcessOptions): Promise<DetachedProcessResult> {
164
+ const startTime = Date.now();
165
+ try {
166
+ const child = spawn(options.command, options.args || [], {
167
+ cwd: options.cwd,
168
+ env: options.env ? { ...process.env, ...options.env } : process.env,
169
+ detached: true,
170
+ stdio: "ignore",
171
+ });
172
+
173
+ child.unref();
174
+
175
+ if (!options.probe) {
176
+ return {
177
+ ok: true,
178
+ pid: child.pid,
179
+ ready: true,
180
+ durationMs: Date.now() - startTime,
181
+ };
182
+ }
183
+
184
+ const probeInterval = options.probeIntervalMs ?? 200;
185
+ const probeTimeout = options.probeTimeoutMs ?? 5000;
186
+ const deadline = Date.now() + probeTimeout;
187
+
188
+ while (Date.now() < deadline) {
189
+ if (options.signal?.aborted) {
190
+ return {
191
+ ok: false,
192
+ pid: child.pid,
193
+ ready: false,
194
+ durationMs: Date.now() - startTime,
195
+ error: {
196
+ code: "PROCESS_CANCELLED",
197
+ message: "Probe was cancelled by AbortSignal",
198
+ },
199
+ };
200
+ }
201
+
202
+ try {
203
+ const checkRes = await this.exec(options.command, ["--version"], {
204
+ timeoutMs: 1000,
205
+ });
206
+ const isReady = await options.probe(checkRes);
207
+ if (isReady) {
208
+ return {
209
+ ok: true,
210
+ pid: child.pid,
211
+ ready: true,
212
+ durationMs: Date.now() - startTime,
213
+ };
214
+ }
215
+ } catch {
216
+ // 探测失败继续轮询
217
+ }
218
+
219
+ await new Promise((r) => setTimeout(r, probeInterval));
220
+ }
221
+
222
+ return {
223
+ ok: false,
224
+ pid: child.pid,
225
+ ready: false,
226
+ durationMs: Date.now() - startTime,
227
+ error: {
228
+ code: "PROCESS_PROBE_TIMEOUT",
229
+ message: `Process probe timed out after ${probeTimeout}ms`,
230
+ },
231
+ };
232
+ } catch (err: any) {
233
+ return {
234
+ ok: false,
235
+ ready: false,
236
+ durationMs: Date.now() - startTime,
237
+ error: {
238
+ code: "PROCESS_DETACHED_FAILED",
239
+ message: err.message,
240
+ },
241
+ };
242
+ }
243
+ }
244
+ }
@@ -3,6 +3,9 @@ import type {
3
3
  ActionContext,
4
4
  ActionDefinition,
5
5
  ExecutionResult,
6
+ JsonValue,
7
+ ProcessAPI,
8
+ ProgressReporter,
6
9
  RuntimeError,
7
10
  RunRecord,
8
11
  } from "@actiondock/sdk";
@@ -10,6 +13,7 @@ import type { ProjectConfig } from "../project/types";
10
13
  import { validateSchema } from "../schema/validator";
11
14
  import type { RuntimeStorage, TerminalRunStatus } from "../storage/types";
12
15
  import { RuntimeConfig, RuntimeStateStore, StderrLogger } from "./context";
16
+ import { getProcessExecutor } from "./process";
13
17
 
14
18
  /**
15
19
  * ActionRunner 初始化配置选项。
@@ -25,20 +29,34 @@ export interface RunnerOptions {
25
29
  configOverrides?: Record<string, unknown>;
26
30
  /** 预加载的 Action 映射表 */
27
31
  actions?: Map<string, ActionDefinition>;
32
+ /** 外部注入的进程执行器 */
33
+ process?: ProcessAPI;
28
34
  }
29
35
 
30
36
  /**
31
37
  * 启动 Action 执行时的可选控制参数。
32
38
  */
33
39
  export interface ExecutionStartOptions {
40
+ /** 根运行 ID */
41
+ rootRunId?: string;
34
42
  /** 父级运行 ID(嵌套调用场景下建立调用链树) */
35
43
  parentRunId?: string;
44
+ /** 包物理实例标识 */
45
+ packageInstanceId?: string;
46
+ /** 快照代次标识 */
47
+ generationId?: string;
48
+ /** 执行所有者标识 */
49
+ ownerId?: string;
36
50
  /** 调用栈数组(用于检测 A -> B -> A 环路死锁) */
37
51
  callStack?: string[];
38
52
  /** 外部传入的 AbortSignal 取消信号 */
39
53
  signal?: AbortSignal;
40
54
  /** 最大超时时间(毫秒),超时将自动中止执行并标记为 ACTION_TIMEOUT */
41
55
  timeoutMs?: number;
56
+ /** 外部注入的进程执行器 */
57
+ process?: ProcessAPI;
58
+ /** 外部注入的进度报告器 */
59
+ progress?: ProgressReporter;
42
60
  }
43
61
 
44
62
  /**
@@ -173,11 +191,15 @@ export class ActionRunner {
173
191
  // 3. 插入初始运行记录 (状态: running)
174
192
  const initialRun: RunRecord = {
175
193
  id: runId,
194
+ rootRunId: options.rootRunId || options.parentRunId || runId,
195
+ parentRunId: options.parentRunId,
176
196
  packageId: this.packageId,
197
+ packageInstanceId: options.packageInstanceId || this.packageId,
177
198
  actionId: action.id,
178
- parentRunId: options.parentRunId,
199
+ generationId: options.generationId || "1",
200
+ ownerId: options.ownerId || "local",
179
201
  status: "running",
180
- input,
202
+ input: input as JsonValue | undefined,
181
203
  startedAt,
182
204
  };
183
205
  this.storage.createRun(initialRun);
@@ -235,9 +257,12 @@ export class ActionRunner {
235
257
  childInput: I
236
258
  ): Promise<O> => {
237
259
  const childResult = await this.execute(childAction, childInput, {
260
+ rootRunId: initialRun.rootRunId,
238
261
  parentRunId: runId,
239
262
  callStack,
240
263
  signal: controller.signal,
264
+ process: options.process,
265
+ progress: options.progress,
241
266
  });
242
267
  if (!childResult.ok) {
243
268
  const err = new Error(childResult.error.message);
@@ -253,8 +278,17 @@ export class ActionRunner {
253
278
  config,
254
279
  state,
255
280
  actions: invoker,
281
+ process: options.process || getProcessExecutor(),
256
282
  log,
283
+ progress: options.progress || {
284
+ report() {},
285
+ },
257
286
  signal: controller.signal,
287
+ run: {
288
+ id: runId,
289
+ rootId: initialRun.rootRunId,
290
+ parentId: options.parentRunId,
291
+ },
258
292
  };
259
293
 
260
294
  // 6. 执行 Action 业务逻辑并与取消/超时信号进行竞态
@@ -295,7 +329,7 @@ export class ActionRunner {
295
329
  return {
296
330
  ok: true,
297
331
  runId,
298
- data: rawOutput,
332
+ data: rawOutput as JsonValue,
299
333
  };
300
334
  } catch (err: any) {
301
335
  if (isTimeout) {
@@ -12,12 +12,47 @@ import type { RuntimeStorage } from "../storage/types";
12
12
  */
13
13
  export class ServerRuntimeRegistry {
14
14
  private storages = new Map<string, RuntimeStorage>();
15
+ private listeners = new Map<string, Set<(event: { type: string; data: any }) => void>>();
15
16
  public executionManager: ExecutionManager;
16
17
 
17
18
  constructor() {
18
19
  this.executionManager = new ExecutionManager();
19
20
  }
20
21
 
22
+ /**
23
+ * 订阅指定 runId 的事件(用于 SSE 流式推送)。
24
+ */
25
+ public subscribe(runId: string, listener: (event: { type: string; data: any }) => void): () => void {
26
+ let set = this.listeners.get(runId);
27
+ if (!set) {
28
+ set = new Set();
29
+ this.listeners.set(runId, set);
30
+ }
31
+ set.add(listener);
32
+ return () => {
33
+ set?.delete(listener);
34
+ if (set && set.size === 0) {
35
+ this.listeners.delete(runId);
36
+ }
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 向指定 runId 的订阅者广播事件。
42
+ */
43
+ public emit(runId: string, event: { type: string; data: any }): void {
44
+ const set = this.listeners.get(runId);
45
+ if (set) {
46
+ for (const listener of set) {
47
+ try {
48
+ listener(event);
49
+ } catch {
50
+ // 忽略单个监听器错误
51
+ }
52
+ }
53
+ }
54
+ }
55
+
21
56
  /**
22
57
  * 获取或懒加载指定 Package 和 projectRoot 的缓存 RuntimeStorage 实例。
23
58
  *