@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.
@@ -1,22 +1,23 @@
1
- import { Database } from "bun:sqlite";
2
1
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
2
  import { dirname } from "node:path";
4
- import type { RuntimeError, RunRecord } from "@actiondock/sdk";
5
- import type { RuntimeStorage, StateEntry, StorageOptions, TerminalRunStatus } from "./types";
3
+ import type { JsonValue, RuntimeError, RunRecord } from "@actiondock/sdk";
4
+ import { createDefaultSqliteDriver } from "./driver";
5
+ import type {
6
+ RuntimeStorage,
7
+ SqliteDriver,
8
+ StateEntry,
9
+ StorageOptions,
10
+ TerminalRunStatus,
11
+ } from "./types";
6
12
 
7
13
  /**
8
- * 基于 Bun 内置原生 SQLite (`bun:sqlite`) 实现的高性能运行时存储。
9
- *
10
- * 关键特性:
11
- * 1. 零网络与外部进程开销:直接通过 Bun C-API 高速操作 SQLite 数据库文件。
12
- * 2. 高并发优化:开启 `WAL`(Write-Ahead Logging)模式与 `synchronous = NORMAL`,支持高并发读写。
13
- * 3. 严格的安全权限:自动配置目录权限为 0700,数据库文件权限为 0600。
14
- * 4. 自动版本迁移:基于 SQLite `user_version` PRAGMA 实现无损自动 Schema 迁移。
15
- * 5. TTL 自动过期:支持状态的过期清理。
14
+ * 统一 SQLite 运行时存储实现。
15
+ * 通过 SqliteDriver 抽象驱动,解耦底层具体运行时引擎(Node.js / Bun)。
16
16
  */
17
17
  export class SqliteRuntimeStorage implements RuntimeStorage {
18
- private db: Database;
18
+ private driver: SqliteDriver;
19
19
  private packageId: string;
20
+ private isClosed = false;
20
21
 
21
22
  constructor(options: StorageOptions) {
22
23
  this.packageId = options.packageId;
@@ -29,12 +30,13 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
29
30
  mkdirSync(dir, { recursive: true, mode: 0o700 });
30
31
  chmodSync(dir, 0o700);
31
32
  } catch {
32
- // 忽略系统权限设置失败(如只读文件系统)
33
+ // 忽略系统权限设置失败
33
34
  }
34
35
  }
35
36
  }
36
37
 
37
- this.db = new Database(dbPath);
38
+ this.driver = options.driver ?? createDefaultSqliteDriver(dbPath);
39
+
38
40
  if (dbPath !== ":memory:" && existsSync(dbPath)) {
39
41
  try {
40
42
  chmodSync(dbPath, 0o600);
@@ -46,21 +48,21 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
46
48
  }
47
49
 
48
50
  /**
49
- * 初始化数据库 Schema 并执行版本迁移。
51
+ * 初始化数据库结构并执行无损版本升级。
50
52
  */
51
53
  private init(): void {
52
- this.db.exec("PRAGMA journal_mode = WAL;");
53
- this.db.exec("PRAGMA synchronous = NORMAL;");
54
+ this.driver.exec("PRAGMA journal_mode = WAL;");
55
+ this.driver.exec("PRAGMA synchronous = NORMAL;");
54
56
 
55
57
  // 读取 Schema 版本号
56
- const versionRes = this.db.query("PRAGMA user_version;").get() as {
58
+ const versionRes = this.driver.prepare("PRAGMA user_version;").get<{
57
59
  user_version: number;
58
- };
60
+ }>();
59
61
  const version = versionRes?.user_version ?? 0;
60
62
 
61
63
  if (version === 0) {
62
- this.db.transaction(() => {
63
- this.db.exec(`
64
+ this.driver.transaction(() => {
65
+ this.driver.exec(`
64
66
  CREATE TABLE IF NOT EXISTS config (
65
67
  package_id TEXT NOT NULL,
66
68
  key TEXT NOT NULL,
@@ -81,52 +83,91 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
81
83
 
82
84
  CREATE TABLE IF NOT EXISTS runs (
83
85
  id TEXT PRIMARY KEY,
86
+ root_run_id TEXT NOT NULL,
87
+ parent_run_id TEXT,
84
88
  package_id TEXT NOT NULL,
89
+ package_instance_id TEXT NOT NULL,
85
90
  action_id TEXT NOT NULL,
86
- parent_run_id TEXT,
91
+ generation_id TEXT NOT NULL,
92
+ owner_id TEXT NOT NULL,
87
93
  status TEXT NOT NULL,
88
94
  input_json TEXT,
89
95
  output_json TEXT,
90
96
  error_json TEXT,
91
97
  started_at TEXT NOT NULL,
92
- finished_at TEXT
98
+ finished_at TEXT,
99
+ duration_ms INTEGER
93
100
  );
94
101
 
95
102
  CREATE INDEX IF NOT EXISTS idx_runs_action ON runs(package_id, action_id);
103
+ CREATE INDEX IF NOT EXISTS idx_runs_root ON runs(root_run_id);
96
104
  CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC);
97
105
  CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);
98
106
 
99
- PRAGMA user_version = 2;
107
+ PRAGMA user_version = 3;
100
108
  `);
101
- })();
109
+ });
102
110
  } else if (version < 2) {
103
- this.db.transaction(() => {
111
+ this.driver.transaction(() => {
104
112
  try {
105
- const columns = this.db
106
- .query("PRAGMA table_info(state);")
107
- .all() as Array<{ name: string }>;
113
+ const columns = this.driver
114
+ .prepare("PRAGMA table_info(state);")
115
+ .all<{ name: string }>();
108
116
  const hasExpiresAt = columns.some((c) => c.name === "expires_at");
109
117
  if (!hasExpiresAt) {
110
- this.db.exec("ALTER TABLE state ADD COLUMN expires_at TEXT;");
118
+ this.driver.exec("ALTER TABLE state ADD COLUMN expires_at TEXT;");
111
119
  }
112
120
  } catch {
113
- // If table doesn't exist yet or already altered
121
+ // 忽略表已存在或字段已添加
114
122
  }
115
- this.db.exec(
123
+ this.driver.exec(
116
124
  "CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);"
117
125
  );
118
- this.db.exec("PRAGMA user_version = 2;");
119
- })();
126
+ this.driver.exec("PRAGMA user_version = 2;");
127
+ });
128
+ }
129
+
130
+ // 升级至版本 3:补齐运行记录调用链与实例字段
131
+ if (version < 3) {
132
+ this.driver.transaction(() => {
133
+ try {
134
+ const columns = this.driver
135
+ .prepare("PRAGMA table_info(runs);")
136
+ .all<{ name: string }>();
137
+ const columnNames = new Set(columns.map((c) => c.name));
138
+
139
+ if (!columnNames.has("root_run_id")) {
140
+ this.driver.exec("ALTER TABLE runs ADD COLUMN root_run_id TEXT DEFAULT '';");
141
+ this.driver.exec("UPDATE runs SET root_run_id = id WHERE root_run_id = '' OR root_run_id IS NULL;");
142
+ }
143
+ if (!columnNames.has("package_instance_id")) {
144
+ this.driver.exec("ALTER TABLE runs ADD COLUMN package_instance_id TEXT DEFAULT '';");
145
+ }
146
+ if (!columnNames.has("generation_id")) {
147
+ this.driver.exec("ALTER TABLE runs ADD COLUMN generation_id TEXT DEFAULT '1';");
148
+ }
149
+ if (!columnNames.has("owner_id")) {
150
+ this.driver.exec("ALTER TABLE runs ADD COLUMN owner_id TEXT DEFAULT 'local';");
151
+ }
152
+ if (!columnNames.has("duration_ms")) {
153
+ this.driver.exec("ALTER TABLE runs ADD COLUMN duration_ms INTEGER;");
154
+ }
155
+ } catch {
156
+ // 忽略已存在的字段
157
+ }
158
+ this.driver.exec("CREATE INDEX IF NOT EXISTS idx_runs_root ON runs(root_run_id);");
159
+ this.driver.exec("PRAGMA user_version = 3;");
160
+ });
120
161
  }
121
162
  }
122
163
 
123
- // --- Config ---
164
+ // --- Config 配置管理 ---
124
165
 
125
166
  getConfig<T = unknown>(key: string): T | undefined {
126
- const stmt = this.db.prepare(
167
+ const stmt = this.driver.prepare(
127
168
  "SELECT value_json FROM config WHERE package_id = ? AND key = ?"
128
169
  );
129
- const row = stmt.get(this.packageId, key) as { value_json: string } | null;
170
+ const row = stmt.get<{ value_json: string }>(this.packageId, key);
130
171
  if (!row || row.value_json === undefined || row.value_json === null) {
131
172
  return undefined;
132
173
  }
@@ -138,13 +179,10 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
138
179
  }
139
180
 
140
181
  listConfig(): Record<string, unknown> {
141
- const stmt = this.db.prepare(
182
+ const stmt = this.driver.prepare(
142
183
  "SELECT key, value_json FROM config WHERE package_id = ?"
143
184
  );
144
- const rows = stmt.all(this.packageId) as Array<{
145
- key: string;
146
- value_json: string;
147
- }>;
185
+ const rows = stmt.all<{ key: string; value_json: string }>(this.packageId);
148
186
  const result: Record<string, unknown> = {};
149
187
  for (const row of rows) {
150
188
  try {
@@ -157,7 +195,7 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
157
195
  }
158
196
 
159
197
  setConfig(key: string, value: unknown): void {
160
- const stmt = this.db.prepare(`
198
+ const stmt = this.driver.prepare(`
161
199
  INSERT INTO config (package_id, key, value_json, updated_at)
162
200
  VALUES (?, ?, ?, ?)
163
201
  ON CONFLICT(package_id, key) DO UPDATE SET
@@ -170,36 +208,36 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
170
208
  }
171
209
 
172
210
  deleteConfig(key: string): boolean {
173
- const stmt = this.db.prepare(
211
+ const stmt = this.driver.prepare(
174
212
  "DELETE FROM config WHERE package_id = ? AND key = ?"
175
213
  );
176
214
  const res = stmt.run(this.packageId, key);
177
215
  return res.changes > 0;
178
216
  }
179
217
 
180
- // --- State ---
218
+ // --- State 状态管理 ---
181
219
 
182
- async getState<T = unknown>(
183
- namespace: string,
184
- key: string
185
- ): Promise<T | undefined> {
186
- const stmt = this.db.prepare(
220
+ async getState<T = unknown>(namespace: string, key: string): Promise<T | undefined> {
221
+ const stmt = this.driver.prepare(
187
222
  "SELECT value_json, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
188
223
  );
189
- const row = stmt.get(this.packageId, namespace, key) as {
190
- value_json: string;
191
- expires_at: string | null;
192
- } | null;
224
+ const row = stmt.get<{ value_json: string; expires_at?: string }>(
225
+ this.packageId,
226
+ namespace,
227
+ key
228
+ );
193
229
  if (!row || row.value_json === undefined || row.value_json === null) {
194
230
  return undefined;
195
231
  }
232
+
196
233
  if (row.expires_at) {
197
- const expiresTime = new Date(row.expires_at).getTime();
198
- if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
199
- await this.deleteState(namespace, key);
234
+ const expires = new Date(row.expires_at).getTime();
235
+ if (Date.now() >= expires) {
236
+ this.deleteState(namespace, key).catch(() => {});
200
237
  return undefined;
201
238
  }
202
239
  }
240
+
203
241
  try {
204
242
  return JSON.parse(row.value_json) as T;
205
243
  } catch {
@@ -211,60 +249,94 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
211
249
  targetKey: string,
212
250
  namespace?: string
213
251
  ): Promise<StateEntry | undefined> {
214
- let row: any = null;
252
+ let ns = namespace;
253
+ let actualKey = targetKey;
254
+
255
+ if (!ns && targetKey.includes(":")) {
256
+ const parts = targetKey.split(":");
257
+ ns = parts[0];
258
+ actualKey = parts.slice(1).join(":");
259
+ }
215
260
 
216
- if (namespace !== undefined) {
217
- const stmt = this.db.prepare(
218
- "SELECT * FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
261
+ if (ns) {
262
+ const val = await this.getState<T>(ns, actualKey);
263
+ if (val === undefined) return undefined;
264
+
265
+ const stmt = this.driver.prepare(
266
+ "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
219
267
  );
220
- row = stmt.get(this.packageId, namespace, targetKey);
221
- } else {
222
- // 1. 先在根命名空间精确查找
223
- const rootStmt = this.db.prepare(
224
- "SELECT * FROM state WHERE package_id = ? AND namespace = '' AND key = ?"
268
+ const row = stmt.get<{ updated_at: string; expires_at?: string }>(
269
+ this.packageId,
270
+ ns,
271
+ actualKey
225
272
  );
226
- row = rootStmt.get(this.packageId, targetKey);
227
273
 
228
- // 2. 若未命中且包含 ':',尝试通过 (namespace || ':' || key) 复合匹配
229
- if (!row && targetKey.includes(":")) {
230
- const compositeStmt = this.db.prepare(
231
- "SELECT * FROM state WHERE package_id = ? AND (namespace || ':' || key) = ?"
232
- );
233
- row = compositeStmt.get(this.packageId, targetKey);
234
- }
274
+ return {
275
+ packageId: this.packageId,
276
+ namespace: ns,
277
+ key: actualKey,
278
+ fullKey: targetKey,
279
+ value: val,
280
+ updatedAt: row?.updated_at || new Date().toISOString(),
281
+ expiresAt: row?.expires_at,
282
+ };
235
283
  }
236
284
 
237
- if (!row) return undefined;
238
-
239
- // 校验过期
240
- if (row.expires_at) {
241
- const expiresTime = new Date(row.expires_at).getTime();
242
- if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
243
- await this.deleteState(row.namespace, row.key);
244
- return undefined;
245
- }
285
+ const val = await this.getState<T>("", actualKey);
286
+ if (val !== undefined) {
287
+ const stmt = this.driver.prepare(
288
+ "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
289
+ );
290
+ const row = stmt.get<{ updated_at: string; expires_at?: string }>(
291
+ this.packageId,
292
+ "",
293
+ actualKey
294
+ );
295
+ return {
296
+ packageId: this.packageId,
297
+ namespace: "",
298
+ key: actualKey,
299
+ fullKey: actualKey,
300
+ value: val,
301
+ updatedAt: row?.updated_at || new Date().toISOString(),
302
+ expiresAt: row?.expires_at,
303
+ };
246
304
  }
247
305
 
248
- let parsedVal: unknown;
249
- try {
250
- parsedVal =
251
- row.value_json !== null && row.value_json !== undefined
252
- ? JSON.parse(row.value_json)
253
- : row.value_json;
254
- } catch {
255
- parsedVal = row.value_json;
306
+ const stmt = this.driver.prepare(
307
+ "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ? AND key = ?"
308
+ );
309
+ const rows = stmt.all<{
310
+ namespace: string;
311
+ key: string;
312
+ value_json: string;
313
+ updated_at: string;
314
+ expires_at?: string;
315
+ }>(this.packageId, actualKey);
316
+
317
+ const now = Date.now();
318
+ for (const row of rows) {
319
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
320
+ continue;
321
+ }
322
+ let parsedVal: unknown;
323
+ try {
324
+ parsedVal = JSON.parse(row.value_json);
325
+ } catch {
326
+ parsedVal = row.value_json;
327
+ }
328
+ return {
329
+ packageId: this.packageId,
330
+ namespace: row.namespace,
331
+ key: row.key,
332
+ fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
333
+ value: parsedVal,
334
+ updatedAt: row.updated_at,
335
+ expiresAt: row.expires_at,
336
+ };
256
337
  }
257
338
 
258
- const fullKey = row.namespace ? `${row.namespace}:${row.key}` : row.key;
259
- return {
260
- packageId: row.package_id,
261
- namespace: row.namespace,
262
- key: row.key,
263
- fullKey,
264
- value: parsedVal as T,
265
- updatedAt: row.updated_at,
266
- expiresAt: row.expires_at || undefined,
267
- };
339
+ return undefined;
268
340
  }
269
341
 
270
342
  async setState<T = unknown>(
@@ -273,12 +345,7 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
273
345
  value: T,
274
346
  ttl?: number
275
347
  ): Promise<void> {
276
- const expiresAtStr =
277
- typeof ttl === "number" && ttl > 0
278
- ? new Date(Date.now() + ttl * 1000).toISOString()
279
- : null;
280
-
281
- const stmt = this.db.prepare(`
348
+ const stmt = this.driver.prepare(`
282
349
  INSERT INTO state (package_id, namespace, key, value_json, updated_at, expires_at)
283
350
  VALUES (?, ?, ?, ?, ?, ?)
284
351
  ON CONFLICT(package_id, namespace, key) DO UPDATE SET
@@ -287,195 +354,207 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
287
354
  expires_at = excluded.expires_at
288
355
  `);
289
356
  const valJson = JSON.stringify(value);
290
- const now = new Date().toISOString();
291
- stmt.run(this.packageId, namespace, key, valJson, now, expiresAtStr);
357
+ const now = new Date();
358
+ const updatedAt = now.toISOString();
359
+
360
+ let expiresAt: string | null = null;
361
+ if (typeof ttl === "number" && ttl > 0) {
362
+ expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
363
+ }
364
+
365
+ stmt.run(this.packageId, namespace, key, valJson, updatedAt, expiresAt);
292
366
  }
293
367
 
294
368
  async deleteState(namespace: string, key: string): Promise<boolean> {
295
- const stmt = this.db.prepare(
369
+ const stmt = this.driver.prepare(
296
370
  "DELETE FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
297
371
  );
298
372
  const res = stmt.run(this.packageId, namespace, key);
299
373
  return res.changes > 0;
300
374
  }
301
375
 
302
- async deleteStateSmart(
303
- targetKey: string,
304
- namespace?: string
305
- ): Promise<boolean> {
306
- if (namespace !== undefined) {
307
- return this.deleteState(namespace, targetKey);
308
- }
309
- // 1. 先尝试在根命名空间删除
310
- const rootDeleted = await this.deleteState("", targetKey);
311
- if (rootDeleted) return true;
312
-
313
- // 2. 若 targetKey 包含 ':',尝试按 (namespace || ':' || key) 复合键删除
314
- if (targetKey.includes(":")) {
315
- const stmt = this.db.prepare(
316
- "DELETE FROM state WHERE package_id = ? AND (namespace || ':' || key) = ?"
317
- );
318
- const res = stmt.run(this.packageId, targetKey);
319
- return res.changes > 0;
376
+ async deleteStateSmart(targetKey: string, namespace?: string): Promise<boolean> {
377
+ let ns = namespace;
378
+ let actualKey = targetKey;
379
+
380
+ if (!ns && targetKey.includes(":")) {
381
+ const parts = targetKey.split(":");
382
+ ns = parts[0];
383
+ actualKey = parts.slice(1).join(":");
384
+ }
385
+
386
+ if (ns !== undefined) {
387
+ return this.deleteState(ns, actualKey);
320
388
  }
321
389
 
322
- return false;
390
+ const deletedRoot = await this.deleteState("", actualKey);
391
+ if (deletedRoot) return true;
392
+
393
+ const stmt = this.driver.prepare(
394
+ "DELETE FROM state WHERE package_id = ? AND key = ?"
395
+ );
396
+ const res = stmt.run(this.packageId, actualKey);
397
+ return res.changes > 0;
323
398
  }
324
399
 
325
400
  async clearState(
326
401
  options: { namespace?: string; all?: boolean; prefix?: string } = {}
327
402
  ): Promise<number> {
328
- if (options.all) {
329
- const stmt = this.db.prepare("DELETE FROM state WHERE package_id = ?");
330
- const res = stmt.run(this.packageId);
331
- return res.changes;
332
- }
403
+ let sql = "DELETE FROM state WHERE package_id = ?";
404
+ const params: any[] = [this.packageId];
333
405
 
334
406
  if (options.namespace !== undefined) {
335
- if (options.prefix) {
336
- const stmt = this.db.prepare(
337
- "DELETE FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ?"
338
- );
339
- const res = stmt.run(
340
- this.packageId,
341
- options.namespace,
342
- `${options.prefix}%`
343
- );
344
- return res.changes;
345
- } else {
346
- const stmt = this.db.prepare(
347
- "DELETE FROM state WHERE package_id = ? AND namespace = ?"
348
- );
349
- const res = stmt.run(this.packageId, options.namespace);
350
- return res.changes;
351
- }
407
+ sql += " AND namespace = ?";
408
+ params.push(options.namespace);
352
409
  }
353
410
 
354
411
  if (options.prefix) {
355
- const pattern = `${options.prefix}%`;
356
- const stmt = this.db.prepare(
357
- "DELETE FROM state WHERE package_id = ? AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ?"
358
- );
359
- const res = stmt.run(this.packageId, pattern);
360
- return res.changes;
412
+ sql += " AND key LIKE ? ESCAPE '\\'";
413
+ const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
414
+ params.push(`${escapedPrefix}%`);
361
415
  }
362
416
 
363
- const stmt = this.db.prepare(
364
- "DELETE FROM state WHERE package_id = ? AND namespace = ''"
365
- );
366
- const res = stmt.run(this.packageId);
417
+ const stmt = this.driver.prepare(sql);
418
+ const res = stmt.run(...params);
367
419
  return res.changes;
368
420
  }
369
421
 
370
422
  async listStateKeys(
371
423
  namespace?: string | null,
372
- prefix = ""
424
+ prefix?: string
373
425
  ): Promise<string[]> {
374
- const nowStr = new Date().toISOString();
375
- try {
376
- const cleanupStmt = this.db.prepare(
377
- "DELETE FROM state WHERE package_id = ? AND expires_at IS NOT NULL AND expires_at <= ?"
378
- );
379
- cleanupStmt.run(this.packageId, nowStr);
380
- } catch {
381
- // Best-effort cleanup
426
+ let sql = "SELECT namespace, key, expires_at FROM state WHERE package_id = ?";
427
+ const params: any[] = [this.packageId];
428
+
429
+ if (namespace !== null && namespace !== undefined) {
430
+ sql += " AND namespace = ?";
431
+ params.push(namespace);
382
432
  }
383
433
 
384
- if (namespace === null || namespace === undefined) {
385
- const pattern = prefix ? `${prefix}%` : "%";
386
- const stmt = this.db.prepare(
387
- "SELECT namespace, key FROM state WHERE package_id = ? AND (expires_at IS NULL OR expires_at > ?) AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ? ORDER BY namespace ASC, key ASC"
388
- );
389
- const rows = stmt.all(this.packageId, nowStr, pattern) as Array<{
390
- namespace: string;
391
- key: string;
392
- }>;
393
- return rows.map((r) => (r.namespace ? `${r.namespace}:${r.key}` : r.key));
394
- } else {
395
- const pattern = prefix ? `${prefix}%` : "%";
396
- const stmt = this.db.prepare(
397
- "SELECT key FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key ASC"
398
- );
399
- const rows = stmt.all(
400
- this.packageId,
401
- namespace,
402
- pattern,
403
- nowStr
404
- ) as Array<{
405
- key: string;
406
- }>;
407
- return rows.map((r) => r.key);
434
+ if (prefix) {
435
+ sql += " AND key LIKE ? ESCAPE '\\'";
436
+ const escapedPrefix = prefix.replace(/([%_\\])/g, "\\$1");
437
+ params.push(`${escapedPrefix}%`);
408
438
  }
439
+
440
+ const stmt = this.driver.prepare(sql);
441
+ const rows = stmt.all<{
442
+ namespace: string;
443
+ key: string;
444
+ expires_at?: string;
445
+ }>(...params);
446
+
447
+ const now = Date.now();
448
+ const result: string[] = [];
449
+
450
+ for (const row of rows) {
451
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
452
+ continue;
453
+ }
454
+ if (namespace !== null && namespace !== undefined) {
455
+ result.push(row.key);
456
+ } else {
457
+ result.push(row.namespace ? `${row.namespace}:${row.key}` : row.key);
458
+ }
459
+ }
460
+
461
+ return result;
409
462
  }
410
463
 
411
464
  async listStateEntries(
412
465
  options: { namespace?: string; prefix?: string } = {}
413
466
  ): Promise<StateEntry[]> {
414
- const nowStr = new Date().toISOString();
415
- try {
416
- const cleanupStmt = this.db.prepare(
417
- "DELETE FROM state WHERE package_id = ? AND expires_at IS NOT NULL AND expires_at <= ?"
418
- );
419
- cleanupStmt.run(this.packageId, nowStr);
420
- } catch {}
421
-
422
- const pattern = options.prefix ? `${options.prefix}%` : "%";
423
- let rows: any[] = [];
467
+ let sql = "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ?";
468
+ const params: any[] = [this.packageId];
424
469
 
425
470
  if (options.namespace !== undefined) {
426
- const stmt = this.db.prepare(
427
- "SELECT * FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key ASC"
428
- );
429
- rows = stmt.all(this.packageId, options.namespace, pattern, nowStr);
430
- } else {
431
- const stmt = this.db.prepare(
432
- "SELECT * FROM state WHERE package_id = ? AND (expires_at IS NULL OR expires_at > ?) AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ? ORDER BY namespace ASC, key ASC"
433
- );
434
- rows = stmt.all(this.packageId, nowStr, pattern);
471
+ sql += " AND namespace = ?";
472
+ params.push(options.namespace);
435
473
  }
436
474
 
437
- return rows.map((row) => {
475
+ if (options.prefix) {
476
+ sql += " AND key LIKE ? ESCAPE '\\'";
477
+ const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
478
+ params.push(`${escapedPrefix}%`);
479
+ }
480
+
481
+ const stmt = this.driver.prepare(sql);
482
+ const rows = stmt.all<{
483
+ namespace: string;
484
+ key: string;
485
+ value_json: string;
486
+ updated_at: string;
487
+ expires_at?: string;
488
+ }>(...params);
489
+
490
+ const now = Date.now();
491
+ const results: StateEntry[] = [];
492
+
493
+ for (const row of rows) {
494
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
495
+ continue;
496
+ }
497
+
438
498
  let parsedVal: unknown;
439
499
  try {
440
- parsedVal =
441
- row.value_json !== null && row.value_json !== undefined
442
- ? JSON.parse(row.value_json)
443
- : row.value_json;
500
+ parsedVal = JSON.parse(row.value_json);
444
501
  } catch {
445
502
  parsedVal = row.value_json;
446
503
  }
447
- return {
448
- packageId: row.package_id,
504
+
505
+ results.push({
506
+ packageId: this.packageId,
449
507
  namespace: row.namespace,
450
508
  key: row.key,
451
509
  fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
452
510
  value: parsedVal,
453
511
  updatedAt: row.updated_at,
454
- expiresAt: row.expires_at || undefined,
455
- };
456
- });
512
+ expiresAt: row.expires_at,
513
+ });
514
+ }
515
+
516
+ return results;
457
517
  }
458
518
 
459
- // --- Runs ---
519
+ // --- Runs 运行记录管理 ---
460
520
 
461
- createRun(record: RunRecord): void {
462
- const stmt = this.db.prepare(`
521
+ createRun(record: RunRecord | any): void {
522
+ const stmt = this.driver.prepare(`
463
523
  INSERT INTO runs (
464
- id, package_id, action_id, parent_run_id, status,
465
- input_json, output_json, error_json, started_at, finished_at
466
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
524
+ id, root_run_id, parent_run_id, package_id, package_instance_id,
525
+ action_id, generation_id, owner_id, status, input_json, output_json,
526
+ error_json, started_at, finished_at, duration_ms
527
+ )
528
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
467
529
  `);
530
+
531
+ const rootRunId = record.rootRunId || record.id;
532
+ const parentRunId = record.parentRunId || null;
533
+ const packageInstanceId = record.packageInstanceId || record.packageId || this.packageId;
534
+ const generationId = record.generationId || "1";
535
+ const ownerId = record.ownerId || "local";
536
+ const inputJson = record.input !== undefined ? JSON.stringify(record.input) : null;
537
+ const outputJson = record.output !== undefined ? JSON.stringify(record.output) : null;
538
+ const errorJson = record.error ? JSON.stringify(record.error) : null;
539
+ const finishedAt = record.finishedAt || null;
540
+ const durationMs = typeof record.durationMs === "number" ? record.durationMs : null;
541
+
468
542
  stmt.run(
469
543
  record.id,
470
- this.packageId,
544
+ rootRunId,
545
+ parentRunId,
546
+ record.packageId || this.packageId,
547
+ packageInstanceId,
471
548
  record.actionId,
472
- record.parentRunId || null,
549
+ generationId,
550
+ ownerId,
473
551
  record.status,
474
- record.input !== undefined ? JSON.stringify(record.input) : null,
475
- record.output !== undefined ? JSON.stringify(record.output) : null,
476
- record.error ? JSON.stringify(record.error) : null,
552
+ inputJson,
553
+ outputJson,
554
+ errorJson,
477
555
  record.startedAt,
478
- record.finishedAt || null
556
+ finishedAt,
557
+ durationMs
479
558
  );
480
559
  }
481
560
 
@@ -486,28 +565,30 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
486
565
  error?: RuntimeError,
487
566
  finishedAt?: string
488
567
  ): void {
489
- const stmt = this.db.prepare(`
490
- UPDATE runs SET
491
- status = ?,
492
- output_json = ?,
493
- error_json = ?,
494
- finished_at = ?
495
- WHERE id = ?
496
- `);
497
- stmt.run(
498
- status,
499
- output !== undefined ? JSON.stringify(output) : null,
500
- error ? JSON.stringify(error) : null,
501
- finishedAt || new Date().toISOString(),
502
- id
503
- );
568
+ if (this.isClosed) return;
569
+ try {
570
+ const stmt = this.driver.prepare(`
571
+ UPDATE runs
572
+ SET status = ?, output_json = ?, error_json = ?, finished_at = ?
573
+ WHERE id = ?
574
+ `);
575
+ stmt.run(
576
+ status,
577
+ output !== undefined ? JSON.stringify(output) : null,
578
+ error ? JSON.stringify(error) : null,
579
+ finishedAt || new Date().toISOString(),
580
+ id
581
+ );
582
+ } catch {
583
+ // 数据库已关闭或操作异常,安全忽略
584
+ }
504
585
  }
505
586
 
506
587
  getRun(id: string): RunRecord | null {
507
- const stmt = this.db.prepare(
588
+ const stmt = this.driver.prepare(
508
589
  "SELECT * FROM runs WHERE id = ? AND package_id = ?"
509
590
  );
510
- const row = stmt.get(id, this.packageId) as any;
591
+ const row = stmt.get<any>(id, this.packageId);
511
592
  if (!row) return null;
512
593
  return this.mapRunRecord(row);
513
594
  }
@@ -516,7 +597,7 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
516
597
  const limit = options.limit || 50;
517
598
  let rows: any[];
518
599
  if (options.actionId) {
519
- const stmt = this.db.prepare(`
600
+ const stmt = this.driver.prepare(`
520
601
  SELECT * FROM runs
521
602
  WHERE package_id = ? AND action_id = ?
522
603
  ORDER BY started_at DESC
@@ -524,7 +605,7 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
524
605
  `);
525
606
  rows = stmt.all(this.packageId, options.actionId, limit);
526
607
  } else {
527
- const stmt = this.db.prepare(`
608
+ const stmt = this.driver.prepare(`
528
609
  SELECT * FROM runs
529
610
  WHERE package_id = ?
530
611
  ORDER BY started_at DESC
@@ -535,9 +616,25 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
535
616
  return rows.map((r) => this.mapRunRecord(r));
536
617
  }
537
618
 
619
+ clearRuns(options: { actionId?: string; status?: string } = {}): number {
620
+ let sql = "DELETE FROM runs WHERE package_id = ?";
621
+ const params: any[] = [this.packageId];
622
+ if (options.actionId) {
623
+ sql += " AND action_id = ?";
624
+ params.push(options.actionId);
625
+ }
626
+ if (options.status) {
627
+ sql += " AND status = ?";
628
+ params.push(options.status);
629
+ }
630
+ const stmt = this.driver.prepare(sql);
631
+ const res = stmt.run(...params);
632
+ return res.changes;
633
+ }
634
+
538
635
  private mapRunRecord(row: any): RunRecord {
539
- let input: unknown;
540
- let output: unknown;
636
+ let input: JsonValue | undefined;
637
+ let output: JsonValue | undefined;
541
638
  let error: RuntimeError | undefined;
542
639
 
543
640
  try {
@@ -560,19 +657,29 @@ export class SqliteRuntimeStorage implements RuntimeStorage {
560
657
 
561
658
  return {
562
659
  id: row.id,
660
+ rootRunId: row.root_run_id || row.id,
661
+ parentRunId: row.parent_run_id || undefined,
563
662
  packageId: row.package_id,
663
+ packageInstanceId: row.package_instance_id || row.package_id,
564
664
  actionId: row.action_id,
565
- parentRunId: row.parent_run_id || undefined,
665
+ generationId: row.generation_id || "1",
666
+ ownerId: row.owner_id || "local",
566
667
  status: row.status,
567
668
  input,
568
669
  output,
569
670
  error,
570
671
  startedAt: row.started_at,
571
672
  finishedAt: row.finished_at || undefined,
673
+ durationMs: typeof row.duration_ms === "number" ? row.duration_ms : undefined,
572
674
  };
573
675
  }
574
676
 
575
677
  close(): void {
576
- this.db.close();
678
+ this.isClosed = true;
679
+ try {
680
+ this.driver.close();
681
+ } catch {
682
+ // 忽略重复关闭异常
683
+ }
577
684
  }
578
685
  }