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.
- package/README.md +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +809 -90
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +469 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +491 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +110 -37
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +530 -86
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +492 -15
- package/src/runner/workflow-runner.ts +1438 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +17 -12
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
package/src/db/memory-db.ts
CHANGED
|
@@ -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[
|
|
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', {});
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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)
|
|
358
|
+
output === undefined ? null : JSON.stringify(output),
|
|
278
359
|
error || null,
|
|
279
360
|
new Date().toISOString(),
|
|
280
|
-
usage ? JSON.stringify(usage)
|
|
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();
|