@boardwalk-labs/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.
Files changed (80) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +69 -0
  3. package/bin/boardwalk-server.js +16 -0
  4. package/dist/agent/conversation.d.ts +42 -0
  5. package/dist/agent/conversation.js +4 -0
  6. package/dist/agent/leaf.d.ts +81 -0
  7. package/dist/agent/leaf.js +190 -0
  8. package/dist/agent/providers.d.ts +23 -0
  9. package/dist/agent/providers.js +347 -0
  10. package/dist/agent/rates.d.ts +13 -0
  11. package/dist/agent/rates.js +35 -0
  12. package/dist/agent/redact.d.ts +9 -0
  13. package/dist/agent/redact.js +27 -0
  14. package/dist/agent/resolve.d.ts +58 -0
  15. package/dist/agent/resolve.js +153 -0
  16. package/dist/agent/sse.d.ts +2 -0
  17. package/dist/agent/sse.js +30 -0
  18. package/dist/agent/tools.d.ts +57 -0
  19. package/dist/agent/tools.js +324 -0
  20. package/dist/clock.d.ts +8 -0
  21. package/dist/clock.js +32 -0
  22. package/dist/cron/cron.d.ts +34 -0
  23. package/dist/cron/cron.js +331 -0
  24. package/dist/engine.d.ts +106 -0
  25. package/dist/engine.js +183 -0
  26. package/dist/errors.d.ts +15 -0
  27. package/dist/errors.js +40 -0
  28. package/dist/ids.d.ts +7 -0
  29. package/dist/ids.js +42 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +8 -0
  32. package/dist/json_value.d.ts +7 -0
  33. package/dist/json_value.js +29 -0
  34. package/dist/mcp/client.d.ts +39 -0
  35. package/dist/mcp/client.js +112 -0
  36. package/dist/mcp/jsonrpc.d.ts +57 -0
  37. package/dist/mcp/jsonrpc.js +117 -0
  38. package/dist/mcp/oauth.d.ts +72 -0
  39. package/dist/mcp/oauth.js +337 -0
  40. package/dist/mcp/token_store.d.ts +30 -0
  41. package/dist/mcp/token_store.js +101 -0
  42. package/dist/mcp/transport_http.d.ts +38 -0
  43. package/dist/mcp/transport_http.js +143 -0
  44. package/dist/mcp/transport_stdio.d.ts +27 -0
  45. package/dist/mcp/transport_stdio.js +94 -0
  46. package/dist/run/child.d.ts +1 -0
  47. package/dist/run/child.js +139 -0
  48. package/dist/run/child_host.d.ts +26 -0
  49. package/dist/run/child_host.js +124 -0
  50. package/dist/run/idempotency.d.ts +5 -0
  51. package/dist/run/idempotency.js +31 -0
  52. package/dist/run/ipc.d.ts +159 -0
  53. package/dist/run/ipc.js +150 -0
  54. package/dist/run/run_dir.d.ts +31 -0
  55. package/dist/run/run_dir.js +106 -0
  56. package/dist/run/supervisor.d.ts +107 -0
  57. package/dist/run/supervisor.js +676 -0
  58. package/dist/scheduler/scheduler.d.ts +54 -0
  59. package/dist/scheduler/scheduler.js +215 -0
  60. package/dist/server/http.d.ts +42 -0
  61. package/dist/server/http.js +183 -0
  62. package/dist/server/routes/api.d.ts +17 -0
  63. package/dist/server/routes/api.js +107 -0
  64. package/dist/server/routes/hooks.d.ts +2 -0
  65. package/dist/server/routes/hooks.js +88 -0
  66. package/dist/server/routes/router.d.ts +15 -0
  67. package/dist/server/routes/router.js +75 -0
  68. package/dist/server/routes/stream.d.ts +2 -0
  69. package/dist/server/routes/stream.js +79 -0
  70. package/dist/server/routes/ui.d.ts +2 -0
  71. package/dist/server/routes/ui.js +120 -0
  72. package/dist/server/server.d.ts +25 -0
  73. package/dist/server/server.js +67 -0
  74. package/dist/server_main.d.ts +46 -0
  75. package/dist/server_main.js +203 -0
  76. package/dist/store/migrations.d.ts +21 -0
  77. package/dist/store/migrations.js +159 -0
  78. package/dist/store/store.d.ts +194 -0
  79. package/dist/store/store.js +567 -0
  80. package/package.json +57 -0
@@ -0,0 +1,567 @@
1
+ // The engine's one persistence module — every SQL statement in the engine lives here
2
+ // (CODE_QUALITY §7.2: "storage access goes through one persistence module"). The backend is
3
+ // node:sqlite, synchronous on purpose: a single-node engine gains nothing from an async
4
+ // driver, and synchronous statements make multi-row invariants trivially transactional.
5
+ //
6
+ // Conventions (CODE_QUALITY §2.2): ULID primary keys, integer-ms timestamps, and JSON columns
7
+ // Zod-validated on READ — a row that fails validation throws EngineError("INTERNAL") naming
8
+ // the table and column, so corrupt state surfaces as a loud error instead of flowing into the
9
+ // scheduler as data.
10
+ import { DatabaseSync } from "node:sqlite";
11
+ import { z } from "zod";
12
+ import { runEventSchema, workflowManifestSchema } from "@boardwalk-labs/workflow";
13
+ import { EngineError } from "../errors.js";
14
+ import { ulid } from "../ids.js";
15
+ import { migrate } from "./migrations.js";
16
+ // ============================================================================
17
+ // Column schemas — every JSON/enum column has exactly one validator (CODE_QUALITY §2.2)
18
+ // ============================================================================
19
+ /**
20
+ * Build a Zod enum from an exhaustive flag record. Why a Record and not a plain array: the
21
+ * `Record<T, true>` argument makes the value list provably complete — adding a member to the
22
+ * union type without adding it here is a compile error, so reads can never reject a value a
23
+ * newer engine legitimately wrote.
24
+ */
25
+ function enumFromKeys(flags) {
26
+ // Why the cast: Object.keys erases key types; the Record argument guarantees the keys are
27
+ // exactly the members of T.
28
+ return z.enum(Object.keys(flags));
29
+ }
30
+ const runStatusSchema = enumFromKeys({
31
+ queued: true,
32
+ pending: true,
33
+ running: true,
34
+ completed: true,
35
+ failed: true,
36
+ cancelled: true,
37
+ cancelling: true,
38
+ });
39
+ const triggerKindSchema = enumFromKeys({ cron: true, manual: true, webhook: true });
40
+ const runErrorSchema = z.object({
41
+ code: z.string(),
42
+ message: z.string(),
43
+ });
44
+ const jsonValueSchema = z.lazy(() => z.union([
45
+ z.string(),
46
+ z.number(),
47
+ z.boolean(),
48
+ z.null(),
49
+ z.array(jsonValueSchema),
50
+ z.record(z.string(), jsonValueSchema),
51
+ ]));
52
+ const configSchema = z.record(z.string(), jsonValueSchema);
53
+ const metadataSchema = z.record(z.string(), z.unknown());
54
+ function columnError(table, column, problem) {
55
+ return new EngineError("INTERNAL", `corrupt row in ${table}.${column}: ${problem}`);
56
+ }
57
+ function describeValue(value) {
58
+ if (value === undefined)
59
+ return "missing column";
60
+ if (value === null)
61
+ return "NULL";
62
+ return typeof value;
63
+ }
64
+ function readText(row, table, column) {
65
+ const value = row[column];
66
+ if (typeof value === "string")
67
+ return value;
68
+ throw columnError(table, column, `expected TEXT, got ${describeValue(value)}`);
69
+ }
70
+ function readTextOrNull(row, table, column) {
71
+ return row[column] === null ? null : readText(row, table, column);
72
+ }
73
+ function readInteger(row, table, column) {
74
+ const value = row[column];
75
+ if (typeof value === "number" && Number.isInteger(value))
76
+ return value;
77
+ // Why bigint handling: node:sqlite returns bigint for values outside the JS safe-integer
78
+ // range. Our integers (timestamps, counters) always fit, so an overflow is corruption.
79
+ if (typeof value === "bigint") {
80
+ throw columnError(table, column, "INTEGER exceeds Number.MAX_SAFE_INTEGER");
81
+ }
82
+ throw columnError(table, column, `expected INTEGER, got ${describeValue(value)}`);
83
+ }
84
+ function readIntegerOrNull(row, table, column) {
85
+ return row[column] === null ? null : readInteger(row, table, column);
86
+ }
87
+ function readJson(row, table, column, schema) {
88
+ const raw = readText(row, table, column);
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ }
93
+ catch {
94
+ throw columnError(table, column, "invalid JSON");
95
+ }
96
+ const result = schema.safeParse(parsed);
97
+ if (!result.success) {
98
+ throw columnError(table, column, `failed schema validation: ${result.error.message}`);
99
+ }
100
+ return result.data;
101
+ }
102
+ function readJsonOrNull(row, table, column, schema) {
103
+ return row[column] === null ? null : readJson(row, table, column, schema);
104
+ }
105
+ function readEnum(row, table, column, schema) {
106
+ const raw = readText(row, table, column);
107
+ const result = schema.safeParse(raw);
108
+ if (!result.success)
109
+ throw columnError(table, column, `unexpected value "${raw}"`);
110
+ return result.data;
111
+ }
112
+ function mapWorkflow(row) {
113
+ return {
114
+ id: readText(row, "workflows", "id"),
115
+ name: readText(row, "workflows", "name"),
116
+ manifest: readJson(row, "workflows", "manifest", workflowManifestSchema),
117
+ program: readText(row, "workflows", "program"),
118
+ config: readJson(row, "workflows", "config", configSchema),
119
+ createdAt: readInteger(row, "workflows", "created_at"),
120
+ updatedAt: readInteger(row, "workflows", "updated_at"),
121
+ };
122
+ }
123
+ function mapRun(row) {
124
+ return {
125
+ id: readText(row, "runs", "id"),
126
+ workflowId: readText(row, "runs", "workflow_id"),
127
+ status: readEnum(row, "runs", "status", runStatusSchema),
128
+ triggerKind: readEnum(row, "runs", "trigger_kind", triggerKindSchema),
129
+ input: readJsonOrNull(row, "runs", "input", jsonValueSchema),
130
+ output: readJsonOrNull(row, "runs", "output", jsonValueSchema),
131
+ error: readJsonOrNull(row, "runs", "error", runErrorSchema),
132
+ parentRunId: readTextOrNull(row, "runs", "parent_run_id"),
133
+ idempotencyKey: readTextOrNull(row, "runs", "idempotency_key"),
134
+ restarts: readInteger(row, "runs", "restarts"),
135
+ tokensIn: readInteger(row, "runs", "tokens_in"),
136
+ tokensOut: readInteger(row, "runs", "tokens_out"),
137
+ usdMicros: readInteger(row, "runs", "usd_micros"),
138
+ createdAt: readInteger(row, "runs", "created_at"),
139
+ startedAt: readIntegerOrNull(row, "runs", "started_at"),
140
+ endedAt: readIntegerOrNull(row, "runs", "ended_at"),
141
+ };
142
+ }
143
+ function mapEvent(row) {
144
+ return {
145
+ runId: readText(row, "run_events", "run_id"),
146
+ cursor: readInteger(row, "run_events", "cursor"),
147
+ event: readJson(row, "run_events", "event", runEventSchema),
148
+ };
149
+ }
150
+ function mapArtifact(row) {
151
+ return {
152
+ id: readText(row, "artifacts", "id"),
153
+ runId: readText(row, "artifacts", "run_id"),
154
+ name: readText(row, "artifacts", "name"),
155
+ contentType: readText(row, "artifacts", "content_type"),
156
+ path: readText(row, "artifacts", "path"),
157
+ size: readInteger(row, "artifacts", "size"),
158
+ metadata: readJsonOrNull(row, "artifacts", "metadata", metadataSchema),
159
+ createdAt: readInteger(row, "artifacts", "created_at"),
160
+ };
161
+ }
162
+ // ============================================================================
163
+ // SQLite error classification
164
+ // ============================================================================
165
+ const SQLITE_CONSTRAINT_FOREIGNKEY = 787;
166
+ const SQLITE_CONSTRAINT_PRIMARYKEY = 1555;
167
+ const SQLITE_CONSTRAINT_UNIQUE = 2067;
168
+ function sqliteErrcode(err) {
169
+ if (typeof err === "object" && err !== null && "errcode" in err) {
170
+ const code = err.errcode;
171
+ if (typeof code === "number")
172
+ return code;
173
+ }
174
+ return undefined;
175
+ }
176
+ function isUniqueViolation(err) {
177
+ const code = sqliteErrcode(err);
178
+ return code === SQLITE_CONSTRAINT_PRIMARYKEY || code === SQLITE_CONSTRAINT_UNIQUE;
179
+ }
180
+ function isForeignKeyViolation(err) {
181
+ return sqliteErrcode(err) === SQLITE_CONSTRAINT_FOREIGNKEY;
182
+ }
183
+ /** Serialize a JSON column value; `undefined` stores NULL. Rejects unserializable values. */
184
+ function serializeJson(value, what) {
185
+ if (value === undefined)
186
+ return null;
187
+ const json = JSON.stringify(value);
188
+ // Why the check: JSON.stringify returns undefined (despite its declared type) for values
189
+ // like bare functions; storing that would write the literal string "undefined".
190
+ if (typeof json !== "string") {
191
+ throw new EngineError("VALIDATION", `${what} is not JSON-serializable`);
192
+ }
193
+ return json;
194
+ }
195
+ function assertCountInteger(value, what) {
196
+ if (!Number.isInteger(value) || value < 0) {
197
+ throw new EngineError("VALIDATION", `${what} must be a non-negative integer (got ${String(value)})`);
198
+ }
199
+ }
200
+ // ============================================================================
201
+ // The Store
202
+ // ============================================================================
203
+ /**
204
+ * The engine database. One instance per engine process; all access is synchronous and goes
205
+ * through this class — no other module writes SQL. Opening migrates the schema to the latest
206
+ * version, so "open the database" and "deploy the schema" are the same operation and can never
207
+ * drift apart.
208
+ */
209
+ export class Store {
210
+ db;
211
+ now;
212
+ // Why a cache: prepared statements are compiled once and reused; event appends and run
213
+ // polling are hot paths, and the set of distinct SQL strings in this module is small and
214
+ // bounded, so the cache cannot grow without limit.
215
+ statements = new Map();
216
+ inTransaction = false;
217
+ /** Open (or create) the engine database at `path` (`":memory:"` for tests) and migrate. */
218
+ constructor(path, options = {}) {
219
+ this.now = options.now ?? Date.now;
220
+ this.db = new DatabaseSync(path);
221
+ // WAL lets readers (SSE tails, the local UI) proceed while a write is in flight. Foreign
222
+ // keys are per-connection in SQLite and OFF by default, so enable them on every open —
223
+ // they are what keeps an orphaned run/event/artifact row from ever existing.
224
+ this.db.exec("PRAGMA journal_mode = WAL");
225
+ this.db.exec("PRAGMA foreign_keys = ON");
226
+ migrate(this.db);
227
+ }
228
+ close() {
229
+ this.statements.clear();
230
+ this.db.close();
231
+ }
232
+ /**
233
+ * Run `fn` inside a single SQLite transaction (BEGIN IMMEDIATE … COMMIT/ROLLBACK).
234
+ * Composable: the scheduler wraps createRun + recordCronFire in one transaction so the
235
+ * exactly-once fire record and the run it spawned commit or vanish together. Nested calls
236
+ * join the outer transaction — an inner throw propagates and rolls back the whole thing
237
+ * (SQLite has no real nested transactions; partial inner commits would break the outer
238
+ * invariant anyway).
239
+ */
240
+ transaction(fn) {
241
+ if (this.inTransaction)
242
+ return fn();
243
+ // Why IMMEDIATE: take the write lock up front so the transaction can never fail with
244
+ // SQLITE_BUSY halfway through after reads have already happened.
245
+ this.db.exec("BEGIN IMMEDIATE");
246
+ this.inTransaction = true;
247
+ try {
248
+ const result = fn();
249
+ this.db.exec("COMMIT");
250
+ return result;
251
+ }
252
+ catch (err) {
253
+ try {
254
+ this.db.exec("ROLLBACK");
255
+ }
256
+ catch {
257
+ // Some SQLite errors abort the transaction themselves; the original error matters.
258
+ }
259
+ throw err;
260
+ }
261
+ finally {
262
+ this.inTransaction = false;
263
+ }
264
+ }
265
+ // --------------------------------------------------------------------------
266
+ // Workflows
267
+ // --------------------------------------------------------------------------
268
+ /**
269
+ * Insert a workflow or update it by name (deploying again is always an update — the name is
270
+ * the user-facing identity, so the id stays stable across redeploys and existing runs keep
271
+ * their foreign keys). `updated_at` bumps on update; `created_at` and `id` never change.
272
+ */
273
+ upsertWorkflow(args) {
274
+ // Why validate on write too: the manifest column is read back through this same schema; a
275
+ // caller bug is better rejected here than persisted and discovered as INTERNAL on read.
276
+ const manifest = workflowManifestSchema.safeParse(args.manifest);
277
+ if (!manifest.success) {
278
+ throw new EngineError("VALIDATION", `manifest for workflow "${args.name}" failed validation: ${manifest.error.message}`);
279
+ }
280
+ const manifestJson = JSON.stringify(manifest.data);
281
+ const configJson = JSON.stringify(args.config ?? {});
282
+ return this.transaction(() => {
283
+ const t = this.now();
284
+ const existing = this.prepare("SELECT id FROM workflows WHERE name = ?").get(args.name);
285
+ if (existing === undefined) {
286
+ this.prepare(`INSERT INTO workflows (id, name, manifest, program, config, created_at, updated_at)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(ulid(t), args.name, manifestJson, args.program, configJson, t, t);
288
+ }
289
+ else {
290
+ this.prepare("UPDATE workflows SET manifest = ?, program = ?, config = ?, updated_at = ? WHERE name = ?").run(manifestJson, args.program, configJson, t, args.name);
291
+ }
292
+ const row = this.getWorkflow(args.name);
293
+ if (row === null) {
294
+ throw new EngineError("INTERNAL", `workflow "${args.name}" vanished mid-upsert`);
295
+ }
296
+ return row;
297
+ });
298
+ }
299
+ getWorkflow(name) {
300
+ const row = this.prepare("SELECT * FROM workflows WHERE name = ?").get(name);
301
+ return row === undefined ? null : mapWorkflow(row);
302
+ }
303
+ getWorkflowById(id) {
304
+ const row = this.prepare("SELECT * FROM workflows WHERE id = ?").get(id);
305
+ return row === undefined ? null : mapWorkflow(row);
306
+ }
307
+ listWorkflows() {
308
+ return this.prepare("SELECT * FROM workflows ORDER BY name ASC").all().map(mapWorkflow);
309
+ }
310
+ // --------------------------------------------------------------------------
311
+ // Runs
312
+ // --------------------------------------------------------------------------
313
+ /**
314
+ * Create a run in status `queued`. With an `idempotencyKey` this is an atomic
315
+ * find-or-create on (parentRunId, idempotencyKey): a restarted parent re-running the same
316
+ * `workflows.call` site re-attaches to the child it already spawned (`created: false`)
317
+ * instead of spawning a duplicate — the heart of restart-on-crash semantics.
318
+ */
319
+ createRun(args) {
320
+ const inputJson = serializeJson(args.input, "run input");
321
+ return this.transaction(() => {
322
+ const parentRunId = args.parentRunId ?? null;
323
+ if (args.idempotencyKey !== undefined) {
324
+ // Why `IS ?`: top-level runs carry idempotency keys with a NULL parent, and `=` never
325
+ // matches NULL. The lookup runs inside the same transaction as the insert, so the
326
+ // find-or-create is atomic.
327
+ const existing = this.prepare("SELECT * FROM runs WHERE parent_run_id IS ? AND idempotency_key = ?").get(parentRunId, args.idempotencyKey);
328
+ if (existing !== undefined)
329
+ return { run: mapRun(existing), created: false };
330
+ }
331
+ // Why explicit existence checks: a bare FK violation can't say WHICH reference was bad;
332
+ // these turn caller mistakes into precise NOT_FOUND errors.
333
+ if (this.prepare("SELECT id FROM workflows WHERE id = ?").get(args.workflowId) === undefined) {
334
+ throw new EngineError("NOT_FOUND", `workflow ${args.workflowId} not found`);
335
+ }
336
+ if (parentRunId !== null &&
337
+ this.prepare("SELECT id FROM runs WHERE id = ?").get(parentRunId) === undefined) {
338
+ throw new EngineError("NOT_FOUND", `parent run ${parentRunId} not found`);
339
+ }
340
+ const t = this.now();
341
+ const id = ulid(t);
342
+ this.prepare(`INSERT INTO runs (id, workflow_id, status, trigger_kind, input, parent_run_id, idempotency_key, created_at)
343
+ VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)`).run(id, args.workflowId, args.triggerKind, inputJson, parentRunId, args.idempotencyKey ?? null, t);
344
+ return { run: this.getRunOrThrow(id), created: true };
345
+ });
346
+ }
347
+ getRun(id) {
348
+ const row = this.prepare("SELECT * FROM runs WHERE id = ?").get(id);
349
+ return row === undefined ? null : mapRun(row);
350
+ }
351
+ /** List runs newest first, optionally filtered — the shape the run-log UI and sweeps need. */
352
+ listRuns(filter = {}) {
353
+ const where = [];
354
+ const params = [];
355
+ if (filter.workflowId !== undefined) {
356
+ where.push("workflow_id = ?");
357
+ params.push(filter.workflowId);
358
+ }
359
+ if (filter.statuses !== undefined) {
360
+ if (filter.statuses.length === 0)
361
+ return [];
362
+ where.push(`status IN (${filter.statuses.map(() => "?").join(", ")})`);
363
+ params.push(...filter.statuses);
364
+ }
365
+ const whereSql = where.length > 0 ? ` WHERE ${where.join(" AND ")}` : "";
366
+ // Why the rowid tiebreak: created_at has millisecond resolution and a ULID's random tail
367
+ // does NOT order same-millisecond inserts — rowid is the only true insertion order, and
368
+ // "newest first" must be exact for the scheduler's oldest-first dispatch to be fair.
369
+ // LIMIT -1 is SQLite for "no limit".
370
+ const sql = `SELECT * FROM runs${whereSql} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`;
371
+ return this.prepare(sql)
372
+ .all(...params, filter.limit ?? -1, filter.offset ?? 0)
373
+ .map(mapRun);
374
+ }
375
+ /**
376
+ * Transition a run's status, optionally recording the outcome (`output` on completion,
377
+ * `error` on failure) and lifecycle timestamps in the same write — a crash can never leave
378
+ * a terminal status without its outcome.
379
+ */
380
+ updateRunStatus(id, status, opts = {}) {
381
+ const sets = ["status = ?"];
382
+ const params = [status];
383
+ if (opts.error !== undefined) {
384
+ sets.push("error = ?");
385
+ params.push(JSON.stringify(opts.error));
386
+ }
387
+ if (opts.output !== undefined) {
388
+ sets.push("output = ?");
389
+ params.push(JSON.stringify(opts.output));
390
+ }
391
+ if (opts.startedAt !== undefined) {
392
+ sets.push("started_at = ?");
393
+ params.push(opts.startedAt);
394
+ }
395
+ if (opts.endedAt !== undefined) {
396
+ sets.push("ended_at = ?");
397
+ params.push(opts.endedAt);
398
+ }
399
+ const result = this.prepare(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
400
+ if (Number(result.changes) === 0) {
401
+ throw new EngineError("NOT_FOUND", `run ${id} not found`);
402
+ }
403
+ }
404
+ /**
405
+ * Bump the restart counter and return the new value in one statement, so the supervisor's
406
+ * "have we exhausted restarts?" check is race-free even across its own crash-recovery.
407
+ */
408
+ incrementRestarts(id) {
409
+ const row = this.prepare("UPDATE runs SET restarts = restarts + 1 WHERE id = ? RETURNING restarts").get(id);
410
+ if (row === undefined)
411
+ throw new EngineError("NOT_FOUND", `run ${id} not found`);
412
+ return readInteger(row, "runs", "restarts");
413
+ }
414
+ /**
415
+ * Accumulate leaf usage onto the run's tallies. Additive (not a set) because each `agent()`
416
+ * leaf reports independently and budgets are checked against the running total.
417
+ */
418
+ addRunUsage(id, usage) {
419
+ const tokensIn = usage.tokensIn ?? 0;
420
+ const tokensOut = usage.tokensOut ?? 0;
421
+ const usdMicros = usage.usdMicros ?? 0;
422
+ assertCountInteger(tokensIn, "tokensIn");
423
+ assertCountInteger(tokensOut, "tokensOut");
424
+ assertCountInteger(usdMicros, "usdMicros");
425
+ const result = this.prepare(`UPDATE runs SET tokens_in = tokens_in + ?, tokens_out = tokens_out + ?, usd_micros = usd_micros + ?
426
+ WHERE id = ?`).run(tokensIn, tokensOut, usdMicros, id);
427
+ if (Number(result.changes) === 0) {
428
+ throw new EngineError("NOT_FOUND", `run ${id} not found`);
429
+ }
430
+ }
431
+ /** Current usage tallies for budget enforcement. Throws NOT_FOUND on an unknown run. */
432
+ getRunUsage(id) {
433
+ const row = this.prepare("SELECT tokens_in, tokens_out, usd_micros FROM runs WHERE id = ?").get(id);
434
+ if (row === undefined)
435
+ throw new EngineError("NOT_FOUND", `run ${id} not found`);
436
+ return {
437
+ tokensIn: readInteger(row, "runs", "tokens_in"),
438
+ tokensOut: readInteger(row, "runs", "tokens_out"),
439
+ usdMicros: readInteger(row, "runs", "usd_micros"),
440
+ };
441
+ }
442
+ // --------------------------------------------------------------------------
443
+ // Run events (append-only)
444
+ // --------------------------------------------------------------------------
445
+ /**
446
+ * Append a batch of events in one transaction: all rows land or none do, so a consumer can
447
+ * never observe a half-written batch and cursor resumption stays gap-free. A duplicate
448
+ * cursor throws CONFLICT — the log is append-only and a cursor is never rewritten.
449
+ */
450
+ appendEvents(runId, rows) {
451
+ if (rows.length === 0)
452
+ return;
453
+ for (const row of rows) {
454
+ if (!Number.isInteger(row.cursor) || row.cursor < 1) {
455
+ throw new EngineError("VALIDATION", `event cursor must be a positive integer (got ${String(row.cursor)})`);
456
+ }
457
+ }
458
+ this.transaction(() => {
459
+ const insert = this.prepare("INSERT INTO run_events (run_id, cursor, event) VALUES (?, ?, ?)");
460
+ for (const row of rows) {
461
+ try {
462
+ insert.run(runId, row.cursor, JSON.stringify(row.event));
463
+ }
464
+ catch (err) {
465
+ if (isUniqueViolation(err)) {
466
+ throw new EngineError("CONFLICT", `event cursor ${String(row.cursor)} already exists for run ${runId}`);
467
+ }
468
+ if (isForeignKeyViolation(err)) {
469
+ throw new EngineError("NOT_FOUND", `run ${runId} not found`);
470
+ }
471
+ throw err;
472
+ }
473
+ }
474
+ });
475
+ }
476
+ /** Events in cursor order, optionally resuming after a cursor (SSE `Last-Event-ID`). */
477
+ listEvents(runId, opts = {}) {
478
+ return this.prepare("SELECT * FROM run_events WHERE run_id = ? AND cursor > ? ORDER BY cursor ASC LIMIT ?")
479
+ .all(runId, opts.afterCursor ?? 0, opts.limit ?? -1)
480
+ .map(mapEvent);
481
+ }
482
+ /** The run's latest cursor, or 0 when it has no events (cursors are 1-based). */
483
+ maxCursor(runId) {
484
+ const row = this.prepare("SELECT COALESCE(MAX(cursor), 0) AS max_cursor FROM run_events WHERE run_id = ?").get(runId);
485
+ // An aggregate always yields one row; its absence means the connection is broken.
486
+ if (row === undefined)
487
+ throw new EngineError("INTERNAL", "MAX(cursor) returned no row");
488
+ return readInteger(row, "run_events", "max_cursor");
489
+ }
490
+ // --------------------------------------------------------------------------
491
+ // Cron fires (exactly-once)
492
+ // --------------------------------------------------------------------------
493
+ /**
494
+ * Record that a cron tick fired. The (workflowId, triggerIndex, fireTime) primary key is the
495
+ * exactly-once guarantee: a scheduler that crashed after firing and restarted gets CONFLICT
496
+ * here instead of silently double-running the workflow. Callers wrap this with createRun in
497
+ * one {@link transaction} so the fire record and the run commit together.
498
+ */
499
+ recordCronFire(args) {
500
+ try {
501
+ this.prepare(`INSERT INTO cron_fires (workflow_id, trigger_index, fire_time, run_id, created_at)
502
+ VALUES (?, ?, ?, ?, ?)`).run(args.workflowId, args.triggerIndex, args.fireTime, args.runId, this.now());
503
+ }
504
+ catch (err) {
505
+ if (isUniqueViolation(err)) {
506
+ throw new EngineError("CONFLICT", `cron fire already recorded for workflow ${args.workflowId} ` +
507
+ `trigger ${String(args.triggerIndex)} at ${String(args.fireTime)}`);
508
+ }
509
+ if (isForeignKeyViolation(err)) {
510
+ throw new EngineError("NOT_FOUND", `workflow ${args.workflowId} or run ${args.runId} not found`);
511
+ }
512
+ throw err;
513
+ }
514
+ }
515
+ /** The latest recorded fire time for a trigger, or null — the catch-up policy's anchor. */
516
+ lastCronFire(workflowId, triggerIndex) {
517
+ const row = this.prepare("SELECT MAX(fire_time) AS last_fire FROM cron_fires WHERE workflow_id = ? AND trigger_index = ?").get(workflowId, triggerIndex);
518
+ return row === undefined ? null : readIntegerOrNull(row, "cron_fires", "last_fire");
519
+ }
520
+ // --------------------------------------------------------------------------
521
+ // Artifacts
522
+ // --------------------------------------------------------------------------
523
+ /** Record an artifact's metadata (the bytes live on disk at `path`, never in SQLite). */
524
+ createArtifact(args) {
525
+ assertCountInteger(args.size, "artifact size");
526
+ const metadataJson = serializeJson(args.metadata, "artifact metadata");
527
+ const t = this.now();
528
+ const id = ulid(t);
529
+ try {
530
+ this.prepare(`INSERT INTO artifacts (id, run_id, name, content_type, path, size, metadata, created_at)
531
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, args.runId, args.name, args.contentType, args.path, args.size, metadataJson, t);
532
+ }
533
+ catch (err) {
534
+ if (isForeignKeyViolation(err)) {
535
+ throw new EngineError("NOT_FOUND", `run ${args.runId} not found`);
536
+ }
537
+ throw err;
538
+ }
539
+ const row = this.prepare("SELECT * FROM artifacts WHERE id = ?").get(id);
540
+ if (row === undefined)
541
+ throw new EngineError("INTERNAL", `artifact ${id} vanished mid-write`);
542
+ return mapArtifact(row);
543
+ }
544
+ /** A run's artifacts in creation order (ULIDs sort by time). */
545
+ listArtifacts(runId) {
546
+ return this.prepare("SELECT * FROM artifacts WHERE run_id = ? ORDER BY id ASC")
547
+ .all(runId)
548
+ .map(mapArtifact);
549
+ }
550
+ // --------------------------------------------------------------------------
551
+ // Internals
552
+ // --------------------------------------------------------------------------
553
+ prepare(sql) {
554
+ let statement = this.statements.get(sql);
555
+ if (statement === undefined) {
556
+ statement = this.db.prepare(sql);
557
+ this.statements.set(sql, statement);
558
+ }
559
+ return statement;
560
+ }
561
+ getRunOrThrow(id) {
562
+ const run = this.getRun(id);
563
+ if (run === null)
564
+ throw new EngineError("INTERNAL", `run ${id} vanished mid-write`);
565
+ return run;
566
+ }
567
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@boardwalk-labs/engine",
3
+ "version": "0.1.0",
4
+ "description": "The Boardwalk single-node engine: cron scheduling, run lifecycle, SQLite state, and the local run log. Powers `boardwalk dev` and the self-hosted server.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/boardwalk-labs/boardwalk.git"
9
+ },
10
+ "homepage": "https://github.com/boardwalk-labs/boardwalk#readme",
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24.0.0"
14
+ },
15
+ "packageManager": "pnpm@10.23.0",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "bin": {
23
+ "boardwalk-server": "bin/boardwalk-server.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p conformance --noEmit",
34
+ "lint": "eslint . --max-warnings 0",
35
+ "lint:fix": "eslint . --fix --max-warnings 0",
36
+ "format": "prettier --write .",
37
+ "format:check": "prettier --check .",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "coverage": "vitest run --coverage"
41
+ },
42
+ "dependencies": {
43
+ "@boardwalk-labs/workflow": "^0.1.0",
44
+ "zod": "^4.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^9.18.0",
48
+ "@types/node": "^24.0.0",
49
+ "@vitest/coverage-v8": "^3.0.0",
50
+ "eslint": "^9.18.0",
51
+ "eslint-config-prettier": "^10.1.8",
52
+ "prettier": "^3.4.0",
53
+ "typescript": "^5.6.0",
54
+ "typescript-eslint": "^8.20.0",
55
+ "vitest": "^3.0.0"
56
+ }
57
+ }