@actiondock/core 2.9.0 → 2.9.1

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 CHANGED
@@ -56,7 +56,7 @@ Service -> Resolution -> PackageRuntime -> ExecutionService -> ActionRunner -> A
56
56
 
57
57
  - 发现端口 `DiscoveryPort`:负责包与动作的元数据发现、清单检索、全文过滤与规程查询。
58
58
  - 执行端口 `ExecutionPort`:负责动作的同步阻塞执行(`run`)与异步启动执行(`start`)。
59
- - 运行端口 `RunsPort`:负责任务运行历史列表、单次详情查询与协同取消(`cancel`)。
59
+ - 运行端口 `RunsPort`:负责任务运行历史列表、单次详情查询、协同取消(`cancel`)以及历史记录清理与保留策略淘汰(`clearRuns`、`cleanExpiredRuns`)。
60
60
  - 事件端口 `EventsPort`:负责任务执行实时事件流订阅。
61
61
  - 配置端口 `ConfigPort`:负责运行时分层配置读取、持久化配置管理与环境变量满足度体检。
62
62
  - 状态端口 `StatePort`:负责包级与动作级持久化键值存取、前缀列举与过期清理。
@@ -91,7 +91,9 @@ export declare class DefaultActionDockHost implements ActionDockHost {
91
91
  actionId?: string;
92
92
  status?: string;
93
93
  olderThanMs?: number;
94
+ keep?: number;
94
95
  }): Promise<number>;
96
+ cleanExpiredRuns(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
95
97
  cancelRun(runId: string, reason?: string): Promise<CancelResult>;
96
98
  events(runId: string, options?: {
97
99
  after?: number | string;
package/dist/host/host.js CHANGED
@@ -684,6 +684,16 @@ export class DefaultActionDockHost {
684
684
  }
685
685
  return total;
686
686
  }
687
+ async cleanExpiredRuns(policy) {
688
+ let total = 0;
689
+ for (const runtime of this.listRuntimes()) {
690
+ if (typeof runtime.cleanExpiredRuns === "function") {
691
+ const res = await runtime.cleanExpiredRuns(policy);
692
+ total += res;
693
+ }
694
+ }
695
+ return total;
696
+ }
687
697
  async cancelRun(runId, reason) {
688
698
  for (const runtime of this.listRuntimes()) {
689
699
  const res = await runtime.cancelRun(runId, reason);
@@ -80,7 +80,10 @@ export interface ActionDockHost {
80
80
  actionId?: string;
81
81
  status?: string;
82
82
  olderThanMs?: number;
83
+ keep?: number;
83
84
  }): Promise<number>;
85
+ /** 按保留策略清理所有运行时中的过期及超额终态运行记录 */
86
+ cleanExpiredRuns(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
84
87
  /** 取消指定在运行的任务 */
85
88
  cancelRun(runId: string, reason?: string): Promise<CancelResult>;
86
89
  /** 订阅指定运行的事件流 */
package/dist/index.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  export { ACTIONDOCK_VERSION } from "./version.js";
9
9
  export { createActionDock, connectActionDock, } from "./service/factory.js";
10
10
  export type { ActionDockService, ActionDockService as ActionDock, DiscoveryPort, ExecutionPort, RunsPort, EventsPort, RunEventSubscriptionOptions, ConfigPort, StatePort, CreateActionDockOptions, ConnectActionDockOptions, RemoteServiceOptions, ConfigValueView, ListRunsOptions, StateScopeOptions, } from "./service/types.js";
11
+ export { DEFAULT_RUNS_RETENTION_MS, DEFAULT_MAX_RUNS_PER_PACKAGE, DEFAULT_MIN_RETAIN_RUNS, type RunsRetentionPolicy, } from "./storage/types.js";
12
+ export { parseDuration } from "./utils/index.js";
11
13
  export type { ActionRef, ResolvedActionRef, ExecutionResult, RunRecord, ExecutionEvent, } from "@actiondock/sdk";
12
14
  export type { RunOptions, ExecutionTicket, CancelResult, } from "./execution/types.js";
13
15
  export type { ActionSpec, ActionSummary, PlaybookSpec, PlaybookSummary, PackageInfo, ListActionsOptions, } from "./package/types.js";
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@
9
9
  export { ACTIONDOCK_VERSION } from "./version.js";
10
10
  // 2. 统一服务门面与聚合契约接口
11
11
  export { createActionDock, connectActionDock, } from "./service/factory.js";
12
+ export { DEFAULT_RUNS_RETENTION_MS, DEFAULT_MAX_RUNS_PER_PACKAGE, DEFAULT_MIN_RETAIN_RUNS, } from "./storage/types.js";
13
+ export { parseDuration } from "./utils/index.js";
12
14
  // 5. 原生平台装配与服务启动
13
15
  export { createNodePlatform, } from "./platform/node.js";
14
16
  export { startActionDockServer, } from "./server/server.js";
package/dist/ipc/host.js CHANGED
@@ -20,6 +20,7 @@ const IPC_METHOD_HANDLERS = {
20
20
  getRun: async (service, a) => service.runs.get(a[0]),
21
21
  cancelRun: async (service, a) => service.runs.cancel(a[0], a[1]),
22
22
  clearRuns: async (service, a) => service.runs.clear ? await service.runs.clear(a[0]) : 0,
23
+ cleanExpiredRuns: async (service, a) => service.runs.cleanExpired ? await service.runs.cleanExpired(a[0]) : 0,
23
24
  getConfig: async (service, a) => service.management?.config.get(a[0], a[1]),
24
25
  setConfig: async (service, a) => service.management?.config.set(a[0], a[1], a[2]),
25
26
  deleteConfig: async (service, a) => service.management?.config.delete(a[0], a[1]),
@@ -184,6 +184,9 @@ export class IpcActionDockService {
184
184
  async clear(opts) {
185
185
  return forward("clearRuns", [opts]);
186
186
  },
187
+ async cleanExpired(policy) {
188
+ return forward("cleanExpiredRuns", [policy]);
189
+ },
187
190
  };
188
191
  if (options.enableManagement !== false) {
189
192
  this.management = {
@@ -97,7 +97,10 @@ export declare class DefaultPackageRuntime implements HostManagedPackageRuntime
97
97
  clearRuns(options?: {
98
98
  actionId?: string;
99
99
  status?: string;
100
+ olderThanMs?: number;
101
+ keep?: number;
100
102
  }): Promise<number>;
103
+ cleanExpiredRuns(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
101
104
  listStateEntries(options?: any): Promise<import("../storage/types.js").StateEntry[]>;
102
105
  close(options?: {
103
106
  graceMs?: number;
@@ -581,8 +581,16 @@ export class DefaultPackageRuntime {
581
581
  return this.storage.clearRuns({
582
582
  actionId: options?.actionId,
583
583
  status: options?.status,
584
+ olderThanMs: options?.olderThanMs,
585
+ keep: options?.keep,
584
586
  });
585
587
  }
588
+ async cleanExpiredRuns(policy) {
589
+ if (typeof this.storage.cleanExpiredRuns === "function") {
590
+ return this.storage.cleanExpiredRuns(policy);
591
+ }
592
+ return 0;
593
+ }
586
594
  async listStateEntries(options) {
587
595
  if (typeof this.storage.listStateEntries === "function") {
588
596
  return this.storage.listStateEntries(options);
@@ -268,7 +268,11 @@ export interface PackageRuntime {
268
268
  clearRuns(options?: {
269
269
  actionId?: string;
270
270
  status?: string;
271
+ olderThanMs?: number;
272
+ keep?: number;
271
273
  }): Promise<number>;
274
+ /** 按保留策略清理过期及超额的终态运行记录(基于时间与数量策略) */
275
+ cleanExpiredRuns?(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
272
276
  /** 订阅指定运行的事件流 */
273
277
  events(runId: string, options?: {
274
278
  after?: number | string;
@@ -64,6 +64,7 @@ export function createNodePlatform(options = {}) {
64
64
  clock,
65
65
  driver: createDriver(dbPath),
66
66
  recoverOrphans: opts?.recoverOrphans === true,
67
+ retentionPolicy: opts?.retentionPolicy,
67
68
  });
68
69
  },
69
70
  createGlobalStorage(opts) {
@@ -1,7 +1,7 @@
1
1
  import type { ProcessAPI } from "@actiondock/sdk";
2
2
  import type { Clock } from "../runtime/clock.js";
3
3
  import type { ModuleLoader } from "../runtime/module-loader.js";
4
- import type { RuntimeStorage } from "../storage/types.js";
4
+ import type { RunsRetentionPolicy, RuntimeStorage } from "../storage/types.js";
5
5
  /**
6
6
  * 文件元数据信息契约。
7
7
  */
@@ -58,6 +58,8 @@ export interface StorageFactoryOptions {
58
58
  * 默认 false(旁观查询打开,不触碰在途记录),供 CLI 查询命令与执行宿主并发共存。
59
59
  */
60
60
  recoverOrphans?: boolean;
61
+ /** 运行记录保留策略配置 */
62
+ retentionPolicy?: RunsRetentionPolicy;
61
63
  }
62
64
  /**
63
65
  * 跨运行时全局存储工厂配置选项。
@@ -30,13 +30,22 @@ export declare function fetchRemoteRuns(serverUrl: string, token?: string, optio
30
30
  items: RunRecord[];
31
31
  }>;
32
32
  /**
33
- * 清空远端运行记录(可按包、Action、状态过滤清除范围)。
33
+ * 清空远端运行记录(可按包、Action、状态、保留时长、保留数量过滤清除范围)。
34
34
  */
35
35
  export declare function clearRemoteRuns(serverUrl: string, token?: string, options?: {
36
36
  packageId?: string;
37
37
  actionId?: string;
38
38
  status?: string;
39
+ olderThanMs?: number;
40
+ keep?: number;
39
41
  } & RemoteClientRequestOptions): Promise<{
40
42
  ok: boolean;
41
43
  clearedCount: number;
42
44
  }>;
45
+ /**
46
+ * 按保留策略清理远端过期及超额运行记录。
47
+ */
48
+ export declare function cleanExpiredRemoteRuns(serverUrl: string, token?: string, options?: import("../storage/types.js").RunsRetentionPolicy & RemoteClientRequestOptions): Promise<{
49
+ ok: boolean;
50
+ cleanedCount: number;
51
+ }>;
@@ -44,7 +44,7 @@ export async function fetchRemoteRuns(serverUrl, token, options) {
44
44
  });
45
45
  }
46
46
  /**
47
- * 清空远端运行记录(可按包、Action、状态过滤清除范围)。
47
+ * 清空远端运行记录(可按包、Action、状态、保留时长、保留数量过滤清除范围)。
48
48
  */
49
49
  export async function clearRemoteRuns(serverUrl, token, options) {
50
50
  return fetchRemoteJson(serverUrl, "/api/v2/runs/clear", token, {
@@ -56,3 +56,16 @@ export async function clearRemoteRuns(serverUrl, token, options) {
56
56
  dispatcher: options?.dispatcher,
57
57
  });
58
58
  }
59
+ /**
60
+ * 按保留策略清理远端过期及超额运行记录。
61
+ */
62
+ export async function cleanExpiredRemoteRuns(serverUrl, token, options) {
63
+ return fetchRemoteJson(serverUrl, "/api/v2/runs/clean-expired", token, {
64
+ method: "POST",
65
+ body: options || {},
66
+ errorPrefix: "Failed to clean expired remote runs",
67
+ allowInsecureHttp: options?.allowInsecureHttp,
68
+ insecure: options?.insecure,
69
+ dispatcher: options?.dispatcher,
70
+ });
71
+ }
@@ -21,6 +21,6 @@ export { checkRemoteHealth } from "./client-health.js";
21
21
  export { executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRemoteDoctor, fetchRemoteInfo, } from "./client-actions.js";
22
22
  export type { RemoteExecuteOptions, RemoteExecutionResult } from "./client-actions.js";
23
23
  export { fetchRemotePlaybookShow, fetchRemotePlaybooks } from "./client-playbooks.js";
24
- export { cancelRemoteRun, clearRemoteRuns, fetchRemoteRun, fetchRemoteRuns, } from "./client-runs.js";
24
+ export { cancelRemoteRun, clearRemoteRuns, cleanExpiredRemoteRuns, fetchRemoteRun, fetchRemoteRuns, } from "./client-runs.js";
25
25
  export { clearRemoteState, deleteRemoteStateKey, fetchRemoteStateList, getRemoteStateKey, setRemoteStateKey, } from "./client-state.js";
26
26
  export { deleteRemoteConfig, fetchRemoteConfig, fetchRemoteConfigEnv, setRemoteConfig, } from "./client-config.js";
@@ -24,7 +24,7 @@ export { executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRe
24
24
  // Playbook 规程查询
25
25
  export { fetchRemotePlaybookShow, fetchRemotePlaybooks } from "./client-playbooks.js";
26
26
  // 运行记录管理
27
- export { cancelRemoteRun, clearRemoteRuns, fetchRemoteRun, fetchRemoteRuns, } from "./client-runs.js";
27
+ export { cancelRemoteRun, clearRemoteRuns, cleanExpiredRemoteRuns, fetchRemoteRun, fetchRemoteRuns, } from "./client-runs.js";
28
28
  // 状态存储管理
29
29
  export { clearRemoteState, deleteRemoteStateKey, fetchRemoteStateList, getRemoteStateKey, setRemoteStateKey, } from "./client-state.js";
30
30
  // 配置管理
@@ -86,10 +86,10 @@ export function initProject(targetDir, options = {}) {
86
86
  node: ">=24.12.0",
87
87
  },
88
88
  dependencies: {
89
- "@actiondock/sdk": "^2.9.0",
89
+ "@actiondock/sdk": "^2.9.1",
90
90
  },
91
91
  devDependencies: {
92
- "@actiondock/testing": "^2.9.0",
92
+ "@actiondock/testing": "^2.9.1",
93
93
  "@types/node": "^22.13.0",
94
94
  "tsx": "^4.19.0",
95
95
  "typescript": "^5.7.0",
@@ -1,5 +1,6 @@
1
1
  import { ACTION_FORBIDDEN, CAPABILITY_UNAVAILABLE, EVENT_BACKPRESSURE_LIMIT, EVENT_CURSOR_EXPIRED, EXECUTION_FAILED, PACKAGE_FORBIDDEN, RUN_ALREADY_FINISHED, RUN_INTERRUPTED, RUN_NOT_FOUND, RUNS_CLEAR_ERROR, RUNS_LIST_ERROR, } from "../../errors.js";
2
2
  import { isTerminalRunStatus } from "../../storage/types.js";
3
+ import { parseDuration } from "../../utils/index.js";
3
4
  import { readJsonBody } from "../body.js";
4
5
  import { getSubPath, isActionAllowedByPolicy, isPackageAllowedByPolicy, jsonResponse } from "./common.js";
5
6
  /**
@@ -69,6 +70,31 @@ export async function handleRunsRoutes(ctx) {
69
70
  const packageId = url.searchParams.get("packageId") || body.packageId || undefined;
70
71
  const actionId = url.searchParams.get("actionId") || body.actionId || undefined;
71
72
  const status = url.searchParams.get("status") || body.status || undefined;
73
+ const rawOlderThan = url.searchParams.get("olderThan") ||
74
+ url.searchParams.get("olderThanMs") ||
75
+ body.olderThan ||
76
+ body.olderThanMs;
77
+ let olderThanMs;
78
+ if (typeof rawOlderThan === "number" && rawOlderThan > 0) {
79
+ olderThanMs = rawOlderThan;
80
+ }
81
+ else if (typeof rawOlderThan === "string" && rawOlderThan.trim()) {
82
+ try {
83
+ olderThanMs = parseDuration(rawOlderThan);
84
+ }
85
+ catch { }
86
+ }
87
+ const rawKeep = url.searchParams.get("keep") || body.keep;
88
+ let keep;
89
+ if (typeof rawKeep === "number" && rawKeep >= 0) {
90
+ keep = rawKeep;
91
+ }
92
+ else if (typeof rawKeep === "string" && rawKeep.trim()) {
93
+ const parsed = parseInt(rawKeep.trim(), 10);
94
+ if (!isNaN(parsed) && parsed >= 0) {
95
+ keep = parsed;
96
+ }
97
+ }
72
98
  if (packageId && !isPackageAllowedByPolicy(packageId, policy)) {
73
99
  return jsonResponse({
74
100
  ok: false,
@@ -80,7 +106,13 @@ export async function handleRunsRoutes(ctx) {
80
106
  }
81
107
  let clearedCount = 0;
82
108
  if (service.runs.clear) {
83
- clearedCount = await service.runs.clear({ packageId, actionId, status });
109
+ clearedCount = await service.runs.clear({
110
+ packageId,
111
+ actionId,
112
+ status,
113
+ olderThanMs,
114
+ keep,
115
+ });
84
116
  }
85
117
  return jsonResponse({ ok: true, clearedCount }, 200, corsHeaders);
86
118
  }
@@ -88,6 +120,23 @@ export async function handleRunsRoutes(ctx) {
88
120
  return jsonResponse({ ok: false, error: { code: RUNS_CLEAR_ERROR, message: err.message } }, 500, corsHeaders);
89
121
  }
90
122
  }
123
+ // 2.1 Runs Clean Expired: POST /api/v2/runs/clean-expired, POST /runs/clean-expired
124
+ if (subpath === "/runs/clean-expired" && req.method === "POST") {
125
+ try {
126
+ let body = {};
127
+ if (req.headers.get("content-type")?.includes("json")) {
128
+ body = await readJsonBody(req, { maxBytes: options.maxBodyBytes }).catch(() => ({}));
129
+ }
130
+ let cleanedCount = 0;
131
+ if (service.runs.cleanExpired) {
132
+ cleanedCount = await service.runs.cleanExpired(body);
133
+ }
134
+ return jsonResponse({ ok: true, cleanedCount }, 200, corsHeaders);
135
+ }
136
+ catch (err) {
137
+ return jsonResponse({ ok: false, error: { code: RUNS_CLEAR_ERROR, message: err.message } }, 500, corsHeaders);
138
+ }
139
+ }
91
140
  // 3. Run Events SSE: GET /api/v2/runs/:runId/events (and stream alias)
92
141
  const runEventsMatch = subpath.match(/^\/runs\/([^/]+)\/(events|stream)$/);
93
142
  if (runEventsMatch && req.method === "GET") {
@@ -340,6 +340,17 @@ export async function startActionDockServer(options = {}) {
340
340
  }
341
341
  const actualHost = host === "0.0.0.0" ? "127.0.0.1" : host;
342
342
  const protocol = options.tls ? "https" : "http";
343
+ // 启动后台运行记录定期自动清理巡检定时器(默认 1 小时巡检一次)
344
+ const runsCleanupIntervalMs = 3600_000;
345
+ let runsCleanupTimer;
346
+ if (serviceInstance.runs?.cleanExpired) {
347
+ runsCleanupTimer = setInterval(() => {
348
+ serviceInstance?.runs?.cleanExpired?.().catch((err) => {
349
+ console.warn(`[ActionDockServer] Periodic runs cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
350
+ });
351
+ }, runsCleanupIntervalMs);
352
+ runsCleanupTimer.unref();
353
+ }
343
354
  const instance = {
344
355
  get port() {
345
356
  return server.port ?? port;
@@ -353,6 +364,10 @@ export async function startActionDockServer(options = {}) {
353
364
  },
354
365
  ready: Promise.resolve(),
355
366
  stop: async (stopOptions) => {
367
+ if (runsCleanupTimer) {
368
+ clearInterval(runsCleanupTimer);
369
+ runsCleanupTimer = undefined;
370
+ }
356
371
  await server.stop(true);
357
372
  if (serviceInstance) {
358
373
  try {
@@ -67,6 +67,9 @@ export class LocalActionDockService {
67
67
  async clear(opts) {
68
68
  return self.host.clearRuns(opts);
69
69
  },
70
+ async cleanExpired(policy) {
71
+ return self.host.cleanExpiredRuns(policy);
72
+ },
70
73
  };
71
74
  this.events = {
72
75
  events(runId, opts) {
@@ -1,6 +1,6 @@
1
1
  import { parseActionRef } from "../catalog/resolve-action.js";
2
2
  import { ActionDockError, ACTION_CANCELLED, CAPABILITY_UNAVAILABLE, INVALID_ARGUMENT, TIMEOUT, } from "../errors.js";
3
- import { cancelRemoteRun, clearRemoteRuns, clearRemoteState, deleteRemoteConfig, deleteRemoteStateKey, executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRemoteConfig, fetchRemoteInfo, fetchRemotePlaybookShow, fetchRemotePlaybooks, fetchRemoteRun, fetchRemoteRuns, fetchRemoteStateList, getRemoteStateKey, setRemoteConfig, setRemoteStateKey, } from "../profile/client.js";
3
+ import { cancelRemoteRun, clearRemoteRuns, cleanExpiredRemoteRuns, clearRemoteState, deleteRemoteConfig, deleteRemoteStateKey, executeRemoteAction, fetchRemoteActionShow, fetchRemoteActions, fetchRemoteConfig, fetchRemoteInfo, fetchRemotePlaybookShow, fetchRemotePlaybooks, fetchRemoteRun, fetchRemoteRuns, fetchRemoteStateList, getRemoteStateKey, setRemoteConfig, setRemoteStateKey, } from "../profile/client.js";
4
4
  import { isRemoteStateKeyNotFound, wrapRemoteError } from "./remote-errors.js";
5
5
  import { formatTerminalRunResult, pollRunCompletion } from "./remote-polling.js";
6
6
  import { streamRemoteEvents } from "./sse-stream.js";
@@ -306,6 +306,21 @@ export class RemoteActionDockService {
306
306
  wrapRemoteError(err);
307
307
  }
308
308
  },
309
+ async cleanExpired(policy) {
310
+ self.assertNotClosed();
311
+ try {
312
+ const res = await cleanExpiredRemoteRuns(self.serverUrl, self.token, {
313
+ ...policy,
314
+ allowInsecureHttp: self.allowInsecureHttp,
315
+ insecure: self.insecure,
316
+ dispatcher: self.dispatcher,
317
+ });
318
+ return res.cleanedCount ?? 0;
319
+ }
320
+ catch (err) {
321
+ wrapRemoteError(err);
322
+ }
323
+ },
309
324
  };
310
325
  this.events = {
311
326
  events(runId, opts) {
@@ -170,7 +170,11 @@ export interface RunsPort {
170
170
  packageId?: string;
171
171
  actionId?: string;
172
172
  status?: string;
173
+ olderThanMs?: number;
174
+ keep?: number;
173
175
  }): Promise<number>;
176
+ /** 按保留策略清理过期及超额的终态运行记录(基于时间与数量策略) */
177
+ cleanExpired?(policy?: import("../storage/types.js").RunsRetentionPolicy): Promise<number>;
174
178
  }
175
179
  /**
176
180
  * 配置项管理服务端口。
@@ -42,6 +42,7 @@ export declare function createStorage(packageId: string, options?: {
42
42
  inMemory?: boolean;
43
43
  customHome?: string;
44
44
  recoverOrphans?: boolean;
45
+ retentionPolicy?: import("./types.js").RunsRetentionPolicy;
45
46
  }): RuntimeStorage;
46
47
  /**
47
48
  * 解析全局共享数据库(global.db)文件的绝对路径。
@@ -59,7 +59,12 @@ export function resolveDatabasePath(packageId, options = {}) {
59
59
  */
60
60
  export function createStorage(packageId, options = {}) {
61
61
  const dbPath = resolveDatabasePath(packageId, options);
62
- return new SqliteRuntimeStorage({ dbPath, packageId, recoverOrphans: options.recoverOrphans });
62
+ return new SqliteRuntimeStorage({
63
+ dbPath,
64
+ packageId,
65
+ recoverOrphans: options.recoverOrphans,
66
+ retentionPolicy: options.retentionPolicy,
67
+ });
63
68
  }
64
69
  /**
65
70
  * 解析全局共享数据库(global.db)文件的绝对路径。
@@ -1,5 +1,5 @@
1
1
  import { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, type RuntimeError, type RunRecord } from "@actiondock/sdk";
2
- import { type IdempotencyCheckResult, type IdempotencyRecord, type RuntimeStorage, type StateEntry, type StorageOptions, type TerminalRunStatus } from "./types.js";
2
+ import { type IdempotencyCheckResult, type IdempotencyRecord, type RunsRetentionPolicy, type RuntimeStorage, type StateEntry, type StorageOptions, type TerminalRunStatus } from "./types.js";
3
3
  export { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, };
4
4
  /**
5
5
  * 统一 SQLite 运行时存储实现。
@@ -25,6 +25,11 @@ export declare class SqliteRuntimeStorage implements RuntimeStorage {
25
25
  private statementCache;
26
26
  private dbPath;
27
27
  private recoverOrphans;
28
+ private retentionPolicy?;
29
+ private lastRunsCleanupAt;
30
+ private runInsertCount;
31
+ private static readonly RUNS_CLEANUP_INTERVAL_MS;
32
+ private static readonly RUNS_CLEANUP_BATCH_THRESHOLD;
28
33
  get isOpen(): boolean;
29
34
  get closed(): boolean;
30
35
  constructor(options: StorageOptions);
@@ -84,7 +89,19 @@ export declare class SqliteRuntimeStorage implements RuntimeStorage {
84
89
  clearRuns(options?: {
85
90
  actionId?: string;
86
91
  status?: string;
92
+ olderThanMs?: number;
93
+ keep?: number;
87
94
  }): number;
95
+ /**
96
+ * 按保留策略清理过期的终态运行记录与超出容量限制的最旧运行记录。
97
+ *
98
+ * 包含:
99
+ * - 基于时间:删除早于 maxAgeMs 的终态记录(保留 minRetainRuns 保底数量)。
100
+ * - 基于数量:若终态记录总数超过 maxRuns,按时间由旧至新淘汰超出部分。
101
+ * - 级联清理孤立的幂等去重记录。
102
+ */
103
+ cleanExpiredRuns(policy?: RunsRetentionPolicy): number;
104
+ private resolveRetentionPolicy;
88
105
  checkAndRecordIdempotency(record: IdempotencyRecord): IdempotencyCheckResult;
89
106
  getIdempotencyRecord(ownerId: string, actionRef: string, requestId: string): IdempotencyRecord | undefined;
90
107
  private mapRunRecord;
@@ -5,7 +5,7 @@ import { SystemClock } from "./clock.js";
5
5
  import { createDefaultSqliteDriver } from "./driver.js";
6
6
  import { safeParseStoredJson } from "./utils.js";
7
7
  import { ActionDockError, AMBIGUOUS_STATE_KEY, STORED_ERROR_DECODE_FAILED, UNSUPPORTED_STORAGE_SCHEMA } from "../errors.js";
8
- import { IDEMPOTENCY_RETENTION_MS, STORAGE_SCHEMA_VERSION, isTerminalRunStatus, } from "./types.js";
8
+ import { DEFAULT_MAX_RUNS_PER_PACKAGE, DEFAULT_MIN_RETAIN_RUNS, DEFAULT_RUNS_RETENTION_MS, IDEMPOTENCY_RETENTION_MS, STORAGE_SCHEMA_VERSION, isTerminalRunStatus, } from "./types.js";
9
9
  export { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, };
10
10
  /**
11
11
  * 统一 SQLite 运行时存储实现。
@@ -31,6 +31,11 @@ export class SqliteRuntimeStorage {
31
31
  statementCache = new Map();
32
32
  dbPath;
33
33
  recoverOrphans;
34
+ retentionPolicy;
35
+ lastRunsCleanupAt = 0;
36
+ runInsertCount = 0;
37
+ static RUNS_CLEANUP_INTERVAL_MS = 3600_000;
38
+ static RUNS_CLEANUP_BATCH_THRESHOLD = 50;
34
39
  get isOpen() {
35
40
  return !this.isClosed;
36
41
  }
@@ -43,6 +48,7 @@ export class SqliteRuntimeStorage {
43
48
  const dbPath = options.dbPath || ":memory:";
44
49
  this.dbPath = dbPath;
45
50
  this.recoverOrphans = options.recoverOrphans === true;
51
+ this.retentionPolicy = options.retentionPolicy;
46
52
  if (dbPath !== ":memory:") {
47
53
  const dir = dirname(dbPath);
48
54
  if (!existsSync(dir)) {
@@ -162,10 +168,11 @@ export class SqliteRuntimeStorage {
162
168
  // 忽略非结构化表或兼容驱动异常
163
169
  }
164
170
  }
165
- // 重启恢复:仅持有者身份的打开方在构造阶段收割死亡会话遗留的非终态运行;
171
+ // 重启恢复:仅持有者身份的打开方在构造阶段收割死亡会话遗留的非终态运行并执行历史记录清理;
166
172
  // 旁观查询打开保持只读语义,绝不触碰其他进程的在途记录
167
173
  if (this.recoverOrphans) {
168
174
  this.recoverDeadSessionRuns();
175
+ this.cleanExpiredRuns();
169
176
  }
170
177
  }
171
178
  /**
@@ -504,6 +511,19 @@ export class SqliteRuntimeStorage {
504
511
  const finishedAt = record.finishedAt || null;
505
512
  const durationMs = typeof record.durationMs === "number" ? record.durationMs : null;
506
513
  stmt.run(record.id, rootRunId, parentRunId, record.packageId || this.packageId, packageInstanceId, record.actionId ?? "", generationId, ownerId, hostSessionId, record.status, inputJson, outputJson, errorJson, record.startedAt || new Date().toISOString(), finishedAt, durationMs);
514
+ this.runInsertCount++;
515
+ const nowMs = this.clock.now().getTime();
516
+ if (this.runInsertCount >= SqliteRuntimeStorage.RUNS_CLEANUP_BATCH_THRESHOLD &&
517
+ nowMs - this.lastRunsCleanupAt >= SqliteRuntimeStorage.RUNS_CLEANUP_INTERVAL_MS) {
518
+ this.lastRunsCleanupAt = nowMs;
519
+ this.runInsertCount = 0;
520
+ try {
521
+ this.cleanExpiredRuns();
522
+ }
523
+ catch (cleanupErr) {
524
+ console.warn(`[actiondock] opportunistic cleanExpiredRuns failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
525
+ }
526
+ }
507
527
  }
508
528
  updateRun(id, status, output, error, finishedAt) {
509
529
  if (this.isClosed)
@@ -569,6 +589,27 @@ export class SqliteRuntimeStorage {
569
589
  sql += " AND status = ?";
570
590
  params.push(options.status);
571
591
  }
592
+ if (typeof options.olderThanMs === "number" && options.olderThanMs > 0) {
593
+ const cutoff = new Date(this.clock.now().getTime() - options.olderThanMs).toISOString();
594
+ sql += " AND started_at < ?";
595
+ params.push(cutoff);
596
+ }
597
+ if (typeof options.keep === "number" && options.keep > 0) {
598
+ let subQuery = "SELECT id FROM runs WHERE package_id = ?";
599
+ const subParams = [this.packageId];
600
+ if (options.actionId) {
601
+ subQuery += " AND action_id = ?";
602
+ subParams.push(options.actionId);
603
+ }
604
+ if (options.status) {
605
+ subQuery += " AND status = ?";
606
+ subParams.push(options.status);
607
+ }
608
+ subQuery += " ORDER BY started_at DESC LIMIT ?";
609
+ subParams.push(options.keep);
610
+ sql += ` AND id NOT IN (${subQuery})`;
611
+ params.push(...subParams);
612
+ }
572
613
  const stmt = this.getStatement(sql);
573
614
  const res = stmt.run(...params);
574
615
  // 运行记录清理后,级联清理已无对应运行的孤立去重索引记录
@@ -581,6 +622,122 @@ export class SqliteRuntimeStorage {
581
622
  }
582
623
  return res.changes;
583
624
  }
625
+ /**
626
+ * 按保留策略清理过期的终态运行记录与超出容量限制的最旧运行记录。
627
+ *
628
+ * 包含:
629
+ * - 基于时间:删除早于 maxAgeMs 的终态记录(保留 minRetainRuns 保底数量)。
630
+ * - 基于数量:若终态记录总数超过 maxRuns,按时间由旧至新淘汰超出部分。
631
+ * - 级联清理孤立的幂等去重记录。
632
+ */
633
+ cleanExpiredRuns(policy) {
634
+ if (this.isClosed)
635
+ return 0;
636
+ try {
637
+ const resolved = this.resolveRetentionPolicy(policy);
638
+ const { maxAgeMs, maxRuns, minRetainRuns } = resolved;
639
+ let totalDeleted = 0;
640
+ // 1. 基于时间策略清理(仅针对终态记录)
641
+ if (maxAgeMs > 0) {
642
+ const now = this.clock.now().getTime();
643
+ const cutoff = new Date(now - maxAgeMs).toISOString();
644
+ if (minRetainRuns > 0) {
645
+ const stmt = this.getStatement(`
646
+ DELETE FROM runs
647
+ WHERE package_id = ?
648
+ AND status IN ('success', 'failed', 'cancelled', 'timed_out', 'interrupted')
649
+ AND started_at < ?
650
+ AND id NOT IN (
651
+ SELECT id FROM runs
652
+ WHERE package_id = ?
653
+ AND status IN ('success', 'failed', 'cancelled', 'timed_out', 'interrupted')
654
+ ORDER BY started_at DESC
655
+ LIMIT ?
656
+ )
657
+ `);
658
+ const res = stmt.run(this.packageId, cutoff, this.packageId, minRetainRuns);
659
+ totalDeleted += res.changes;
660
+ }
661
+ else {
662
+ const stmt = this.getStatement(`
663
+ DELETE FROM runs
664
+ WHERE package_id = ?
665
+ AND status IN ('success', 'failed', 'cancelled', 'timed_out', 'interrupted')
666
+ AND started_at < ?
667
+ `);
668
+ const res = stmt.run(this.packageId, cutoff);
669
+ totalDeleted += res.changes;
670
+ }
671
+ }
672
+ // 2. 基于数量策略清理(若当前终态记录数超过 maxRuns,淘汰最旧的记录)
673
+ if (maxRuns > 0) {
674
+ const stmt = this.getStatement(`
675
+ DELETE FROM runs
676
+ WHERE package_id = ?
677
+ AND status IN ('success', 'failed', 'cancelled', 'timed_out', 'interrupted')
678
+ AND id NOT IN (
679
+ SELECT id FROM runs
680
+ WHERE package_id = ?
681
+ AND status IN ('success', 'failed', 'cancelled', 'timed_out', 'interrupted')
682
+ ORDER BY started_at DESC
683
+ LIMIT ?
684
+ )
685
+ `);
686
+ const res = stmt.run(this.packageId, this.packageId, maxRuns);
687
+ totalDeleted += res.changes;
688
+ }
689
+ // 3. 级联清理无对应运行记录的孤立幂等去重索引
690
+ if (totalDeleted > 0) {
691
+ try {
692
+ this.getStatement("DELETE FROM idempotency_keys WHERE run_id NOT IN (SELECT id FROM runs)").run();
693
+ }
694
+ catch (err) {
695
+ console.warn(`[actiondock] orphaned idempotency keys cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
696
+ }
697
+ }
698
+ return totalDeleted;
699
+ }
700
+ catch (err) {
701
+ console.warn(`[actiondock] cleanExpiredRuns failed: ${err instanceof Error ? err.message : String(err)}`);
702
+ return 0;
703
+ }
704
+ }
705
+ resolveRetentionPolicy(policy) {
706
+ let configDays;
707
+ let configMaxRuns;
708
+ let configMinRetain;
709
+ try {
710
+ configDays = this.getConfig("runs.retentionDays");
711
+ configMaxRuns = this.getConfig("runs.maxRuns");
712
+ configMinRetain = this.getConfig("runs.minRetainRuns");
713
+ }
714
+ catch { }
715
+ const envDays = process.env.ACTIONDOCK_RUNS_RETENTION_DAYS
716
+ ? parseInt(process.env.ACTIONDOCK_RUNS_RETENTION_DAYS, 10)
717
+ : undefined;
718
+ const envMaxRuns = process.env.ACTIONDOCK_RUNS_MAX_COUNT
719
+ ? parseInt(process.env.ACTIONDOCK_RUNS_MAX_COUNT, 10)
720
+ : undefined;
721
+ const envMinRetain = process.env.ACTIONDOCK_RUNS_MIN_RETAIN
722
+ ? parseInt(process.env.ACTIONDOCK_RUNS_MIN_RETAIN, 10)
723
+ : undefined;
724
+ const maxAgeMs = policy?.maxAgeMs ??
725
+ this.retentionPolicy?.maxAgeMs ??
726
+ (typeof configDays === "number" && configDays > 0 ? configDays * 86_400_000 : undefined) ??
727
+ (typeof envDays === "number" && !isNaN(envDays) && envDays > 0 ? envDays * 86_400_000 : undefined) ??
728
+ DEFAULT_RUNS_RETENTION_MS;
729
+ const maxRuns = policy?.maxRuns ??
730
+ this.retentionPolicy?.maxRuns ??
731
+ (typeof configMaxRuns === "number" && configMaxRuns > 0 ? configMaxRuns : undefined) ??
732
+ (typeof envMaxRuns === "number" && !isNaN(envMaxRuns) && envMaxRuns > 0 ? envMaxRuns : undefined) ??
733
+ DEFAULT_MAX_RUNS_PER_PACKAGE;
734
+ const minRetainRuns = policy?.minRetainRuns ??
735
+ this.retentionPolicy?.minRetainRuns ??
736
+ (typeof configMinRetain === "number" && configMinRetain >= 0 ? configMinRetain : undefined) ??
737
+ (typeof envMinRetain === "number" && !isNaN(envMinRetain) && envMinRetain >= 0 ? envMinRetain : undefined) ??
738
+ DEFAULT_MIN_RETAIN_RUNS;
739
+ return { maxAgeMs, maxRuns, minRetainRuns };
740
+ }
584
741
  // --- Idempotency 幂等去重管理 ---
585
742
  checkAndRecordIdempotency(record) {
586
743
  if (this.isClosed) {
@@ -9,6 +9,29 @@ export declare const STORAGE_SCHEMA_VERSION = 2;
9
9
  * 中的 idempotencyPolicy.retentionMs 保持单一事实源。
10
10
  */
11
11
  export declare const IDEMPOTENCY_RETENTION_MS = 86400000;
12
+ /**
13
+ * 运行记录默认保留时长常量(14 天)。
14
+ */
15
+ export declare const DEFAULT_RUNS_RETENTION_MS: number;
16
+ /**
17
+ * 运行记录单个包默认最大终态记录保留条数(5000 条)。
18
+ */
19
+ export declare const DEFAULT_MAX_RUNS_PER_PACKAGE = 5000;
20
+ /**
21
+ * 运行记录时间过期清理默认保底保留条数(50 条)。
22
+ */
23
+ export declare const DEFAULT_MIN_RETAIN_RUNS = 50;
24
+ /**
25
+ * 运行记录双重保留策略契约(基于时间与基于数量)。
26
+ */
27
+ export interface RunsRetentionPolicy {
28
+ /** 最大保留时长(毫秒,默认 14 天) */
29
+ maxAgeMs?: number;
30
+ /** 单个包最大保留终态记录数(默认 5000,超出按最旧先淘汰) */
31
+ maxRuns?: number;
32
+ /** 最小保底保留终态记录数(默认 50,防止全清空) */
33
+ minRetainRuns?: number;
34
+ }
12
35
  /**
13
36
  * SQLite 基础参数值类型。
14
37
  */
@@ -95,6 +118,8 @@ export interface StorageOptions {
95
118
  * 避免跨进程互毁在途运行。
96
119
  */
97
120
  recoverOrphans?: boolean;
121
+ /** 运行记录保留策略配置 */
122
+ retentionPolicy?: RunsRetentionPolicy;
98
123
  }
99
124
  /**
100
125
  * 数据库中存储的幂等请求去重记录实体。
@@ -179,7 +204,11 @@ export interface RuntimeStorage {
179
204
  clearRuns(options?: {
180
205
  actionId?: string;
181
206
  status?: string;
207
+ olderThanMs?: number;
208
+ keep?: number;
182
209
  }): number;
210
+ /** 按保留策略清理过期及超额的终态运行记录(基于时间与数量策略) */
211
+ cleanExpiredRuns?(policy?: RunsRetentionPolicy): number;
183
212
  /**
184
213
  * 收敛死亡会话遗留的非终态运行任务(含无会话标识的遗留非终态记录),
185
214
  * 统一收敛为 interrupted。历史别名 recoverRunningRuns 已合并至本方法。
@@ -8,6 +8,18 @@ export const STORAGE_SCHEMA_VERSION = 2;
8
8
  * 中的 idempotencyPolicy.retentionMs 保持单一事实源。
9
9
  */
10
10
  export const IDEMPOTENCY_RETENTION_MS = 86_400_000;
11
+ /**
12
+ * 运行记录默认保留时长常量(14 天)。
13
+ */
14
+ export const DEFAULT_RUNS_RETENTION_MS = 14 * 86_400_000;
15
+ /**
16
+ * 运行记录单个包默认最大终态记录保留条数(5000 条)。
17
+ */
18
+ export const DEFAULT_MAX_RUNS_PER_PACKAGE = 5000;
19
+ /**
20
+ * 运行记录时间过期清理默认保底保留条数(50 条)。
21
+ */
22
+ export const DEFAULT_MIN_RETAIN_RUNS = 50;
11
23
  /**
12
24
  * 判断指定运行状态是否为终态(收敛 success, failed, cancelled, timed_out, interrupted)。
13
25
  */
package/dist/version.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * ActionDock 核心版本号单一事实源。
3
3
  */
4
- export declare const ACTIONDOCK_VERSION = "2.9.0";
4
+ export declare const ACTIONDOCK_VERSION = "2.9.1";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * ActionDock 核心版本号单一事实源。
3
3
  */
4
- export const ACTIONDOCK_VERSION = "2.9.0";
4
+ export const ACTIONDOCK_VERSION = "2.9.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actiondock/core",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "ActionDock Core Engine - Project loader, runtime execution, SQLite storage, standalone builder, and skill exporter",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -66,7 +66,7 @@
66
66
  "test": "node ../../scripts/run-tests.ts"
67
67
  },
68
68
  "dependencies": {
69
- "@actiondock/sdk": "^2.9.0",
69
+ "@actiondock/sdk": "^2.9.1",
70
70
  "ajv": "^8.17.1",
71
71
  "ajv-formats": "^3.0.1",
72
72
  "undici": "^7.29.1"