lightflow-engine 0.1.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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.js +409 -0
- package/dist/pg-store.js +83 -0
- package/dist/src/index.js +430 -0
- package/dist/src/pg-store.js +194 -0
- package/dist/test/coverage.js +107 -0
- package/dist/test/drain.js +21 -0
- package/dist/test/entry-parity.js +115 -0
- package/dist/test/resume-all.js +14 -0
- package/dist/test/resume.js +12 -0
- package/dist/test/simulate.js +46 -0
- package/dist/test/smoke.js +157 -0
- package/dist/test/workflow-def.js +54 -0
- package/package.json +50 -0
package/dist/pg-store.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres-backed store for lightflow.
|
|
3
|
+
*
|
|
4
|
+
* Two tables only. All times are BIGINT epoch milliseconds — no timestamp
|
|
5
|
+
* columns, so no timezone ambiguity (the bug that cost hours in world-postgres).
|
|
6
|
+
*/
|
|
7
|
+
import pg from "pg";
|
|
8
|
+
export async function createPostgresStore(url) {
|
|
9
|
+
const pool = new pg.Pool({ connectionString: url, max: 20 });
|
|
10
|
+
await pool.query(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS lightflow_runs (
|
|
12
|
+
run_id TEXT PRIMARY KEY,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
15
|
+
input JSONB NOT NULL,
|
|
16
|
+
output JSONB,
|
|
17
|
+
created_at BIGINT NOT NULL,
|
|
18
|
+
updated_at BIGINT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
`);
|
|
21
|
+
await pool.query(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS lightflow_events (
|
|
23
|
+
id BIGSERIAL PRIMARY KEY,
|
|
24
|
+
run_id TEXT NOT NULL REFERENCES lightflow_runs(run_id) ON DELETE CASCADE,
|
|
25
|
+
seq INTEGER NOT NULL,
|
|
26
|
+
type TEXT NOT NULL,
|
|
27
|
+
payload JSONB NOT NULL,
|
|
28
|
+
created_at BIGINT NOT NULL,
|
|
29
|
+
UNIQUE (run_id, seq)
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
await pool.query(`
|
|
33
|
+
CREATE INDEX IF NOT EXISTS lightflow_events_run_idx
|
|
34
|
+
ON lightflow_events (run_id, seq);
|
|
35
|
+
`);
|
|
36
|
+
await pool.query(`
|
|
37
|
+
CREATE INDEX IF NOT EXISTS lightflow_events_timer_idx
|
|
38
|
+
ON lightflow_events ((payload->>'wakeAt'))
|
|
39
|
+
WHERE type = 'sleep_created';
|
|
40
|
+
`);
|
|
41
|
+
return {
|
|
42
|
+
async createRun(runId, name, input) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
await pool.query(`INSERT INTO lightflow_runs (run_id, name, status, input, created_at, updated_at)
|
|
45
|
+
VALUES ($1,$2,'running',$3,$4,$4) ON CONFLICT (run_id) DO NOTHING`, [runId, name, JSON.stringify(input), now]);
|
|
46
|
+
await pool.query(`INSERT INTO lightflow_events (run_id, seq, type, payload, created_at)
|
|
47
|
+
VALUES ($1,0,'run_completed',$2,$3) ON CONFLICT DO NOTHING`, [runId, JSON.stringify({ name, args: input }), now]);
|
|
48
|
+
},
|
|
49
|
+
async getRun(runId) {
|
|
50
|
+
const r = await pool.query(`SELECT status, output FROM lightflow_runs WHERE run_id=$1`, [runId]);
|
|
51
|
+
if (r.rowCount === 0)
|
|
52
|
+
return null;
|
|
53
|
+
return { status: r.rows[0].status, output: r.rows[0].output };
|
|
54
|
+
},
|
|
55
|
+
async appendEvent(e) {
|
|
56
|
+
await pool.query(`INSERT INTO lightflow_events (run_id, seq, type, payload, created_at)
|
|
57
|
+
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (run_id, seq) DO NOTHING`, [e.runId, e.seq, e.type, JSON.stringify(e.payload), e.createdAt]);
|
|
58
|
+
},
|
|
59
|
+
async getEvents(runId) {
|
|
60
|
+
const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
|
|
61
|
+
WHERE run_id=$1 ORDER BY seq ASC`, [runId]);
|
|
62
|
+
return r.rows.map((x) => ({
|
|
63
|
+
runId: x.run_id, seq: x.seq, type: x.type,
|
|
64
|
+
payload: x.payload, createdAt: Number(x.created_at),
|
|
65
|
+
}));
|
|
66
|
+
},
|
|
67
|
+
async dueTimers(now) {
|
|
68
|
+
const r = await pool.query(`SELECT run_id, seq FROM lightflow_events
|
|
69
|
+
WHERE type='sleep_created' AND (payload->>'wakeAt')::bigint <= $1`, [now]);
|
|
70
|
+
return r.rows.map((x) => ({ runId: x.run_id, seq: x.seq }));
|
|
71
|
+
},
|
|
72
|
+
async claimDue() { return false; },
|
|
73
|
+
async setStatus(runId, status, output) {
|
|
74
|
+
await pool.query(`UPDATE lightflow_runs SET status=$2, output=$3, updated_at=$4 WHERE run_id=$1`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function applyTimezoneGuard(url) {
|
|
79
|
+
// Belt and braces: even though we store BIGINT, ensure the session TZ is UTC.
|
|
80
|
+
const pool = new pg.Pool({ connectionString: url, max: 2 });
|
|
81
|
+
await pool.query(`SET timezone = 'UTC'`);
|
|
82
|
+
await pool.end();
|
|
83
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lightflow — a durable workflow engine, from scratch.
|
|
3
|
+
*
|
|
4
|
+
* Implements every primitive Entry's workflows rely on:
|
|
5
|
+
* - "use workflow" deterministic orchestration, replayed from an event log
|
|
6
|
+
* - "use step" at-least-once, memoized side effects
|
|
7
|
+
* - sleep(ms | Date) durable timers (survive process death)
|
|
8
|
+
* - getWritable() ordered, resumable output stream
|
|
9
|
+
* - start() / getRun() start, resume by id, await returnValue
|
|
10
|
+
* - FatalError non-retryable failure
|
|
11
|
+
* - getWorkflowMetadata() run id inside a workflow
|
|
12
|
+
*
|
|
13
|
+
* Design goals that fix the two defects found in world-postgres:
|
|
14
|
+
* 1. All timestamps are epoch milliseconds (integer). No naive/UTC skew.
|
|
15
|
+
* 2. No spec-version coupling: events are plain JSON rows with a schema
|
|
16
|
+
* version integer that the engine upgrades itself.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
/* Errors */
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
export class FatalError extends Error {
|
|
23
|
+
fatal = true;
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "FatalError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const workflows = new Map();
|
|
30
|
+
const steps = new Map();
|
|
31
|
+
export function registerWorkflow(id, fn) {
|
|
32
|
+
workflows.set(id, fn);
|
|
33
|
+
}
|
|
34
|
+
export function registerStep(id, fn) {
|
|
35
|
+
steps.set(id, fn);
|
|
36
|
+
}
|
|
37
|
+
let current = null;
|
|
38
|
+
export function getWorkflowMetadata() {
|
|
39
|
+
if (!current)
|
|
40
|
+
throw new Error("getWorkflowMetadata() outside a workflow");
|
|
41
|
+
return { runId: current.runId };
|
|
42
|
+
}
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
/* The step id: derived from call order, so replay is deterministic */
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
function nextStepKey() {
|
|
47
|
+
if (!current)
|
|
48
|
+
throw new Error("steps can only be called inside a workflow");
|
|
49
|
+
return `step:${current.stepCalls}`;
|
|
50
|
+
}
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
/* Public API inside a workflow */
|
|
53
|
+
/* ------------------------------------------------------------------ */
|
|
54
|
+
/**
|
|
55
|
+
* Run a side effect durably. On replay the memoized result is returned and
|
|
56
|
+
* the function body is NOT re-executed.
|
|
57
|
+
*/
|
|
58
|
+
export async function step(fn) {
|
|
59
|
+
if (!current)
|
|
60
|
+
return fn(); // plain call outside a workflow
|
|
61
|
+
const ctx = current;
|
|
62
|
+
const key = nextStepKey();
|
|
63
|
+
ctx.stepCalls += 1;
|
|
64
|
+
// Replay: memoized result for this call position?
|
|
65
|
+
const done = ctx.log.find((e) => e.type === "step_completed" && e.payload?.key === key);
|
|
66
|
+
if (done)
|
|
67
|
+
return done.payload.value;
|
|
68
|
+
ctx.seq += 1;
|
|
69
|
+
await ctx.store.appendEvent({
|
|
70
|
+
runId: ctx.runId, seq: ctx.seq, type: "step_started",
|
|
71
|
+
payload: { key }, createdAt: ctx.now(),
|
|
72
|
+
});
|
|
73
|
+
const fnId = fn.__lightflowStepId;
|
|
74
|
+
const impl = fnId ? steps.get(fnId) : undefined;
|
|
75
|
+
// Retry with backoff. FatalError is never retried (Entry retries 4x, then
|
|
76
|
+
// bubbles FatalError -- same contract here).
|
|
77
|
+
const maxAttempts = fn
|
|
78
|
+
.__lightflowRetries ?? DEFAULT_STEP_RETRIES;
|
|
79
|
+
let lastErr;
|
|
80
|
+
let value;
|
|
81
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
value = await (impl ? impl() : fn());
|
|
84
|
+
lastErr = undefined;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
lastErr = err;
|
|
89
|
+
if (err instanceof FatalError)
|
|
90
|
+
break;
|
|
91
|
+
if (attempt < maxAttempts) {
|
|
92
|
+
await new Promise((r) => setTimeout(r, Math.min(100 * 2 ** (attempt - 1), 2000)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (lastErr instanceof FatalError)
|
|
97
|
+
throw lastErr;
|
|
98
|
+
if (lastErr) {
|
|
99
|
+
ctx.seq += 1;
|
|
100
|
+
await ctx.store.appendEvent({
|
|
101
|
+
runId: ctx.runId, seq: ctx.seq, type: "step_failed",
|
|
102
|
+
payload: { key, error: String(lastErr instanceof Error ? lastErr.message : lastErr) },
|
|
103
|
+
createdAt: ctx.now(),
|
|
104
|
+
});
|
|
105
|
+
throw lastErr;
|
|
106
|
+
}
|
|
107
|
+
ctx.seq += 1;
|
|
108
|
+
await ctx.store.appendEvent({
|
|
109
|
+
runId: ctx.runId, seq: ctx.seq, type: "step_completed",
|
|
110
|
+
payload: { key, value }, createdAt: ctx.now(),
|
|
111
|
+
});
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
/** Durable sleep. Accepts milliseconds or an absolute Date. */
|
|
115
|
+
export async function sleep(until) {
|
|
116
|
+
if (!current) {
|
|
117
|
+
const ms = until instanceof Date ? until.getTime() - Date.now() : until;
|
|
118
|
+
return new Promise((r) => setTimeout(r, Math.max(0, ms)));
|
|
119
|
+
}
|
|
120
|
+
const ctx = current;
|
|
121
|
+
const wakeAt = until instanceof Date ? until.getTime() : ctx.now() + until;
|
|
122
|
+
const key = `sleep:${ctx.sleepCalls}`;
|
|
123
|
+
ctx.sleepCalls += 1;
|
|
124
|
+
// Replay: already slept?
|
|
125
|
+
const done = ctx.log.find((e) => e.type === "sleep_completed" && e.payload?.key === key);
|
|
126
|
+
if (done)
|
|
127
|
+
return;
|
|
128
|
+
ctx.seq += 1;
|
|
129
|
+
await ctx.store.appendEvent({
|
|
130
|
+
runId: ctx.runId, seq: ctx.seq, type: "sleep_created",
|
|
131
|
+
payload: { key, wakeAt }, createdAt: ctx.now(),
|
|
132
|
+
});
|
|
133
|
+
// Suspend: throw a control-flow signal caught by the runner.
|
|
134
|
+
throw new SuspendSignal(key, wakeAt);
|
|
135
|
+
}
|
|
136
|
+
export class SuspendSignal {
|
|
137
|
+
key;
|
|
138
|
+
wakeAt;
|
|
139
|
+
constructor(key, wakeAt) {
|
|
140
|
+
this.key = key;
|
|
141
|
+
this.wakeAt = wakeAt;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Ordered output stream for a run. Chunks are persisted and replayable. */
|
|
145
|
+
export function getWritable() {
|
|
146
|
+
const ctx = current;
|
|
147
|
+
if (!current)
|
|
148
|
+
throw new Error("getWritable() outside a workflow");
|
|
149
|
+
/** Deterministic key: (durable-timer position, writes since it). */
|
|
150
|
+
const keyFor = () => `${ctx.sleepCalls}:${ctx.writes}`;
|
|
151
|
+
return {
|
|
152
|
+
async write(chunk) {
|
|
153
|
+
// Memoized by call position (like steps): on replay, a write whose
|
|
154
|
+
// position was already emitted is skipped, and the loop advances.
|
|
155
|
+
const key = `w:${ctx.writes}`;
|
|
156
|
+
const fresh = await ctx.store.getEvents(ctx.runId);
|
|
157
|
+
const already = fresh.some((e) => e.type === "chunk" && e.payload?.key === key);
|
|
158
|
+
ctx.writes += 1;
|
|
159
|
+
if (already)
|
|
160
|
+
return;
|
|
161
|
+
const index = await ctx.store.nextChunkIndex(ctx.runId);
|
|
162
|
+
ctx.seq += 1;
|
|
163
|
+
await ctx.store.appendEvent({
|
|
164
|
+
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
165
|
+
payload: { value: chunk, index, key }, createdAt: ctx.now(),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
async close() {
|
|
169
|
+
const already = ctx.log.some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
170
|
+
if (already)
|
|
171
|
+
return;
|
|
172
|
+
ctx.seq += 1;
|
|
173
|
+
await ctx.store.appendEvent({
|
|
174
|
+
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
175
|
+
payload: { value: null, done: true, key: "close", index: ctx.writes },
|
|
176
|
+
createdAt: ctx.now(),
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
/* Runner */
|
|
183
|
+
/* ------------------------------------------------------------------ */
|
|
184
|
+
const DEFAULT_STEP_RETRIES = 3;
|
|
185
|
+
export class Engine {
|
|
186
|
+
store;
|
|
187
|
+
opts;
|
|
188
|
+
constructor(store, opts = {}) {
|
|
189
|
+
this.store = store;
|
|
190
|
+
this.opts = opts;
|
|
191
|
+
}
|
|
192
|
+
async start(workflowId, args) {
|
|
193
|
+
const runId = `lrun_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
194
|
+
await this.store.createRun(runId, workflowId, args);
|
|
195
|
+
void this.run(runId, workflowId, args);
|
|
196
|
+
return { runId };
|
|
197
|
+
}
|
|
198
|
+
async getRun(runId) {
|
|
199
|
+
const row = await this.store.getRun(runId);
|
|
200
|
+
if (!row)
|
|
201
|
+
throw new Error(`run not found: ${runId}`);
|
|
202
|
+
return {
|
|
203
|
+
runId,
|
|
204
|
+
status: row.status,
|
|
205
|
+
returnValue: this.waitFor(runId),
|
|
206
|
+
getReadable: () => this.readable(runId),
|
|
207
|
+
/** Entry calls this to kill a duplicate stream (route.ts:172). */
|
|
208
|
+
cancel: async () => {
|
|
209
|
+
await this.store.cancel?.(runId);
|
|
210
|
+
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async waitFor(runId) {
|
|
215
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
const row = await this.store.getRun(runId);
|
|
218
|
+
if (row && row.status !== "running")
|
|
219
|
+
return row.output ?? null;
|
|
220
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
221
|
+
}
|
|
222
|
+
throw new Error("returnValue timeout");
|
|
223
|
+
}
|
|
224
|
+
readable(runId) {
|
|
225
|
+
const store = this.store;
|
|
226
|
+
let sent = 0;
|
|
227
|
+
return new ReadableStream({
|
|
228
|
+
async pull(controller) {
|
|
229
|
+
const events = await store.getEvents(runId);
|
|
230
|
+
const chunks = events.filter((e) => e.type === "chunk");
|
|
231
|
+
for (const c of chunks.slice(sent)) {
|
|
232
|
+
const p = c.payload;
|
|
233
|
+
if (p.done) {
|
|
234
|
+
controller.close();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
controller.enqueue(new TextEncoder().encode(String(p.value)));
|
|
238
|
+
}
|
|
239
|
+
sent = chunks.length;
|
|
240
|
+
const row = await store.getRun(runId);
|
|
241
|
+
if (row && row.status !== "running" && sent >= chunks.length) {
|
|
242
|
+
controller.close();
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Execute (or resume) a run. Safe to call repeatedly — replay is idempotent. */
|
|
248
|
+
async run(runId, workflowId, args) {
|
|
249
|
+
const fn = workflows.get(workflowId);
|
|
250
|
+
if (!fn)
|
|
251
|
+
throw new Error(`unknown workflow: ${workflowId}`);
|
|
252
|
+
const log = await this.store.getEvents(runId);
|
|
253
|
+
const ctx = {
|
|
254
|
+
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
255
|
+
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
256
|
+
writes: 0,
|
|
257
|
+
store: this.store, log, cursor: 0, chunks: [], now: () => Date.now(),
|
|
258
|
+
};
|
|
259
|
+
const prev = current;
|
|
260
|
+
current = ctx;
|
|
261
|
+
try {
|
|
262
|
+
if (await this.store.isCancelled?.(runId)) {
|
|
263
|
+
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const output = await fn(...args);
|
|
267
|
+
await this.store.setStatus(runId, "completed", output);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
if (err instanceof SuspendSignal)
|
|
271
|
+
return; // waiting on a timer
|
|
272
|
+
if (err instanceof CancelledError) {
|
|
273
|
+
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (err instanceof FatalError) {
|
|
277
|
+
await this.store.setStatus(runId, "failed", { error: err.message });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await this.store.setStatus(runId, "failed", {
|
|
281
|
+
error: err instanceof Error ? err.message : String(err),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
current = prev;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
workerStopped = false;
|
|
289
|
+
/** Signal the worker loop to exit after its current poll cycle. */
|
|
290
|
+
stopWorker() {
|
|
291
|
+
this.workerStopped = true;
|
|
292
|
+
}
|
|
293
|
+
/** Worker loop: resume runs whose timers are due. Resolves when stopWorker() is called. */
|
|
294
|
+
async startWorker(onError) {
|
|
295
|
+
this.workerStopped = false;
|
|
296
|
+
while (!this.workerStopped) {
|
|
297
|
+
try {
|
|
298
|
+
const due = await this.store.dueTimers(Date.now());
|
|
299
|
+
for (const t of due) {
|
|
300
|
+
const ev = await this.store.getEvents(t.runId);
|
|
301
|
+
const created = ev.find((x) => x.seq === t.seq);
|
|
302
|
+
const key = created?.payload?.key
|
|
303
|
+
?? `sleep:${t.seq}`;
|
|
304
|
+
const alreadyDone = ev.some((x) => x.type === "sleep_completed" &&
|
|
305
|
+
x.payload?.key === key);
|
|
306
|
+
if (alreadyDone)
|
|
307
|
+
continue;
|
|
308
|
+
await this.store.appendEvent({
|
|
309
|
+
runId: t.runId, seq: ev.reduce((m, e) => Math.max(m, e.seq), 0) + 1,
|
|
310
|
+
type: "sleep_completed", payload: { key }, createdAt: Date.now(),
|
|
311
|
+
});
|
|
312
|
+
const row = await this.store.getRun(t.runId);
|
|
313
|
+
if (!row || row.status !== "running")
|
|
314
|
+
continue;
|
|
315
|
+
// resume: re-run with replay; sleep is memoized now
|
|
316
|
+
void this.resume(t.runId);
|
|
317
|
+
}
|
|
318
|
+
await this.reapStale(onError);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
onError?.(e);
|
|
322
|
+
}
|
|
323
|
+
await new Promise((r) => setTimeout(r, this.opts.pollMs ?? 500));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Built-in reaper: resume 'running' runs with no event activity in the
|
|
328
|
+
* last `staleMs`. Fixes the orphaned-run failure mode found in testing
|
|
329
|
+
* world-postgres (a run wedged in 'running' forever).
|
|
330
|
+
*/
|
|
331
|
+
async reapStale(onError) {
|
|
332
|
+
const staleMs = this.opts.staleRunMs ?? 60_000;
|
|
333
|
+
try {
|
|
334
|
+
const cutoff = Date.now() - staleMs;
|
|
335
|
+
const stale = await this.store
|
|
336
|
+
.staleRuns?.(cutoff);
|
|
337
|
+
if (!stale)
|
|
338
|
+
return;
|
|
339
|
+
for (const runId of stale) {
|
|
340
|
+
onError?.(new Error(`reaping stale run ${runId}`));
|
|
341
|
+
await this.resume(runId);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
onError?.(e);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async resume(runId) {
|
|
349
|
+
const log = await this.store.getEvents(runId);
|
|
350
|
+
const name = log[0]?.payload?.name;
|
|
351
|
+
const args = log[0]?.payload?.args ?? [];
|
|
352
|
+
if (!name)
|
|
353
|
+
return;
|
|
354
|
+
// Keep resuming through suspends until the run reaches a terminal state.
|
|
355
|
+
for (let i = 0; i < 500; i += 1) {
|
|
356
|
+
await this.run(runId, name, args);
|
|
357
|
+
const row = await this.store.getRun(runId);
|
|
358
|
+
if (!row || row.status !== "running")
|
|
359
|
+
return;
|
|
360
|
+
const evs = await this.store.getEvents(runId);
|
|
361
|
+
// Find a pending sleep: created but not yet completed.
|
|
362
|
+
const completed = new Set(evs.filter((e) => e.type === "sleep_completed")
|
|
363
|
+
.map((e) => e.payload.key));
|
|
364
|
+
const pending = evs
|
|
365
|
+
.filter((e) => e.type === "sleep_created")
|
|
366
|
+
.find((e) => !completed.has(e.payload.key));
|
|
367
|
+
if (!pending)
|
|
368
|
+
return; // nothing suspended -> genuinely stuck or done
|
|
369
|
+
const wake = pending.payload.wakeAt ?? 0;
|
|
370
|
+
const delay = wake - Date.now();
|
|
371
|
+
if (delay > 0)
|
|
372
|
+
await new Promise((r) => setTimeout(r, Math.min(delay, 2000)));
|
|
373
|
+
await this.store.appendEvent({
|
|
374
|
+
runId, seq: evs.reduce((m, e) => Math.max(m, e.seq), 0) + 1,
|
|
375
|
+
type: "sleep_completed",
|
|
376
|
+
payload: { key: pending.payload.key },
|
|
377
|
+
createdAt: Date.now(),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Cancel a run (Entry's route.ts calls getRun(id).cancel() to kill duplicate
|
|
384
|
+
* streams). Terminal runs ignore this.
|
|
385
|
+
*/
|
|
386
|
+
export class CancelledError extends Error {
|
|
387
|
+
constructor(runId) {
|
|
388
|
+
super(`run cancelled: ${runId}`);
|
|
389
|
+
this.name = "CancelledError";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Create a durable hook a workflow can await; resolve it from outside. */
|
|
393
|
+
export function defineHook() {
|
|
394
|
+
return {
|
|
395
|
+
async create() {
|
|
396
|
+
if (!current)
|
|
397
|
+
throw new Error("hooks only inside a workflow");
|
|
398
|
+
const token = `hook_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
399
|
+
const key = `hook:${current.hookCalls}`;
|
|
400
|
+
current.hookCalls += 1;
|
|
401
|
+
await current.store.createHook?.(current.runId, token, key);
|
|
402
|
+
return { token };
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/** Await a previously created hook until an external caller resolves it. */
|
|
407
|
+
export async function hookResult(token) {
|
|
408
|
+
if (!current)
|
|
409
|
+
throw new Error("hooks only inside a workflow");
|
|
410
|
+
const existing = await current.store.getHook?.(token);
|
|
411
|
+
if (existing && existing.payload?.resolved) {
|
|
412
|
+
return existing.payload.value;
|
|
413
|
+
}
|
|
414
|
+
throw new SuspendSignal(`hook:${token}`, 0);
|
|
415
|
+
}
|
|
416
|
+
/** Durable fetch: memoized per step position, like any other side effect. */
|
|
417
|
+
export async function workflowFetch(input, init) {
|
|
418
|
+
return step(async () => {
|
|
419
|
+
const res = await fetch(input, init);
|
|
420
|
+
return { __lfResponse: true, status: res.status, body: await res.text() };
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Durable loop: executes body(i) for i in [0, n). Each iteration is guarded by
|
|
425
|
+
* a durable timer barrier; completed iterations are skipped on replay, so a
|
|
426
|
+
* resumed run continues where it stopped instead of restarting.
|
|
427
|
+
*/
|
|
428
|
+
export function hashId(s) {
|
|
429
|
+
return createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
430
|
+
}
|