@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,331 @@
1
+ // Cron parsing + next-fire computation for the scheduler (SPEC §2.1).
2
+ //
3
+ // One-validator philosophy: the SDK manifest schema only checks a cron trigger shallowly
4
+ // (5 or 6 whitespace-separated fields; timezone = a non-empty string). This module is the
5
+ // deep validator of record — anything `parseCron`/`nextFire` reject must be rejected at
6
+ // deploy time, so a stored manifest can never contain a schedule the engine can't fire.
7
+ //
8
+ // Semantics are Vixie cron's where Vixie has an opinion:
9
+ // - dom/dow: when NEITHER field starts with `*`, a day matches if EITHER matches (the
10
+ // classic OR rule); when at least one starts with `*`, both must match (the star field
11
+ // matches everything, so effectively the other decides). Vixie keys this off the literal
12
+ // leading `*` (its DOM_STAR/DOW_STAR flags), so `*/2` counts as a star field — we match.
13
+ // - dow 0 and 7 are both Sunday.
14
+ // Plus the modern (cronie/Quartz/AWS) extension `N/step` = `N-max/step`, because it is what
15
+ // authors coming from any contemporary cron expect and accepting it loses nothing.
16
+ //
17
+ // Timezones use Intl only — this package takes no runtime dependency for cron (CODE_QUALITY
18
+ // §10: every dependency is supply-chain surface). DST policy: a wall time erased by
19
+ // spring-forward is skipped (it never occurs, so it never fires); a wall time repeated by
20
+ // fall-back fires once, at its first (earlier-UTC) occurrence.
21
+ import { EngineError } from "../errors.js";
22
+ const SECOND = { label: "second", min: 0, max: 59 };
23
+ const MINUTE = { label: "minute", min: 0, max: 59 };
24
+ const HOUR = { label: "hour", min: 0, max: 23 };
25
+ const DOM = { label: "day-of-month", min: 1, max: 31 };
26
+ const MONTH = {
27
+ label: "month",
28
+ min: 1,
29
+ max: 12,
30
+ names: {
31
+ JAN: 1,
32
+ FEB: 2,
33
+ MAR: 3,
34
+ APR: 4,
35
+ MAY: 5,
36
+ JUN: 6,
37
+ JUL: 7,
38
+ AUG: 8,
39
+ SEP: 9,
40
+ OCT: 10,
41
+ NOV: 11,
42
+ DEC: 12,
43
+ },
44
+ };
45
+ const DOW = {
46
+ label: "day-of-week",
47
+ min: 0,
48
+ max: 7, // 7 accepted as Sunday, folded to 0 below
49
+ names: { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 },
50
+ normalize: (v) => (v === 7 ? 0 : v),
51
+ };
52
+ function fieldError(spec, detail) {
53
+ return new EngineError("VALIDATION", `cron: invalid ${spec.label} field: ${detail}`);
54
+ }
55
+ /** Resolve one endpoint of a range: a decimal number or a three-letter name. */
56
+ function parseValue(raw, spec) {
57
+ if (/^\d+$/.test(raw)) {
58
+ const value = Number.parseInt(raw, 10);
59
+ if (value < spec.min || value > spec.max) {
60
+ throw fieldError(spec, `value ${String(value)} out of range ${String(spec.min)}-${String(spec.max)}`);
61
+ }
62
+ return value;
63
+ }
64
+ const named = spec.names?.[raw.toUpperCase()];
65
+ if (named !== undefined)
66
+ return named;
67
+ throw fieldError(spec, `unrecognized value "${raw}"`);
68
+ }
69
+ /** Expand one comma-separated item (`*`, `N`, `A-B`, with optional `/step`) into values. */
70
+ function parseItem(item, spec, into) {
71
+ const slashParts = item.split("/");
72
+ if (slashParts.length > 2)
73
+ throw fieldError(spec, `too many "/" in "${item}"`);
74
+ const body = slashParts[0] ?? "";
75
+ const stepRaw = slashParts[1];
76
+ const hasStep = slashParts.length === 2;
77
+ if (body === "")
78
+ throw fieldError(spec, `empty value in "${item}"`);
79
+ let step = 1;
80
+ if (hasStep) {
81
+ if (stepRaw === undefined || !/^\d+$/.test(stepRaw)) {
82
+ throw fieldError(spec, `step in "${item}" must be a positive integer`);
83
+ }
84
+ step = Number.parseInt(stepRaw, 10);
85
+ if (step === 0)
86
+ throw fieldError(spec, `step in "${item}" must be at least 1`);
87
+ }
88
+ let lo;
89
+ let hi;
90
+ if (body === "*") {
91
+ lo = spec.min;
92
+ hi = spec.max;
93
+ }
94
+ else {
95
+ const dashParts = body.split("-");
96
+ if (dashParts.length > 2)
97
+ throw fieldError(spec, `too many "-" in "${item}"`);
98
+ const loRaw = dashParts[0] ?? "";
99
+ const hiRaw = dashParts.length === 2 ? (dashParts[1] ?? "") : undefined;
100
+ if (loRaw === "" || hiRaw === "")
101
+ throw fieldError(spec, `malformed range "${item}"`);
102
+ lo = parseValue(loRaw, spec);
103
+ if (hiRaw !== undefined) {
104
+ hi = parseValue(hiRaw, spec);
105
+ }
106
+ else if (hasStep) {
107
+ // Why: `N/step` means N-through-max by step (cronie/Quartz/AWS extension) — what
108
+ // contemporary cron authors expect; original Vixie simply errored here.
109
+ hi = spec.max;
110
+ }
111
+ else {
112
+ hi = lo;
113
+ }
114
+ if (lo > hi) {
115
+ throw fieldError(spec, `range ${String(lo)}-${String(hi)} is reversed in "${item}"`);
116
+ }
117
+ }
118
+ for (let v = lo; v <= hi; v += step) {
119
+ into.add(spec.normalize ? spec.normalize(v) : v);
120
+ }
121
+ }
122
+ function parseField(text, spec) {
123
+ const values = new Set();
124
+ for (const item of text.split(",")) {
125
+ if (item === "")
126
+ throw fieldError(spec, `empty list entry in "${text}"`);
127
+ parseItem(item, spec, values);
128
+ }
129
+ return values;
130
+ }
131
+ /**
132
+ * Parse a 5-field (min hour dom mon dow) or 6-field (sec min hour dom mon dow) cron
133
+ * expression. Throws `EngineError("VALIDATION", …)` naming the bad field, so deploy-time
134
+ * errors point the author at exactly what to fix in their `meta` trigger.
135
+ */
136
+ export function parseCron(expr) {
137
+ const fields = expr.trim().split(/\s+/);
138
+ // Why mirror the SDK's field-count check exactly: the manifest schema's only cron
139
+ // validation is "5 or 6 fields" — the two validators must agree on that boundary.
140
+ if (fields.length !== 5 && fields.length !== 6) {
141
+ throw new EngineError("VALIDATION", `cron: expression "${expr}" has ${String(fields.length)} field(s); ` +
142
+ `expected 5 (min hour dom mon dow) or 6 (sec min hour dom mon dow)`);
143
+ }
144
+ const six = fields.length === 6;
145
+ // The length check above guarantees every index below exists; `?? ""` (which parseField
146
+ // rejects) keeps the narrowing honest under noUncheckedIndexedAccess without a cast.
147
+ const field = (i) => fields[i] ?? "";
148
+ const domText = field(six ? 3 : 2);
149
+ const dowText = field(six ? 5 : 4);
150
+ return {
151
+ seconds: six ? sorted(parseField(field(0), SECOND)) : [0],
152
+ minutes: sorted(parseField(field(six ? 1 : 0), MINUTE)),
153
+ hours: sorted(parseField(field(six ? 2 : 1), HOUR)),
154
+ daysOfMonth: parseField(domText, DOM),
155
+ months: parseField(field(six ? 4 : 3), MONTH),
156
+ daysOfWeek: parseField(dowText, DOW),
157
+ domIsStar: domText.startsWith("*"),
158
+ dowIsStar: dowText.startsWith("*"),
159
+ };
160
+ }
161
+ const asc = (a, b) => a - b;
162
+ const sorted = (values) => [...values].sort(asc);
163
+ // ============================================================================
164
+ // Next-fire computation
165
+ // ============================================================================
166
+ const DAY_MS = 86_400_000;
167
+ // Why ~5 years: long enough that any schedule with a real fire (worst case: a dom/month
168
+ // combo that only exists in leap years) is found, short enough that an impossible schedule
169
+ // (Feb 30) returns null after a bounded, fast day scan instead of looping forever.
170
+ const HORIZON_DAYS = 366 * 5 + 7;
171
+ /**
172
+ * Why a cached formatter per timezone: constructing Intl.DateTimeFormat is the expensive
173
+ * part (locale + tz data resolution); formatToParts on a cached instance is cheap enough
174
+ * to call a handful of times per candidate fire.
175
+ */
176
+ const formatterCache = new Map();
177
+ function getFormatter(timezone) {
178
+ const cached = formatterCache.get(timezone);
179
+ if (cached)
180
+ return cached;
181
+ let fmt;
182
+ try {
183
+ fmt = new Intl.DateTimeFormat("en-US", {
184
+ timeZone: timezone,
185
+ // Why h23: guarantees hours 00-23 (h24 would render midnight as "24").
186
+ hourCycle: "h23",
187
+ year: "numeric",
188
+ month: "2-digit",
189
+ day: "2-digit",
190
+ hour: "2-digit",
191
+ minute: "2-digit",
192
+ second: "2-digit",
193
+ });
194
+ }
195
+ catch {
196
+ throw new EngineError("VALIDATION", `cron: unknown timezone "${timezone}"`, 'use an IANA zone name like "America/New_York" or "UTC"');
197
+ }
198
+ formatterCache.set(timezone, fmt);
199
+ return fmt;
200
+ }
201
+ /** Read the wall-clock reading of a UTC instant in the formatter's timezone. */
202
+ function wallTimeAt(fmt, epochMs) {
203
+ let year = 0;
204
+ let month = 0;
205
+ let day = 0;
206
+ let hour = 0;
207
+ let minute = 0;
208
+ let second = 0;
209
+ for (const part of fmt.formatToParts(epochMs)) {
210
+ switch (part.type) {
211
+ case "year":
212
+ year = Number.parseInt(part.value, 10);
213
+ break;
214
+ case "month":
215
+ month = Number.parseInt(part.value, 10);
216
+ break;
217
+ case "day":
218
+ day = Number.parseInt(part.value, 10);
219
+ break;
220
+ case "hour":
221
+ hour = Number.parseInt(part.value, 10);
222
+ break;
223
+ case "minute":
224
+ minute = Number.parseInt(part.value, 10);
225
+ break;
226
+ case "second":
227
+ second = Number.parseInt(part.value, 10);
228
+ break;
229
+ default:
230
+ break;
231
+ }
232
+ }
233
+ return { year, month, day, hour, minute, second };
234
+ }
235
+ /** UTC-offset of the zone at `epochMs`, in ms (positive east of UTC). */
236
+ function offsetAt(fmt, epochMs) {
237
+ const w = wallTimeAt(fmt, epochMs);
238
+ return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - epochMs;
239
+ }
240
+ /**
241
+ * All UTC instants (sorted ascending) at which the zone's wall clock reads exactly the given
242
+ * time: 0 instants in a spring-forward gap, 1 normally, 2 across a fall-back overlap.
243
+ *
244
+ * Why probe offsets a day either side of the naive guess: the zone's offset at the target
245
+ * instant is unknown until we pick an instant. Sampling the offset at guess±24h brackets any
246
+ * single transition (real-world DST shifts are well under 24h), and each candidate offset is
247
+ * then verified by reading the wall clock back — a stale or irrelevant offset simply fails
248
+ * verification, so over-probing is harmless.
249
+ */
250
+ function wallTimeToEpochs(fmt, target) {
251
+ const guess = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute, target.second);
252
+ const candidateOffsets = new Set([
253
+ offsetAt(fmt, guess - DAY_MS),
254
+ offsetAt(fmt, guess),
255
+ offsetAt(fmt, guess + DAY_MS),
256
+ ]);
257
+ const epochs = [];
258
+ for (const offset of candidateOffsets) {
259
+ const epoch = guess - offset;
260
+ const readBack = wallTimeAt(fmt, epoch);
261
+ if (readBack.year === target.year &&
262
+ readBack.month === target.month &&
263
+ readBack.day === target.day &&
264
+ readBack.hour === target.hour &&
265
+ readBack.minute === target.minute &&
266
+ readBack.second === target.second) {
267
+ epochs.push(epoch);
268
+ }
269
+ }
270
+ return epochs.sort(asc);
271
+ }
272
+ /** Vixie's day-match rule — see the module header for why the star flags (not set contents) decide. */
273
+ function dayMatches(schedule, year, month, day) {
274
+ const dow = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
275
+ const domHit = schedule.daysOfMonth.has(day);
276
+ const dowHit = schedule.daysOfWeek.has(dow);
277
+ return schedule.domIsStar || schedule.dowIsStar ? domHit && dowHit : domHit || dowHit;
278
+ }
279
+ /**
280
+ * The next fire time STRICTLY AFTER `afterMs`, computed in the given IANA timezone (default
281
+ * "UTC"), as epoch ms. Returns null when no fire occurs within the ~5-year search horizon
282
+ * (an impossible schedule like Feb 30). Throws `EngineError("VALIDATION", …)` on an invalid
283
+ * timezone.
284
+ *
285
+ * Why day-first enumeration instead of stepping minute-by-minute: a sparse schedule (e.g.
286
+ * `0 0 29 2 *`) would otherwise scan millions of minutes across years; scanning calendar
287
+ * days needs only ~1.8k cheap iterations, and Intl is consulted only on days that match.
288
+ */
289
+ export function nextFire(schedule, afterMs, timezone = "UTC") {
290
+ const fmt = getFormatter(timezone);
291
+ const start = wallTimeAt(fmt, afterMs);
292
+ // Why iterate wall dates via UTC date arithmetic: calendar succession (Jun 30 → Jul 1) is
293
+ // timezone-independent, so a UTC day cursor never needs Intl; noon keeps the cursor clear
294
+ // of any midnight-adjacent DST weirdness when adding 24h.
295
+ let dayCursor = Date.UTC(start.year, start.month - 1, start.day, 12);
296
+ for (let i = 0; i < HORIZON_DAYS; i++) {
297
+ const cursor = new Date(dayCursor);
298
+ const year = cursor.getUTCFullYear();
299
+ const month = cursor.getUTCMonth() + 1;
300
+ const day = cursor.getUTCDate();
301
+ if (schedule.months.has(month) && dayMatches(schedule, year, month, day)) {
302
+ // Why lower-bounding by wall time is safe on the first day: the earliest-occurrence
303
+ // wall→epoch mapping is monotonic, and the first occurrence of afterMs's own wall
304
+ // reading is ≤ afterMs — so earlier wall times can never yield an epoch > afterMs.
305
+ const first = i === 0;
306
+ for (const hour of schedule.hours) {
307
+ if (first && hour < start.hour)
308
+ continue;
309
+ for (const minute of schedule.minutes) {
310
+ if (first && hour === start.hour && minute < start.minute)
311
+ continue;
312
+ for (const second of schedule.seconds) {
313
+ if (first && hour === start.hour && minute === start.minute && second < start.second) {
314
+ continue;
315
+ }
316
+ const epochs = wallTimeToEpochs(fmt, { year, month, day, hour, minute, second });
317
+ // `epochs[0]` is undefined in a spring-forward gap (the wall time never occurs,
318
+ // so it never fires); otherwise it is the FIRST (earlier-UTC) occurrence, which
319
+ // makes a fall-back-repeated wall time fire exactly once. The strict `>` also
320
+ // enforces the STRICTLY-after contract.
321
+ const epoch = epochs[0];
322
+ if (epoch !== undefined && epoch > afterMs)
323
+ return epoch;
324
+ }
325
+ }
326
+ }
327
+ }
328
+ dayCursor += DAY_MS;
329
+ }
330
+ return null;
331
+ }
@@ -0,0 +1,106 @@
1
+ import type { JsonValue } from "@boardwalk-labs/workflow";
2
+ import type { InferenceConfig } from "./agent/resolve.js";
3
+ import type { Clock } from "./clock.js";
4
+ import { Store, type EventRow, type RunRow, type TriggerKind, type WorkflowRow } from "./store/store.js";
5
+ export interface EngineOptions {
6
+ /** Everything lives under here: `engine.db`, `runs/<id>/`. Created if missing. */
7
+ dataDir: string;
8
+ /** The local secret/env source (parsed .env contents). process.env is the fallback. */
9
+ env?: Record<string, string>;
10
+ /** Where secrets come from, for error hints (e.g. the .env path). Never values. */
11
+ envLabel?: string;
12
+ /** Default model + provider table for agent() leaves. */
13
+ inference?: InferenceConfig;
14
+ clock?: Clock;
15
+ /** Engine diagnostics (scheduler notices). Default: stderr. */
16
+ log?: (line: string) => void;
17
+ /** Crash restarts per run. Default 2. */
18
+ maxRestarts?: number;
19
+ /** Cooperative-cancellation window before SIGKILL. Default 10s. */
20
+ cancelGraceMs?: number;
21
+ /** Override the spawned run-process entry (tests point this at a built dist). */
22
+ childEntryPath?: string;
23
+ /** Scheduler loop cadence. Default 1s. */
24
+ tickIntervalMs?: number;
25
+ }
26
+ export interface AuthorizeMcpServerOptions {
27
+ /**
28
+ * Receives the authorization URL a HUMAN must open in a browser. The engine never opens a
29
+ * browser itself — the consumer (CLI prints it, a UI links it) owns that interaction.
30
+ */
31
+ onAuthorizationUrl: (url: string) => void;
32
+ /** How long to wait for the human to complete authorization. Default 5 minutes. */
33
+ timeoutMs?: number;
34
+ }
35
+ export interface DeployArgs {
36
+ /** The bundled workflow program (ESM, `@boardwalk-labs/workflow` external, pure-literal meta). */
37
+ program: string;
38
+ /** Engine-side deploy config (e.g. catch_up). Replaced wholesale on redeploy. */
39
+ config?: Record<string, JsonValue>;
40
+ /**
41
+ * Skill markdown deployed alongside the program, keyed by skill name (the CLI ships the
42
+ * project's skills/ dir this way). Every name declared in meta.skills must be present;
43
+ * replaced wholesale on redeploy.
44
+ */
45
+ skills?: Record<string, string>;
46
+ }
47
+ export declare class Engine {
48
+ readonly store: Store;
49
+ private readonly dataDir;
50
+ private readonly supervisor;
51
+ private readonly scheduler;
52
+ private started;
53
+ private closed;
54
+ constructor(opts: EngineOptions);
55
+ /**
56
+ * Server-mode boot: finalize/restart whatever a dead engine left behind, then run the cron
57
+ * loop. Embedded consumers never call this.
58
+ */
59
+ start(): {
60
+ resumed: string[];
61
+ cancelled: string[];
62
+ };
63
+ /** Stop scheduling, signal children (next boot's sweep recovers them), release the DB. */
64
+ close(): void;
65
+ /** Subscribe to every stamped run event (feeds SSE, the CLI renderer, the log UI). */
66
+ onEvent(listener: (row: EventRow) => void): () => void;
67
+ /**
68
+ * Deploy (or redeploy, by manifest name) a workflow from its bundled program source. The
69
+ * manifest is DERIVED from the program's pure-literal `meta` — the program file is the
70
+ * author's source of truth, manifest drift is impossible by construction.
71
+ */
72
+ deployWorkflow(args: DeployArgs): WorkflowRow;
73
+ /**
74
+ * Queue a run and dispatch it through the concurrency gate. Returns the queued row
75
+ * immediately; `waitForRun` for the terminal row.
76
+ */
77
+ startRun(workflowName: string, opts?: {
78
+ input?: JsonValue;
79
+ triggerKind?: TriggerKind;
80
+ }): RunRow;
81
+ /**
82
+ * One scheduler pass (fire due crons, dispatch queued runs through the concurrency gate).
83
+ * The background loop calls this every tick; expose it so embedders and tests can drive
84
+ * dispatch deterministically without the loop.
85
+ */
86
+ tick(): void;
87
+ /** Resolve when the run reaches a terminal status (idempotent; safe to call repeatedly). */
88
+ waitForRun(runId: string): Promise<RunRow>;
89
+ cancelRun(runId: string): Promise<void>;
90
+ /**
91
+ * The ONE-TIME interactive step for an OAuth-protected MCP server: discovery (RFC 9728 +
92
+ * 8414), dynamic client registration (RFC 7591), and the authorization-code + PKCE grant
93
+ * over a loopback redirect. Tokens land in `<dataDir>/mcp_tokens.json` (0600); after this,
94
+ * runs use the server headlessly (the engine refreshes silently). A headless run that needs
95
+ * authorization never prompts — it fails with a hint naming this method.
96
+ */
97
+ authorizeMcpServer(serverUrl: string, opts: AuthorizeMcpServerOptions): Promise<void>;
98
+ /**
99
+ * Embedded one-shot (the `boardwalk dev` path): deploy, run, await terminal. The workflow
100
+ * persists in the data dir like any other — dev reuses a throwaway dir per invocation.
101
+ */
102
+ runOnce(args: DeployArgs & {
103
+ input?: JsonValue;
104
+ }): Promise<RunRow>;
105
+ private assertOpen;
106
+ }
package/dist/engine.js ADDED
@@ -0,0 +1,183 @@
1
+ // The engine facade — the one object both consumers construct (SPEC §1):
2
+ // - SERVER mode: `start()` boots the recovery sweep + the cron scheduler loop and the
3
+ // process stays up (the HTTP surface sits on top of this object).
4
+ // - EMBEDDED mode: construct, `runOnce()`, `close()` — what `boardwalk dev` does. No
5
+ // scheduler loop, no recovery thread; one run, in-process supervision, exit.
6
+ //
7
+ // Layering: this file only wires store + supervisor + scheduler together and translates
8
+ // program source → manifest at the deploy boundary. No business logic lives here.
9
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { extractManifest } from "@boardwalk-labs/workflow/extract";
13
+ import { EngineError } from "./errors.js";
14
+ import { runAuthorizationFlow } from "./mcp/oauth.js";
15
+ import { McpTokenStore, MCP_TOKENS_FILENAME } from "./mcp/token_store.js";
16
+ import { Scheduler } from "./scheduler/scheduler.js";
17
+ import { Store, } from "./store/store.js";
18
+ import { isTerminal, RunSupervisor } from "./run/supervisor.js";
19
+ export class Engine {
20
+ store;
21
+ dataDir;
22
+ supervisor;
23
+ scheduler;
24
+ started = false;
25
+ closed = false;
26
+ constructor(opts) {
27
+ mkdirSync(opts.dataDir, { recursive: true });
28
+ this.dataDir = opts.dataDir;
29
+ this.store = new Store(join(opts.dataDir, "engine.db"));
30
+ this.supervisor = new RunSupervisor({
31
+ store: this.store,
32
+ dataDir: opts.dataDir,
33
+ childEntryPath: opts.childEntryPath ?? defaultChildEntryPath(),
34
+ env: new Map(Object.entries(opts.env ?? {})),
35
+ envLabel: opts.envLabel ?? "the engine environment",
36
+ ...(opts.clock !== undefined ? { clock: opts.clock } : {}),
37
+ ...(opts.maxRestarts !== undefined ? { maxRestarts: opts.maxRestarts } : {}),
38
+ ...(opts.cancelGraceMs !== undefined ? { cancelGraceMs: opts.cancelGraceMs } : {}),
39
+ ...(opts.inference !== undefined ? { inference: opts.inference } : {}),
40
+ });
41
+ this.scheduler = new Scheduler({
42
+ store: this.store,
43
+ dispatch: (runId) => void this.supervisor.supervise(runId),
44
+ emitQueued: (runId) => this.supervisor.emitQueued(runId),
45
+ ...(opts.clock !== undefined ? { clock: opts.clock } : {}),
46
+ ...(opts.log !== undefined ? { log: opts.log } : {}),
47
+ ...(opts.tickIntervalMs !== undefined ? { tickIntervalMs: opts.tickIntervalMs } : {}),
48
+ });
49
+ }
50
+ /**
51
+ * Server-mode boot: finalize/restart whatever a dead engine left behind, then run the cron
52
+ * loop. Embedded consumers never call this.
53
+ */
54
+ start() {
55
+ this.assertOpen();
56
+ if (this.started)
57
+ return { resumed: [], cancelled: [] };
58
+ this.started = true;
59
+ const swept = this.supervisor.recoverOnBoot();
60
+ this.scheduler.start();
61
+ return swept;
62
+ }
63
+ /** Stop scheduling, signal children (next boot's sweep recovers them), release the DB. */
64
+ close() {
65
+ if (this.closed)
66
+ return;
67
+ this.closed = true;
68
+ this.scheduler.stop();
69
+ this.supervisor.shutdown();
70
+ this.store.close();
71
+ }
72
+ /** Subscribe to every stamped run event (feeds SSE, the CLI renderer, the log UI). */
73
+ onEvent(listener) {
74
+ return this.supervisor.onEvent(listener);
75
+ }
76
+ /**
77
+ * Deploy (or redeploy, by manifest name) a workflow from its bundled program source. The
78
+ * manifest is DERIVED from the program's pure-literal `meta` — the program file is the
79
+ * author's source of truth, manifest drift is impossible by construction.
80
+ */
81
+ deployWorkflow(args) {
82
+ this.assertOpen();
83
+ const manifest = extractManifest(args.program, { fileName: "index.mjs" });
84
+ const workflow = this.store.upsertWorkflow({
85
+ name: manifest.name,
86
+ manifest,
87
+ program: args.program,
88
+ ...(args.config !== undefined ? { config: args.config } : {}),
89
+ });
90
+ // Skills are deploy artifacts, replaced wholesale: stale files from a previous deploy must
91
+ // not survive a redeploy that dropped them. (Skills are per-agent — no manifest field —
92
+ // so an agent() selecting an undeployed skill fails at call time, not here.)
93
+ const skills = Object.entries(args.skills ?? {});
94
+ const skillsDir = join(this.dataDir, "skills", workflow.id);
95
+ rmSync(skillsDir, { recursive: true, force: true });
96
+ if (skills.length > 0) {
97
+ mkdirSync(skillsDir, { recursive: true });
98
+ for (const [name, markdown] of skills) {
99
+ writeFileSync(join(skillsDir, `${name}.md`), markdown, "utf8");
100
+ }
101
+ }
102
+ return workflow;
103
+ }
104
+ /**
105
+ * Queue a run and dispatch it through the concurrency gate. Returns the queued row
106
+ * immediately; `waitForRun` for the terminal row.
107
+ */
108
+ startRun(workflowName, opts = {}) {
109
+ this.assertOpen();
110
+ const workflow = this.store.getWorkflow(workflowName);
111
+ if (workflow === null) {
112
+ throw new EngineError("NOT_FOUND", `Workflow "${workflowName}" is not deployed on this engine.`);
113
+ }
114
+ const { run } = this.store.createRun({
115
+ workflowId: workflow.id,
116
+ triggerKind: opts.triggerKind ?? "manual",
117
+ ...(opts.input !== undefined ? { input: opts.input } : {}),
118
+ });
119
+ this.supervisor.emitQueued(run.id);
120
+ // Why a synchronous tick: run-now should not wait for the next loop iteration, and manual
121
+ // runs go through the same dispatch gate as cron fires so concurrency modes hold uniformly.
122
+ this.scheduler.tick();
123
+ return run;
124
+ }
125
+ /**
126
+ * One scheduler pass (fire due crons, dispatch queued runs through the concurrency gate).
127
+ * The background loop calls this every tick; expose it so embedders and tests can drive
128
+ * dispatch deterministically without the loop.
129
+ */
130
+ tick() {
131
+ this.assertOpen();
132
+ this.scheduler.tick();
133
+ }
134
+ /** Resolve when the run reaches a terminal status (idempotent; safe to call repeatedly). */
135
+ waitForRun(runId) {
136
+ this.assertOpen();
137
+ const run = this.store.getRun(runId);
138
+ if (run === null)
139
+ throw new EngineError("NOT_FOUND", `Unknown run: ${runId}`);
140
+ if (isTerminal(run.status))
141
+ return Promise.resolve(run);
142
+ return this.supervisor.supervise(runId);
143
+ }
144
+ cancelRun(runId) {
145
+ this.assertOpen();
146
+ return this.supervisor.cancel(runId);
147
+ }
148
+ /**
149
+ * The ONE-TIME interactive step for an OAuth-protected MCP server: discovery (RFC 9728 +
150
+ * 8414), dynamic client registration (RFC 7591), and the authorization-code + PKCE grant
151
+ * over a loopback redirect. Tokens land in `<dataDir>/mcp_tokens.json` (0600); after this,
152
+ * runs use the server headlessly (the engine refreshes silently). A headless run that needs
153
+ * authorization never prompts — it fails with a hint naming this method.
154
+ */
155
+ async authorizeMcpServer(serverUrl, opts) {
156
+ this.assertOpen();
157
+ await runAuthorizationFlow({
158
+ serverUrl,
159
+ store: new McpTokenStore(join(this.dataDir, MCP_TOKENS_FILENAME)),
160
+ onAuthorizationUrl: opts.onAuthorizationUrl,
161
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
162
+ });
163
+ }
164
+ /**
165
+ * Embedded one-shot (the `boardwalk dev` path): deploy, run, await terminal. The workflow
166
+ * persists in the data dir like any other — dev reuses a throwaway dir per invocation.
167
+ */
168
+ async runOnce(args) {
169
+ const workflow = this.deployWorkflow(args);
170
+ const run = this.startRun(workflow.name, {
171
+ ...(args.input !== undefined ? { input: args.input } : {}),
172
+ });
173
+ return await this.waitForRun(run.id);
174
+ }
175
+ assertOpen() {
176
+ if (this.closed)
177
+ throw new EngineError("INTERNAL", "This engine has been closed.");
178
+ }
179
+ }
180
+ /** The compiled child entry that ships next to this module in dist/. */
181
+ function defaultChildEntryPath() {
182
+ return fileURLToPath(new URL("./run/child.js", import.meta.url));
183
+ }
@@ -0,0 +1,15 @@
1
+ export declare const ENGINE_ERROR_CODES: readonly ["VALIDATION", "NOT_FOUND", "CONFLICT", "SECRET_MISSING", "SECRET_UNDECLARED", "MODEL_UNRESOLVED", "PROVIDER_ERROR", "BUDGET_EXCEEDED", "PROGRAM_ERROR", "CRASHED", "CANCELLED", "UNSUPPORTED", "INTERNAL"];
2
+ export type EngineErrorCode = (typeof ENGINE_ERROR_CODES)[number];
3
+ /** Narrow a string (e.g. an error code off the IPC wire) to a known engine code. */
4
+ export declare function isEngineErrorCode(code: string): code is EngineErrorCode;
5
+ export declare class EngineError extends Error {
6
+ readonly code: EngineErrorCode;
7
+ /** A one-line pointer at the fix (file to edit, config to set) — safe to show anywhere. */
8
+ readonly hint: string | undefined;
9
+ constructor(code: EngineErrorCode, message: string, hint?: string);
10
+ }
11
+ /** Narrow an unknown thrown value to a safe { code, message } for events/API responses. */
12
+ export declare function toErrorShape(err: unknown): {
13
+ code: string;
14
+ message: string;
15
+ };
package/dist/errors.js ADDED
@@ -0,0 +1,40 @@
1
+ // Engine errors carry a stable machine-readable code (surfaced in run_status `failed` events
2
+ // and API responses) plus an actionable message. Messages NEVER contain secret values.
3
+ export const ENGINE_ERROR_CODES = [
4
+ "VALIDATION", // bad manifest/config/input at a trust boundary
5
+ "NOT_FOUND", // unknown workflow/run
6
+ "CONFLICT", // duplicate name, concurrent state conflict
7
+ "SECRET_MISSING", // declared secret has no value in this environment
8
+ "SECRET_UNDECLARED", // program read a secret not in meta.secrets
9
+ "MODEL_UNRESOLVED", // agent() with no model and no configured default
10
+ "PROVIDER_ERROR", // upstream inference provider failure
11
+ "BUDGET_EXCEEDED", // run terminated by budget.*
12
+ "PROGRAM_ERROR", // the workflow program threw
13
+ "CRASHED", // run process died and restarts were exhausted
14
+ "CANCELLED",
15
+ "UNSUPPORTED", // capability not present on this engine (MASTER_SPEC §4)
16
+ "INTERNAL",
17
+ ];
18
+ /** Narrow a string (e.g. an error code off the IPC wire) to a known engine code. */
19
+ export function isEngineErrorCode(code) {
20
+ return ENGINE_ERROR_CODES.some((c) => c === code);
21
+ }
22
+ export class EngineError extends Error {
23
+ code;
24
+ /** A one-line pointer at the fix (file to edit, config to set) — safe to show anywhere. */
25
+ hint;
26
+ constructor(code, message, hint) {
27
+ super(message);
28
+ this.name = "EngineError";
29
+ this.code = code;
30
+ this.hint = hint;
31
+ }
32
+ }
33
+ /** Narrow an unknown thrown value to a safe { code, message } for events/API responses. */
34
+ export function toErrorShape(err) {
35
+ if (err instanceof EngineError)
36
+ return { code: err.code, message: err.message };
37
+ if (err instanceof Error)
38
+ return { code: "PROGRAM_ERROR", message: err.message };
39
+ return { code: "PROGRAM_ERROR", message: String(err) };
40
+ }