@actiondock/core 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog/graph.js +5 -2
  3. package/dist/doctor/checks/project.js +4 -4
  4. package/dist/doctor/checks/registry.js +1 -1
  5. package/dist/errors.d.ts +32 -1
  6. package/dist/errors.js +62 -2
  7. package/dist/host/host.d.ts +2 -0
  8. package/dist/host/host.js +13 -1
  9. package/dist/host/types.d.ts +3 -0
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/input/describe.d.ts +6 -0
  13. package/dist/input/describe.js +26 -1
  14. package/dist/input/input-resolver.js +1 -1
  15. package/dist/invocation/policy.js +2 -0
  16. package/dist/ipc/host.js +1 -0
  17. package/dist/ipc/service.js +3 -0
  18. package/dist/package/runtime.d.ts +3 -0
  19. package/dist/package/runtime.js +8 -0
  20. package/dist/package/types.d.ts +4 -0
  21. package/dist/platform/node.js +1 -0
  22. package/dist/platform/types.d.ts +3 -1
  23. package/dist/profile/client-runs.d.ts +10 -1
  24. package/dist/profile/client-runs.js +14 -1
  25. package/dist/profile/client.d.ts +1 -1
  26. package/dist/profile/client.js +1 -1
  27. package/dist/project/index.d.ts +1 -1
  28. package/dist/project/index.js +1 -1
  29. package/dist/project/init.js +2 -2
  30. package/dist/project/loader.js +1 -1
  31. package/dist/runtime/standalone.js +50 -4
  32. package/dist/server/routes/runs.js +50 -1
  33. package/dist/server/server.js +15 -0
  34. package/dist/service/local.js +3 -0
  35. package/dist/service/remote.js +16 -1
  36. package/dist/service/types.d.ts +4 -0
  37. package/dist/storage/index.d.ts +1 -0
  38. package/dist/storage/index.js +6 -1
  39. package/dist/storage/sqlite.d.ts +18 -1
  40. package/dist/storage/sqlite.js +159 -2
  41. package/dist/storage/types.d.ts +29 -0
  42. package/dist/storage/types.js +12 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import { decodeStateKey } from "@actiondock/sdk";
2
2
  import { filterWithFallbackInfo } from "../filter/index.js";
3
3
  import { createActionDock } from "../service/factory.js";
4
- import { STANDALONE_ASYNC_UNSUPPORTED } from "../errors.js";
4
+ import { STANDALONE_ASYNC_UNSUPPORTED, resolveExecutionHint, } from "../errors.js";
5
5
  import { resolveActionInput, formatActionDetail, buildActionDescribePayload, mapInputValidationFailure, } from "../input/index.js";
6
6
  import { validateActionInputValue } from "../json/value-validator.js";
7
7
  import { normalizeActionCollection } from "./action-collection.js";
@@ -62,6 +62,10 @@ export class StandaloneDispatcher {
62
62
  emitError(isJson, code, message, exitCode, options) {
63
63
  if (isJson) {
64
64
  const details = options?.details;
65
+ const hint = options?.hint ??
66
+ (details && typeof details === "object" && typeof details.hint === "string"
67
+ ? details.hint
68
+ : undefined);
65
69
  this.writeOut(JSON.stringify({
66
70
  ok: false,
67
71
  error: {
@@ -69,6 +73,7 @@ export class StandaloneDispatcher {
69
73
  message,
70
74
  ...(details !== undefined ? { details } : {}),
71
75
  },
76
+ ...(hint !== undefined ? { hint } : {}),
72
77
  }, null, 2));
73
78
  }
74
79
  else {
@@ -249,8 +254,11 @@ export class StandaloneDispatcher {
249
254
  description: a.description || "",
250
255
  }));
251
256
  const filterRes = filterWithFallbackInfo(list, effectiveIntent, [(a) => a.id, (a) => a.description], !noFallback);
257
+ const hints = [
258
+ "Tip: For composite or multi-step tasks, check 'ad playbook list' for standard operating procedures.",
259
+ ];
252
260
  if (isJson) {
253
- this.writeOut(JSON.stringify(filterRes.items, null, 2));
261
+ this.writeOut(JSON.stringify({ items: filterRes.items, hints }, null, 2));
254
262
  }
255
263
  else {
256
264
  let text = `Actions in ${this.options.packageId} (v${this.options.version}):\n\n`;
@@ -272,7 +280,11 @@ export class StandaloneDispatcher {
272
280
  action = await service.discovery.describeAction(id);
273
281
  }
274
282
  catch {
275
- return this.emitError(isJson, "INVALID_ARGUMENT", `Action '${id}' not found`, ExitCode.INVALID_ARGUMENT);
283
+ const hint = "Tip: Run 'ad list' to discover available actions.";
284
+ return this.emitError(isJson, "INVALID_ARGUMENT", `Action '${id}' not found`, ExitCode.INVALID_ARGUMENT, {
285
+ textMessage: `Error: Action '${id}' not found\n${hint}`,
286
+ hint,
287
+ });
276
288
  }
277
289
  const payload = buildActionDescribePayload(action, {
278
290
  packageId: this.options.packageId,
@@ -294,8 +306,12 @@ export class StandaloneDispatcher {
294
306
  let inputStr;
295
307
  let inputFile;
296
308
  let timeoutMs;
309
+ const recognizedFlags = new Set(["--json", "--async"]);
297
310
  for (let i = 0; i < subArgs.length; i++) {
298
311
  const arg = subArgs[i];
312
+ if (arg === id || recognizedFlags.has(arg)) {
313
+ continue;
314
+ }
299
315
  if (arg === "--timeout" && i + 1 < subArgs.length) {
300
316
  try {
301
317
  timeoutMs = parseDuration(subArgs[++i]);
@@ -318,12 +334,31 @@ export class StandaloneDispatcher {
318
334
  else if (arg.startsWith("--input=")) {
319
335
  inputStr = arg.slice(8);
320
336
  }
337
+ else if (arg === "-i" && i + 1 < subArgs.length) {
338
+ inputStr = subArgs[++i];
339
+ }
340
+ else if (arg.startsWith("-i=")) {
341
+ inputStr = arg.slice(3);
342
+ }
321
343
  else if (arg === "--input-file" && i + 1 < subArgs.length) {
322
344
  inputFile = subArgs[++i];
323
345
  }
324
346
  else if (arg.startsWith("--input-file=")) {
325
347
  inputFile = arg.slice(13);
326
348
  }
349
+ else if (arg === "-f" && i + 1 < subArgs.length) {
350
+ inputFile = subArgs[++i];
351
+ }
352
+ else if (arg.startsWith("-f=")) {
353
+ inputFile = arg.slice(3);
354
+ }
355
+ else if (arg.startsWith("-")) {
356
+ const hint = "Hint: Separate action inputs from CLI options using '--', e.g.: ad run <id> [options] -- <param>=<val> or <param>:=<json>.";
357
+ return this.emitError(isJson, "INVALID_ARGUMENT", `error: unknown option '${arg}'`, ExitCode.INVALID_ARGUMENT, {
358
+ textMessage: `error: unknown option '${arg}'\n${hint}`,
359
+ hint,
360
+ });
361
+ }
327
362
  }
328
363
  let input;
329
364
  try {
@@ -348,7 +383,14 @@ export class StandaloneDispatcher {
348
383
  timeoutMs,
349
384
  });
350
385
  if (isJson) {
351
- this.writeOut(JSON.stringify(result, null, 2));
386
+ if (!result.ok) {
387
+ const hint = resolveExecutionHint(id, result.error);
388
+ const machineOutput = hint !== undefined ? { ...result, hint } : result;
389
+ this.writeOut(JSON.stringify(machineOutput, null, 2));
390
+ }
391
+ else {
392
+ this.writeOut(JSON.stringify(result, null, 2));
393
+ }
352
394
  }
353
395
  else {
354
396
  if (result.ok) {
@@ -428,6 +470,10 @@ export class StandaloneDispatcher {
428
470
  ? result.error.details
429
471
  : JSON.stringify(result.error.details, null, 2));
430
472
  }
473
+ const hint = resolveExecutionHint(id, result.error);
474
+ if (hint) {
475
+ this.writeErr(hint);
476
+ }
431
477
  }
432
478
  }
433
479
  return result.ok ? ExitCode.SUCCESS : ExitCode.FAILURE;
@@ -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.10.0";
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.10.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actiondock/core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
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.10.0",
70
70
  "ajv": "^8.17.1",
71
71
  "ajv-formats": "^3.0.1",
72
72
  "undici": "^7.29.1"