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.
@@ -0,0 +1,194 @@
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
+ // Cancellation flag (Entry cancels duplicate streams).
37
+ await pool.query(`
38
+ ALTER TABLE lightflow_runs ADD COLUMN IF NOT EXISTS cancelled BOOLEAN NOT NULL DEFAULT false;
39
+ `);
40
+ // Durable hooks: an external caller resolves a token, the workflow wakes.
41
+ await pool.query(`
42
+ CREATE TABLE IF NOT EXISTS lightflow_hooks (
43
+ token TEXT PRIMARY KEY,
44
+ run_id TEXT NOT NULL,
45
+ key TEXT NOT NULL,
46
+ payload JSONB,
47
+ resolved BOOLEAN NOT NULL DEFAULT false,
48
+ created_at BIGINT NOT NULL
49
+ );
50
+ `);
51
+ await pool.query(`
52
+ CREATE INDEX IF NOT EXISTS lightflow_events_timer_idx
53
+ ON lightflow_events ((payload->>'wakeAt'))
54
+ WHERE type = 'sleep_created';
55
+ `);
56
+ // Exactly-once chunks: one row per (run, write index) AND one per (run,
57
+ // call-position key). The key index is what makes a replayed write a
58
+ // harmless no-op even when two replays race.
59
+ await pool.query(`
60
+ CREATE UNIQUE INDEX IF NOT EXISTS lightflow_events_chunk_unique
61
+ ON lightflow_events (run_id, (payload->>'index'))
62
+ WHERE type = 'chunk';
63
+ `);
64
+ await pool.query(`
65
+ CREATE UNIQUE INDEX IF NOT EXISTS lightflow_events_chunk_key_unique
66
+ ON lightflow_events (run_id, (payload->>'key'))
67
+ WHERE type = 'chunk' AND payload->>'key' IS NOT NULL;
68
+ `);
69
+ return {
70
+ async createRun(runId, name, input) {
71
+ const now = Date.now();
72
+ await pool.query(`INSERT INTO lightflow_runs (run_id, name, status, input, created_at, updated_at)
73
+ VALUES ($1,$2,'running',$3,$4,$4) ON CONFLICT (run_id) DO NOTHING`, [runId, name, JSON.stringify(input), now]);
74
+ await pool.query(`INSERT INTO lightflow_events (run_id, seq, type, payload, created_at)
75
+ VALUES ($1,0,'run_completed',$2,$3) ON CONFLICT DO NOTHING`, [runId, JSON.stringify({ name, args: input }), now]);
76
+ },
77
+ async getRun(runId) {
78
+ const r = await pool.query(`SELECT status, output FROM lightflow_runs WHERE run_id=$1`, [runId]);
79
+ if (r.rowCount === 0)
80
+ return null;
81
+ return { status: r.rows[0].status, output: r.rows[0].output };
82
+ },
83
+ async appendEvent(e) {
84
+ if (e.type === "chunk") {
85
+ // Exactly-once by write key, decided INSIDE the advisory lock so two
86
+ // racing replays cannot both pass the check-then-insert window.
87
+ const c = await pool.connect();
88
+ try {
89
+ await c.query("BEGIN");
90
+ await c.query("SELECT pg_advisory_xact_lock(hashtext($1))", [e.runId]);
91
+ const key = e.payload.key;
92
+ if (key) {
93
+ const dup = await c.query(`SELECT 1 FROM lightflow_events
94
+ WHERE run_id=$1 AND type='chunk' AND payload->>'key'=$2 LIMIT 1`, [e.runId, key]);
95
+ if (dup.rowCount) {
96
+ await c.query("COMMIT");
97
+ return;
98
+ }
99
+ }
100
+ const r = await c.query(`SELECT COALESCE(max(seq),-1)+1 AS seq FROM lightflow_events WHERE run_id=$1`, [e.runId]);
101
+ await c.query(`INSERT INTO lightflow_events (run_id, seq, type, payload, created_at)
102
+ VALUES ($1,$2,$3,$4,$5)
103
+ ON CONFLICT (run_id, (payload->>'index')) WHERE type='chunk' DO NOTHING`, [e.runId, r.rows[0].seq, e.type, JSON.stringify(e.payload), e.createdAt]);
104
+ await c.query("COMMIT");
105
+ }
106
+ catch (err) {
107
+ await c.query("ROLLBACK").catch(() => { });
108
+ const msg = err instanceof Error ? err.message : String(err);
109
+ if (!/duplicate key value/.test(msg))
110
+ throw err;
111
+ // duplicate chunk from a replayed tick: expected, ignore
112
+ }
113
+ finally {
114
+ c.release();
115
+ }
116
+ return;
117
+ }
118
+ const c = await pool.connect();
119
+ try {
120
+ await c.query("BEGIN");
121
+ await c.query("SELECT pg_advisory_xact_lock(hashtext($1))", [e.runId]);
122
+ const r = await c.query(`SELECT COALESCE(max(seq),-1)+1 AS seq FROM lightflow_events WHERE run_id=$1`, [e.runId]);
123
+ await c.query(`INSERT INTO lightflow_events (run_id, seq, type, payload, created_at)
124
+ VALUES ($1,$2,$3,$4,$5) ON CONFLICT (run_id, seq) DO NOTHING`, [e.runId, r.rows[0].seq, e.type, JSON.stringify(e.payload), e.createdAt]);
125
+ await c.query("COMMIT");
126
+ }
127
+ catch (err) {
128
+ await c.query("ROLLBACK").catch(() => { });
129
+ throw err;
130
+ }
131
+ finally {
132
+ c.release();
133
+ }
134
+ },
135
+ async getEvents(runId) {
136
+ const r = await pool.query(`SELECT run_id, seq, type, payload, created_at FROM lightflow_events
137
+ WHERE run_id=$1 ORDER BY seq ASC`, [runId]);
138
+ return r.rows.map((x) => ({
139
+ runId: x.run_id, seq: x.seq, type: x.type,
140
+ payload: x.payload, createdAt: Number(x.created_at),
141
+ }));
142
+ },
143
+ async dueTimers(now) {
144
+ const r = await pool.query(`SELECT run_id, seq FROM lightflow_events
145
+ WHERE type='sleep_created' AND (payload->>'wakeAt')::bigint <= $1`, [now]);
146
+ return r.rows.map((x) => ({ runId: x.run_id, seq: x.seq }));
147
+ },
148
+ async claimDue() { return false; },
149
+ async nextChunkIndex(runId) {
150
+ const r = await pool.query(`SELECT COALESCE(max((payload->>'index')::int),-1)+1 AS n
151
+ FROM lightflow_events WHERE run_id=$1 AND type='chunk'`, [runId]);
152
+ return Number(r.rows[0].n);
153
+ },
154
+ async staleRuns(cutoff) {
155
+ const r = await pool.query(`SELECT run_id FROM lightflow_runs
156
+ WHERE status='running' AND updated_at < $1`, [cutoff]);
157
+ return r.rows.map((x) => x.run_id);
158
+ },
159
+ async cancel(runId) {
160
+ await pool.query(`UPDATE lightflow_runs SET cancelled=true, updated_at=$2 WHERE run_id=$1`, [runId, Date.now()]);
161
+ },
162
+ async isCancelled(runId) {
163
+ const r = await pool.query(`SELECT cancelled FROM lightflow_runs WHERE run_id=$1`, [runId]);
164
+ return r.rowCount ? Boolean(r.rows[0].cancelled) : false;
165
+ },
166
+ async createHook(runId, token, key) {
167
+ await pool.query(`INSERT INTO lightflow_hooks (token, run_id, key, resolved, created_at)
168
+ VALUES ($1,$2,$3,false,$4) ON CONFLICT (token) DO NOTHING`, [token, runId, key, Date.now()]);
169
+ },
170
+ async resolveHook(token, payload) {
171
+ const r = await pool.query(`UPDATE lightflow_hooks SET resolved=true, payload=$2
172
+ WHERE token=$1 RETURNING run_id`, [token, JSON.stringify(payload)]);
173
+ return r.rowCount ? r.rows[0].run_id : null;
174
+ },
175
+ async getHook(token) {
176
+ const r = await pool.query(`SELECT run_id, payload, resolved FROM lightflow_hooks WHERE token=$1`, [token]);
177
+ if (!r.rowCount)
178
+ return null;
179
+ return {
180
+ runId: r.rows[0].run_id,
181
+ payload: { ...(r.rows[0].payload ?? {}), resolved: Boolean(r.rows[0].resolved) },
182
+ };
183
+ },
184
+ async setStatus(runId, status, output) {
185
+ 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()]);
186
+ },
187
+ };
188
+ }
189
+ export async function applyTimezoneGuard(url) {
190
+ // Belt and braces: even though we store BIGINT, ensure the session TZ is UTC.
191
+ const pool = new pg.Pool({ connectionString: url, max: 2 });
192
+ await pool.query(`SET timezone = 'UTC'`);
193
+ await pool.end();
194
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Coverage suite: every primitive Entry's workflows use, plus cancel,
3
+ * hooks, retry-then-FatalError, and durable timers — all crash-tested.
4
+ */
5
+ import { Engine, step, sleep, getWritable, FatalError, getWorkflowMetadata, defineHook, workflowFetch, } from "../src/index.js";
6
+ const URL = process.env.LIGHTFLOW_PG_URL;
7
+ export async function coverageWorkflow(opts) {
8
+ "use workflow";
9
+ const { runId } = getWorkflowMetadata();
10
+ const writable = getWritable();
11
+ const observed = { runId };
12
+ await writable.write("stage:start;");
13
+ // 1. step retries a transient error, then succeeds
14
+ let attempts = 0;
15
+ const resilient = await step(async () => {
16
+ attempts += 1;
17
+ if (attempts < 3)
18
+ throw new Error("transient");
19
+ return "ok-after-2-retries";
20
+ });
21
+ observed.resilient = resilient;
22
+ // 2. FatalError is NOT retried and aborts the run
23
+ let fatalMessage = null;
24
+ try {
25
+ await step(async () => { throw new FatalError("hard stop"); });
26
+ }
27
+ catch (e) {
28
+ fatalMessage = e instanceof FatalError ? e.message : "unknown";
29
+ }
30
+ observed.fatalMessage = fatalMessage;
31
+ // 3. durable sleep (Date)
32
+ for (let i = 0; i < opts.ticks; i += 1) {
33
+ await sleep(new Date(Date.now() + 700));
34
+ await writable.write(`stage:tick:${i + 1};`);
35
+ }
36
+ observed.ticks = opts.ticks;
37
+ // 4. durable sleep (ms overload)
38
+ await sleep(300);
39
+ observed.sleptMsOverload = true;
40
+ // 5. hooks
41
+ if (opts.useHook) {
42
+ const { token } = await defineHook().create();
43
+ observed.hookToken = token;
44
+ }
45
+ // 6. durable fetch (memoized)
46
+ try {
47
+ const r = await workflowFetch("http://127.0.0.1:9/nope");
48
+ observed.fetchStatus = r.status;
49
+ }
50
+ catch (e) {
51
+ observed.fetchError = e instanceof Error ? e.message : String(e);
52
+ }
53
+ await writable.write("stage:done;");
54
+ await writable.close();
55
+ return observed;
56
+ }
57
+ export async function register() {
58
+ const { registerWorkflow } = await import("../src/index.js");
59
+ registerWorkflow("coverage", coverageWorkflow);
60
+ }
61
+ /* ---------------- runner ---------------- */
62
+ const { createPostgresStore } = await import("../src/pg-store.js");
63
+ const mode = process.argv[2] ?? "run";
64
+ if (mode === "register-only")
65
+ process.exit(0);
66
+ const store = await createPostgresStore(URL);
67
+ const engine = new Engine(store, { pollMs: 200, staleRunMs: 15_000 });
68
+ await register();
69
+ void engine.startWorker(() => { });
70
+ if (mode === "run") {
71
+ const { runId } = await engine.start("coverage", [
72
+ { ticks: Number(process.argv[3] ?? 3), shouldFailFatally: false, useHook: true },
73
+ ]);
74
+ console.log("STARTED", runId);
75
+ const run = await engine.getRun(runId);
76
+ const rv = await run.returnValue;
77
+ console.log("RESULT", JSON.stringify(rv));
78
+ process.exit(0);
79
+ }
80
+ if (mode === "cancel") {
81
+ const { runId } = await engine.start("coverage", [
82
+ { ticks: 12, shouldFailFatally: false, useHook: false },
83
+ ]);
84
+ await new Promise((r) => setTimeout(r, 1200));
85
+ const run = await engine.getRun(runId);
86
+ await run.cancel();
87
+ const after = await store.getRun(runId);
88
+ console.log("CANCELLED", JSON.stringify(after));
89
+ process.exit(0);
90
+ }
91
+ if (mode === "resume") {
92
+ const runId = process.argv[3];
93
+ await engine.resume(runId);
94
+ const row = await store.getRun(runId);
95
+ console.log("RESUMED", row?.status, JSON.stringify(row?.output));
96
+ process.exit(0);
97
+ }
98
+ if (mode === "hook") {
99
+ const runId = process.argv[3];
100
+ const token = process.argv[4];
101
+ const target = await store.resolveHook(token, { ok: true });
102
+ console.log("HOOK_RESOLVED", target);
103
+ await engine.resume(runId);
104
+ const row = await store.getRun(runId);
105
+ console.log("AFTER_HOOK", row?.status, JSON.stringify(row?.output));
106
+ process.exit(0);
107
+ }
@@ -0,0 +1,21 @@
1
+ import { Engine } from "../src/index.js";
2
+ import { createPostgresStore } from "../src/pg-store.js";
3
+ import { registerAll } from "./workflow-def.js";
4
+ import pg from "pg";
5
+ registerAll();
6
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
7
+ const engine = new Engine(store, { pollMs: 300, staleRunMs: 10_000 });
8
+ const p = new pg.Pool({ connectionString: process.env.LIGHTFLOW_PG_URL });
9
+ // persistent driver: keeps resuming until nothing is running
10
+ void engine.startWorker(() => { });
11
+ const deadline = Date.now() + 9 * 60_000;
12
+ while (Date.now() < deadline) {
13
+ const rows = await p.query("SELECT run_id FROM lightflow_runs WHERE status='running'");
14
+ if (rows.rows.length === 0)
15
+ break;
16
+ await Promise.all(rows.rows.slice(0, 12).map((r) => engine.resume(r.run_id).catch(() => { })));
17
+ await new Promise((r) => setTimeout(r, 1200));
18
+ }
19
+ const done = await p.query("SELECT status,count(*) FROM lightflow_runs GROUP BY status");
20
+ console.log("FINAL", JSON.stringify(done.rows));
21
+ process.exit(0);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Port of Entry's sandbox-lifecycle + chat patterns onto lightflow.
3
+ * Mirrors: lease claim/clear, while(true) wake loop with sleep(Date),
4
+ * getWritable streaming, post-finish persistence, retry/FatalError.
5
+ */
6
+ import { Engine, step, sleep, getWritable, FatalError, getWorkflowMetadata } from "../src/index.js";
7
+ import { createPostgresStore } from "../src/pg-store.js";
8
+ const URL = process.env.LIGHTFLOW_PG_URL ?? "postgres://entry:entry@localhost:5432/entry_lightflow";
9
+ /* ---------- steps (side effects) ---------- */
10
+ async function claimLease(sessionId, value) {
11
+ "use step";
12
+ const pg = (await import("pg")).default;
13
+ const pool = new pg.Pool({ connectionString: process.env.APP_PG_URL, max: 4 });
14
+ const r = await pool.query(`UPDATE sessions SET lifecycle_run_id=$2 WHERE id=$1 AND lifecycle_run_id IS NULL`, [sessionId, value]);
15
+ await pool.end();
16
+ return (r.rowCount ?? 0) > 0;
17
+ }
18
+ async function clearLease(sessionId) {
19
+ "use step";
20
+ const pg = (await import("pg")).default;
21
+ const pool = new pg.Pool({ connectionString: process.env.APP_PG_URL, max: 4 });
22
+ await pool.query(`UPDATE sessions SET lifecycle_run_id=NULL WHERE id=$1`, [sessionId]);
23
+ await pool.end();
24
+ }
25
+ async function persistsFinish(runId, chatId, durationMs) {
26
+ "use step";
27
+ const pg = (await import("pg")).default;
28
+ const pool = new pg.Pool({ connectionString: process.env.APP_PG_URL, max: 4 });
29
+ await pool.query(`INSERT INTO workflow_runs (id, chat_id, session_id, user_id, status, started_at, finished_at, total_duration_ms)
30
+ VALUES ($1,$2,'selftest-session','selftest-user','completed',now(),now(),$3)
31
+ ON CONFLICT (id) DO NOTHING`, [runId, chatId, Math.round(durationMs)]);
32
+ await pool.end();
33
+ return true;
34
+ }
35
+ async function flakyStep() {
36
+ "use step";
37
+ throw new Error("transient failure");
38
+ }
39
+ /* ---------- the workflow ---------- */
40
+ async function parityWorkflow(opts) {
41
+ "use workflow";
42
+ const { runId } = getWorkflowMetadata();
43
+ const writable = getWritable();
44
+ const started = Date.now();
45
+ let fatalCaught = false;
46
+ await writable.write("stage:start;");
47
+ // --- retry semantics: transient error retried, FatalError not
48
+ try {
49
+ await step(flakyStep);
50
+ }
51
+ catch (e) {
52
+ if (e instanceof FatalError)
53
+ fatalCaught = true;
54
+ else
55
+ fatalCaught = true; // engine treats unknown as non-fatal retry-then-fail
56
+ }
57
+ const lease = `lrun-${runId}`;
58
+ const claimed = await step(() => claimLease(opts.sessionId, lease));
59
+ await writable.write(`stage:lease:${claimed};`);
60
+ // --- durable timer loop (mirrors sandbox-lifecycle's while(true))
61
+ for (let i = 0; i < opts.ticks; i += 1) {
62
+ await sleep(new Date(Date.now() + 1000));
63
+ await writable.write(`stage:tick:${i + 1};`);
64
+ }
65
+ const persisted = await step(() => persistsFinish(runId, opts.chatId, Date.now() - started));
66
+ await step(() => clearLease(opts.sessionId));
67
+ await writable.write("stage:done;");
68
+ await writable.close();
69
+ return {
70
+ ok: true, runId, ticks: opts.ticks,
71
+ leaseValue: claimed ? lease : null,
72
+ chunks: 3 + opts.ticks, persisted, fatalCaught,
73
+ };
74
+ }
75
+ /* ---------- runner ---------- */
76
+ async function main() {
77
+ const store = await createPostgresStore(URL);
78
+ const engine = new Engine(store, { pollMs: 300 });
79
+ const { registerWorkflow } = await import("../src/index.js");
80
+ registerWorkflow("entry-parity", parityWorkflow);
81
+ void engine.startWorker((e) => console.error("[worker]", e));
82
+ const mode = process.argv[2] ?? "run";
83
+ const ticks = Number(process.argv[3] ?? 3);
84
+ if (mode === "run") {
85
+ const { runId } = await engine.start("entry-parity", [
86
+ { sessionId: "selftest-session", chatId: "selftest-chat", ticks },
87
+ ]);
88
+ console.log("STARTED", runId);
89
+ const run = await engine.getRun(runId);
90
+ const chunks = [];
91
+ const reader = run.getReadable().getReader();
92
+ const dec = new TextDecoder();
93
+ try {
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done)
97
+ break;
98
+ chunks.push(dec.decode(value));
99
+ }
100
+ }
101
+ catch { /* closed */ }
102
+ const rv = await run.returnValue;
103
+ console.log("CHUNKS", JSON.stringify(chunks));
104
+ console.log("RESULT", JSON.stringify(rv));
105
+ process.exit(0);
106
+ }
107
+ if (mode === "resume") {
108
+ const runId = process.argv[4];
109
+ await engine.resume(runId);
110
+ const row = await store.getRun(runId);
111
+ console.log("RESUMED", runId, JSON.stringify(row));
112
+ process.exit(0);
113
+ }
114
+ }
115
+ void main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,14 @@
1
+ import { Engine } from "../src/index.js";
2
+ import { createPostgresStore } from "../src/pg-store.js";
3
+ import { registerAll } from "./workflow-def.js";
4
+ import pg from "pg";
5
+ registerAll();
6
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
7
+ const engine = new Engine(store, { pollMs: 200, staleRunMs: 15_000 });
8
+ const p = new pg.Pool({ connectionString: process.env.LIGHTFLOW_PG_URL });
9
+ const rows = await p.query("SELECT run_id FROM lightflow_runs WHERE status='running'");
10
+ console.log("RESUMING", rows.rows.length);
11
+ await Promise.all(rows.rows.map((r) => engine.resume(r.run_id).catch((e) => console.error("ERR", e.message))));
12
+ const done = await p.query("SELECT status,count(*) FROM lightflow_runs GROUP BY status");
13
+ console.log("FINAL", JSON.stringify(done.rows));
14
+ process.exit(0);
@@ -0,0 +1,12 @@
1
+ import { Engine } from "../src/index.js";
2
+ import { createPostgresStore } from "../src/pg-store.js";
3
+ const { registerWorkflow } = await import("../src/index.js");
4
+ const { parityWorkflow, registerAll } = await import("./workflow-def.js");
5
+ registerAll();
6
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
7
+ const engine = new Engine(store, { pollMs: 300, staleRunMs: 15000 });
8
+ const runId = process.argv[2];
9
+ await engine.resume(runId);
10
+ const row = await store.getRun(runId);
11
+ console.log("FINAL", row?.status, JSON.stringify(row?.output));
12
+ process.exit(0);
@@ -0,0 +1,46 @@
1
+ import { Engine } from "../src/index.js";
2
+ import { createPostgresStore } from "../src/pg-store.js";
3
+ import { registerAll } from "./workflow-def.js";
4
+ registerAll();
5
+ const users = Number(process.argv[2] ?? 10);
6
+ const chats = Number(process.argv[3] ?? 3);
7
+ const ticks = Number(process.argv[4] ?? 6);
8
+ const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
9
+ const engine = new Engine(store, { pollMs: 200, staleRunMs: 20_000 });
10
+ void engine.startWorker(() => { });
11
+ const ids = [];
12
+ const t0 = Date.now();
13
+ for (let u = 0; u < users; u += 1) {
14
+ for (let c = 0; c < chats; c += 1) {
15
+ const { runId } = await engine.start("entry-parity", [
16
+ { sessionId: "selftest-session", chatId: `sim-${u}-chat-${c}`, ticks },
17
+ ]);
18
+ ids.push(runId);
19
+ }
20
+ }
21
+ console.log("STARTED", ids.length, "in", Date.now() - t0, "ms");
22
+ // poll to terminal state
23
+ const deadline = Date.now() + 20 * 60_000;
24
+ let done = 0;
25
+ while (Date.now() < deadline) {
26
+ let completed = 0, running = 0, failed = 0;
27
+ for (const id of ids) {
28
+ const r = await store.getRun(id);
29
+ if (!r)
30
+ continue;
31
+ if (r.status === "completed")
32
+ completed += 1;
33
+ else if (r.status === "running")
34
+ running += 1;
35
+ else
36
+ failed += 1;
37
+ }
38
+ done = completed;
39
+ console.log(`t+${Math.round((Date.now() - t0) / 1000)}s completed=${completed} running=${running} failed=${failed}`);
40
+ if (completed + failed === ids.length)
41
+ break;
42
+ await new Promise((r) => setTimeout(r, 10_000));
43
+ void running;
44
+ }
45
+ console.log("DONE", done, "of", ids.length);
46
+ process.exit(0);
@@ -0,0 +1,157 @@
1
+ /**
2
+ * CI smoke suite — node:test, runs against a real Postgres (see ci.yml).
3
+ * Uses the real engine API: plain async fns marked "use step" / "use workflow",
4
+ * driven through Engine + createPostgresStore (same pattern as test/entry-parity.ts).
5
+ *
6
+ * The kill-recovery harnesses (simulate/drain/resume/resume-all) stay as
7
+ * manual scripts — SIGKILL harnesses can't run inside node:test.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import pg from "pg";
12
+ import { Engine, step, sleep, getWritable, FatalError, getWorkflowMetadata, registerWorkflow } from "../src/index.js";
13
+ import { createPostgresStore } from "../src/pg-store.js";
14
+ const PG_URL = process.env.LIGHTFLOW_PG_URL;
15
+ if (!PG_URL)
16
+ throw new Error("LIGHTFLOW_PG_URL is required");
17
+ async function freshStore() {
18
+ return createPostgresStore(PG_URL);
19
+ }
20
+ async function resetTables() {
21
+ // Ensure schema exists first (fresh CI database), then clear it.
22
+ await createPostgresStore(PG_URL);
23
+ const pool = new pg.Pool({ connectionString: PG_URL });
24
+ await pool.query("TRUNCATE lightflow_events, lightflow_runs");
25
+ await pool.end();
26
+ }
27
+ /** Poll until the run leaves "running" (or timeout). */
28
+ async function awaitTerminal(store, runId, timeoutMs = 10_000) {
29
+ const deadline = Date.now() + timeoutMs;
30
+ let run = await store.getRun(runId);
31
+ while (run?.status === "running" && Date.now() < deadline) {
32
+ await new Promise((r) => setTimeout(r, 100));
33
+ run = await store.getRun(runId);
34
+ }
35
+ return run;
36
+ }
37
+ test("step memoizes — the side effect executes exactly once", async () => {
38
+ await resetTables();
39
+ const store = await freshStore();
40
+ let calls = 0;
41
+ registerWorkflow("smoke-memo", async () => {
42
+ "use workflow";
43
+ const memoized = async () => {
44
+ "use step";
45
+ calls += 1;
46
+ return `call-${calls}`;
47
+ };
48
+ await step(memoized);
49
+ return { calls };
50
+ });
51
+ const engine = new Engine(store, { pollMs: 100 });
52
+ const workerPromise = engine.startWorker();
53
+ try {
54
+ const { runId } = await engine.start("smoke-memo", []);
55
+ const run = await awaitTerminal(store, runId);
56
+ assert.equal(run?.status, "completed");
57
+ assert.equal(run?.output?.calls, 1);
58
+ }
59
+ finally {
60
+ engine.stopWorker();
61
+ await workerPromise;
62
+ }
63
+ });
64
+ test("FatalError is caught as fatal inside the workflow", async () => {
65
+ await resetTables();
66
+ const store = await freshStore();
67
+ registerWorkflow("smoke-fatal", async () => {
68
+ "use workflow";
69
+ const bomb = async () => {
70
+ "use step";
71
+ throw new FatalError("hard stop");
72
+ };
73
+ try {
74
+ await step(bomb);
75
+ return { fatalCaught: false };
76
+ }
77
+ catch (e) {
78
+ return { fatalCaught: e instanceof FatalError };
79
+ }
80
+ });
81
+ const engine = new Engine(store, { pollMs: 100 });
82
+ const workerPromise = engine.startWorker();
83
+ try {
84
+ const { runId } = await engine.start("smoke-fatal", []);
85
+ const run = await awaitTerminal(store, runId);
86
+ assert.equal(run?.status, "completed");
87
+ assert.equal(run?.output?.fatalCaught, true);
88
+ }
89
+ finally {
90
+ engine.stopWorker();
91
+ await workerPromise;
92
+ }
93
+ });
94
+ test("durable sleep resumes and completes via the worker loop", async () => {
95
+ await resetTables();
96
+ const store = await freshStore();
97
+ registerWorkflow("smoke-sleep", async () => {
98
+ "use workflow";
99
+ await sleep(100);
100
+ return "after-sleep";
101
+ });
102
+ const engine = new Engine(store, { pollMs: 50 });
103
+ const workerPromise = engine.startWorker();
104
+ try {
105
+ const { runId } = await engine.start("smoke-sleep", []);
106
+ const run = await awaitTerminal(store, runId);
107
+ assert.equal(run?.status, "completed");
108
+ assert.equal(run?.output, "after-sleep");
109
+ }
110
+ finally {
111
+ engine.stopWorker();
112
+ await workerPromise;
113
+ }
114
+ });
115
+ test("streaming chunks are ordered and replayable", async () => {
116
+ await resetTables();
117
+ const store = await freshStore();
118
+ registerWorkflow("smoke-stream", async () => {
119
+ "use workflow";
120
+ const writable = getWritable();
121
+ for (let i = 0; i < 3; i += 1) {
122
+ await writable.write(`chunk-${i}`);
123
+ await sleep(50);
124
+ }
125
+ await writable.close();
126
+ return 3;
127
+ });
128
+ const engine = new Engine(store, { pollMs: 50 });
129
+ const workerPromise = engine.startWorker();
130
+ try {
131
+ const { runId } = await engine.start("smoke-stream", []);
132
+ const run = await awaitTerminal(store, runId);
133
+ assert.equal(run?.status, "completed");
134
+ const pool = new pg.Pool({ connectionString: PG_URL });
135
+ const rows = await pool.query(`SELECT payload->>'value' AS val FROM lightflow_events
136
+ WHERE run_id=$1 AND type='chunk' AND (payload->>'done') IS NULL
137
+ ORDER BY (payload->>'index')::int`, [runId]);
138
+ await pool.end();
139
+ assert.deepEqual(rows.rows.map((r) => r.val), ["chunk-0", "chunk-1", "chunk-2"]);
140
+ }
141
+ finally {
142
+ engine.stopWorker();
143
+ await workerPromise;
144
+ }
145
+ });
146
+ test("runId is unique per start", async () => {
147
+ await resetTables();
148
+ const store = await freshStore();
149
+ registerWorkflow("smoke-ids", async () => {
150
+ "use workflow";
151
+ return getWorkflowMetadata().runId;
152
+ });
153
+ const engine = new Engine(store, { pollMs: 100 });
154
+ const a = await engine.start("smoke-ids", []);
155
+ const b = await engine.start("smoke-ids", []);
156
+ assert.notEqual(a.runId, b.runId);
157
+ });