@kylebegeman/pulse 0.3.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 +1078 -0
- package/dist/cron.d.ts +16 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +32 -0
- package/dist/cron.js.map +1 -0
- package/dist/executor.d.ts +43 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +333 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +198 -0
- package/dist/index.js.map +1 -0
- package/dist/logs.d.ts +11 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +25 -0
- package/dist/logs.js.map +1 -0
- package/dist/matcher.d.ts +23 -0
- package/dist/matcher.d.ts.map +1 -0
- package/dist/matcher.js +184 -0
- package/dist/matcher.js.map +1 -0
- package/dist/queue.d.ts +42 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +85 -0
- package/dist/queue.js.map +1 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +33 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +10 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +24 -0
- package/dist/replay.js.map +1 -0
- package/dist/runs.d.ts +37 -0
- package/dist/runs.d.ts.map +1 -0
- package/dist/runs.js +296 -0
- package/dist/runs.js.map +1 -0
- package/dist/scheduler.d.ts +21 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +67 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/schema/migrate.d.ts +3 -0
- package/dist/schema/migrate.d.ts.map +1 -0
- package/dist/schema/migrate.js +56 -0
- package/dist/schema/migrate.js.map +1 -0
- package/dist/schema/tables.d.ts +10 -0
- package/dist/schema/tables.d.ts.map +1 -0
- package/dist/schema/tables.js +123 -0
- package/dist/schema/tables.js.map +1 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/errors.d.ts +30 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +61 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/ids.d.ts +2 -0
- package/dist/utils/ids.d.ts.map +1 -0
- package/dist/utils/ids.js +6 -0
- package/dist/utils/ids.js.map +1 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Pulse
|
|
4
|
+
|
|
5
|
+
**Signal-driven workflow engine for multi-tenant applications**
|
|
6
|
+
|
|
7
|
+
[](https://www.typescriptlang.org)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://www.postgresql.org)
|
|
10
|
+
[](https://redis.io)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
|
|
13
|
+
[Quick Start](#quick-start) • [Core Concepts](#core-concepts) • [API Reference](#api-reference) • [Examples](#examples) • [Database Schema](#database-schema)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
Pulse is a TypeScript library for building event-driven workflow automation. Your application emits **signals**, and the engine matches them to registered **workflows** — scheduling and executing **steps** in sequence with full persistence, observability, and replay support.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Signal -> Match -> Schedule Steps -> Evaluate Conditions -> Execute Actions -> Record Results
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Built for multi-tenant SaaS. Every signal, workflow, and run is scoped to a tenant. The engine runs in-process alongside your application using your existing PostgreSQL database and Redis instance.
|
|
28
|
+
|
|
29
|
+
### Highlights
|
|
30
|
+
|
|
31
|
+
- **Signal-driven** — Emit structured events, let the engine match and execute workflows
|
|
32
|
+
- **Multi-tenant** — Every resource is scoped to a tenant out of the box
|
|
33
|
+
- **Parallel execution** — Fan out into concurrent branches with automatic convergence
|
|
34
|
+
- **Cron scheduling** — Trigger workflows on recurring schedules via BullMQ
|
|
35
|
+
- **Replay** — Re-process historical signals with replay-safety controls
|
|
36
|
+
- **Cancellation & retry** — Cancel in-progress runs or retry from the point of failure
|
|
37
|
+
- **Timeline** — Chronological audit trail for every run
|
|
38
|
+
- **Lifecycle hooks** — Observe step and run completion for integrations
|
|
39
|
+
- **Production-hardened** — CAS step claiming, state guards, idempotent migrations
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createEngine } from '@kylebegeman/pulse'
|
|
47
|
+
import { Pool } from 'pg'
|
|
48
|
+
import Redis from 'ioredis'
|
|
49
|
+
|
|
50
|
+
const engine = createEngine({
|
|
51
|
+
db: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
52
|
+
redis: new Redis(process.env.REDIS_URL),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Register a trigger, action, and condition
|
|
56
|
+
engine.registerTrigger('heartbeat.missed', {
|
|
57
|
+
source: 'heartbeat',
|
|
58
|
+
resourceType: 'service',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
engine.registerAction('create_incident', async (ctx) => {
|
|
62
|
+
const incident = await createIncident({
|
|
63
|
+
service: ctx.trigger.resourceId,
|
|
64
|
+
tenant: ctx.tenantId,
|
|
65
|
+
})
|
|
66
|
+
ctx.log('Incident created', { incidentId: incident.id })
|
|
67
|
+
return { success: true, data: { incidentId: incident.id } }
|
|
68
|
+
}, { replaySafe: true })
|
|
69
|
+
|
|
70
|
+
engine.registerCondition('is_still_failing', async (ctx) => {
|
|
71
|
+
const status = await checkServiceHealth(ctx.trigger.resourceId)
|
|
72
|
+
return status === 'unhealthy'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Run migrations and create a workflow
|
|
76
|
+
await engine.migrate()
|
|
77
|
+
|
|
78
|
+
await engine.createWorkflow({
|
|
79
|
+
tenantId: 'workspace_1',
|
|
80
|
+
name: 'Incident on missed heartbeat',
|
|
81
|
+
triggerType: 'heartbeat.missed',
|
|
82
|
+
steps: [
|
|
83
|
+
{ type: 'delay', name: 'wait_5m', delayMs: 5 * 60 * 1000 },
|
|
84
|
+
{ type: 'condition', name: 'is_still_failing' },
|
|
85
|
+
{ type: 'action', name: 'create_incident' },
|
|
86
|
+
],
|
|
87
|
+
config: {},
|
|
88
|
+
isEnabled: true,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Start processing and emit signals
|
|
92
|
+
await engine.start()
|
|
93
|
+
|
|
94
|
+
await engine.emit({
|
|
95
|
+
tenantId: 'workspace_1',
|
|
96
|
+
source: 'heartbeat',
|
|
97
|
+
type: 'heartbeat.missed',
|
|
98
|
+
resourceType: 'service',
|
|
99
|
+
resourceId: 'api-server',
|
|
100
|
+
payload: { lastSeenAt: new Date().toISOString() },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Graceful shutdown
|
|
104
|
+
await engine.stop()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Installation
|
|
110
|
+
|
|
111
|
+
Pulse is a private package. Install from GitHub:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# package.json
|
|
115
|
+
"@kylebegeman/pulse": "github:mrbagels/pulse"
|
|
116
|
+
|
|
117
|
+
# or via SSH
|
|
118
|
+
npm install git+ssh://git@github.com:mrbagels/pulse.git
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Peer requirements:**
|
|
122
|
+
|
|
123
|
+
| Dependency | Purpose |
|
|
124
|
+
|:--|:--|
|
|
125
|
+
| `pg` | PostgreSQL client — engine uses your connection pool |
|
|
126
|
+
| `ioredis` | Redis client — used by BullMQ for job queuing |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Core Concepts
|
|
131
|
+
|
|
132
|
+
### Signals (Triggers)
|
|
133
|
+
|
|
134
|
+
A **signal** is a structured event emitted by your application. Every signal includes a tenant, a source system, a type, and optional resource and payload data.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await engine.emit({
|
|
138
|
+
tenantId: 'workspace_1',
|
|
139
|
+
source: 'heartbeat',
|
|
140
|
+
type: 'heartbeat.missed',
|
|
141
|
+
resourceType: 'service',
|
|
142
|
+
resourceId: 'api-server',
|
|
143
|
+
environment: 'production',
|
|
144
|
+
payload: { lastSeenAt: '...' },
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Triggers are **registered** to declare their source, expected resource type, and optional Zod payload schema:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { z } from 'zod'
|
|
152
|
+
|
|
153
|
+
engine.registerTrigger('heartbeat.missed', {
|
|
154
|
+
source: 'heartbeat',
|
|
155
|
+
resourceType: 'service',
|
|
156
|
+
payloadSchema: z.object({
|
|
157
|
+
lastSeenAt: z.string().datetime(),
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
All emitted signals are persisted to the database for auditing and [replay](#replay).
|
|
163
|
+
|
|
164
|
+
### Workflows
|
|
165
|
+
|
|
166
|
+
A **workflow** is a named sequence of steps that runs when a matching signal is emitted. Workflows are stored in the database and scoped to a tenant.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
await engine.createWorkflow({
|
|
170
|
+
tenantId: 'workspace_1',
|
|
171
|
+
name: 'Incident on missed heartbeat',
|
|
172
|
+
triggerType: 'heartbeat.missed',
|
|
173
|
+
environmentFilter: 'production',
|
|
174
|
+
resourceTypeFilter: 'service',
|
|
175
|
+
steps: [
|
|
176
|
+
{ type: 'delay', name: 'wait_5m', delayMs: 300_000 },
|
|
177
|
+
{ type: 'condition', name: 'is_still_failing' },
|
|
178
|
+
{ type: 'action', name: 'create_incident' },
|
|
179
|
+
],
|
|
180
|
+
config: { severity: 'high' },
|
|
181
|
+
isEnabled: true,
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
When a signal is emitted, the engine finds all enabled workflows matching the signal type (and optional filters), creates a **run** for each, and begins scheduling steps. Workflows can be enabled or disabled at runtime:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
await engine.disableWorkflow('wfd_abc123')
|
|
189
|
+
await engine.enableWorkflow('wfd_abc123')
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Steps
|
|
193
|
+
|
|
194
|
+
Each workflow contains an ordered list of steps. Steps execute sequentially — each must complete before the next begins.
|
|
195
|
+
|
|
196
|
+
| Type | Purpose |
|
|
197
|
+
|:--|:--|
|
|
198
|
+
| `action` | Execute a registered handler function |
|
|
199
|
+
| `condition` | Evaluate a boolean to branch or complete early |
|
|
200
|
+
| `delay` | Pause execution for a duration (via BullMQ delayed jobs) |
|
|
201
|
+
| `parallel` | Execute multiple branches concurrently |
|
|
202
|
+
|
|
203
|
+
#### Action steps
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
engine.registerAction('send_alert', async (ctx) => {
|
|
207
|
+
await sendSlackMessage(ctx.config.channel, `Alert for ${ctx.trigger.resourceId}`)
|
|
208
|
+
return { success: true, data: { sent: true } }
|
|
209
|
+
}, { replaySafe: false })
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Actions support retry policies and timeouts:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
{
|
|
216
|
+
type: 'action',
|
|
217
|
+
name: 'send_alert',
|
|
218
|
+
retryPolicy: { maxAttempts: 3, backoffMs: 5000 },
|
|
219
|
+
timeoutMs: 30_000,
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### Condition steps
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
engine.registerCondition('monitor_still_failing', async (ctx) => {
|
|
227
|
+
const health = await checkHealth(ctx.trigger.resourceId)
|
|
228
|
+
return health.status === 'down'
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Control what happens when a condition returns `false`:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
{ type: 'condition', name: 'check', onFalse: 'complete' } // Default: complete early
|
|
236
|
+
{ type: 'condition', name: 'check', onFalse: 'skip' } // Skip next step
|
|
237
|
+
{ type: 'condition', name: 'check', onFalse: 3 } // Skip next N steps
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### Delay steps
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
{ type: 'delay', name: 'wait_5_minutes', delayMs: 5 * 60 * 1000 }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Maximum delay: 30 days. Uses BullMQ delayed jobs for reliability.
|
|
247
|
+
|
|
248
|
+
#### Parallel steps
|
|
249
|
+
|
|
250
|
+
Run multiple branches concurrently. All branches must complete before the workflow continues.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
{
|
|
254
|
+
type: 'parallel',
|
|
255
|
+
name: 'notify_all_channels',
|
|
256
|
+
branches: [
|
|
257
|
+
[{ type: 'action', name: 'send_email' }, { type: 'action', name: 'log_email_sent' }],
|
|
258
|
+
[{ type: 'action', name: 'send_slack' }],
|
|
259
|
+
[{ type: 'action', name: 'send_sms' }],
|
|
260
|
+
],
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- Branches execute independently via BullMQ jobs
|
|
265
|
+
- Branches can contain any step types
|
|
266
|
+
- If any branch fails, the parallel step fails and the run fails
|
|
267
|
+
- Minimum 2 branches required
|
|
268
|
+
|
|
269
|
+
### Cron Scheduling
|
|
270
|
+
|
|
271
|
+
Workflows can run on recurring schedules. When a cron fires, the engine emits a trigger of the workflow's type automatically.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await engine.createWorkflow({
|
|
275
|
+
tenantId: 'workspace_1',
|
|
276
|
+
name: 'Daily cleanup',
|
|
277
|
+
triggerType: 'maintenance.cleanup',
|
|
278
|
+
steps: [{ type: 'action', name: 'cleanup_old_records' }],
|
|
279
|
+
config: {},
|
|
280
|
+
isEnabled: true,
|
|
281
|
+
cronExpression: '0 2 * * *', // Daily at 2:00 AM
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Cron jobs use BullMQ repeatable jobs. Enabling/disabling a workflow starts/stops its cron. Standard 5-field format: `minute hour dayOfMonth month dayOfWeek`.
|
|
286
|
+
|
|
287
|
+
### Handler Context
|
|
288
|
+
|
|
289
|
+
Every action and condition handler receives a `WorkflowContext`:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
engine.registerAction('my_action', async (ctx) => {
|
|
293
|
+
ctx.tenantId // Current tenant
|
|
294
|
+
ctx.trigger // The signal that started this run
|
|
295
|
+
ctx.run // Current run state
|
|
296
|
+
ctx.step // Current step state
|
|
297
|
+
ctx.config // Workflow-level config
|
|
298
|
+
ctx.isReplay // True during replay execution
|
|
299
|
+
ctx.emit(...) // Emit another signal (workflow chaining)
|
|
300
|
+
ctx.log(...) // Write to execution log
|
|
301
|
+
|
|
302
|
+
return { success: true }
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Lifecycle Hooks
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const engine = createEngine({
|
|
310
|
+
db: pool,
|
|
311
|
+
redis: redisClient,
|
|
312
|
+
onStepComplete: (event) => {
|
|
313
|
+
console.log(`Step ${event.step.stepName}: ${event.step.status}`)
|
|
314
|
+
},
|
|
315
|
+
onRunComplete: (event) => {
|
|
316
|
+
console.log(`Run ${event.run.id}: ${event.status}`)
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Hooks are fire-and-forget — errors in hooks do not affect workflow execution.
|
|
322
|
+
|
|
323
|
+
### Replay
|
|
324
|
+
|
|
325
|
+
Re-process a historical signal through the matching pipeline:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
await engine.replay('trg_abc123') // Full replay
|
|
329
|
+
await engine.replay('trg_abc123', { dryRun: true }) // Dry run — skip actions
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Actions declare replay safety. Actions with `replaySafe: false` are **skipped** during replay to prevent duplicate side effects.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
engine.registerAction('create_incident', handler, { replaySafe: true })
|
|
336
|
+
engine.registerAction('send_email', handler, { replaySafe: false })
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Cancel & Retry
|
|
340
|
+
|
|
341
|
+
**Cancel** an in-progress run:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
const canceledRun = await engine.cancelRun('run_abc123', 'No longer needed')
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Sets status to `canceled`, marks pending steps as `skipped`, removes pending BullMQ jobs.
|
|
348
|
+
|
|
349
|
+
**Retry** a failed run from the point of failure:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const retriedRun = await engine.retryRun('run_abc123')
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Resets the failed step to `pending`, sets the run back to `running`, and re-enqueues via BullMQ.
|
|
356
|
+
|
|
357
|
+
**List** failed runs:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
const allFailed = await engine.getFailedRuns()
|
|
361
|
+
const tenantFailed = await engine.getFailedRuns('workspace_1')
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Run Timeline
|
|
365
|
+
|
|
366
|
+
Chronological audit trail for debugging dashboards:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const timeline = await engine.getRunTimeline('run_abc123')
|
|
370
|
+
|
|
371
|
+
for (const entry of timeline) {
|
|
372
|
+
console.log(`[${entry.timestamp}] ${entry.type}`, entry.detail)
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Entry types: `run_created`, `step_scheduled`, `step_started`, `step_completed`, `step_failed`, `step_skipped`, `run_completed`, `run_failed`, `run_canceled`, `log`
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## API Reference
|
|
381
|
+
|
|
382
|
+
### `createEngine(config)`
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createEngine } from '@kylebegeman/pulse'
|
|
386
|
+
|
|
387
|
+
const engine = createEngine({
|
|
388
|
+
db: pool, // Required — pg Pool instance
|
|
389
|
+
redis: redisClient, // Required — ioredis instance
|
|
390
|
+
tablePrefix: 'pulse_', // Default: 'pulse_'
|
|
391
|
+
queuePrefix: 'pulse', // Default: 'pulse'
|
|
392
|
+
concurrency: 5, // Default: 5
|
|
393
|
+
onStepComplete: (e) => {}, // Optional lifecycle hook
|
|
394
|
+
onRunComplete: (e) => {}, // Optional lifecycle hook
|
|
395
|
+
})
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Engine Methods
|
|
399
|
+
|
|
400
|
+
#### Registration
|
|
401
|
+
|
|
402
|
+
| Method | Description |
|
|
403
|
+
|:--|:--|
|
|
404
|
+
| `registerTrigger(type, registration)` | Register a signal type with source, optional resource type, and optional Zod payload schema |
|
|
405
|
+
| `registerAction(name, handler, options?)` | Register an action handler. Set `replaySafe` in options |
|
|
406
|
+
| `registerCondition(name, handler)` | Register a condition handler returning a boolean |
|
|
407
|
+
|
|
408
|
+
#### Signals
|
|
409
|
+
|
|
410
|
+
| Method | Returns | Description |
|
|
411
|
+
|:--|:--|:--|
|
|
412
|
+
| `emit(trigger)` | `TriggerEnvelope` | Emit a signal — persists, matches workflows, creates runs |
|
|
413
|
+
|
|
414
|
+
#### Lifecycle
|
|
415
|
+
|
|
416
|
+
| Method | Description |
|
|
417
|
+
|:--|:--|
|
|
418
|
+
| `start()` | Start BullMQ workers and restore cron jobs |
|
|
419
|
+
| `stop()` | Graceful shutdown — waits for active jobs, stops cron |
|
|
420
|
+
|
|
421
|
+
#### Queries
|
|
422
|
+
|
|
423
|
+
| Method | Returns | Description |
|
|
424
|
+
|:--|:--|:--|
|
|
425
|
+
| `getRun(runId)` | `WorkflowRun \| null` | Get a run by ID |
|
|
426
|
+
| `getRunSteps(runId)` | `WorkflowStepRun[]` | Get all step runs for a run |
|
|
427
|
+
| `getRunsByTrigger(triggerId)` | `WorkflowRun[]` | Get runs spawned by a trigger |
|
|
428
|
+
| `getTrigger(triggerId)` | `TriggerEnvelope \| null` | Get a persisted trigger |
|
|
429
|
+
|
|
430
|
+
#### Timeline
|
|
431
|
+
|
|
432
|
+
| Method | Returns | Description |
|
|
433
|
+
|:--|:--|:--|
|
|
434
|
+
| `getRunTimeline(runId)` | `RunTimelineEntry[]` | Chronological lifecycle view |
|
|
435
|
+
|
|
436
|
+
#### Cancel & Retry
|
|
437
|
+
|
|
438
|
+
| Method | Returns | Description |
|
|
439
|
+
|:--|:--|:--|
|
|
440
|
+
| `cancelRun(runId, reason?)` | `WorkflowRun` | Cancel an in-progress run |
|
|
441
|
+
| `retryRun(runId)` | `WorkflowRun` | Retry a failed run from the failed step |
|
|
442
|
+
| `getFailedRuns(tenantId?)` | `WorkflowRun[]` | List failed runs |
|
|
443
|
+
|
|
444
|
+
#### Replay
|
|
445
|
+
|
|
446
|
+
| Method | Returns | Description |
|
|
447
|
+
|:--|:--|:--|
|
|
448
|
+
| `replay(triggerId, options?)` | `WorkflowRun[]` | Re-emit a historical trigger |
|
|
449
|
+
|
|
450
|
+
#### Workflow Management
|
|
451
|
+
|
|
452
|
+
| Method | Returns | Description |
|
|
453
|
+
|:--|:--|:--|
|
|
454
|
+
| `createWorkflow(definition)` | `WorkflowDefinition` | Create a workflow, schedules cron if applicable |
|
|
455
|
+
| `enableWorkflow(definitionId)` | `void` | Enable a workflow |
|
|
456
|
+
| `disableWorkflow(definitionId)` | `void` | Disable a workflow |
|
|
457
|
+
|
|
458
|
+
#### Schema
|
|
459
|
+
|
|
460
|
+
| Method | Description |
|
|
461
|
+
|:--|:--|
|
|
462
|
+
| `migrate()` | Create `pulse_*` tables (idempotent) |
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Examples
|
|
467
|
+
|
|
468
|
+
### Monitoring: Incident on Missed Heartbeat
|
|
469
|
+
|
|
470
|
+
Wait for a missed heartbeat, verify the service is still down, then create an incident and page on-call.
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
engine.registerTrigger('heartbeat.missed', {
|
|
474
|
+
source: 'heartbeat',
|
|
475
|
+
resourceType: 'service',
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
engine.registerCondition('service_still_down', async (ctx) => {
|
|
479
|
+
const status = await healthCheck(ctx.trigger.resourceId)
|
|
480
|
+
return status !== 'healthy'
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
engine.registerAction('create_incident', async (ctx) => {
|
|
484
|
+
const incident = await db.incidents.create({
|
|
485
|
+
tenantId: ctx.tenantId,
|
|
486
|
+
serviceId: ctx.trigger.resourceId,
|
|
487
|
+
severity: ctx.config.severity || 'medium',
|
|
488
|
+
})
|
|
489
|
+
ctx.log('Incident created', { incidentId: incident.id })
|
|
490
|
+
return { success: true, data: { incidentId: incident.id } }
|
|
491
|
+
}, { replaySafe: true })
|
|
492
|
+
|
|
493
|
+
engine.registerAction('notify_oncall', async (ctx) => {
|
|
494
|
+
await pagerduty.trigger({ service: ctx.trigger.resourceId })
|
|
495
|
+
return { success: true }
|
|
496
|
+
}, { replaySafe: false })
|
|
497
|
+
|
|
498
|
+
await engine.createWorkflow({
|
|
499
|
+
tenantId: 'workspace_1',
|
|
500
|
+
name: 'Incident on missed heartbeat',
|
|
501
|
+
triggerType: 'heartbeat.missed',
|
|
502
|
+
environmentFilter: 'production',
|
|
503
|
+
steps: [
|
|
504
|
+
{ type: 'delay', name: 'wait_5m', delayMs: 5 * 60 * 1000 },
|
|
505
|
+
{ type: 'condition', name: 'service_still_down' },
|
|
506
|
+
{ type: 'action', name: 'create_incident' },
|
|
507
|
+
{ type: 'action', name: 'notify_oncall' },
|
|
508
|
+
],
|
|
509
|
+
config: { severity: 'high' },
|
|
510
|
+
isEnabled: true,
|
|
511
|
+
})
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Billing: Trial Expiration
|
|
515
|
+
|
|
516
|
+
Send a warning email, wait 3 days, then downgrade if the user hasn't upgraded.
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
engine.registerAction('send_trial_warning', async (ctx) => {
|
|
520
|
+
await emails.send({
|
|
521
|
+
to: ctx.trigger.payload.email,
|
|
522
|
+
template: 'trial-expiring',
|
|
523
|
+
data: { daysLeft: ctx.trigger.payload.daysLeft },
|
|
524
|
+
})
|
|
525
|
+
return { success: true }
|
|
526
|
+
}, { replaySafe: false })
|
|
527
|
+
|
|
528
|
+
engine.registerCondition('has_not_upgraded', async (ctx) => {
|
|
529
|
+
const sub = await billing.getSubscription(ctx.tenantId)
|
|
530
|
+
return sub.plan === 'trial'
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
engine.registerAction('downgrade_to_free', async (ctx) => {
|
|
534
|
+
await billing.changePlan(ctx.tenantId, 'free')
|
|
535
|
+
ctx.log('Downgraded to free plan')
|
|
536
|
+
return { success: true }
|
|
537
|
+
}, { replaySafe: true })
|
|
538
|
+
|
|
539
|
+
await engine.createWorkflow({
|
|
540
|
+
tenantId: 'workspace_1',
|
|
541
|
+
name: 'Trial expiration',
|
|
542
|
+
triggerType: 'trial.expiring',
|
|
543
|
+
steps: [
|
|
544
|
+
{ type: 'action', name: 'send_trial_warning' },
|
|
545
|
+
{ type: 'delay', name: 'wait_3_days', delayMs: 3 * 24 * 60 * 60 * 1000 },
|
|
546
|
+
{ type: 'condition', name: 'has_not_upgraded' },
|
|
547
|
+
{ type: 'action', name: 'downgrade_to_free' },
|
|
548
|
+
],
|
|
549
|
+
config: {},
|
|
550
|
+
isEnabled: true,
|
|
551
|
+
})
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Workflow Chaining
|
|
555
|
+
|
|
556
|
+
Actions can emit signals, allowing workflows to trigger other workflows.
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
engine.registerAction('attempt_auto_fix', async (ctx) => {
|
|
560
|
+
const result = await autoRemediate(ctx.trigger.resourceId)
|
|
561
|
+
|
|
562
|
+
if (result.fixed) {
|
|
563
|
+
await ctx.emit({
|
|
564
|
+
tenantId: ctx.tenantId,
|
|
565
|
+
source: 'auto-remediation',
|
|
566
|
+
type: 'service.recovered',
|
|
567
|
+
resourceType: 'service',
|
|
568
|
+
resourceId: ctx.trigger.resourceId,
|
|
569
|
+
payload: { fix: result.action },
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return { success: true, data: result }
|
|
574
|
+
}, { replaySafe: true })
|
|
575
|
+
|
|
576
|
+
// A separate workflow reacts to the recovery signal
|
|
577
|
+
await engine.createWorkflow({
|
|
578
|
+
tenantId: 'workspace_1',
|
|
579
|
+
name: 'Auto-close on recovery',
|
|
580
|
+
triggerType: 'service.recovered',
|
|
581
|
+
steps: [{ type: 'action', name: 'close_incident' }],
|
|
582
|
+
config: {},
|
|
583
|
+
isEnabled: true,
|
|
584
|
+
})
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Parallel: Multi-Channel Notification
|
|
588
|
+
|
|
589
|
+
Send notifications across multiple channels simultaneously.
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
await engine.createWorkflow({
|
|
593
|
+
tenantId: 'workspace_1',
|
|
594
|
+
name: 'Multi-channel alert',
|
|
595
|
+
triggerType: 'alert.triggered',
|
|
596
|
+
steps: [
|
|
597
|
+
{
|
|
598
|
+
type: 'parallel',
|
|
599
|
+
name: 'notify_all',
|
|
600
|
+
branches: [
|
|
601
|
+
[{ type: 'action', name: 'send_email' }],
|
|
602
|
+
[{ type: 'action', name: 'send_slack' }],
|
|
603
|
+
[{ type: 'action', name: 'send_sms' }],
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
{ type: 'action', name: 'mark_notified' },
|
|
607
|
+
],
|
|
608
|
+
config: {
|
|
609
|
+
alertEmail: 'oncall@company.com',
|
|
610
|
+
slackChannel: '#alerts',
|
|
611
|
+
phoneNumber: '+1234567890',
|
|
612
|
+
},
|
|
613
|
+
isEnabled: true,
|
|
614
|
+
})
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Cron: Daily Cleanup Job
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
await engine.createWorkflow({
|
|
621
|
+
tenantId: 'workspace_1',
|
|
622
|
+
name: 'Daily cleanup',
|
|
623
|
+
triggerType: 'maintenance.cleanup',
|
|
624
|
+
steps: [
|
|
625
|
+
{ type: 'action', name: 'cleanup_old_records' },
|
|
626
|
+
{ type: 'action', name: 'send_cleanup_report' },
|
|
627
|
+
],
|
|
628
|
+
config: {},
|
|
629
|
+
isEnabled: true,
|
|
630
|
+
cronExpression: '0 2 * * *',
|
|
631
|
+
})
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### Error Recovery: Retry Failed Runs
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
const failed = await engine.getFailedRuns('workspace_1')
|
|
638
|
+
|
|
639
|
+
for (const run of failed) {
|
|
640
|
+
const retried = await engine.retryRun(run.id)
|
|
641
|
+
console.log(`Retried run ${retried.id}, now ${retried.status}`)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await engine.cancelRun('run_xyz789', 'Superseded by newer deployment')
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## Runtime Guarantees
|
|
650
|
+
|
|
651
|
+
### Execution Semantics
|
|
652
|
+
|
|
653
|
+
Pulse provides **at-least-once** execution. If a worker crashes after an action completes but before the engine records success, the step may execute again on retry. Design actions to be **idempotent** where possible:
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
engine.registerAction('send_email', async (ctx) => {
|
|
657
|
+
const idempotencyKey = `${ctx.run.id}:${ctx.step.name}`
|
|
658
|
+
// Use this key with your provider to prevent duplicates
|
|
659
|
+
})
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Step Claiming
|
|
663
|
+
|
|
664
|
+
Every step is claimed atomically before execution using compare-and-set:
|
|
665
|
+
|
|
666
|
+
```sql
|
|
667
|
+
UPDATE step_runs SET status = 'running', started_at = NOW()
|
|
668
|
+
WHERE id = $1 AND status IN ('pending', 'scheduled')
|
|
669
|
+
RETURNING *
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
If two workers pick up the same job, only one successfully claims it. The other silently no-ops.
|
|
673
|
+
|
|
674
|
+
### State Transitions
|
|
675
|
+
|
|
676
|
+
Run status changes are guarded by the current status. Invalid transitions are rejected:
|
|
677
|
+
|
|
678
|
+
```
|
|
679
|
+
pending -> running, canceled
|
|
680
|
+
running -> waiting, completed, failed, canceled
|
|
681
|
+
waiting -> running, canceled
|
|
682
|
+
failed -> running (retry)
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Context Accumulation
|
|
686
|
+
|
|
687
|
+
Each action's return value is stored in the run context keyed by action name:
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
// After "check_status" returns { healthy: true }
|
|
691
|
+
// ctx.run.context.check_status === { healthy: true }
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
Step names within a workflow must be unique to prevent key collisions.
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
## Database Schema
|
|
699
|
+
|
|
700
|
+
The engine creates tables with the prefix `pulse_` (configurable). All tables use `TEXT` primary keys with auto-generated IDs. Migrations are idempotent — safe to call on every startup.
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
await engine.migrate()
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### `pulse_triggers`
|
|
707
|
+
|
|
708
|
+
| Column | Type | Description |
|
|
709
|
+
|:--|:--|:--|
|
|
710
|
+
| `id` | `TEXT PK` | Trigger ID (`trg_...`) |
|
|
711
|
+
| `tenant_id` | `TEXT` | Tenant scope |
|
|
712
|
+
| `source` | `TEXT` | Originating system |
|
|
713
|
+
| `type` | `TEXT` | Signal type |
|
|
714
|
+
| `resource_type` | `TEXT` | Resource category |
|
|
715
|
+
| `resource_id` | `TEXT` | Specific resource |
|
|
716
|
+
| `environment` | `TEXT` | Environment scope |
|
|
717
|
+
| `payload` | `JSONB` | Arbitrary event data |
|
|
718
|
+
| `created_at` | `TIMESTAMPTZ` | When emitted |
|
|
719
|
+
|
|
720
|
+
### `pulse_workflow_definitions`
|
|
721
|
+
|
|
722
|
+
| Column | Type | Description |
|
|
723
|
+
|:--|:--|:--|
|
|
724
|
+
| `id` | `TEXT PK` | Definition ID |
|
|
725
|
+
| `tenant_id` | `TEXT` | Tenant scope |
|
|
726
|
+
| `name` | `TEXT` | Human-readable name |
|
|
727
|
+
| `description` | `TEXT` | Optional description |
|
|
728
|
+
| `trigger_type` | `TEXT` | Signal type to match |
|
|
729
|
+
| `environment_filter` | `TEXT` | Optional environment filter |
|
|
730
|
+
| `resource_type_filter` | `TEXT` | Optional resource type filter |
|
|
731
|
+
| `steps` | `JSONB` | Ordered step definitions |
|
|
732
|
+
| `config` | `JSONB` | Config passed to handlers |
|
|
733
|
+
| `is_enabled` | `BOOLEAN` | Whether active |
|
|
734
|
+
| `cron_expression` | `TEXT` | Optional cron schedule |
|
|
735
|
+
| `created_at` | `TIMESTAMPTZ` | Creation time |
|
|
736
|
+
| `updated_at` | `TIMESTAMPTZ` | Last update |
|
|
737
|
+
|
|
738
|
+
### `pulse_workflow_runs`
|
|
739
|
+
|
|
740
|
+
| Column | Type | Description |
|
|
741
|
+
|:--|:--|:--|
|
|
742
|
+
| `id` | `TEXT PK` | Run ID |
|
|
743
|
+
| `definition_id` | `TEXT FK` | Workflow definition |
|
|
744
|
+
| `tenant_id` | `TEXT` | Tenant scope |
|
|
745
|
+
| `trigger_id` | `TEXT FK` | Signal that started this run |
|
|
746
|
+
| `status` | `TEXT` | `pending` / `running` / `waiting` / `completed` / `failed` / `canceled` |
|
|
747
|
+
| `context` | `JSONB` | Shared run context |
|
|
748
|
+
| `current_step_index` | `INTEGER` | Active step position |
|
|
749
|
+
| `is_replay` | `BOOLEAN` | Whether this is a replay |
|
|
750
|
+
| `definition_snapshot` | `JSONB` | Frozen copy of workflow at run creation |
|
|
751
|
+
| `started_at` | `TIMESTAMPTZ` | When execution began |
|
|
752
|
+
| `completed_at` | `TIMESTAMPTZ` | When completed |
|
|
753
|
+
| `failed_at` | `TIMESTAMPTZ` | When failed |
|
|
754
|
+
| `canceled_at` | `TIMESTAMPTZ` | When canceled |
|
|
755
|
+
| `cancel_reason` | `TEXT` | Cancel reason |
|
|
756
|
+
| `created_at` | `TIMESTAMPTZ` | Creation time |
|
|
757
|
+
| `updated_at` | `TIMESTAMPTZ` | Last update |
|
|
758
|
+
|
|
759
|
+
### `pulse_workflow_step_runs`
|
|
760
|
+
|
|
761
|
+
| Column | Type | Description |
|
|
762
|
+
|:--|:--|:--|
|
|
763
|
+
| `id` | `TEXT PK` | Step run ID |
|
|
764
|
+
| `run_id` | `TEXT FK` | Parent workflow run |
|
|
765
|
+
| `tenant_id` | `TEXT` | Tenant scope |
|
|
766
|
+
| `step_index` | `INTEGER` | Position in sequence |
|
|
767
|
+
| `step_type` | `TEXT` | `action` / `condition` / `delay` / `parallel` |
|
|
768
|
+
| `step_name` | `TEXT` | Registered handler name |
|
|
769
|
+
| `status` | `TEXT` | `pending` / `scheduled` / `running` / `completed` / `failed` / `skipped` |
|
|
770
|
+
| `scheduled_for` | `TIMESTAMPTZ` | When to execute (delays) |
|
|
771
|
+
| `started_at` | `TIMESTAMPTZ` | When execution began |
|
|
772
|
+
| `completed_at` | `TIMESTAMPTZ` | When completed |
|
|
773
|
+
| `result` | `JSONB` | Action result data |
|
|
774
|
+
| `error_message` | `TEXT` | Error details |
|
|
775
|
+
| `branch_index` | `INTEGER` | Branch index (parallel) |
|
|
776
|
+
| `parent_step_run_id` | `TEXT` | Parent parallel step |
|
|
777
|
+
| `created_at` | `TIMESTAMPTZ` | Creation time |
|
|
778
|
+
|
|
779
|
+
### `pulse_execution_logs`
|
|
780
|
+
|
|
781
|
+
| Column | Type | Description |
|
|
782
|
+
|:--|:--|:--|
|
|
783
|
+
| `id` | `TEXT PK` | Log entry ID |
|
|
784
|
+
| `run_id` | `TEXT FK` | Parent workflow run |
|
|
785
|
+
| `step_run_id` | `TEXT FK` | Associated step |
|
|
786
|
+
| `tenant_id` | `TEXT` | Tenant scope |
|
|
787
|
+
| `level` | `TEXT` | `info` / `warn` / `error` |
|
|
788
|
+
| `message` | `TEXT` | Log message |
|
|
789
|
+
| `data` | `JSONB` | Structured log data |
|
|
790
|
+
| `created_at` | `TIMESTAMPTZ` | When written |
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
794
|
+
## Type Reference
|
|
795
|
+
|
|
796
|
+
<details>
|
|
797
|
+
<summary><code>TriggerInput</code></summary>
|
|
798
|
+
|
|
799
|
+
```ts
|
|
800
|
+
interface TriggerInput {
|
|
801
|
+
tenantId: string
|
|
802
|
+
source: string
|
|
803
|
+
type: string
|
|
804
|
+
resourceType?: string
|
|
805
|
+
resourceId?: string
|
|
806
|
+
environment?: string
|
|
807
|
+
payload?: Record<string, unknown>
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
</details>
|
|
811
|
+
|
|
812
|
+
<details>
|
|
813
|
+
<summary><code>TriggerEnvelope</code></summary>
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
interface TriggerEnvelope extends TriggerInput {
|
|
817
|
+
id: string
|
|
818
|
+
createdAt: Date
|
|
819
|
+
}
|
|
820
|
+
```
|
|
821
|
+
</details>
|
|
822
|
+
|
|
823
|
+
<details>
|
|
824
|
+
<summary><code>TriggerRegistration</code></summary>
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
interface TriggerRegistration {
|
|
828
|
+
source: string
|
|
829
|
+
resourceType?: string
|
|
830
|
+
payloadSchema?: ZodSchema
|
|
831
|
+
}
|
|
832
|
+
```
|
|
833
|
+
</details>
|
|
834
|
+
|
|
835
|
+
<details>
|
|
836
|
+
<summary><code>WorkflowDefinition</code></summary>
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
interface WorkflowDefinition {
|
|
840
|
+
id: string
|
|
841
|
+
tenantId: string
|
|
842
|
+
name: string
|
|
843
|
+
description?: string
|
|
844
|
+
triggerType: string
|
|
845
|
+
environmentFilter?: string
|
|
846
|
+
resourceTypeFilter?: string
|
|
847
|
+
steps: WorkflowStep[]
|
|
848
|
+
config: Record<string, unknown>
|
|
849
|
+
isEnabled: boolean
|
|
850
|
+
cronExpression?: string
|
|
851
|
+
createdAt: Date
|
|
852
|
+
updatedAt: Date
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
</details>
|
|
856
|
+
|
|
857
|
+
<details>
|
|
858
|
+
<summary><code>WorkflowStep</code></summary>
|
|
859
|
+
|
|
860
|
+
```ts
|
|
861
|
+
type StepType = 'action' | 'condition' | 'delay' | 'parallel'
|
|
862
|
+
|
|
863
|
+
interface WorkflowStep {
|
|
864
|
+
type: StepType
|
|
865
|
+
name: string
|
|
866
|
+
config?: Record<string, unknown>
|
|
867
|
+
delayMs?: number
|
|
868
|
+
timeoutMs?: number
|
|
869
|
+
retryPolicy?: RetryPolicy
|
|
870
|
+
onFalse?: 'complete' | 'skip' | number
|
|
871
|
+
branches?: WorkflowStep[][]
|
|
872
|
+
}
|
|
873
|
+
```
|
|
874
|
+
</details>
|
|
875
|
+
|
|
876
|
+
<details>
|
|
877
|
+
<summary><code>WorkflowRun</code></summary>
|
|
878
|
+
|
|
879
|
+
```ts
|
|
880
|
+
type WorkflowStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'canceled'
|
|
881
|
+
|
|
882
|
+
interface WorkflowRun {
|
|
883
|
+
id: string
|
|
884
|
+
definitionId: string
|
|
885
|
+
tenantId: string
|
|
886
|
+
triggerId: string
|
|
887
|
+
status: WorkflowStatus
|
|
888
|
+
context: Record<string, unknown>
|
|
889
|
+
currentStepIndex: number
|
|
890
|
+
isReplay: boolean
|
|
891
|
+
definitionSnapshot: DefinitionSnapshot
|
|
892
|
+
startedAt?: Date
|
|
893
|
+
completedAt?: Date
|
|
894
|
+
failedAt?: Date
|
|
895
|
+
canceledAt?: Date
|
|
896
|
+
cancelReason?: string
|
|
897
|
+
createdAt: Date
|
|
898
|
+
updatedAt: Date
|
|
899
|
+
}
|
|
900
|
+
```
|
|
901
|
+
</details>
|
|
902
|
+
|
|
903
|
+
<details>
|
|
904
|
+
<summary><code>WorkflowStepRun</code></summary>
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
type StepStatus = 'pending' | 'scheduled' | 'running' | 'completed' | 'failed' | 'skipped'
|
|
908
|
+
|
|
909
|
+
interface WorkflowStepRun {
|
|
910
|
+
id: string
|
|
911
|
+
runId: string
|
|
912
|
+
tenantId: string
|
|
913
|
+
stepIndex: number
|
|
914
|
+
stepType: StepType
|
|
915
|
+
stepName: string
|
|
916
|
+
status: StepStatus
|
|
917
|
+
scheduledFor?: Date
|
|
918
|
+
startedAt?: Date
|
|
919
|
+
completedAt?: Date
|
|
920
|
+
result?: Record<string, unknown>
|
|
921
|
+
errorMessage?: string
|
|
922
|
+
branchIndex?: number
|
|
923
|
+
parentStepRunId?: string
|
|
924
|
+
createdAt: Date
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
</details>
|
|
928
|
+
|
|
929
|
+
<details>
|
|
930
|
+
<summary><code>RunTimelineEntry</code></summary>
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
interface RunTimelineEntry {
|
|
934
|
+
timestamp: Date
|
|
935
|
+
type:
|
|
936
|
+
| 'run_created' | 'run_completed' | 'run_failed' | 'run_canceled'
|
|
937
|
+
| 'step_scheduled' | 'step_started' | 'step_completed'
|
|
938
|
+
| 'step_failed' | 'step_skipped' | 'log'
|
|
939
|
+
stepIndex?: number
|
|
940
|
+
stepName?: string
|
|
941
|
+
stepType?: string
|
|
942
|
+
detail?: Record<string, unknown>
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
</details>
|
|
946
|
+
|
|
947
|
+
<details>
|
|
948
|
+
<summary><code>WorkflowContext</code></summary>
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
interface WorkflowContext {
|
|
952
|
+
tenantId: string
|
|
953
|
+
trigger: TriggerEnvelope
|
|
954
|
+
run: WorkflowRun
|
|
955
|
+
step: WorkflowStepRun
|
|
956
|
+
config: Record<string, unknown>
|
|
957
|
+
isReplay: boolean
|
|
958
|
+
emit: (trigger: TriggerInput) => Promise<TriggerEnvelope>
|
|
959
|
+
log: (message: string, data?: Record<string, unknown>) => void
|
|
960
|
+
}
|
|
961
|
+
```
|
|
962
|
+
</details>
|
|
963
|
+
|
|
964
|
+
<details>
|
|
965
|
+
<summary><code>EngineConfig</code></summary>
|
|
966
|
+
|
|
967
|
+
```ts
|
|
968
|
+
interface EngineConfig {
|
|
969
|
+
db: Pool
|
|
970
|
+
redis: unknown
|
|
971
|
+
tablePrefix?: string // Default: 'pulse_'
|
|
972
|
+
queuePrefix?: string // Default: 'pulse'
|
|
973
|
+
concurrency?: number // Default: 5
|
|
974
|
+
onStepComplete?: LifecycleHook<StepCompleteEvent>
|
|
975
|
+
onRunComplete?: LifecycleHook<RunCompleteEvent>
|
|
976
|
+
}
|
|
977
|
+
```
|
|
978
|
+
</details>
|
|
979
|
+
|
|
980
|
+
<details>
|
|
981
|
+
<summary><code>ActionResult</code> / <code>ActionOptions</code> / <code>RetryPolicy</code> / <code>ReplayOptions</code></summary>
|
|
982
|
+
|
|
983
|
+
```ts
|
|
984
|
+
interface ActionResult {
|
|
985
|
+
success: boolean
|
|
986
|
+
data?: Record<string, unknown>
|
|
987
|
+
error?: string
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
interface ActionOptions {
|
|
991
|
+
replaySafe: boolean
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
interface RetryPolicy {
|
|
995
|
+
maxAttempts: number // 1-10
|
|
996
|
+
backoffMs: number // 100-300000 ms
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
interface ReplayOptions {
|
|
1000
|
+
dryRun?: boolean
|
|
1001
|
+
}
|
|
1002
|
+
```
|
|
1003
|
+
</details>
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
1007
|
+
## Architecture
|
|
1008
|
+
|
|
1009
|
+
```
|
|
1010
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
1011
|
+
│ Your Application │
|
|
1012
|
+
│ │
|
|
1013
|
+
│ engine.emit({ type: 'heartbeat.missed', ... }) │
|
|
1014
|
+
│ │ │
|
|
1015
|
+
│ v │
|
|
1016
|
+
│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
|
|
1017
|
+
│ │ Registry │ │ Matcher │--->│ Run Manager │ │
|
|
1018
|
+
│ │ │ │ │ │ (state machine) │ │
|
|
1019
|
+
│ │ triggers │ │ matches │ │ │ │
|
|
1020
|
+
│ │ actions │ │ signal │ │ pending -> running │ │
|
|
1021
|
+
│ │ conds │ │ to wfs │ │ -> waiting -> done │ │
|
|
1022
|
+
│ └──────────┘ └──────────┘ └─────────┬──────────┘ │
|
|
1023
|
+
│ │ │
|
|
1024
|
+
│ v │
|
|
1025
|
+
│ ┌──────────────────────┐ ┌────────────────────────┐ │
|
|
1026
|
+
│ │ Step Scheduler │ │ Step Executor │ │
|
|
1027
|
+
│ │ │--->│ │ │
|
|
1028
|
+
│ │ BullMQ delayed jobs │ │ delay -> cond -> action │ │
|
|
1029
|
+
│ │ + parallel branches │ │ + parallel dispatch │ │
|
|
1030
|
+
│ └──────────────────────┘ └────────────────────────┘ │
|
|
1031
|
+
│ │
|
|
1032
|
+
│ ┌───────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
|
1033
|
+
│ │ Replay Mgr │ │ Cron Mgr │ │ Timeline & │ │
|
|
1034
|
+
│ │ │ │ │ │ Exec Logs │ │
|
|
1035
|
+
│ │ re-emit with │ │ BullMQ │ │ │ │
|
|
1036
|
+
│ │ safety checks │ │ repeatables │ │ ctx.log() │ │
|
|
1037
|
+
│ └───────────────┘ └──────────────┘ └────────────────┘ │
|
|
1038
|
+
│ │
|
|
1039
|
+
│ ┌──────────────┐ ┌────────────────┐ │
|
|
1040
|
+
│ │ PostgreSQL │ │ Redis │ │
|
|
1041
|
+
│ │ (your DB) │ │ (BullMQ jobs) │ │
|
|
1042
|
+
│ └──────────────┘ └────────────────┘ │
|
|
1043
|
+
└──────────────────────────────────────────────────────────────┘
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
### Module Breakdown
|
|
1047
|
+
|
|
1048
|
+
| Module | File | Purpose |
|
|
1049
|
+
|:--|:--|:--|
|
|
1050
|
+
| Engine Factory | `src/index.ts` | `createEngine()` — wires everything together |
|
|
1051
|
+
| Types | `src/types.ts` | All TypeScript interfaces and type definitions |
|
|
1052
|
+
| Registry | `src/registry.ts` | Trigger, action, and condition registration |
|
|
1053
|
+
| Matcher | `src/matcher.ts` | Signal-to-workflow matching, run creation, validation |
|
|
1054
|
+
| Run Manager | `src/runs.ts` | Run lifecycle, state machine, timeline, cancel |
|
|
1055
|
+
| Scheduler | `src/scheduler.ts` | Step scheduling with BullMQ delayed jobs + parallel |
|
|
1056
|
+
| Executor | `src/executor.ts` | Step dispatch — delay, condition, action, parallel |
|
|
1057
|
+
| Cron Manager | `src/cron.ts` | Cron scheduling via BullMQ repeatable jobs |
|
|
1058
|
+
| Replay | `src/replay.ts` | Trigger persistence and replay support |
|
|
1059
|
+
| Queue | `src/queue.ts` | BullMQ queue and worker setup |
|
|
1060
|
+
| Logs | `src/logs.ts` | Execution logging and query helpers |
|
|
1061
|
+
| Schema | `src/schema/` | Table definitions and migration runner |
|
|
1062
|
+
|
|
1063
|
+
---
|
|
1064
|
+
|
|
1065
|
+
## Requirements
|
|
1066
|
+
|
|
1067
|
+
| Requirement | Version |
|
|
1068
|
+
|:--|:--|
|
|
1069
|
+
| Node.js | 18+ |
|
|
1070
|
+
| PostgreSQL | 12+ |
|
|
1071
|
+
| Redis | 6+ |
|
|
1072
|
+
| TypeScript | 5.0+ |
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1076
|
+
## License
|
|
1077
|
+
|
|
1078
|
+
MIT
|