@actiondock/testing 2.0.8 → 2.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actiondock/testing",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "ActionDock Test Runtime for testing Actions with real Core execution semantics",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -27,14 +27,20 @@
27
27
  "test": "bun test"
28
28
  },
29
29
  "dependencies": {
30
- "@actiondock/core": "^2.0.0",
31
- "@actiondock/sdk": "^2.0.0"
30
+ "@actiondock/core": "^2.0.10",
31
+ "@actiondock/sdk": "^2.0.10"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/bun": "latest",
35
35
  "typescript": "^5.7.0"
36
36
  },
37
- "keywords": ["actiondock", "testing", "agent", "actions", "mock"],
37
+ "keywords": [
38
+ "actiondock",
39
+ "testing",
40
+ "agent",
41
+ "actions",
42
+ "mock"
43
+ ],
38
44
  "author": "team4u",
39
45
  "license": "Apache-2.0",
40
46
  "repository": {
package/src/runtime.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  RuntimeStateStore,
8
8
  setDefaultEventSink,
9
9
  setProcessExecutor,
10
- setSystemClock,
11
10
  } from "@actiondock/core";
12
11
  import type {
13
12
  ActionDefinition,
@@ -226,8 +225,7 @@ export function createTestRuntime(options: TestRuntimeOptions = {}): TestRuntime
226
225
  clock,
227
226
  });
228
227
 
229
- // 全局注入测试时钟与进程执行器
230
- setSystemClock(clock);
228
+ // 全局注入测试进程执行器
231
229
  setProcessExecutor(process);
232
230
 
233
231
  // 初始化配置数据
package/src/storage.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  type Clock,
3
- createDefaultSqliteDriver,
4
- getSystemClock,
5
3
  type RuntimeStorage,
4
+ SqliteRuntimeStorage,
6
5
  type SqliteDriver,
7
6
  type StateEntry,
8
7
  type TerminalRunStatus,
@@ -23,579 +22,16 @@ export interface MemoryStorageOptions {
23
22
 
24
23
  /**
25
24
  * 统一内存运行时存储实现。
26
- * 基于 SQLite 内存模式构建,确保与生产环境核心存储具备完全相同的配置优先级、状态过期契约与运行终态行为。
25
+ * 基于 SqliteRuntimeStorage 构建,默认使用 :memory: 内存数据库并对接虚拟时钟,
26
+ * 确保与生产环境具备完全相同的配置优先级、状态过期契约与运行终态行为。
27
27
  */
28
- export class MemoryStorage implements RuntimeStorage {
29
- private driver: SqliteDriver;
30
- private packageId: string;
31
- private clock: Clock;
32
- private isClosed = false;
33
-
28
+ export class MemoryStorage extends SqliteRuntimeStorage {
34
29
  constructor(options: MemoryStorageOptions = {}) {
35
- this.packageId = options.packageId || "test-pkg";
36
- this.clock = options.clock || getSystemClock();
37
- this.driver = options.driver || createDefaultSqliteDriver(":memory:");
38
- this.init();
39
- }
40
-
41
- /**
42
- * 初始化内存表结构与索引。
43
- */
44
- private init(): void {
45
- this.driver.exec("PRAGMA journal_mode = MEMORY;");
46
- this.driver.exec("PRAGMA synchronous = OFF;");
47
-
48
- this.driver.transaction(() => {
49
- this.driver.exec(`
50
- CREATE TABLE IF NOT EXISTS config (
51
- package_id TEXT NOT NULL,
52
- key TEXT NOT NULL,
53
- value_json TEXT,
54
- updated_at TEXT NOT NULL,
55
- PRIMARY KEY (package_id, key)
56
- );
57
-
58
- CREATE TABLE IF NOT EXISTS state (
59
- package_id TEXT NOT NULL,
60
- namespace TEXT NOT NULL,
61
- key TEXT NOT NULL,
62
- value_json TEXT,
63
- updated_at TEXT NOT NULL,
64
- expires_at TEXT,
65
- PRIMARY KEY (package_id, namespace, key)
66
- );
67
-
68
- CREATE TABLE IF NOT EXISTS runs (
69
- id TEXT PRIMARY KEY,
70
- root_run_id TEXT NOT NULL,
71
- parent_run_id TEXT,
72
- package_id TEXT NOT NULL,
73
- package_instance_id TEXT NOT NULL,
74
- action_id TEXT NOT NULL,
75
- generation_id TEXT NOT NULL,
76
- owner_id TEXT NOT NULL,
77
- status TEXT NOT NULL,
78
- input_json TEXT,
79
- output_json TEXT,
80
- error_json TEXT,
81
- started_at TEXT NOT NULL,
82
- finished_at TEXT,
83
- duration_ms INTEGER
84
- );
85
-
86
- CREATE INDEX IF NOT EXISTS idx_runs_action ON runs(package_id, action_id);
87
- CREATE INDEX IF NOT EXISTS idx_runs_root ON runs(root_run_id);
88
- CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC);
89
- CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);
90
- `);
30
+ super({
31
+ packageId: options.packageId || "test-pkg",
32
+ dbPath: ":memory:",
33
+ driver: options.driver,
34
+ clock: options.clock,
91
35
  });
92
36
  }
93
-
94
- // --- 配置管理 ---
95
-
96
- getConfig<T = unknown>(key: string): T | undefined {
97
- const stmt = this.driver.prepare(
98
- "SELECT value_json FROM config WHERE package_id = ? AND key = ?"
99
- );
100
- const row = stmt.get<{ value_json: string }>(this.packageId, key);
101
- if (!row || row.value_json === undefined || row.value_json === null) {
102
- return undefined;
103
- }
104
- try {
105
- return JSON.parse(row.value_json) as T;
106
- } catch {
107
- return row.value_json as unknown as T;
108
- }
109
- }
110
-
111
- listConfig(): Record<string, unknown> {
112
- const stmt = this.driver.prepare(
113
- "SELECT key, value_json FROM config WHERE package_id = ?"
114
- );
115
- const rows = stmt.all<{ key: string; value_json: string }>(this.packageId);
116
- const result: Record<string, unknown> = {};
117
- for (const row of rows) {
118
- try {
119
- result[row.key] = JSON.parse(row.value_json);
120
- } catch {
121
- result[row.key] = row.value_json;
122
- }
123
- }
124
- return result;
125
- }
126
-
127
- setConfig(key: string, value: unknown): void {
128
- const stmt = this.driver.prepare(`
129
- INSERT INTO config (package_id, key, value_json, updated_at)
130
- VALUES (?, ?, ?, ?)
131
- ON CONFLICT(package_id, key) DO UPDATE SET
132
- value_json = excluded.value_json,
133
- updated_at = excluded.updated_at
134
- `);
135
- const valJson = JSON.stringify(value);
136
- const now = this.clock.now().toISOString();
137
- stmt.run(this.packageId, key, valJson, now);
138
- }
139
-
140
- deleteConfig(key: string): boolean {
141
- const stmt = this.driver.prepare(
142
- "DELETE FROM config WHERE package_id = ? AND key = ?"
143
- );
144
- const res = stmt.run(this.packageId, key);
145
- return res.changes > 0;
146
- }
147
-
148
- // --- 状态管理 ---
149
-
150
- async getState<T = unknown>(
151
- namespace: string,
152
- key: string
153
- ): Promise<T | undefined> {
154
- const stmt = this.driver.prepare(
155
- "SELECT value_json, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
156
- );
157
- const row = stmt.get<{ value_json: string; expires_at?: string }>(
158
- this.packageId,
159
- namespace,
160
- key
161
- );
162
- if (!row || row.value_json === undefined || row.value_json === null) {
163
- return undefined;
164
- }
165
-
166
- if (row.expires_at) {
167
- const expires = new Date(row.expires_at).getTime();
168
- const current = this.clock.now().getTime();
169
- if (current >= expires) {
170
- this.deleteState(namespace, key).catch(() => {});
171
- return undefined;
172
- }
173
- }
174
-
175
- try {
176
- return JSON.parse(row.value_json) as T;
177
- } catch {
178
- return row.value_json as unknown as T;
179
- }
180
- }
181
-
182
- async findState<T = unknown>(
183
- targetKey: string,
184
- namespace?: string
185
- ): Promise<StateEntry | undefined> {
186
- let ns = namespace;
187
- let actualKey = targetKey;
188
-
189
- if (!ns && targetKey.includes(":")) {
190
- const parts = targetKey.split(":");
191
- ns = parts[0];
192
- actualKey = parts.slice(1).join(":");
193
- }
194
-
195
- if (ns) {
196
- const val = await this.getState<T>(ns, actualKey);
197
- if (val === undefined) return undefined;
198
-
199
- const stmt = this.driver.prepare(
200
- "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
201
- );
202
- const row = stmt.get<{ updated_at: string; expires_at?: string }>(
203
- this.packageId,
204
- ns,
205
- actualKey
206
- );
207
-
208
- return {
209
- packageId: this.packageId,
210
- namespace: ns,
211
- key: actualKey,
212
- fullKey: targetKey,
213
- value: val,
214
- updatedAt: row?.updated_at || this.clock.now().toISOString(),
215
- expiresAt: row?.expires_at,
216
- };
217
- }
218
-
219
- const val = await this.getState<T>("", actualKey);
220
- if (val !== undefined) {
221
- const stmt = this.driver.prepare(
222
- "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
223
- );
224
- const row = stmt.get<{ updated_at: string; expires_at?: string }>(
225
- this.packageId,
226
- "",
227
- actualKey
228
- );
229
- return {
230
- packageId: this.packageId,
231
- namespace: "",
232
- key: actualKey,
233
- fullKey: actualKey,
234
- value: val,
235
- updatedAt: row?.updated_at || this.clock.now().toISOString(),
236
- expiresAt: row?.expires_at,
237
- };
238
- }
239
-
240
- const stmt = this.driver.prepare(
241
- "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ? AND key = ?"
242
- );
243
- const rows = stmt.all<{
244
- namespace: string;
245
- key: string;
246
- value_json: string;
247
- updated_at: string;
248
- expires_at?: string;
249
- }>(this.packageId, actualKey);
250
-
251
- const now = this.clock.now().getTime();
252
- for (const row of rows) {
253
- if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
254
- continue;
255
- }
256
- let parsedVal: unknown;
257
- try {
258
- parsedVal = JSON.parse(row.value_json);
259
- } catch {
260
- parsedVal = row.value_json;
261
- }
262
- return {
263
- packageId: this.packageId,
264
- namespace: row.namespace,
265
- key: row.key,
266
- fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
267
- value: parsedVal,
268
- updatedAt: row.updated_at,
269
- expiresAt: row.expires_at,
270
- };
271
- }
272
-
273
- return undefined;
274
- }
275
-
276
- async setState<T = unknown>(
277
- namespace: string,
278
- key: string,
279
- value: T,
280
- ttl?: number
281
- ): Promise<void> {
282
- const stmt = this.driver.prepare(`
283
- INSERT INTO state (package_id, namespace, key, value_json, updated_at, expires_at)
284
- VALUES (?, ?, ?, ?, ?, ?)
285
- ON CONFLICT(package_id, namespace, key) DO UPDATE SET
286
- value_json = excluded.value_json,
287
- updated_at = excluded.updated_at,
288
- expires_at = excluded.expires_at
289
- `);
290
- const valJson = JSON.stringify(value);
291
- const now = this.clock.now();
292
- const updatedAt = now.toISOString();
293
-
294
- let expiresAt: string | null = null;
295
- if (typeof ttl === "number" && ttl > 0) {
296
- expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
297
- }
298
-
299
- stmt.run(this.packageId, namespace, key, valJson, updatedAt, expiresAt);
300
- }
301
-
302
- async deleteState(namespace: string, key: string): Promise<boolean> {
303
- const stmt = this.driver.prepare(
304
- "DELETE FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
305
- );
306
- const res = stmt.run(this.packageId, namespace, key);
307
- return res.changes > 0;
308
- }
309
-
310
- async deleteStateSmart(
311
- targetKey: string,
312
- namespace?: string
313
- ): Promise<boolean> {
314
- let ns = namespace;
315
- let actualKey = targetKey;
316
-
317
- if (!ns && targetKey.includes(":")) {
318
- const parts = targetKey.split(":");
319
- ns = parts[0];
320
- actualKey = parts.slice(1).join(":");
321
- }
322
-
323
- if (ns !== undefined) {
324
- return this.deleteState(ns, actualKey);
325
- }
326
-
327
- const deletedRoot = await this.deleteState("", actualKey);
328
- if (deletedRoot) return true;
329
-
330
- const stmt = this.driver.prepare(
331
- "DELETE FROM state WHERE package_id = ? AND key = ?"
332
- );
333
- const res = stmt.run(this.packageId, actualKey);
334
- return res.changes > 0;
335
- }
336
-
337
- async clearState(
338
- options: { namespace?: string; all?: boolean; prefix?: string } = {}
339
- ): Promise<number> {
340
- let sql = "DELETE FROM state WHERE package_id = ?";
341
- const params: unknown[] = [this.packageId];
342
-
343
- if (options.namespace !== undefined) {
344
- sql += " AND namespace = ?";
345
- params.push(options.namespace);
346
- }
347
-
348
- if (options.prefix) {
349
- sql += " AND key LIKE ? ESCAPE '\\'";
350
- const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
351
- params.push(`${escapedPrefix}%`);
352
- }
353
-
354
- const stmt = this.driver.prepare(sql);
355
- const res = stmt.run(...params);
356
- return res.changes;
357
- }
358
-
359
- async listStateKeys(
360
- namespace?: string | null,
361
- prefix?: string
362
- ): Promise<string[]> {
363
- let sql = "SELECT namespace, key, expires_at FROM state WHERE package_id = ?";
364
- const params: unknown[] = [this.packageId];
365
-
366
- if (namespace !== null && namespace !== undefined) {
367
- sql += " AND namespace = ?";
368
- params.push(namespace);
369
- }
370
-
371
- if (prefix) {
372
- sql += " AND key LIKE ? ESCAPE '\\'";
373
- const escapedPrefix = prefix.replace(/([%_\\])/g, "\\$1");
374
- params.push(`${escapedPrefix}%`);
375
- }
376
-
377
- const stmt = this.driver.prepare(sql);
378
- const rows = stmt.all<{
379
- namespace: string;
380
- key: string;
381
- expires_at?: string;
382
- }>(...params);
383
-
384
- const now = this.clock.now().getTime();
385
- const result: string[] = [];
386
-
387
- for (const row of rows) {
388
- if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
389
- continue;
390
- }
391
- if (namespace !== null && namespace !== undefined) {
392
- result.push(row.key);
393
- } else {
394
- result.push(row.namespace ? `${row.namespace}:${row.key}` : row.key);
395
- }
396
- }
397
-
398
- return result;
399
- }
400
-
401
- async listStateEntries(
402
- options: { namespace?: string; prefix?: string } = {}
403
- ): Promise<StateEntry[]> {
404
- let sql = "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ?";
405
- const params: unknown[] = [this.packageId];
406
-
407
- if (options.namespace !== undefined) {
408
- sql += " AND namespace = ?";
409
- params.push(options.namespace);
410
- }
411
-
412
- if (options.prefix) {
413
- sql += " AND key LIKE ? ESCAPE '\\'";
414
- const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
415
- params.push(`${escapedPrefix}%`);
416
- }
417
-
418
- const stmt = this.driver.prepare(sql);
419
- const rows = stmt.all<{
420
- namespace: string;
421
- key: string;
422
- value_json: string;
423
- updated_at: string;
424
- expires_at?: string;
425
- }>(...params);
426
-
427
- const now = this.clock.now().getTime();
428
- const results: StateEntry[] = [];
429
-
430
- for (const row of rows) {
431
- if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
432
- continue;
433
- }
434
-
435
- let parsedVal: unknown;
436
- try {
437
- parsedVal = JSON.parse(row.value_json);
438
- } catch {
439
- parsedVal = row.value_json;
440
- }
441
-
442
- results.push({
443
- packageId: this.packageId,
444
- namespace: row.namespace,
445
- key: row.key,
446
- fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
447
- value: parsedVal,
448
- updatedAt: row.updated_at,
449
- expiresAt: row.expires_at,
450
- });
451
- }
452
-
453
- return results;
454
- }
455
-
456
- // --- 运行记录管理 ---
457
-
458
- createRun(record: RunRecord): void {
459
- const stmt = this.driver.prepare(`
460
- INSERT INTO runs (
461
- id, root_run_id, parent_run_id, package_id, package_instance_id,
462
- action_id, generation_id, owner_id, status, input_json, output_json,
463
- error_json, started_at, finished_at, duration_ms
464
- )
465
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
466
- `);
467
-
468
- const rootRunId = record.rootRunId || record.id;
469
- const parentRunId = record.parentRunId || null;
470
- const packageInstanceId = record.packageInstanceId || record.packageId || this.packageId;
471
- const generationId = record.generationId || "1";
472
- const ownerId = record.ownerId || "local";
473
- const inputJson = record.input !== undefined ? JSON.stringify(record.input) : null;
474
- const outputJson = record.output !== undefined ? JSON.stringify(record.output) : null;
475
- const errorJson = record.error ? JSON.stringify(record.error) : null;
476
- const finishedAt = record.finishedAt || null;
477
- const durationMs = typeof record.durationMs === "number" ? record.durationMs : null;
478
-
479
- stmt.run(
480
- record.id,
481
- rootRunId,
482
- parentRunId,
483
- record.packageId || this.packageId,
484
- packageInstanceId,
485
- record.actionId,
486
- generationId,
487
- ownerId,
488
- record.status,
489
- inputJson,
490
- outputJson,
491
- errorJson,
492
- record.startedAt,
493
- finishedAt,
494
- durationMs
495
- );
496
- }
497
-
498
- updateRun(
499
- id: string,
500
- status: TerminalRunStatus,
501
- output?: unknown,
502
- error?: RuntimeError,
503
- finishedAt?: string
504
- ): void {
505
- if (this.isClosed) return;
506
- try {
507
- const stmt = this.driver.prepare(`
508
- UPDATE runs
509
- SET status = ?, output_json = ?, error_json = ?, finished_at = ?
510
- WHERE id = ?
511
- `);
512
- stmt.run(
513
- status,
514
- output !== undefined ? JSON.stringify(output) : null,
515
- error ? JSON.stringify(error) : null,
516
- finishedAt || this.clock.now().toISOString(),
517
- id
518
- );
519
- } catch {
520
- // 存储连接已释放时忽略
521
- }
522
- }
523
-
524
- getRun(id: string): RunRecord | null {
525
- const stmt = this.driver.prepare(
526
- "SELECT * FROM runs WHERE id = ? AND package_id = ?"
527
- );
528
- const row = stmt.get<Record<string, unknown>>(id, this.packageId);
529
- if (!row) return null;
530
- return this.mapRunRecord(row);
531
- }
532
-
533
- listRuns(options: { actionId?: string; limit?: number } = {}): RunRecord[] {
534
- const limit = options.limit || 50;
535
- let rows: Record<string, unknown>[];
536
- if (options.actionId) {
537
- const stmt = this.driver.prepare(`
538
- SELECT * FROM runs
539
- WHERE package_id = ? AND action_id = ?
540
- ORDER BY started_at DESC
541
- LIMIT ?
542
- `);
543
- rows = stmt.all<Record<string, unknown>>(this.packageId, options.actionId, limit);
544
- } else {
545
- const stmt = this.driver.prepare(`
546
- SELECT * FROM runs
547
- WHERE package_id = ?
548
- ORDER BY started_at DESC
549
- LIMIT ?
550
- `);
551
- rows = stmt.all<Record<string, unknown>>(this.packageId, limit);
552
- }
553
- return rows.map((r) => this.mapRunRecord(r));
554
- }
555
-
556
- clearRuns(options: { actionId?: string; status?: string } = {}): number {
557
- let sql = "DELETE FROM runs WHERE package_id = ?";
558
- const params: unknown[] = [this.packageId];
559
-
560
- if (options.actionId) {
561
- sql += " AND action_id = ?";
562
- params.push(options.actionId);
563
- }
564
-
565
- if (options.status) {
566
- sql += " AND status = ?";
567
- params.push(options.status);
568
- }
569
-
570
- const stmt = this.driver.prepare(sql);
571
- const res = stmt.run(...params);
572
- return res.changes;
573
- }
574
-
575
- close(): void {
576
- if (!this.isClosed) {
577
- this.isClosed = true;
578
- this.driver.close();
579
- }
580
- }
581
-
582
- private mapRunRecord(row: Record<string, unknown>): RunRecord {
583
- return {
584
- id: String(row.id),
585
- rootRunId: String(row.root_run_id || row.id),
586
- parentRunId: row.parent_run_id ? String(row.parent_run_id) : undefined,
587
- packageId: String(row.package_id),
588
- packageInstanceId: String(row.package_instance_id || row.package_id),
589
- actionId: String(row.action_id),
590
- generationId: String(row.generation_id || "1"),
591
- ownerId: String(row.owner_id || "local"),
592
- status: row.status as RunRecord["status"],
593
- input: row.input_json ? JSON.parse(String(row.input_json)) : undefined,
594
- output: row.output_json ? JSON.parse(String(row.output_json)) : undefined,
595
- error: row.error_json ? (JSON.parse(String(row.error_json)) as RuntimeError) : undefined,
596
- startedAt: String(row.started_at),
597
- finishedAt: row.finished_at ? String(row.finished_at) : undefined,
598
- durationMs: typeof row.duration_ms === "number" ? row.duration_ms : undefined,
599
- };
600
- }
601
37
  }