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.
- package/README.md +55 -8
- package/package.json +8 -17
- package/src/cli.ts +219 -166
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +128 -0
- package/src/db/sqlite-setup.test.ts +47 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +90 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +308 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +37 -18
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +47 -6
- package/src/runner/llm-executor.ts +18 -3
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +240 -2
- package/src/runner/step-executor.ts +183 -68
- package/src/runner/stream-utils.test.ts +171 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +438 -259
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- 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, '
|
|
31
|
-
const run = db.getRun(runId);
|
|
32
|
-
expect(run?.status).toBe('
|
|
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
|
});
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -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'
|
|
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 === '
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
});
|