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