@multiplier-labs/stepflow 0.3.4 → 0.3.8
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 +9 -0
- package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
- package/dist/chunk-4IDPWWGS.js.map +1 -0
- package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
- package/dist/chunk-T6GAZBTQ.js.map +1 -0
- package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
- package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
- package/dist/events/index.d.ts +43 -3
- package/dist/events/index.js +7 -3
- package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +159 -46
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +2 -2
- package/dist/scheduler/index.js +1 -1
- package/dist/storage/index.d.ts +6 -4
- package/dist/storage/index.js +1 -1
- package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
- package/package.json +26 -14
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
- package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
- package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
- package/.github/CODEOWNERS +0 -15
- package/.github/dependabot.yml +0 -23
- package/.github/workflows/ci.yml +0 -38
- package/.github/workflows/publish.yml +0 -42
- package/.github/workflows/security.yml +0 -29
- package/CHANGELOG.md +0 -34
- package/SECURITY.md +0 -40
- package/dist/chunk-5XJBAPXF.js.map +0 -1
- package/dist/chunk-U3PCVK6O.js.map +0 -1
- package/docs/github-packages-setup.md +0 -97
- package/docs/stepflow-api-reference.md +0 -1663
- package/src/core/engine/concurrency.ts +0 -229
- package/src/core/engine/lifecycle.ts +0 -176
- package/src/core/engine.test.ts +0 -1435
- package/src/core/engine.ts +0 -598
- package/src/core/index.ts +0 -7
- package/src/core/orchestrator/persistence.ts +0 -165
- package/src/core/orchestrator/plan-resolution.ts +0 -140
- package/src/core/orchestrator/state-transitions.ts +0 -109
- package/src/core/orchestrator/step-runner.ts +0 -427
- package/src/core/orchestrator.ts +0 -252
- package/src/core/types.ts +0 -220
- package/src/events/index.ts +0 -8
- package/src/events/memory.test.ts +0 -107
- package/src/events/memory.ts +0 -92
- package/src/events/socketio.test.ts +0 -378
- package/src/events/socketio.ts +0 -262
- package/src/events/types.ts +0 -118
- package/src/events/webhook.test.ts +0 -949
- package/src/events/webhook.ts +0 -807
- package/src/index.ts +0 -270
- package/src/planning/evaluate-condition.test.ts +0 -153
- package/src/planning/evaluate-condition.ts +0 -105
- package/src/planning/index.ts +0 -55
- package/src/planning/planner.ts +0 -494
- package/src/planning/planning.test.ts +0 -727
- package/src/planning/registry.test.ts +0 -398
- package/src/planning/registry.ts +0 -283
- package/src/planning/types.ts +0 -566
- package/src/scheduler/cron.test.ts +0 -479
- package/src/scheduler/cron.ts +0 -438
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/postgres-persistence.ts +0 -530
- package/src/scheduler/sqlite-persistence.test.ts +0 -235
- package/src/scheduler/sqlite-persistence.ts +0 -286
- package/src/scheduler/types.ts +0 -73
- package/src/storage/index.ts +0 -27
- package/src/storage/memory.test.ts +0 -349
- package/src/storage/memory.ts +0 -227
- package/src/storage/postgres-core.ts +0 -550
- package/src/storage/postgres-migration.test.ts +0 -69
- package/src/storage/postgres-transaction-pagination.test.ts +0 -107
- package/src/storage/postgres.ts +0 -835
- package/src/storage/sqlite-core.ts +0 -151
- package/src/storage/sqlite.test.ts +0 -642
- package/src/storage/sqlite.ts +0 -717
- package/src/storage/types.ts +0 -343
- package/src/utils/errors.test.ts +0 -150
- package/src/utils/errors.ts +0 -161
- package/src/utils/id.ts +0 -21
- package/src/utils/index.ts +0 -10
- package/src/utils/logger.test.ts +0 -121
- package/src/utils/logger.ts +0 -96
- package/src/utils/pinned-fetch.test.ts +0 -84
- package/src/utils/pinned-fetch.ts +0 -118
- package/src/utils/postgres-deps.ts +0 -79
- package/src/utils/retry.test.ts +0 -177
- package/src/utils/retry.ts +0 -121
- package/src/utils/safe-json.test.ts +0 -166
- package/src/utils/safe-json.ts +0 -107
- package/src/utils/webhook-signature.test.ts +0 -111
- package/src/utils/webhook-signature.ts +0 -74
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -9
|
@@ -1,642 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import Database from 'better-sqlite3';
|
|
3
|
-
import { SQLiteStorageAdapter } from './sqlite';
|
|
4
|
-
import type { WorkflowRunRecord, WorkflowRunStepRecord } from './types';
|
|
5
|
-
|
|
6
|
-
describe('SQLiteStorageAdapter', () => {
|
|
7
|
-
let db: Database.Database;
|
|
8
|
-
let storage: SQLiteStorageAdapter;
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
// Use in-memory database for testing
|
|
12
|
-
db = new Database(':memory:');
|
|
13
|
-
storage = new SQLiteStorageAdapter({ db });
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
storage.close();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe('createRun', () => {
|
|
21
|
-
it('should create a run and return it with generated id and createdAt', async () => {
|
|
22
|
-
const run = await storage.createRun({
|
|
23
|
-
kind: 'test.workflow',
|
|
24
|
-
status: 'queued',
|
|
25
|
-
input: { foo: 'bar' },
|
|
26
|
-
metadata: { userId: 'user1' },
|
|
27
|
-
context: {},
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
expect(run.id).toBeDefined();
|
|
31
|
-
expect(run.kind).toBe('test.workflow');
|
|
32
|
-
expect(run.status).toBe('queued');
|
|
33
|
-
expect(run.input).toEqual({ foo: 'bar' });
|
|
34
|
-
expect(run.metadata).toEqual({ userId: 'user1' });
|
|
35
|
-
expect(run.createdAt).toBeInstanceOf(Date);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe('getRun', () => {
|
|
40
|
-
it('should return null for non-existent run', async () => {
|
|
41
|
-
const run = await storage.getRun('nonexistent');
|
|
42
|
-
expect(run).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should retrieve a created run', async () => {
|
|
46
|
-
const created = await storage.createRun({
|
|
47
|
-
kind: 'test.workflow',
|
|
48
|
-
status: 'queued',
|
|
49
|
-
input: { value: 123 },
|
|
50
|
-
metadata: {},
|
|
51
|
-
context: {},
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const retrieved = await storage.getRun(created.id);
|
|
55
|
-
expect(retrieved).not.toBeNull();
|
|
56
|
-
expect(retrieved!.id).toBe(created.id);
|
|
57
|
-
expect(retrieved!.input).toEqual({ value: 123 });
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('updateRun', () => {
|
|
62
|
-
it('should update run status', async () => {
|
|
63
|
-
const run = await storage.createRun({
|
|
64
|
-
kind: 'test.workflow',
|
|
65
|
-
status: 'queued',
|
|
66
|
-
input: {},
|
|
67
|
-
metadata: {},
|
|
68
|
-
context: {},
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
await storage.updateRun(run.id, { status: 'running' });
|
|
72
|
-
|
|
73
|
-
const updated = await storage.getRun(run.id);
|
|
74
|
-
expect(updated!.status).toBe('running');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should update run context', async () => {
|
|
78
|
-
const run = await storage.createRun({
|
|
79
|
-
kind: 'test.workflow',
|
|
80
|
-
status: 'running',
|
|
81
|
-
input: {},
|
|
82
|
-
metadata: {},
|
|
83
|
-
context: { step1: 'result1' },
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
await storage.updateRun(run.id, {
|
|
87
|
-
context: { step1: 'result1', step2: 'result2' },
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const updated = await storage.getRun(run.id);
|
|
91
|
-
expect(updated!.context).toEqual({ step1: 'result1', step2: 'result2' });
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('should update run error', async () => {
|
|
95
|
-
const run = await storage.createRun({
|
|
96
|
-
kind: 'test.workflow',
|
|
97
|
-
status: 'running',
|
|
98
|
-
input: {},
|
|
99
|
-
metadata: {},
|
|
100
|
-
context: {},
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
await storage.updateRun(run.id, {
|
|
104
|
-
status: 'failed',
|
|
105
|
-
error: { code: 'TEST_ERROR', message: 'Something went wrong' },
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const updated = await storage.getRun(run.id);
|
|
109
|
-
expect(updated!.status).toBe('failed');
|
|
110
|
-
expect(updated!.error).toEqual({ code: 'TEST_ERROR', message: 'Something went wrong' });
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('listRuns', () => {
|
|
115
|
-
beforeEach(async () => {
|
|
116
|
-
// Create some test runs
|
|
117
|
-
await storage.createRun({ kind: 'workflow.a', status: 'succeeded', input: {}, metadata: {}, context: {} });
|
|
118
|
-
await storage.createRun({ kind: 'workflow.a', status: 'failed', input: {}, metadata: {}, context: {} });
|
|
119
|
-
await storage.createRun({ kind: 'workflow.b', status: 'running', input: {}, metadata: {}, context: {} });
|
|
120
|
-
await storage.createRun({ kind: 'workflow.b', status: 'queued', input: {}, metadata: {}, context: {} });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should list all runs', async () => {
|
|
124
|
-
const result = await storage.listRuns();
|
|
125
|
-
expect(result.items).toHaveLength(4);
|
|
126
|
-
expect(result.total).toBe(4);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should filter by kind', async () => {
|
|
130
|
-
const result = await storage.listRuns({ kind: 'workflow.a' });
|
|
131
|
-
expect(result.items).toHaveLength(2);
|
|
132
|
-
expect(result.items.every(r => r.kind === 'workflow.a')).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should filter by single status', async () => {
|
|
136
|
-
const result = await storage.listRuns({ status: 'running' });
|
|
137
|
-
expect(result.items).toHaveLength(1);
|
|
138
|
-
expect(result.items[0].status).toBe('running');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should filter by multiple statuses', async () => {
|
|
142
|
-
const result = await storage.listRuns({ status: ['queued', 'running'] });
|
|
143
|
-
expect(result.items).toHaveLength(2);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('should support pagination', async () => {
|
|
147
|
-
const page1 = await storage.listRuns({ limit: 2, offset: 0 });
|
|
148
|
-
const page2 = await storage.listRuns({ limit: 2, offset: 2 });
|
|
149
|
-
|
|
150
|
-
expect(page1.items).toHaveLength(2);
|
|
151
|
-
expect(page2.items).toHaveLength(2);
|
|
152
|
-
expect(page1.items[0].id).not.toBe(page2.items[0].id);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('step operations', () => {
|
|
157
|
-
let runId: string;
|
|
158
|
-
|
|
159
|
-
beforeEach(async () => {
|
|
160
|
-
const run = await storage.createRun({
|
|
161
|
-
kind: 'test.workflow',
|
|
162
|
-
status: 'running',
|
|
163
|
-
input: {},
|
|
164
|
-
metadata: {},
|
|
165
|
-
context: {},
|
|
166
|
-
});
|
|
167
|
-
runId = run.id;
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should create and retrieve a step', async () => {
|
|
171
|
-
const step = await storage.createStep({
|
|
172
|
-
runId,
|
|
173
|
-
stepKey: 'step1',
|
|
174
|
-
stepName: 'First Step',
|
|
175
|
-
status: 'running',
|
|
176
|
-
attempt: 1,
|
|
177
|
-
startedAt: new Date(),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
expect(step.id).toBeDefined();
|
|
181
|
-
expect(step.stepKey).toBe('step1');
|
|
182
|
-
|
|
183
|
-
const retrieved = await storage.getStep(step.id);
|
|
184
|
-
expect(retrieved).not.toBeNull();
|
|
185
|
-
expect(retrieved!.stepKey).toBe('step1');
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('should update step status and result', async () => {
|
|
189
|
-
const step = await storage.createStep({
|
|
190
|
-
runId,
|
|
191
|
-
stepKey: 'step1',
|
|
192
|
-
stepName: 'First Step',
|
|
193
|
-
status: 'running',
|
|
194
|
-
attempt: 1,
|
|
195
|
-
startedAt: new Date(),
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
await storage.updateStep(step.id, {
|
|
199
|
-
status: 'succeeded',
|
|
200
|
-
result: { data: [1, 2, 3] },
|
|
201
|
-
finishedAt: new Date(),
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
const updated = await storage.getStep(step.id);
|
|
205
|
-
expect(updated!.status).toBe('succeeded');
|
|
206
|
-
expect(updated!.result).toEqual({ data: [1, 2, 3] });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('should get all steps for a run', async () => {
|
|
210
|
-
await storage.createStep({ runId, stepKey: 'step1', stepName: 'Step 1', status: 'succeeded', attempt: 1, startedAt: new Date() });
|
|
211
|
-
await storage.createStep({ runId, stepKey: 'step2', stepName: 'Step 2', status: 'succeeded', attempt: 1, startedAt: new Date() });
|
|
212
|
-
await storage.createStep({ runId, stepKey: 'step3', stepName: 'Step 3', status: 'running', attempt: 1, startedAt: new Date() });
|
|
213
|
-
|
|
214
|
-
const steps = await storage.getStepsForRun(runId);
|
|
215
|
-
expect(steps).toHaveLength(3);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe('event operations', () => {
|
|
220
|
-
let runId: string;
|
|
221
|
-
|
|
222
|
-
beforeEach(async () => {
|
|
223
|
-
const run = await storage.createRun({
|
|
224
|
-
kind: 'test.workflow',
|
|
225
|
-
status: 'running',
|
|
226
|
-
input: {},
|
|
227
|
-
metadata: {},
|
|
228
|
-
context: {},
|
|
229
|
-
});
|
|
230
|
-
runId = run.id;
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('should save and retrieve events', async () => {
|
|
234
|
-
await storage.saveEvent({
|
|
235
|
-
runId,
|
|
236
|
-
eventType: 'run.started',
|
|
237
|
-
level: 'info',
|
|
238
|
-
timestamp: new Date(),
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
await storage.saveEvent({
|
|
242
|
-
runId,
|
|
243
|
-
stepKey: 'step1',
|
|
244
|
-
eventType: 'step.completed',
|
|
245
|
-
level: 'info',
|
|
246
|
-
payload: { result: 'success' },
|
|
247
|
-
timestamp: new Date(),
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
const events = await storage.getEventsForRun(runId);
|
|
251
|
-
expect(events).toHaveLength(2);
|
|
252
|
-
expect(events[0].eventType).toBe('run.started');
|
|
253
|
-
expect(events[1].eventType).toBe('step.completed');
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('should filter events by step', async () => {
|
|
257
|
-
await storage.saveEvent({ runId, eventType: 'run.started', level: 'info', timestamp: new Date() });
|
|
258
|
-
await storage.saveEvent({ runId, stepKey: 'step1', eventType: 'step.started', level: 'info', timestamp: new Date() });
|
|
259
|
-
await storage.saveEvent({ runId, stepKey: 'step1', eventType: 'step.completed', level: 'info', timestamp: new Date() });
|
|
260
|
-
await storage.saveEvent({ runId, stepKey: 'step2', eventType: 'step.started', level: 'info', timestamp: new Date() });
|
|
261
|
-
|
|
262
|
-
const step1Events = await storage.getEventsForRun(runId, { stepKey: 'step1' });
|
|
263
|
-
expect(step1Events).toHaveLength(2);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('should filter events by level', async () => {
|
|
267
|
-
await storage.saveEvent({ runId, eventType: 'run.started', level: 'info', timestamp: new Date() });
|
|
268
|
-
await storage.saveEvent({ runId, eventType: 'step.failed', level: 'error', timestamp: new Date() });
|
|
269
|
-
await storage.saveEvent({ runId, eventType: 'step.retry', level: 'warn', timestamp: new Date() });
|
|
270
|
-
|
|
271
|
-
const errorEvents = await storage.getEventsForRun(runId, { level: 'error' });
|
|
272
|
-
expect(errorEvents).toHaveLength(1);
|
|
273
|
-
expect(errorEvents[0].eventType).toBe('step.failed');
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe('transaction', () => {
|
|
278
|
-
it('should execute operations within a synchronous transaction', () => {
|
|
279
|
-
// Use the synchronous transaction method for better-sqlite3
|
|
280
|
-
const result = storage.transactionSync(() => {
|
|
281
|
-
// Note: We access the underlying sync methods via the statements
|
|
282
|
-
const id = 'test-tx-run';
|
|
283
|
-
storage['stmts']!.insertRun.run(
|
|
284
|
-
id,
|
|
285
|
-
'test.workflow',
|
|
286
|
-
'queued',
|
|
287
|
-
null,
|
|
288
|
-
JSON.stringify({}),
|
|
289
|
-
JSON.stringify({}),
|
|
290
|
-
JSON.stringify({}),
|
|
291
|
-
null,
|
|
292
|
-
null,
|
|
293
|
-
new Date().toISOString(),
|
|
294
|
-
null,
|
|
295
|
-
null
|
|
296
|
-
);
|
|
297
|
-
storage['stmts']!.updateRun.run('running', null, null, null, null, null, id);
|
|
298
|
-
return id;
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
expect(result).toBe('test-tx-run');
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('should rollback on error', async () => {
|
|
305
|
-
const initialStats = storage.getStats();
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
storage.transactionSync(() => {
|
|
309
|
-
storage['stmts']!.insertRun.run(
|
|
310
|
-
'rollback-test',
|
|
311
|
-
'test.workflow',
|
|
312
|
-
'queued',
|
|
313
|
-
null,
|
|
314
|
-
JSON.stringify({}),
|
|
315
|
-
JSON.stringify({}),
|
|
316
|
-
JSON.stringify({}),
|
|
317
|
-
null,
|
|
318
|
-
new Date().toISOString(),
|
|
319
|
-
null,
|
|
320
|
-
null
|
|
321
|
-
);
|
|
322
|
-
throw new Error('Simulated error');
|
|
323
|
-
});
|
|
324
|
-
} catch {
|
|
325
|
-
// Expected
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const finalStats = storage.getStats();
|
|
329
|
-
expect(finalStats.runs).toBe(initialStats.runs);
|
|
330
|
-
});
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
describe('deleteOldRuns', () => {
|
|
334
|
-
it('should delete runs older than the specified date', async () => {
|
|
335
|
-
// Create a run
|
|
336
|
-
const run = await storage.createRun({
|
|
337
|
-
kind: 'test.workflow',
|
|
338
|
-
status: 'succeeded',
|
|
339
|
-
input: {},
|
|
340
|
-
metadata: {},
|
|
341
|
-
context: {},
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// Create a step for the run
|
|
345
|
-
await storage.createStep({
|
|
346
|
-
runId: run.id,
|
|
347
|
-
stepKey: 'step1',
|
|
348
|
-
stepName: 'Step 1',
|
|
349
|
-
status: 'succeeded',
|
|
350
|
-
attempt: 1,
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// Create an event for the run
|
|
354
|
-
await storage.saveEvent({
|
|
355
|
-
runId: run.id,
|
|
356
|
-
eventType: 'run.completed',
|
|
357
|
-
level: 'info',
|
|
358
|
-
timestamp: new Date(),
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// Delete runs older than tomorrow
|
|
362
|
-
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
363
|
-
const deleted = await storage.deleteOldRuns(tomorrow);
|
|
364
|
-
|
|
365
|
-
expect(deleted).toBe(1);
|
|
366
|
-
|
|
367
|
-
// Verify run is deleted
|
|
368
|
-
const retrieved = await storage.getRun(run.id);
|
|
369
|
-
expect(retrieved).toBeNull();
|
|
370
|
-
|
|
371
|
-
// Verify steps are deleted
|
|
372
|
-
const steps = await storage.getStepsForRun(run.id);
|
|
373
|
-
expect(steps).toHaveLength(0);
|
|
374
|
-
|
|
375
|
-
// Verify events are deleted
|
|
376
|
-
const events = await storage.getEventsForRun(run.id);
|
|
377
|
-
expect(events).toHaveLength(0);
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
describe('resume support', () => {
|
|
382
|
-
it('should get interrupted runs', async () => {
|
|
383
|
-
await storage.createRun({ kind: 'workflow.a', status: 'succeeded', input: {}, metadata: {}, context: {} });
|
|
384
|
-
await storage.createRun({ kind: 'workflow.a', status: 'running', input: {}, metadata: {}, context: {} });
|
|
385
|
-
await storage.createRun({ kind: 'workflow.b', status: 'queued', input: {}, metadata: {}, context: {} });
|
|
386
|
-
await storage.createRun({ kind: 'workflow.b', status: 'failed', input: {}, metadata: {}, context: {} });
|
|
387
|
-
|
|
388
|
-
const interrupted = await storage.getInterruptedRuns();
|
|
389
|
-
expect(interrupted).toHaveLength(2);
|
|
390
|
-
expect(interrupted.every(r => ['queued', 'running'].includes(r.status))).toBe(true);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('should get last completed step', async () => {
|
|
394
|
-
const run = await storage.createRun({
|
|
395
|
-
kind: 'test.workflow',
|
|
396
|
-
status: 'running',
|
|
397
|
-
input: {},
|
|
398
|
-
metadata: {},
|
|
399
|
-
context: {},
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
const now = new Date();
|
|
403
|
-
await storage.createStep({
|
|
404
|
-
runId: run.id,
|
|
405
|
-
stepKey: 'step1',
|
|
406
|
-
stepName: 'Step 1',
|
|
407
|
-
status: 'succeeded',
|
|
408
|
-
attempt: 1,
|
|
409
|
-
startedAt: now,
|
|
410
|
-
finishedAt: new Date(now.getTime() + 100),
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
await storage.createStep({
|
|
414
|
-
runId: run.id,
|
|
415
|
-
stepKey: 'step2',
|
|
416
|
-
stepName: 'Step 2',
|
|
417
|
-
status: 'succeeded',
|
|
418
|
-
attempt: 1,
|
|
419
|
-
startedAt: new Date(now.getTime() + 200),
|
|
420
|
-
finishedAt: new Date(now.getTime() + 300),
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
await storage.createStep({
|
|
424
|
-
runId: run.id,
|
|
425
|
-
stepKey: 'step3',
|
|
426
|
-
stepName: 'Step 3',
|
|
427
|
-
status: 'running',
|
|
428
|
-
attempt: 1,
|
|
429
|
-
startedAt: new Date(now.getTime() + 400),
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
const lastCompleted = await storage.getLastCompletedStep(run.id);
|
|
433
|
-
expect(lastCompleted).not.toBeNull();
|
|
434
|
-
expect(lastCompleted!.stepKey).toBe('step2');
|
|
435
|
-
});
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
describe('deprecated transaction() returns callback result', () => {
|
|
439
|
-
it('should return the callback result for sync operations', async () => {
|
|
440
|
-
const result = await storage.transaction(async () => {
|
|
441
|
-
return 42;
|
|
442
|
-
});
|
|
443
|
-
expect(result).toBe(42);
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it('should return the callback result when awaiting resolved promises', async () => {
|
|
447
|
-
const result = await storage.transaction(async () => {
|
|
448
|
-
await Promise.resolve();
|
|
449
|
-
return 'hello';
|
|
450
|
-
});
|
|
451
|
-
expect(result).toBe('hello');
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it('warns when the callback awaits real async I/O (post-COMMIT work is not transactional)', async () => {
|
|
455
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
456
|
-
try {
|
|
457
|
-
const result = await storage.transaction(async () => {
|
|
458
|
-
// setTimeout is a macrotask — by the time it fires, COMMIT is long done.
|
|
459
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
460
|
-
return 'late';
|
|
461
|
-
});
|
|
462
|
-
expect(result).toBe('late');
|
|
463
|
-
expect(warnSpy).toHaveBeenCalled();
|
|
464
|
-
const message = warnSpy.mock.calls[0]?.[0] ?? '';
|
|
465
|
-
expect(message).toContain('did not settle synchronously');
|
|
466
|
-
expect(message).toContain('transactionSync');
|
|
467
|
-
} finally {
|
|
468
|
-
warnSpy.mockRestore();
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it('does not warn when the callback completes synchronously', async () => {
|
|
473
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
474
|
-
try {
|
|
475
|
-
await storage.transaction(async () => 1);
|
|
476
|
-
await storage.transaction(async () => {
|
|
477
|
-
await Promise.resolve();
|
|
478
|
-
return 2;
|
|
479
|
-
});
|
|
480
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
481
|
-
} finally {
|
|
482
|
-
warnSpy.mockRestore();
|
|
483
|
-
}
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
describe('getStats', () => {
|
|
488
|
-
it('should return database statistics', async () => {
|
|
489
|
-
await storage.createRun({ kind: 'test', status: 'queued', input: {}, metadata: {}, context: {} });
|
|
490
|
-
await storage.createRun({ kind: 'test', status: 'queued', input: {}, metadata: {}, context: {} });
|
|
491
|
-
|
|
492
|
-
const stats = storage.getStats();
|
|
493
|
-
expect(stats.runs).toBe(2);
|
|
494
|
-
expect(stats.steps).toBe(0);
|
|
495
|
-
expect(stats.events).toBe(0);
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
describe('schema migrations', () => {
|
|
500
|
-
/**
|
|
501
|
-
* The pre-`completed_steps_json` schema. Reproduces the state of a database
|
|
502
|
-
* created by an older version of the adapter, before the column was added.
|
|
503
|
-
* Includes the unchanged sibling tables so prepared statements that touch
|
|
504
|
-
* `workflow_run_steps` / `workflow_events` find them on construction.
|
|
505
|
-
*/
|
|
506
|
-
const LEGACY_SCHEMA_SQL = `
|
|
507
|
-
CREATE TABLE workflow_runs (
|
|
508
|
-
id TEXT PRIMARY KEY,
|
|
509
|
-
kind TEXT NOT NULL,
|
|
510
|
-
status TEXT NOT NULL,
|
|
511
|
-
parent_run_id TEXT,
|
|
512
|
-
input_json TEXT NOT NULL,
|
|
513
|
-
metadata_json TEXT NOT NULL,
|
|
514
|
-
context_json TEXT NOT NULL,
|
|
515
|
-
error_json TEXT,
|
|
516
|
-
created_at TEXT NOT NULL,
|
|
517
|
-
started_at TEXT,
|
|
518
|
-
finished_at TEXT
|
|
519
|
-
);
|
|
520
|
-
CREATE TABLE workflow_run_steps (
|
|
521
|
-
id TEXT PRIMARY KEY,
|
|
522
|
-
run_id TEXT NOT NULL,
|
|
523
|
-
step_key TEXT NOT NULL,
|
|
524
|
-
step_name TEXT,
|
|
525
|
-
status TEXT NOT NULL,
|
|
526
|
-
attempt INTEGER NOT NULL DEFAULT 1,
|
|
527
|
-
result_json TEXT,
|
|
528
|
-
error_json TEXT,
|
|
529
|
-
started_at TEXT,
|
|
530
|
-
finished_at TEXT,
|
|
531
|
-
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
532
|
-
);
|
|
533
|
-
CREATE TABLE workflow_events (
|
|
534
|
-
id TEXT PRIMARY KEY,
|
|
535
|
-
run_id TEXT NOT NULL,
|
|
536
|
-
step_key TEXT,
|
|
537
|
-
event_type TEXT NOT NULL,
|
|
538
|
-
level TEXT NOT NULL,
|
|
539
|
-
payload_json TEXT,
|
|
540
|
-
timestamp TEXT NOT NULL,
|
|
541
|
-
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
542
|
-
);
|
|
543
|
-
`;
|
|
544
|
-
|
|
545
|
-
function columnNames(database: Database.Database, table: string): string[] {
|
|
546
|
-
const rows = database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
547
|
-
return rows.map((r) => r.name);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
it('adds completed_steps_json to a legacy workflow_runs table on startup', () => {
|
|
551
|
-
// Regression: this is the exact column that broke dev-benchmark when an
|
|
552
|
-
// older deploy's `workflow_runs` survived an upgrade. Renaming the column
|
|
553
|
-
// requires updating this assertion AND `SCHEMA_MIGRATIONS` in sqlite.ts.
|
|
554
|
-
const legacyDb = new Database(':memory:');
|
|
555
|
-
legacyDb.exec(LEGACY_SCHEMA_SQL);
|
|
556
|
-
expect(columnNames(legacyDb, 'workflow_runs')).not.toContain('completed_steps_json');
|
|
557
|
-
|
|
558
|
-
const adapter = new SQLiteStorageAdapter({ db: legacyDb });
|
|
559
|
-
try {
|
|
560
|
-
expect(columnNames(legacyDb, 'workflow_runs')).toContain('completed_steps_json');
|
|
561
|
-
} finally {
|
|
562
|
-
adapter.close();
|
|
563
|
-
}
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
it('persists completedSteps after migrating a legacy table', async () => {
|
|
567
|
-
const legacyDb = new Database(':memory:');
|
|
568
|
-
legacyDb.exec(LEGACY_SCHEMA_SQL);
|
|
569
|
-
|
|
570
|
-
const adapter = new SQLiteStorageAdapter({ db: legacyDb });
|
|
571
|
-
try {
|
|
572
|
-
const run = await adapter.createRun({
|
|
573
|
-
kind: 'test.workflow',
|
|
574
|
-
status: 'running',
|
|
575
|
-
input: {},
|
|
576
|
-
metadata: {},
|
|
577
|
-
context: {},
|
|
578
|
-
completedSteps: ['step1', 'step2'],
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
const retrieved = await adapter.getRun(run.id);
|
|
582
|
-
expect(retrieved!.completedSteps).toEqual(['step1', 'step2']);
|
|
583
|
-
} finally {
|
|
584
|
-
adapter.close();
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
it('is a no-op on a fresh database', () => {
|
|
589
|
-
// Pure additive: the second adapter on the same DB must not error or
|
|
590
|
-
// change anything when every column is already present.
|
|
591
|
-
const freshDb = new Database(':memory:');
|
|
592
|
-
const adapter1 = new SQLiteStorageAdapter({ db: freshDb });
|
|
593
|
-
const before = columnNames(freshDb, 'workflow_runs');
|
|
594
|
-
|
|
595
|
-
// Simulate a second startup against a fully-migrated DB.
|
|
596
|
-
const adapter2 = new SQLiteStorageAdapter({ db: freshDb });
|
|
597
|
-
const after = columnNames(freshDb, 'workflow_runs');
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
expect(after).toEqual(before);
|
|
601
|
-
expect(after).toContain('completed_steps_json');
|
|
602
|
-
} finally {
|
|
603
|
-
adapter1.close();
|
|
604
|
-
adapter2.close();
|
|
605
|
-
}
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
it('still applies migrations when autoCreateTables is false', () => {
|
|
609
|
-
// The prepared statements always reference the latest schema, so we have
|
|
610
|
-
// to reconcile columns even when the caller manages CREATE TABLE.
|
|
611
|
-
const legacyDb = new Database(':memory:');
|
|
612
|
-
legacyDb.exec(LEGACY_SCHEMA_SQL);
|
|
613
|
-
|
|
614
|
-
const adapter = new SQLiteStorageAdapter({ db: legacyDb, autoCreateTables: false });
|
|
615
|
-
try {
|
|
616
|
-
expect(columnNames(legacyDb, 'workflow_runs')).toContain('completed_steps_json');
|
|
617
|
-
} finally {
|
|
618
|
-
adapter.close();
|
|
619
|
-
}
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it('is idempotent across multiple instantiations', () => {
|
|
623
|
-
// Migrating a DB twice must not error or duplicate columns — exercises
|
|
624
|
-
// the existence-check skip path for an already-migrated column.
|
|
625
|
-
const legacyDb = new Database(':memory:');
|
|
626
|
-
legacyDb.exec(LEGACY_SCHEMA_SQL);
|
|
627
|
-
|
|
628
|
-
// First adapter migrates the legacy table.
|
|
629
|
-
new SQLiteStorageAdapter({ db: legacyDb });
|
|
630
|
-
const after1 = columnNames(legacyDb, 'workflow_runs');
|
|
631
|
-
expect(after1).toContain('completed_steps_json');
|
|
632
|
-
|
|
633
|
-
// Second adapter on the same DB must be a no-op.
|
|
634
|
-
const second = new SQLiteStorageAdapter({ db: legacyDb });
|
|
635
|
-
try {
|
|
636
|
-
expect(columnNames(legacyDb, 'workflow_runs')).toEqual(after1);
|
|
637
|
-
} finally {
|
|
638
|
-
second.close();
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
});
|