keystone-cli 0.8.0 → 1.0.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 (103) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +491 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +17 -12
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/child-rollback.yaml +11 -0
  58. package/src/templates/decompose-implement.yaml +53 -0
  59. package/src/templates/decompose-problem.yaml +159 -0
  60. package/src/templates/decompose-research.yaml +52 -0
  61. package/src/templates/decompose-review.yaml +51 -0
  62. package/src/templates/dev.yaml +134 -0
  63. package/src/templates/engine-example.yaml +33 -0
  64. package/src/templates/fan-out-fan-in.yaml +61 -0
  65. package/src/templates/memory-service.yaml +1 -1
  66. package/src/templates/parent-rollback.yaml +16 -0
  67. package/src/templates/robust-automation.yaml +1 -1
  68. package/src/templates/scaffold-feature.yaml +29 -27
  69. package/src/templates/scaffold-generate.yaml +41 -0
  70. package/src/templates/scaffold-plan.yaml +53 -0
  71. package/src/types/status.ts +3 -0
  72. package/src/ui/dashboard.tsx +4 -3
  73. package/src/utils/assets.macro.ts +36 -0
  74. package/src/utils/auth-manager.ts +585 -8
  75. package/src/utils/blueprint-utils.test.ts +49 -0
  76. package/src/utils/blueprint-utils.ts +80 -0
  77. package/src/utils/circuit-breaker.test.ts +177 -0
  78. package/src/utils/circuit-breaker.ts +160 -0
  79. package/src/utils/config-loader.test.ts +100 -13
  80. package/src/utils/config-loader.ts +44 -17
  81. package/src/utils/constants.ts +62 -0
  82. package/src/utils/error-renderer.test.ts +267 -0
  83. package/src/utils/error-renderer.ts +320 -0
  84. package/src/utils/json-parser.test.ts +4 -0
  85. package/src/utils/json-parser.ts +18 -1
  86. package/src/utils/mermaid.ts +4 -0
  87. package/src/utils/paths.test.ts +46 -0
  88. package/src/utils/paths.ts +70 -0
  89. package/src/utils/process-sandbox.test.ts +128 -0
  90. package/src/utils/process-sandbox.ts +293 -0
  91. package/src/utils/rate-limiter.test.ts +143 -0
  92. package/src/utils/rate-limiter.ts +221 -0
  93. package/src/utils/redactor.test.ts +23 -15
  94. package/src/utils/redactor.ts +65 -25
  95. package/src/utils/resource-loader.test.ts +54 -0
  96. package/src/utils/resource-loader.ts +158 -0
  97. package/src/utils/sandbox.test.ts +69 -4
  98. package/src/utils/sandbox.ts +69 -6
  99. package/src/utils/schema-validator.ts +65 -0
  100. package/src/utils/workflow-registry.test.ts +57 -0
  101. package/src/utils/workflow-registry.ts +45 -25
  102. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  103. /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
@@ -16,6 +16,7 @@ export class MemoryDb {
16
16
  private db: Database;
17
17
  // Cache connections by path to avoid reloading extensions
18
18
  private static connectionCache = new Map<string, { db: Database; refCount: number }>();
19
+ static readonly EMBEDDING_DIMENSION = 384;
19
20
 
20
21
  constructor(public readonly dbPath = '.keystone/memory.db') {
21
22
  const cached = MemoryDb.connectionCache.get(dbPath);
@@ -44,7 +45,7 @@ export class MemoryDb {
44
45
  this.db.run(`
45
46
  CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory USING vec0(
46
47
  id TEXT PRIMARY KEY,
47
- embedding FLOAT[384]
48
+ embedding FLOAT[${MemoryDb.EMBEDDING_DIMENSION}]
48
49
  );
49
50
  `);
50
51
 
@@ -58,6 +59,26 @@ export class MemoryDb {
58
59
  `);
59
60
  }
60
61
 
62
+ private static assertEmbeddingDimension(embedding: number[]): void {
63
+ if (embedding.length !== MemoryDb.EMBEDDING_DIMENSION) {
64
+ throw new Error(
65
+ `Embedding dimension mismatch: expected ${MemoryDb.EMBEDDING_DIMENSION}, got ${embedding.length}`
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Store an embedding and its associated text/metadata.
72
+ *
73
+ * Note: The async signature provides interface compatibility with potentially
74
+ * async backends (e.g., remote vector DBs). The current implementation uses
75
+ * synchronous bun:sqlite operations internally.
76
+ *
77
+ * @param text - The text content to store
78
+ * @param embedding - The embedding vector (384 dimensions)
79
+ * @param metadata - Optional metadata to associate with the entry
80
+ * @returns The generated entry ID
81
+ */
61
82
  async store(
62
83
  text: string,
63
84
  embedding: number[],
@@ -65,6 +86,7 @@ export class MemoryDb {
65
86
  ): Promise<string> {
66
87
  const id = randomUUID();
67
88
  const createdAt = new Date().toISOString();
89
+ MemoryDb.assertEmbeddingDimension(embedding);
68
90
 
69
91
  // bun:sqlite transaction wrapper ensures atomicity synchronously
70
92
  const insertTransaction = this.db.transaction(() => {
@@ -83,7 +105,19 @@ export class MemoryDb {
83
105
  return id;
84
106
  }
85
107
 
108
+ /**
109
+ * Search for similar embeddings using vector similarity.
110
+ *
111
+ * Note: The async signature provides interface compatibility with potentially
112
+ * async backends (e.g., remote vector DBs). The current implementation uses
113
+ * synchronous bun:sqlite operations internally.
114
+ *
115
+ * @param embedding - The query embedding vector
116
+ * @param limit - Maximum number of results to return (default: 5)
117
+ * @returns Array of matching entries with distance scores
118
+ */
86
119
  async search(embedding: number[], limit = 5): Promise<MemoryEntry[]> {
120
+ MemoryDb.assertEmbeddingDimension(embedding);
87
121
  const query = `
88
122
  SELECT
89
123
  v.id,
@@ -47,6 +47,30 @@ describe('WorkflowDb', () => {
47
47
  expect(steps[0].status).toBe('success');
48
48
  });
49
49
 
50
+ it('should persist falsy outputs', async () => {
51
+ const runId = 'run-falsy';
52
+ await db.createRun(runId, 'test-wf', {});
53
+
54
+ await db.createStep('exec-false', runId, 'step-false');
55
+ await db.startStep('exec-false');
56
+ await db.completeStep('exec-false', 'success', false);
57
+
58
+ await db.createStep('exec-zero', runId, 'step-zero');
59
+ await db.startStep('exec-zero');
60
+ await db.completeStep('exec-zero', 'success', 0);
61
+
62
+ await db.createStep('exec-empty', runId, 'step-empty');
63
+ await db.startStep('exec-empty');
64
+ await db.completeStep('exec-empty', 'success', '');
65
+
66
+ const steps = await db.getStepsByRun(runId);
67
+ const outputsById = Object.fromEntries(steps.map((step) => [step.step_id, step.output]));
68
+
69
+ expect(JSON.parse(outputsById['step-false'] || 'null')).toBe(false);
70
+ expect(JSON.parse(outputsById['step-zero'] || 'null')).toBe(0);
71
+ expect(JSON.parse(outputsById['step-empty'] || 'null')).toBe('');
72
+ });
73
+
50
74
  it('should handle iterations in steps', async () => {
51
75
  const runId = 'run-4';
52
76
  await db.createRun(runId, 'test-wf', {});
@@ -8,8 +8,8 @@ import {
8
8
  WorkflowStatus as WorkflowStatusConst,
9
9
  type WorkflowStatusType,
10
10
  } from '../types/status';
11
+ import { PathResolver } from '../utils/paths';
11
12
 
12
- // Re-export for backward compatibility - these map to the database column values
13
13
  export type RunStatus = WorkflowStatusType | 'pending';
14
14
  export type StepStatus = StepStatusType;
15
15
 
@@ -38,10 +38,44 @@ export interface StepExecution {
38
38
  usage: string | null; // JSON
39
39
  }
40
40
 
41
+ export interface IdempotencyRecord {
42
+ idempotency_key: string;
43
+ run_id: string;
44
+ step_id: string;
45
+ status: StepStatus;
46
+ output: string | null; // JSON
47
+ error: string | null;
48
+ created_at: string;
49
+ expires_at: string | null;
50
+ }
51
+
52
+ export interface DurableTimer {
53
+ id: string;
54
+ run_id: string;
55
+ step_id: string;
56
+ timer_type: 'sleep' | 'human';
57
+ wake_at: string | null; // ISO datetime - null for human-triggered timers
58
+ created_at: string;
59
+ completed_at: string | null;
60
+ }
61
+
62
+ export interface CompensationRecord {
63
+ id: string;
64
+ run_id: string;
65
+ step_id: string;
66
+ compensation_step_id: string; // The ID of the compensation step definition (usually randomUUID)
67
+ definition: string; // JSON definition of the compensation step
68
+ status: StepStatus;
69
+ output: string | null;
70
+ error: string | null;
71
+ created_at: string;
72
+ completed_at: string | null;
73
+ }
74
+
41
75
  export class WorkflowDb {
42
76
  private db: Database;
43
77
 
44
- constructor(public readonly dbPath = '.keystone/state.db') {
78
+ constructor(public readonly dbPath = PathResolver.resolveDbPath()) {
45
79
  const dir = dirname(dbPath);
46
80
  if (!existsSync(dir)) {
47
81
  mkdirSync(dir, { recursive: true });
@@ -96,6 +130,16 @@ export class WorkflowDb {
96
130
  throw lastError || new Error('SQLite operation failed after retries');
97
131
  }
98
132
 
133
+ private formatExpiresAt(ttlSeconds?: number): string | null {
134
+ if (!ttlSeconds || ttlSeconds <= 0) return null;
135
+ const date = new Date(Date.now() + ttlSeconds * 1000);
136
+ return date
137
+ .toISOString()
138
+ .replace('T', ' ')
139
+ .replace('Z', '')
140
+ .replace(/\.\d{3}$/, '');
141
+ }
142
+
99
143
  private initSchema(): void {
100
144
  this.db.exec(`
101
145
  CREATE TABLE IF NOT EXISTS workflow_runs (
@@ -130,17 +174,54 @@ export class WorkflowDb {
130
174
  CREATE INDEX IF NOT EXISTS idx_steps_run ON step_executions(run_id);
131
175
  CREATE INDEX IF NOT EXISTS idx_steps_status ON step_executions(status);
132
176
  CREATE INDEX IF NOT EXISTS idx_steps_iteration ON step_executions(run_id, step_id, iteration_index);
133
- `);
134
177
 
135
- // Ensure usage column exists (migration for older databases)
136
- // Use PRAGMA table_info to check column existence - more reliable than catching errors
137
- const columns = this.db.prepare('PRAGMA table_info(step_executions)').all() as {
138
- name: string;
139
- }[];
140
- const hasUsageColumn = columns.some((col) => col.name === 'usage');
141
- if (!hasUsageColumn) {
142
- this.db.exec('ALTER TABLE step_executions ADD COLUMN usage TEXT;');
143
- }
178
+ CREATE TABLE IF NOT EXISTS idempotency_records (
179
+ idempotency_key TEXT PRIMARY KEY,
180
+ run_id TEXT NOT NULL,
181
+ step_id TEXT NOT NULL,
182
+ status TEXT NOT NULL,
183
+ output TEXT,
184
+ error TEXT,
185
+ created_at TEXT NOT NULL,
186
+ expires_at TEXT,
187
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
188
+ );
189
+
190
+ CREATE INDEX IF NOT EXISTS idx_idempotency_run ON idempotency_records(run_id);
191
+ CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON idempotency_records(expires_at);
192
+
193
+ CREATE TABLE IF NOT EXISTS durable_timers (
194
+ id TEXT PRIMARY KEY,
195
+ run_id TEXT NOT NULL,
196
+ step_id TEXT NOT NULL,
197
+ timer_type TEXT NOT NULL,
198
+ wake_at TEXT,
199
+ created_at TEXT NOT NULL,
200
+ completed_at TEXT,
201
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
202
+ );
203
+
204
+ CREATE INDEX IF NOT EXISTS idx_timers_wake ON durable_timers(wake_at);
205
+ CREATE INDEX IF NOT EXISTS idx_timers_run ON durable_timers(run_id);
206
+ CREATE INDEX IF NOT EXISTS idx_timers_pending ON durable_timers(wake_at, completed_at);
207
+
208
+ CREATE TABLE IF NOT EXISTS compensations (
209
+ id TEXT PRIMARY KEY,
210
+ run_id TEXT NOT NULL,
211
+ step_id TEXT NOT NULL,
212
+ compensation_step_id TEXT NOT NULL,
213
+ definition TEXT NOT NULL,
214
+ status TEXT NOT NULL,
215
+ output TEXT,
216
+ error TEXT,
217
+ created_at TEXT NOT NULL,
218
+ completed_at TEXT,
219
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
220
+ );
221
+
222
+ CREATE INDEX IF NOT EXISTS idx_compensations_run ON compensations(run_id);
223
+ CREATE INDEX IF NOT EXISTS idx_compensations_status ON compensations(status);
224
+ `);
144
225
  }
145
226
 
146
227
  // ===== Workflow Runs =====
@@ -274,10 +355,10 @@ export class WorkflowDb {
274
355
  `);
275
356
  stmt.run(
276
357
  status,
277
- output ? JSON.stringify(output) : null,
358
+ output === undefined ? null : JSON.stringify(output),
278
359
  error || null,
279
360
  new Date().toISOString(),
280
- usage ? JSON.stringify(usage) : null,
361
+ usage === undefined ? null : JSON.stringify(usage),
281
362
  id
282
363
  );
283
364
  });
@@ -314,6 +395,21 @@ export class WorkflowDb {
314
395
  });
315
396
  }
316
397
 
398
+ /**
399
+ * Get the main execution (non-iteration) of a step
400
+ */
401
+ public async getMainStep(runId: string, stepId: string): Promise<StepExecution | null> {
402
+ return this.withRetry(() => {
403
+ const stmt = this.db.prepare(`
404
+ SELECT * FROM step_executions
405
+ WHERE run_id = ? AND step_id = ? AND iteration_index IS NULL
406
+ ORDER BY started_at DESC
407
+ LIMIT 1
408
+ `);
409
+ return stmt.get(runId, stepId) as StepExecution | null;
410
+ });
411
+ }
412
+
317
413
  /**
318
414
  * Get all step executions for a workflow run
319
415
  * @note Synchronous method - wrapped in sync retry logic
@@ -356,6 +452,365 @@ export class WorkflowDb {
356
452
  return stmt.get(workflowName) as WorkflowRun | null;
357
453
  });
358
454
  }
455
+ // ===== Idempotency Records =====
456
+
457
+ /**
458
+ * Get an idempotency record by key
459
+ * Returns null if not found or expired
460
+ */
461
+ async getIdempotencyRecord(key: string): Promise<IdempotencyRecord | null> {
462
+ return this.withRetry(() => {
463
+ const stmt = this.db.prepare(`
464
+ SELECT * FROM idempotency_records
465
+ WHERE idempotency_key = ?
466
+ AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
467
+ `);
468
+ return stmt.get(key) as IdempotencyRecord | null;
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Remove an expired idempotency record by key
474
+ */
475
+ async clearExpiredIdempotencyRecord(key: string): Promise<number> {
476
+ return await this.withRetry(() => {
477
+ const stmt = this.db.prepare(`
478
+ DELETE FROM idempotency_records
479
+ WHERE idempotency_key = ?
480
+ AND expires_at IS NOT NULL
481
+ AND datetime(expires_at) < datetime('now')
482
+ `);
483
+ const result = stmt.run(key);
484
+ return result.changes;
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Insert an idempotency record only if it doesn't exist
490
+ */
491
+ async insertIdempotencyRecordIfAbsent(
492
+ key: string,
493
+ runId: string,
494
+ stepId: string,
495
+ status: StepStatus,
496
+ ttlSeconds?: number
497
+ ): Promise<boolean> {
498
+ return await this.withRetry(() => {
499
+ const expiresAt = this.formatExpiresAt(ttlSeconds);
500
+ const stmt = this.db.prepare(`
501
+ INSERT OR IGNORE INTO idempotency_records
502
+ (idempotency_key, run_id, step_id, status, output, error, created_at, expires_at)
503
+ VALUES (?, ?, ?, ?, NULL, NULL, datetime('now'), ?)
504
+ `);
505
+ const result = stmt.run(key, runId, stepId, status, expiresAt);
506
+ return result.changes > 0;
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Mark an idempotency record as running if it's not already running or successful
512
+ */
513
+ async markIdempotencyRecordRunning(
514
+ key: string,
515
+ runId: string,
516
+ stepId: string,
517
+ ttlSeconds?: number
518
+ ): Promise<boolean> {
519
+ return await this.withRetry(() => {
520
+ const expiresAt = this.formatExpiresAt(ttlSeconds);
521
+ const stmt = this.db.prepare(`
522
+ UPDATE idempotency_records
523
+ SET status = ?, run_id = ?, step_id = ?, output = NULL, error = NULL, created_at = datetime('now'), expires_at = ?
524
+ WHERE idempotency_key = ?
525
+ AND status NOT IN (?, ?)
526
+ `);
527
+ const result = stmt.run(
528
+ StepStatusConst.RUNNING,
529
+ runId,
530
+ stepId,
531
+ expiresAt,
532
+ key,
533
+ StepStatusConst.RUNNING,
534
+ StepStatusConst.SUCCESS
535
+ );
536
+ return result.changes > 0;
537
+ });
538
+ }
539
+
540
+ /**
541
+ * Store an idempotency record
542
+ * If a record with the same key exists, it will be replaced
543
+ */
544
+ async storeIdempotencyRecord(
545
+ key: string,
546
+ runId: string,
547
+ stepId: string,
548
+ status: StepStatus,
549
+ output?: unknown,
550
+ error?: string,
551
+ ttlSeconds?: number
552
+ ): Promise<void> {
553
+ await this.withRetry(() => {
554
+ const expiresAt = this.formatExpiresAt(ttlSeconds);
555
+ const stmt = this.db.prepare(`
556
+ INSERT OR REPLACE INTO idempotency_records
557
+ (idempotency_key, run_id, step_id, status, output, error, created_at, expires_at)
558
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)
559
+ `);
560
+ stmt.run(
561
+ key,
562
+ runId,
563
+ stepId,
564
+ status,
565
+ output === undefined ? null : JSON.stringify(output),
566
+ error || null,
567
+ expiresAt
568
+ );
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Remove expired idempotency records
574
+ */
575
+ async pruneIdempotencyRecords(): Promise<number> {
576
+ return await this.withRetry(() => {
577
+ const stmt = this.db.prepare(`
578
+ DELETE FROM idempotency_records
579
+ WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')
580
+ `);
581
+ const result = stmt.run();
582
+ return result.changes;
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Clear idempotency records for a specific run
588
+ */
589
+ async clearIdempotencyRecords(runId: string): Promise<number> {
590
+ return await this.withRetry(() => {
591
+ const stmt = this.db.prepare('DELETE FROM idempotency_records WHERE run_id = ?');
592
+ const result = stmt.run(runId);
593
+ return result.changes;
594
+ });
595
+ }
596
+
597
+ /**
598
+ * List idempotency records, optionally filtered by run ID
599
+ */
600
+ async listIdempotencyRecords(runId?: string): Promise<IdempotencyRecord[]> {
601
+ return this.withRetry(() => {
602
+ if (runId) {
603
+ const stmt = this.db.prepare(`
604
+ SELECT * FROM idempotency_records
605
+ WHERE run_id = ?
606
+ ORDER BY created_at DESC
607
+ `);
608
+ return stmt.all(runId) as IdempotencyRecord[];
609
+ }
610
+ const stmt = this.db.prepare(`
611
+ SELECT * FROM idempotency_records
612
+ ORDER BY created_at DESC
613
+ LIMIT 100
614
+ `);
615
+ return stmt.all() as IdempotencyRecord[];
616
+ });
617
+ }
618
+
619
+ /**
620
+ * Clear all idempotency records
621
+ */
622
+ async clearAllIdempotencyRecords(): Promise<number> {
623
+ return await this.withRetry(() => {
624
+ const stmt = this.db.prepare('DELETE FROM idempotency_records');
625
+ const result = stmt.run();
626
+ return result.changes;
627
+ });
628
+ }
629
+
630
+ // ===== Durable Timers =====
631
+
632
+ /**
633
+ * Create a durable timer for a step
634
+ */
635
+ async createTimer(
636
+ id: string,
637
+ runId: string,
638
+ stepId: string,
639
+ timerType: 'sleep' | 'human',
640
+ wakeAt?: string
641
+ ): Promise<void> {
642
+ await this.withRetry(() => {
643
+ const stmt = this.db.prepare(`
644
+ INSERT INTO durable_timers (id, run_id, step_id, timer_type, wake_at, created_at)
645
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
646
+ `);
647
+ stmt.run(id, runId, stepId, timerType, wakeAt || null);
648
+ });
649
+ }
650
+
651
+ /**
652
+ * Get a durable timer by ID
653
+ */
654
+ async getTimer(id: string): Promise<DurableTimer | null> {
655
+ return this.withRetry(() => {
656
+ const stmt = this.db.prepare('SELECT * FROM durable_timers WHERE id = ?');
657
+ return stmt.get(id) as DurableTimer | null;
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Get a durable timer by run ID and step ID
663
+ */
664
+ async getTimerByStep(runId: string, stepId: string): Promise<DurableTimer | null> {
665
+ return this.withRetry(() => {
666
+ const stmt = this.db.prepare(`
667
+ SELECT * FROM durable_timers
668
+ WHERE run_id = ? AND step_id = ? AND completed_at IS NULL
669
+ ORDER BY created_at DESC
670
+ LIMIT 1
671
+ `);
672
+ return stmt.get(runId, stepId) as DurableTimer | null;
673
+ });
674
+ }
675
+
676
+ /**
677
+ * Get pending timers that are ready to fire
678
+ * @param before Optional cutoff time (defaults to now)
679
+ */
680
+ async getPendingTimers(
681
+ before?: Date,
682
+ timerType: 'sleep' | 'human' | 'all' = 'sleep'
683
+ ): Promise<DurableTimer[]> {
684
+ return this.withRetry(() => {
685
+ const cutoff = (before || new Date()).toISOString();
686
+ const filterType = timerType !== 'all';
687
+ const stmt = this.db.prepare(`
688
+ SELECT * FROM durable_timers
689
+ WHERE completed_at IS NULL
690
+ AND (wake_at IS NULL OR wake_at <= ?)
691
+ ${filterType ? 'AND timer_type = ?' : ''}
692
+ ORDER BY wake_at ASC
693
+ `);
694
+ return (filterType ? stmt.all(cutoff, timerType) : stmt.all(cutoff)) as DurableTimer[];
695
+ });
696
+ }
697
+
698
+ /**
699
+ * Complete a durable timer
700
+ */
701
+ async completeTimer(id: string): Promise<void> {
702
+ await this.withRetry(() => {
703
+ const stmt = this.db.prepare(`
704
+ UPDATE durable_timers
705
+ SET completed_at = datetime('now')
706
+ WHERE id = ?
707
+ `);
708
+ stmt.run(id);
709
+ });
710
+ }
711
+
712
+ /**
713
+ * List all timers, optionally filtered by run ID
714
+ */
715
+ async listTimers(runId?: string, limit = 50): Promise<DurableTimer[]> {
716
+ return this.withRetry(() => {
717
+ if (runId) {
718
+ const stmt = this.db.prepare(`
719
+ SELECT * FROM durable_timers
720
+ WHERE run_id = ?
721
+ ORDER BY created_at DESC
722
+ LIMIT ?
723
+ `);
724
+ return stmt.all(runId, limit) as DurableTimer[];
725
+ }
726
+ const stmt = this.db.prepare(`
727
+ SELECT * FROM durable_timers
728
+ ORDER BY created_at DESC
729
+ LIMIT ?
730
+ `);
731
+ return stmt.all(limit) as DurableTimer[];
732
+ });
733
+ }
734
+
735
+ /**
736
+ * Clear timers for a specific run or all timers
737
+ */
738
+ async clearTimers(runId?: string): Promise<number> {
739
+ return await this.withRetry(() => {
740
+ if (runId) {
741
+ const stmt = this.db.prepare('DELETE FROM durable_timers WHERE run_id = ?');
742
+ const result = stmt.run(runId);
743
+ return result.changes;
744
+ }
745
+ const stmt = this.db.prepare('DELETE FROM durable_timers');
746
+ const result = stmt.run();
747
+ return result.changes;
748
+ });
749
+ }
750
+
751
+ // ===== Compensations =====
752
+
753
+ async registerCompensation(
754
+ id: string,
755
+ runId: string,
756
+ stepId: string,
757
+ compensationStepId: string,
758
+ definition: string
759
+ ): Promise<void> {
760
+ await this.withRetry(() => {
761
+ const stmt = this.db.prepare(`
762
+ INSERT INTO compensations (id, run_id, step_id, compensation_step_id, definition, status, created_at)
763
+ VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))
764
+ `);
765
+ stmt.run(id, runId, stepId, compensationStepId, definition);
766
+ });
767
+ }
768
+
769
+ async updateCompensationStatus(
770
+ id: string,
771
+ status: StepStatus,
772
+ output?: unknown,
773
+ error?: string
774
+ ): Promise<void> {
775
+ await this.withRetry(() => {
776
+ const stmt = this.db.prepare(`
777
+ UPDATE compensations
778
+ SET status = ?, output = ?, error = ?, completed_at = ?
779
+ WHERE id = ?
780
+ `);
781
+ const completedAt =
782
+ status === 'success' || status === 'failed' ? new Date().toISOString() : null;
783
+ stmt.run(
784
+ status,
785
+ output === undefined ? null : JSON.stringify(output),
786
+ error || null,
787
+ completedAt,
788
+ id
789
+ );
790
+ });
791
+ }
792
+
793
+ async getPendingCompensations(runId: string): Promise<CompensationRecord[]> {
794
+ return this.withRetry(() => {
795
+ const stmt = this.db.prepare(`
796
+ SELECT * FROM compensations
797
+ WHERE run_id = ? AND status = 'pending'
798
+ ORDER BY created_at DESC, rowid DESC
799
+ `);
800
+ return stmt.all(runId) as CompensationRecord[];
801
+ });
802
+ }
803
+
804
+ async getAllCompensations(runId: string): Promise<CompensationRecord[]> {
805
+ return this.withRetry(() => {
806
+ const stmt = this.db.prepare(`
807
+ SELECT * FROM compensations
808
+ WHERE run_id = ?
809
+ ORDER BY created_at DESC, rowid DESC
810
+ `);
811
+ return stmt.all(runId) as CompensationRecord[];
812
+ });
813
+ }
359
814
 
360
815
  close(): void {
361
816
  this.db.close();