keystone-cli 0.5.1 → 0.6.1

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 (48) hide show
  1. package/README.md +55 -8
  2. package/package.json +8 -17
  3. package/src/cli.ts +219 -166
  4. package/src/db/memory-db.test.ts +54 -0
  5. package/src/db/memory-db.ts +128 -0
  6. package/src/db/sqlite-setup.test.ts +47 -0
  7. package/src/db/sqlite-setup.ts +49 -0
  8. package/src/db/workflow-db.test.ts +41 -10
  9. package/src/db/workflow-db.ts +90 -28
  10. package/src/expression/evaluator.test.ts +19 -0
  11. package/src/expression/evaluator.ts +134 -39
  12. package/src/parser/schema.ts +41 -0
  13. package/src/runner/audit-verification.test.ts +23 -0
  14. package/src/runner/auto-heal.test.ts +64 -0
  15. package/src/runner/debug-repl.test.ts +308 -0
  16. package/src/runner/debug-repl.ts +225 -0
  17. package/src/runner/foreach-executor.ts +327 -0
  18. package/src/runner/llm-adapter.test.ts +37 -18
  19. package/src/runner/llm-adapter.ts +90 -112
  20. package/src/runner/llm-executor.test.ts +47 -6
  21. package/src/runner/llm-executor.ts +18 -3
  22. package/src/runner/mcp-client.audit.test.ts +69 -0
  23. package/src/runner/mcp-client.test.ts +12 -3
  24. package/src/runner/mcp-client.ts +199 -19
  25. package/src/runner/mcp-manager.ts +19 -8
  26. package/src/runner/mcp-server.test.ts +8 -5
  27. package/src/runner/mcp-server.ts +31 -17
  28. package/src/runner/optimization-runner.ts +305 -0
  29. package/src/runner/reflexion.test.ts +87 -0
  30. package/src/runner/shell-executor.test.ts +12 -0
  31. package/src/runner/shell-executor.ts +9 -6
  32. package/src/runner/step-executor.test.ts +240 -2
  33. package/src/runner/step-executor.ts +183 -68
  34. package/src/runner/stream-utils.test.ts +171 -0
  35. package/src/runner/stream-utils.ts +186 -0
  36. package/src/runner/workflow-runner.test.ts +4 -4
  37. package/src/runner/workflow-runner.ts +438 -259
  38. package/src/templates/agents/keystone-architect.md +6 -4
  39. package/src/templates/full-feature-demo.yaml +4 -4
  40. package/src/types/assets.d.ts +14 -0
  41. package/src/types/status.ts +1 -1
  42. package/src/ui/dashboard.tsx +38 -26
  43. package/src/utils/auth-manager.ts +3 -1
  44. package/src/utils/logger.test.ts +76 -0
  45. package/src/utils/logger.ts +39 -0
  46. package/src/utils/prompt.ts +75 -0
  47. package/src/utils/redactor.test.ts +86 -4
  48. package/src/utils/redactor.ts +48 -13
@@ -0,0 +1,54 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import { MemoryDb } from './memory-db';
4
+
5
+ const TEST_DB = '.keystone/test-memory.db';
6
+
7
+ describe('MemoryDb', () => {
8
+ // Clean up previous runs
9
+ if (fs.existsSync(TEST_DB)) {
10
+ fs.unlinkSync(TEST_DB);
11
+ }
12
+
13
+ const db = new MemoryDb(TEST_DB);
14
+
15
+ afterAll(() => {
16
+ db.close();
17
+ if (fs.existsSync(TEST_DB)) {
18
+ fs.unlinkSync(TEST_DB);
19
+ }
20
+ });
21
+
22
+ test('should initialize and store embedding', async () => {
23
+ const id = await db.store('hello world', Array(384).fill(0.1), { tag: 'test' });
24
+ expect(id).toBeDefined();
25
+ expect(typeof id).toBe('string');
26
+ });
27
+
28
+ test('should search and retrieve result', async () => {
29
+ // Store another item to search for
30
+ await db.store('search target', Array(384).fill(0.9), { tag: 'target' });
31
+
32
+ const results = await db.search(Array(384).fill(0.9), 1);
33
+ expect(results.length).toBe(1);
34
+ expect(results[0].text).toBe('search target');
35
+ expect(results[0].metadata).toEqual({ tag: 'target' });
36
+ });
37
+
38
+ test('should fail gracefully with invalid dimensions', async () => {
39
+ // sqlite-vec requires fixed dimensions (384 defined in schema)
40
+ // bun:sqlite usually throws an error for constraint violations
41
+ let error: unknown;
42
+ try {
43
+ await db.store('fail', Array(10).fill(0));
44
+ } catch (e) {
45
+ error = e;
46
+ }
47
+ if (error) {
48
+ expect(error).toBeDefined();
49
+ } else {
50
+ const results = await db.search(Array(384).fill(0), 1);
51
+ expect(Array.isArray(results)).toBe(true);
52
+ }
53
+ });
54
+ });
@@ -0,0 +1,128 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import * as sqliteVec from 'sqlite-vec';
6
+ import './sqlite-setup.ts';
7
+
8
+ export interface MemoryEntry {
9
+ id: string;
10
+ text: string;
11
+ metadata: Record<string, unknown>;
12
+ distance?: number;
13
+ }
14
+
15
+ export class MemoryDb {
16
+ private db: Database;
17
+ // Cache connections by path to avoid reloading extensions
18
+ private static connectionCache = new Map<string, { db: Database; refCount: number }>();
19
+
20
+ constructor(public readonly dbPath = '.keystone/memory.db') {
21
+ const cached = MemoryDb.connectionCache.get(dbPath);
22
+ if (cached) {
23
+ cached.refCount++;
24
+ this.db = cached.db;
25
+ } else {
26
+ const { Database } = require('bun:sqlite');
27
+ const dir = dirname(dbPath);
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+ this.db = new Database(dbPath, { create: true });
32
+
33
+ // Load sqlite-vec extension
34
+ const extensionPath = sqliteVec.getLoadablePath();
35
+ this.db.loadExtension(extensionPath);
36
+
37
+ this.initSchema();
38
+
39
+ MemoryDb.connectionCache.set(dbPath, { db: this.db, refCount: 1 });
40
+ }
41
+ }
42
+
43
+ private initSchema(): void {
44
+ this.db.run(`
45
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory USING vec0(
46
+ id TEXT PRIMARY KEY,
47
+ embedding FLOAT[384]
48
+ );
49
+ `);
50
+
51
+ this.db.run(`
52
+ CREATE TABLE IF NOT EXISTS memory_metadata (
53
+ id TEXT PRIMARY KEY,
54
+ text TEXT NOT NULL,
55
+ metadata TEXT NOT NULL,
56
+ created_at TEXT NOT NULL
57
+ );
58
+ `);
59
+ }
60
+
61
+ async store(
62
+ text: string,
63
+ embedding: number[],
64
+ metadata: Record<string, unknown> = {}
65
+ ): Promise<string> {
66
+ const id = randomUUID();
67
+ const createdAt = new Date().toISOString();
68
+
69
+ // bun:sqlite transaction wrapper ensures atomicity synchronously
70
+ const insertTransaction = this.db.transaction(() => {
71
+ this.db.run('INSERT INTO vec_memory(id, embedding) VALUES (?, ?)', [
72
+ id,
73
+ new Float32Array(embedding),
74
+ ]);
75
+
76
+ this.db.run(
77
+ 'INSERT INTO memory_metadata(id, text, metadata, created_at) VALUES (?, ?, ?, ?)',
78
+ [id, text, JSON.stringify(metadata), createdAt]
79
+ );
80
+ });
81
+
82
+ insertTransaction();
83
+ return id;
84
+ }
85
+
86
+ async search(embedding: number[], limit = 5): Promise<MemoryEntry[]> {
87
+ const query = `
88
+ SELECT
89
+ v.id,
90
+ v.distance,
91
+ m.text,
92
+ m.metadata
93
+ FROM vec_memory v
94
+ JOIN memory_metadata m ON v.id = m.id
95
+ WHERE embedding MATCH ? AND k = ?
96
+ ORDER BY distance
97
+ `;
98
+
99
+ // bun:sqlite is synchronous
100
+ const rows = this.db.prepare(query).all(new Float32Array(embedding), limit) as {
101
+ id: string;
102
+ distance: number;
103
+ text: string;
104
+ metadata: string;
105
+ }[];
106
+
107
+ return rows.map((row) => ({
108
+ id: row.id,
109
+ distance: row.distance,
110
+ text: row.text,
111
+ metadata: JSON.parse(row.metadata),
112
+ }));
113
+ }
114
+
115
+ close(): void {
116
+ const cached = MemoryDb.connectionCache.get(this.dbPath);
117
+ if (cached) {
118
+ cached.refCount--;
119
+ if (cached.refCount <= 0) {
120
+ cached.db.close();
121
+ MemoryDb.connectionCache.delete(this.dbPath);
122
+ }
123
+ } else {
124
+ // Fallback if not in cache for some reason
125
+ this.db.close();
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import type { Logger } from '../utils/logger';
3
+ import { setupSqlite } from './sqlite-setup';
4
+
5
+ describe('setupSqlite', () => {
6
+ const originalPlatform = process.platform;
7
+
8
+ afterEach(() => {
9
+ Object.defineProperty(process, 'platform', {
10
+ value: originalPlatform,
11
+ });
12
+ });
13
+
14
+ it('does nothing on non-darwin platforms', () => {
15
+ Object.defineProperty(process, 'platform', { value: 'linux' });
16
+ const logger: Logger = {
17
+ log: mock(() => {}),
18
+ warn: mock(() => {}),
19
+ error: mock(() => {}),
20
+ info: mock(() => {}),
21
+ };
22
+ setupSqlite(logger);
23
+ expect(logger.log).not.toHaveBeenCalled();
24
+ expect(logger.warn).not.toHaveBeenCalled();
25
+ });
26
+
27
+ it('logs warning if no custom sqlite found on darwin', () => {
28
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
29
+ const logger: Logger = {
30
+ log: mock(() => {}),
31
+ warn: mock(() => {}),
32
+ error: mock(() => {}),
33
+ info: mock(() => {}),
34
+ };
35
+
36
+ // Mock Bun.spawnSync for brew
37
+ const spawnSpy = spyOn(Bun, 'spawnSync').mockImplementation(
38
+ () => ({ success: false }) as unknown as ReturnType<typeof Bun.spawnSync>
39
+ );
40
+
41
+ try {
42
+ setupSqlite(logger);
43
+ } finally {
44
+ spawnSpy.mockRestore();
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,49 @@
1
+ import { ConsoleLogger, type Logger } from '../utils/logger.ts';
2
+
3
+ export function setupSqlite(logger: Logger = new ConsoleLogger()) {
4
+ // macOS typically comes with a system SQLite that doesn't support extensions
5
+ // We need to try to load a custom one (e.g. from Homebrew) if on macOS
6
+ if (process.platform === 'darwin') {
7
+ try {
8
+ const { Database } = require('bun:sqlite');
9
+ const { existsSync } = require('node:fs');
10
+
11
+ // Common Homebrew paths for SQLite
12
+ const paths = [
13
+ '/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib',
14
+ '/usr/local/opt/sqlite/lib/libsqlite3.dylib',
15
+ // Fallback to checking brew prefix if available
16
+ ];
17
+
18
+ // Try to find brew prefix dynamically if possible
19
+ try {
20
+ const proc = Bun.spawnSync(['brew', '--prefix', 'sqlite'], {
21
+ stderr: 'ignore',
22
+ });
23
+ if (proc.success) {
24
+ const prefix = proc.stdout.toString().trim();
25
+ paths.unshift(`${prefix}/lib/libsqlite3.dylib`);
26
+ }
27
+ } catch {
28
+ // Brew might not be installed or in path
29
+ }
30
+
31
+ for (const libPath of paths) {
32
+ if (existsSync(libPath)) {
33
+ logger.log(`[SqliteSetup] Using custom SQLite library: ${libPath}`);
34
+ Database.setCustomSQLite(libPath);
35
+ return;
36
+ }
37
+ }
38
+
39
+ logger.warn(
40
+ '[SqliteSetup] Warning: Could not find Homebrew SQLite. Extension loading might fail.'
41
+ );
42
+ } catch (error) {
43
+ logger.warn(`[SqliteSetup] Failed to set custom SQLite: ${error}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ // Run setup immediately when imported
49
+ setupSqlite();
@@ -18,7 +18,7 @@ describe('WorkflowDb', () => {
18
18
  it('should create and retrieve a run', async () => {
19
19
  const runId = 'run-1';
20
20
  await db.createRun(runId, 'test-wf', { input: 1 });
21
- const run = db.getRun(runId);
21
+ const run = await db.getRun(runId);
22
22
  expect(run).toBeDefined();
23
23
  expect(run?.workflow_name).toBe('test-wf');
24
24
  expect(JSON.parse(run?.inputs || '{}')).toEqual({ input: 1 });
@@ -27,9 +27,9 @@ describe('WorkflowDb', () => {
27
27
  it('should update run status', async () => {
28
28
  const runId = 'run-2';
29
29
  await db.createRun(runId, 'test-wf', {});
30
- await db.updateRunStatus(runId, 'completed', { result: 'ok' });
31
- const run = db.getRun(runId);
32
- expect(run?.status).toBe('completed');
30
+ await db.updateRunStatus(runId, 'success', { result: 'ok' });
31
+ const run = await db.getRun(runId);
32
+ expect(run?.status).toBe('success');
33
33
  expect(JSON.parse(run?.outputs || '{}')).toEqual({ result: 'ok' });
34
34
  });
35
35
 
@@ -41,7 +41,7 @@ describe('WorkflowDb', () => {
41
41
  await db.startStep('exec-1');
42
42
  await db.completeStep('exec-1', 'success', { out: 'val' });
43
43
 
44
- const steps = db.getStepsByRun(runId);
44
+ const steps = await db.getStepsByRun(runId);
45
45
  expect(steps).toHaveLength(1);
46
46
  expect(steps[0].step_id).toBe(stepId);
47
47
  expect(steps[0].status).toBe('success');
@@ -53,11 +53,11 @@ describe('WorkflowDb', () => {
53
53
  await db.createStep('exec-i0', runId, 'loop', 0);
54
54
  await db.createStep('exec-i1', runId, 'loop', 1);
55
55
 
56
- const step0 = db.getStepByIteration(runId, 'loop', 0);
56
+ const step0 = await db.getStepByIteration(runId, 'loop', 0);
57
57
  expect(step0).toBeDefined();
58
58
  expect(step0?.iteration_index).toBe(0);
59
59
 
60
- const steps = db.getStepsByRun(runId);
60
+ const steps = await db.getStepsByRun(runId);
61
61
  expect(steps).toHaveLength(2);
62
62
  });
63
63
 
@@ -68,14 +68,14 @@ describe('WorkflowDb', () => {
68
68
  await db.incrementRetry('exec-r');
69
69
  await db.incrementRetry('exec-r');
70
70
 
71
- const steps = db.getStepsByRun(runId);
71
+ const steps = await db.getStepsByRun(runId);
72
72
  expect(steps[0].retry_count).toBe(2);
73
73
  });
74
74
 
75
75
  it('should list runs with limit', async () => {
76
76
  await db.createRun('run-l1', 'wf', {});
77
77
  await db.createRun('run-l2', 'wf', {});
78
- const runs = db.listRuns(1);
78
+ const runs = await db.listRuns(1);
79
79
  expect(runs).toHaveLength(1);
80
80
  });
81
81
 
@@ -93,7 +93,38 @@ describe('WorkflowDb', () => {
93
93
  const deleted = await db.pruneRuns(30);
94
94
  expect(deleted).toBe(0);
95
95
 
96
- const run = db.getRun(runId);
96
+ const run = await db.getRun(runId);
97
97
  expect(run).toBeDefined();
98
98
  });
99
+
100
+ it('should retrieve successful runs', async () => {
101
+ // pending run
102
+ await db.createRun('run-s1', 'my-wf', { i: 1 });
103
+
104
+ // successful run
105
+ await db.createRun('run-s2', 'my-wf', { i: 2 });
106
+ await db.updateRunStatus('run-s2', 'success', { o: 2 });
107
+ await new Promise((r) => setTimeout(r, 10));
108
+
109
+ // failed run
110
+ await db.createRun('run-s3', 'my-wf', { i: 3 });
111
+ await db.updateRunStatus('run-s3', 'failed', undefined, 'err');
112
+ await new Promise((r) => setTimeout(r, 10));
113
+
114
+ // another successful run
115
+ await db.createRun('run-s4', 'my-wf', { i: 4 });
116
+ await db.updateRunStatus('run-s4', 'success', { o: 4 });
117
+
118
+ const runs = await db.getSuccessfulRuns('my-wf', 5);
119
+ expect(runs).toHaveLength(2);
120
+ // ordered by started_at DESC, so run-s4 then run-s2
121
+ expect(runs[0].id).toBe('run-s4');
122
+ expect(JSON.parse(runs[0].outputs || '{}')).toEqual({ o: 4 });
123
+ expect(runs[1].id).toBe('run-s2');
124
+
125
+ // Limit check
126
+ const limitedOne = await db.getSuccessfulRuns('my-wf', 1);
127
+ expect(limitedOne).toHaveLength(1);
128
+ expect(limitedOne[0].id).toBe('run-s4');
129
+ });
99
130
  });
@@ -1,4 +1,7 @@
1
1
  import { Database } from 'bun:sqlite';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import './sqlite-setup.ts';
2
5
  import {
3
6
  StepStatus as StepStatusConst,
4
7
  type StepStatusType,
@@ -7,7 +10,7 @@ import {
7
10
  } from '../types/status';
8
11
 
9
12
  // Re-export for backward compatibility - these map to the database column values
10
- export type RunStatus = WorkflowStatusType | 'pending' | 'completed';
13
+ export type RunStatus = WorkflowStatusType | 'pending';
11
14
  export type StepStatus = StepStatusType;
12
15
 
13
16
  export interface WorkflowRun {
@@ -39,6 +42,10 @@ export class WorkflowDb {
39
42
  private db: Database;
40
43
 
41
44
  constructor(public readonly dbPath = '.keystone/state.db') {
45
+ const dir = dirname(dbPath);
46
+ if (!existsSync(dir)) {
47
+ mkdirSync(dir, { recursive: true });
48
+ }
42
49
  this.db = new Database(dbPath, { create: true });
43
50
  this.db.exec('PRAGMA journal_mode = WAL;'); // Write-ahead logging
44
51
  this.db.exec('PRAGMA foreign_keys = ON;'); // Enable foreign key enforcement
@@ -124,6 +131,16 @@ export class WorkflowDb {
124
131
  CREATE INDEX IF NOT EXISTS idx_steps_status ON step_executions(status);
125
132
  CREATE INDEX IF NOT EXISTS idx_steps_iteration ON step_executions(run_id, step_id, iteration_index);
126
133
  `);
134
+
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
+ }
127
144
  }
128
145
 
129
146
  // ===== Workflow Runs =====
@@ -155,23 +172,40 @@ export class WorkflowDb {
155
172
  WHERE id = ?
156
173
  `);
157
174
  const completedAt =
158
- status === 'completed' || status === 'failed' ? new Date().toISOString() : null;
175
+ status === 'success' || status === 'failed' ? new Date().toISOString() : null;
159
176
  stmt.run(status, outputs ? JSON.stringify(outputs) : null, error || null, completedAt, id);
160
177
  });
161
178
  }
162
179
 
163
- getRun(id: string): WorkflowRun | null {
164
- const stmt = this.db.prepare('SELECT * FROM workflow_runs WHERE id = ?');
165
- return stmt.get(id) as WorkflowRun | null;
180
+ /**
181
+ * Helper for synchronous retries on SQLITE_BUSY
182
+ * Since bun:sqlite is synchronous, we use a busy-wait loop with sleep
183
+ */
184
+
185
+ /**
186
+ * Get a workflow run by ID
187
+ * @note Synchronous method - wrapped in sync retry logic
188
+ */
189
+ async getRun(id: string): Promise<WorkflowRun | null> {
190
+ return this.withRetry(() => {
191
+ const stmt = this.db.prepare('SELECT * FROM workflow_runs WHERE id = ?');
192
+ return stmt.get(id) as WorkflowRun | null;
193
+ });
166
194
  }
167
195
 
168
- listRuns(limit = 50): WorkflowRun[] {
169
- const stmt = this.db.prepare(`
170
- SELECT * FROM workflow_runs
171
- ORDER BY started_at DESC
172
- LIMIT ?
173
- `);
174
- return stmt.all(limit) as WorkflowRun[];
196
+ /**
197
+ * List recent workflow runs
198
+ * @note Synchronous method - wrapped in sync retry logic
199
+ */
200
+ async listRuns(limit = 50): Promise<WorkflowRun[]> {
201
+ return this.withRetry(() => {
202
+ const stmt = this.db.prepare(`
203
+ SELECT * FROM workflow_runs
204
+ ORDER BY started_at DESC
205
+ LIMIT ?
206
+ `);
207
+ return stmt.all(limit) as WorkflowRun[];
208
+ });
175
209
  }
176
210
 
177
211
  /**
@@ -260,24 +294,52 @@ export class WorkflowDb {
260
294
  });
261
295
  }
262
296
 
263
- getStepByIteration(runId: string, stepId: string, iterationIndex: number): StepExecution | null {
264
- const stmt = this.db.prepare(`
265
- SELECT * FROM step_executions
266
- WHERE run_id = ? AND step_id = ? AND iteration_index = ?
267
- ORDER BY started_at DESC
268
- LIMIT 1
269
- `);
270
- return stmt.get(runId, stepId, iterationIndex) as StepExecution | null;
297
+ /**
298
+ * Get a step execution by run ID, step ID, and iteration index
299
+ * @note Synchronous method - wrapped in sync retry logic
300
+ */
301
+ async getStepByIteration(
302
+ runId: string,
303
+ stepId: string,
304
+ iterationIndex: number
305
+ ): Promise<StepExecution | null> {
306
+ return this.withRetry(() => {
307
+ const stmt = this.db.prepare(`
308
+ SELECT * FROM step_executions
309
+ WHERE run_id = ? AND step_id = ? AND iteration_index = ?
310
+ ORDER BY started_at DESC
311
+ LIMIT 1
312
+ `);
313
+ return stmt.get(runId, stepId, iterationIndex) as StepExecution | null;
314
+ });
271
315
  }
272
316
 
273
- getStepsByRun(runId: string, limit = -1, offset = 0): StepExecution[] {
274
- const stmt = this.db.prepare(`
275
- SELECT * FROM step_executions
276
- WHERE run_id = ?
277
- ORDER BY started_at ASC, iteration_index ASC, rowid ASC
278
- LIMIT ? OFFSET ?
279
- `);
280
- return stmt.all(runId, limit, offset) as StepExecution[];
317
+ /**
318
+ * Get all step executions for a workflow run
319
+ * @note Synchronous method - wrapped in sync retry logic
320
+ */
321
+ async getStepsByRun(runId: string, limit = -1, offset = 0): Promise<StepExecution[]> {
322
+ return this.withRetry(() => {
323
+ const stmt = this.db.prepare(`
324
+ SELECT * FROM step_executions
325
+ WHERE run_id = ?
326
+ ORDER BY started_at ASC, iteration_index ASC, rowid ASC
327
+ LIMIT ? OFFSET ?
328
+ `);
329
+ return stmt.all(runId, limit, offset) as StepExecution[];
330
+ });
331
+ }
332
+
333
+ async getSuccessfulRuns(workflowName: string, limit = 3): Promise<WorkflowRun[]> {
334
+ return await this.withRetry(() => {
335
+ const stmt = this.db.prepare(`
336
+ SELECT * FROM workflow_runs
337
+ WHERE workflow_name = ? AND status = 'success'
338
+ ORDER BY started_at DESC
339
+ LIMIT ?
340
+ `);
341
+ return stmt.all(workflowName, limit) as WorkflowRun[];
342
+ });
281
343
  }
282
344
 
283
345
  close(): void {
@@ -303,4 +303,23 @@ describe('ExpressionEvaluator', () => {
303
303
  const contextWithNull = { ...context, nullVal: null };
304
304
  expect(ExpressionEvaluator.evaluate('${{ nullVal }}', contextWithNull)).toBe(null);
305
305
  });
306
+
307
+ test('should allow plain strings longer than 10k', () => {
308
+ const longString = 'a'.repeat(11000);
309
+ expect(ExpressionEvaluator.evaluate(longString, context)).toBe(longString);
310
+ });
311
+
312
+ test('should still enforce 10k limit for strings with expressions', () => {
313
+ const longStringWithExpr = `${'a'.repeat(10000)}\${{ inputs.name }}`;
314
+ expect(() => ExpressionEvaluator.evaluate(longStringWithExpr, context)).toThrow(
315
+ /Template with expressions exceeds maximum length/
316
+ );
317
+ });
318
+
319
+ test('should enforce 1MB limit for plain strings', () => {
320
+ const wayTooLongString = 'a'.repeat(1000001);
321
+ expect(() => ExpressionEvaluator.evaluate(wayTooLongString, context)).toThrow(
322
+ /Plain string exceeds maximum length/
323
+ );
324
+ });
306
325
  });