@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.
- package/LICENSE +202 -0
- package/README.md +69 -0
- package/bin/boardwalk-server.js +16 -0
- package/dist/agent/conversation.d.ts +42 -0
- package/dist/agent/conversation.js +4 -0
- package/dist/agent/leaf.d.ts +81 -0
- package/dist/agent/leaf.js +190 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +347 -0
- package/dist/agent/rates.d.ts +13 -0
- package/dist/agent/rates.js +35 -0
- package/dist/agent/redact.d.ts +9 -0
- package/dist/agent/redact.js +27 -0
- package/dist/agent/resolve.d.ts +58 -0
- package/dist/agent/resolve.js +153 -0
- package/dist/agent/sse.d.ts +2 -0
- package/dist/agent/sse.js +30 -0
- package/dist/agent/tools.d.ts +57 -0
- package/dist/agent/tools.js +324 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +32 -0
- package/dist/cron/cron.d.ts +34 -0
- package/dist/cron/cron.js +331 -0
- package/dist/engine.d.ts +106 -0
- package/dist/engine.js +183 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +40 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/json_value.d.ts +7 -0
- package/dist/json_value.js +29 -0
- package/dist/mcp/client.d.ts +39 -0
- package/dist/mcp/client.js +112 -0
- package/dist/mcp/jsonrpc.d.ts +57 -0
- package/dist/mcp/jsonrpc.js +117 -0
- package/dist/mcp/oauth.d.ts +72 -0
- package/dist/mcp/oauth.js +337 -0
- package/dist/mcp/token_store.d.ts +30 -0
- package/dist/mcp/token_store.js +101 -0
- package/dist/mcp/transport_http.d.ts +38 -0
- package/dist/mcp/transport_http.js +143 -0
- package/dist/mcp/transport_stdio.d.ts +27 -0
- package/dist/mcp/transport_stdio.js +94 -0
- package/dist/run/child.d.ts +1 -0
- package/dist/run/child.js +139 -0
- package/dist/run/child_host.d.ts +26 -0
- package/dist/run/child_host.js +124 -0
- package/dist/run/idempotency.d.ts +5 -0
- package/dist/run/idempotency.js +31 -0
- package/dist/run/ipc.d.ts +159 -0
- package/dist/run/ipc.js +150 -0
- package/dist/run/run_dir.d.ts +31 -0
- package/dist/run/run_dir.js +106 -0
- package/dist/run/supervisor.d.ts +107 -0
- package/dist/run/supervisor.js +676 -0
- package/dist/scheduler/scheduler.d.ts +54 -0
- package/dist/scheduler/scheduler.js +215 -0
- package/dist/server/http.d.ts +42 -0
- package/dist/server/http.js +183 -0
- package/dist/server/routes/api.d.ts +17 -0
- package/dist/server/routes/api.js +107 -0
- package/dist/server/routes/hooks.d.ts +2 -0
- package/dist/server/routes/hooks.js +88 -0
- package/dist/server/routes/router.d.ts +15 -0
- package/dist/server/routes/router.js +75 -0
- package/dist/server/routes/stream.d.ts +2 -0
- package/dist/server/routes/stream.js +79 -0
- package/dist/server/routes/ui.d.ts +2 -0
- package/dist/server/routes/ui.js +120 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.js +67 -0
- package/dist/server_main.d.ts +46 -0
- package/dist/server_main.js +203 -0
- package/dist/store/migrations.d.ts +21 -0
- package/dist/store/migrations.js +159 -0
- package/dist/store/store.d.ts +194 -0
- package/dist/store/store.js +567 -0
- package/package.json +57 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Clock } from "../clock.js";
|
|
2
|
+
import type { Store } from "../store/store.js";
|
|
3
|
+
export interface SchedulerOptions {
|
|
4
|
+
store: Store;
|
|
5
|
+
/** Start executing a queued run (the engine wires supervisor.supervise here). */
|
|
6
|
+
dispatch: (runId: string) => void;
|
|
7
|
+
/** Emit the `queued` lifecycle event for a run this scheduler created. */
|
|
8
|
+
emitQueued: (runId: string) => void;
|
|
9
|
+
clock?: Clock;
|
|
10
|
+
/** Engine diagnostics (catch-up notices, tick overruns). Default: console.error. */
|
|
11
|
+
log?: (line: string) => void;
|
|
12
|
+
/** Tick cadence of the background loop. Default 1s. */
|
|
13
|
+
tickIntervalMs?: number;
|
|
14
|
+
/** Ticks slower than this are logged (CODE_QUALITY §4.2). Default 250ms. */
|
|
15
|
+
tickBudgetMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class Scheduler {
|
|
18
|
+
private readonly store;
|
|
19
|
+
private readonly dispatch;
|
|
20
|
+
private readonly emitQueued;
|
|
21
|
+
private readonly clock;
|
|
22
|
+
private readonly log;
|
|
23
|
+
private readonly tickIntervalMs;
|
|
24
|
+
private readonly tickBudgetMs;
|
|
25
|
+
/** Per (workflow, trigger) fire anchor: the last fire time we are PAST (epoch ms). */
|
|
26
|
+
private readonly anchors;
|
|
27
|
+
private readonly scheduleCache;
|
|
28
|
+
/** Runs handed to `dispatch` that haven't left `queued` yet — prevents re-dispatch spam. */
|
|
29
|
+
private readonly handedOut;
|
|
30
|
+
private stopController;
|
|
31
|
+
constructor(opts: SchedulerOptions);
|
|
32
|
+
/** Start the background loop. Idempotent; `stop()` to end it. */
|
|
33
|
+
start(): void;
|
|
34
|
+
stop(): void;
|
|
35
|
+
private loop;
|
|
36
|
+
/**
|
|
37
|
+
* One scheduler pass: fire every due cron trigger, then dispatch queued runs through the
|
|
38
|
+
* concurrency gate. Public so tests (and the engine's boot path) can drive it directly.
|
|
39
|
+
*/
|
|
40
|
+
tick(): void;
|
|
41
|
+
private fireDueTriggers;
|
|
42
|
+
/** Create the run + the fire record atomically; a duplicate fire is impossible by schema. */
|
|
43
|
+
private fireOnce;
|
|
44
|
+
/**
|
|
45
|
+
* The anchor for a trigger, establishing the catch-up policy on first sight: a trigger with
|
|
46
|
+
* fire history starts from its last recorded fire — missed fires in between are counted and
|
|
47
|
+
* either skipped (default, with a notice) or coalesced into ONE immediate run
|
|
48
|
+
* (`catch_up: "once"`). A trigger never seen before starts from now (no retroactive fires).
|
|
49
|
+
*/
|
|
50
|
+
private ensureAnchor;
|
|
51
|
+
private dispatchQueued;
|
|
52
|
+
private groupHasActiveRun;
|
|
53
|
+
private schedule;
|
|
54
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The cron scheduler (SPEC §2.1). Layering: it fires runs; it knows nothing about what a
|
|
2
|
+
// workflow does (CODE_QUALITY §7.2). Execution is injected (`dispatch`) so this module never
|
|
3
|
+
// touches processes, and time is injected (`Clock`) so tests drive it deterministically.
|
|
4
|
+
//
|
|
5
|
+
// Correctness rules implemented here:
|
|
6
|
+
// - A due fire happens exactly once: the fire record and the run row are written in ONE
|
|
7
|
+
// store transaction, keyed (workflow, trigger index, fire time) — a crash between tick
|
|
8
|
+
// and dispatch leaves a queued run the boot sweep picks up, never a duplicate.
|
|
9
|
+
// - Catch-up on restart: missed fires are detected from the persisted fire history and
|
|
10
|
+
// SKIPPED with a logged notice by default; deploy config `catch_up: "once"` runs a single
|
|
11
|
+
// run for any number of missed fires (the manifest has no catch_up field — this is
|
|
12
|
+
// engine-operational policy, so it lives in the engine's deploy-time config).
|
|
13
|
+
// Never silent, never a thundering herd.
|
|
14
|
+
// - Concurrency modes gate DISPATCH, not queueing: `serial` / `serial_by_key` hold queued
|
|
15
|
+
// runs until the group's active run reaches a terminal status.
|
|
16
|
+
import { systemClock } from "../clock.js";
|
|
17
|
+
import { nextFire, parseCron } from "../cron/cron.js";
|
|
18
|
+
/** How many missed fires we enumerate before giving up counting (the notice says "≥"). */
|
|
19
|
+
const MISSED_SCAN_CAP = 10_000;
|
|
20
|
+
const ACTIVE_STATUSES = ["pending", "running", "cancelling"];
|
|
21
|
+
export class Scheduler {
|
|
22
|
+
store;
|
|
23
|
+
dispatch;
|
|
24
|
+
emitQueued;
|
|
25
|
+
clock;
|
|
26
|
+
log;
|
|
27
|
+
tickIntervalMs;
|
|
28
|
+
tickBudgetMs;
|
|
29
|
+
/** Per (workflow, trigger) fire anchor: the last fire time we are PAST (epoch ms). */
|
|
30
|
+
anchors = new Map();
|
|
31
|
+
scheduleCache = new Map();
|
|
32
|
+
/** Runs handed to `dispatch` that haven't left `queued` yet — prevents re-dispatch spam. */
|
|
33
|
+
handedOut = new Set();
|
|
34
|
+
stopController = null;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
this.store = opts.store;
|
|
37
|
+
this.dispatch = opts.dispatch;
|
|
38
|
+
this.emitQueued = opts.emitQueued;
|
|
39
|
+
this.clock = opts.clock ?? systemClock;
|
|
40
|
+
this.log = opts.log ?? ((line) => console.error(line));
|
|
41
|
+
this.tickIntervalMs = opts.tickIntervalMs ?? 1000;
|
|
42
|
+
this.tickBudgetMs = opts.tickBudgetMs ?? 250;
|
|
43
|
+
}
|
|
44
|
+
/** Start the background loop. Idempotent; `stop()` to end it. */
|
|
45
|
+
start() {
|
|
46
|
+
if (this.stopController !== null)
|
|
47
|
+
return;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
this.stopController = controller;
|
|
50
|
+
void this.loop(controller.signal);
|
|
51
|
+
}
|
|
52
|
+
stop() {
|
|
53
|
+
this.stopController?.abort();
|
|
54
|
+
this.stopController = null;
|
|
55
|
+
}
|
|
56
|
+
async loop(signal) {
|
|
57
|
+
while (!signal.aborted) {
|
|
58
|
+
this.tick();
|
|
59
|
+
try {
|
|
60
|
+
await this.clock.sleep(this.tickIntervalMs, signal);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return; // aborted — clean stop
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One scheduler pass: fire every due cron trigger, then dispatch queued runs through the
|
|
69
|
+
* concurrency gate. Public so tests (and the engine's boot path) can drive it directly.
|
|
70
|
+
*/
|
|
71
|
+
tick() {
|
|
72
|
+
const started = this.clock.now();
|
|
73
|
+
for (const workflow of this.store.listWorkflows()) {
|
|
74
|
+
this.fireDueTriggers(workflow);
|
|
75
|
+
}
|
|
76
|
+
this.dispatchQueued();
|
|
77
|
+
const elapsed = this.clock.now() - started;
|
|
78
|
+
if (elapsed > this.tickBudgetMs) {
|
|
79
|
+
this.log(`[scheduler] tick took ${String(elapsed)}ms (budget ${String(this.tickBudgetMs)}ms)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// --------------------------------------------------------------------------
|
|
83
|
+
// Firing
|
|
84
|
+
// --------------------------------------------------------------------------
|
|
85
|
+
fireDueTriggers(workflow) {
|
|
86
|
+
const now = this.clock.now();
|
|
87
|
+
workflow.manifest.triggers.forEach((trigger, index) => {
|
|
88
|
+
if (trigger.kind !== "cron")
|
|
89
|
+
return;
|
|
90
|
+
const schedule = this.schedule(trigger.expr, trigger.timezone);
|
|
91
|
+
let anchor = this.ensureAnchor(workflow, index, schedule, trigger.timezone, now);
|
|
92
|
+
let due = nextFire(schedule, anchor, trigger.timezone);
|
|
93
|
+
while (due !== null && due <= now) {
|
|
94
|
+
this.fireOnce(workflow, index, due);
|
|
95
|
+
anchor = due;
|
|
96
|
+
due = nextFire(schedule, anchor, trigger.timezone);
|
|
97
|
+
}
|
|
98
|
+
this.anchors.set(anchorKey(workflow.id, index), anchor);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Create the run + the fire record atomically; a duplicate fire is impossible by schema. */
|
|
102
|
+
fireOnce(workflow, triggerIndex, fireTime) {
|
|
103
|
+
const runId = this.store.transaction(() => {
|
|
104
|
+
const { run } = this.store.createRun({ workflowId: workflow.id, triggerKind: "cron" });
|
|
105
|
+
this.store.recordCronFire({ workflowId: workflow.id, triggerIndex, fireTime, runId: run.id });
|
|
106
|
+
return run.id;
|
|
107
|
+
});
|
|
108
|
+
this.emitQueued(runId);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The anchor for a trigger, establishing the catch-up policy on first sight: a trigger with
|
|
112
|
+
* fire history starts from its last recorded fire — missed fires in between are counted and
|
|
113
|
+
* either skipped (default, with a notice) or coalesced into ONE immediate run
|
|
114
|
+
* (`catch_up: "once"`). A trigger never seen before starts from now (no retroactive fires).
|
|
115
|
+
*/
|
|
116
|
+
ensureAnchor(workflow, triggerIndex, schedule, timezone, now) {
|
|
117
|
+
const key = anchorKey(workflow.id, triggerIndex);
|
|
118
|
+
const existing = this.anchors.get(key);
|
|
119
|
+
if (existing !== undefined)
|
|
120
|
+
return existing;
|
|
121
|
+
const lastFire = this.store.lastCronFire(workflow.id, triggerIndex);
|
|
122
|
+
if (lastFire === null) {
|
|
123
|
+
this.anchors.set(key, now);
|
|
124
|
+
return now;
|
|
125
|
+
}
|
|
126
|
+
// Count what was missed while no engine was running.
|
|
127
|
+
let missedCount = 0;
|
|
128
|
+
let latestMissed = null;
|
|
129
|
+
let cursor = lastFire;
|
|
130
|
+
while (missedCount < MISSED_SCAN_CAP) {
|
|
131
|
+
const next = nextFire(schedule, cursor, timezone);
|
|
132
|
+
if (next === null || next > now)
|
|
133
|
+
break;
|
|
134
|
+
missedCount += 1;
|
|
135
|
+
latestMissed = next;
|
|
136
|
+
cursor = next;
|
|
137
|
+
}
|
|
138
|
+
let anchor = lastFire;
|
|
139
|
+
if (latestMissed !== null) {
|
|
140
|
+
anchor = latestMissed;
|
|
141
|
+
const mode = catchUpMode(workflow);
|
|
142
|
+
const counted = missedCount >= MISSED_SCAN_CAP ? `≥${String(missedCount)}` : String(missedCount);
|
|
143
|
+
if (mode === "once") {
|
|
144
|
+
this.fireOnce(workflow, triggerIndex, latestMissed);
|
|
145
|
+
this.log(`[scheduler] ${workflow.name}: ${counted} missed cron fire(s) while the engine was down; ran once (catch_up: "once").`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.log(`[scheduler] ${workflow.name}: skipped ${counted} missed cron fire(s) while the engine was down (catch_up: "skip").`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.anchors.set(key, anchor);
|
|
152
|
+
return anchor;
|
|
153
|
+
}
|
|
154
|
+
// --------------------------------------------------------------------------
|
|
155
|
+
// Dispatch (the concurrency gate)
|
|
156
|
+
// --------------------------------------------------------------------------
|
|
157
|
+
dispatchQueued() {
|
|
158
|
+
const queued = this.store.listRuns({ statuses: ["queued"] });
|
|
159
|
+
// Prune the handed-out set: anything no longer queued has been picked up (or finished).
|
|
160
|
+
const queuedIds = new Set(queued.map((r) => r.id));
|
|
161
|
+
for (const id of this.handedOut) {
|
|
162
|
+
if (!queuedIds.has(id))
|
|
163
|
+
this.handedOut.delete(id);
|
|
164
|
+
}
|
|
165
|
+
// Oldest first — listRuns returns newest first.
|
|
166
|
+
queued.reverse();
|
|
167
|
+
const groupsDispatchedNow = new Set();
|
|
168
|
+
for (const run of queued) {
|
|
169
|
+
if (this.handedOut.has(run.id))
|
|
170
|
+
continue;
|
|
171
|
+
const workflow = this.store.getWorkflowById(run.workflowId);
|
|
172
|
+
if (workflow === null)
|
|
173
|
+
continue;
|
|
174
|
+
const group = concurrencyGroup(workflow);
|
|
175
|
+
if (group !== null) {
|
|
176
|
+
if (groupsDispatchedNow.has(group))
|
|
177
|
+
continue;
|
|
178
|
+
if (this.groupHasActiveRun(workflow, run))
|
|
179
|
+
continue;
|
|
180
|
+
groupsDispatchedNow.add(group);
|
|
181
|
+
}
|
|
182
|
+
this.handedOut.add(run.id);
|
|
183
|
+
this.dispatch(run.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
groupHasActiveRun(workflow, _candidate) {
|
|
187
|
+
// serial_by_key's key is a static manifest string (SDK schema), so within one workflow it
|
|
188
|
+
// gates exactly like serial: any active run of the workflow blocks dispatch.
|
|
189
|
+
return this.store.listRuns({ workflowId: workflow.id, statuses: ACTIVE_STATUSES }).length > 0;
|
|
190
|
+
}
|
|
191
|
+
schedule(expr, timezone) {
|
|
192
|
+
const key = `${expr}${timezone ?? ""}`;
|
|
193
|
+
let parsed = this.scheduleCache.get(key);
|
|
194
|
+
if (parsed === undefined) {
|
|
195
|
+
parsed = parseCron(expr);
|
|
196
|
+
this.scheduleCache.set(key, parsed);
|
|
197
|
+
}
|
|
198
|
+
return parsed;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function anchorKey(workflowId, triggerIndex) {
|
|
202
|
+
return `${workflowId}#${String(triggerIndex)}`;
|
|
203
|
+
}
|
|
204
|
+
function catchUpMode(workflow) {
|
|
205
|
+
return workflow.config.catch_up === "once" ? "once" : "skip";
|
|
206
|
+
}
|
|
207
|
+
/** The dispatch-gate group for a workflow, or null for unlimited concurrency. */
|
|
208
|
+
function concurrencyGroup(workflow) {
|
|
209
|
+
const concurrency = workflow.manifest.concurrency;
|
|
210
|
+
if (concurrency === undefined || concurrency.mode === "unlimited")
|
|
211
|
+
return null;
|
|
212
|
+
if (concurrency.mode === "serial")
|
|
213
|
+
return workflow.id;
|
|
214
|
+
return `${workflow.id}:${concurrency.key}`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Channel, JsonValue } from "@boardwalk-labs/workflow";
|
|
4
|
+
/** Bodies above this reject with 413 — nothing on this surface needs more than 1 MiB. */
|
|
5
|
+
export declare const MAX_BODY_BYTES: number;
|
|
6
|
+
/**
|
|
7
|
+
* An error that already knows its HTTP response. Routes throw these (or EngineErrors) and the
|
|
8
|
+
* top-level dispatcher renders them, so every endpoint shares one wire shape:
|
|
9
|
+
* `{ error: { code, message, hint? } }`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class HttpError extends Error {
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readonly code: string;
|
|
14
|
+
/** A one-line pointer at the fix (env var to set, valid values) — safe to show anywhere. */
|
|
15
|
+
readonly hint: string | undefined;
|
|
16
|
+
constructor(status: number, code: string, message: string, hint?: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* JSON values as a Zod schema, for narrowing parsed request bodies. The store keeps its own
|
|
20
|
+
* private copy — two private copies beat widening the store's public surface for one schema.
|
|
21
|
+
*/
|
|
22
|
+
export declare const jsonValueSchema: z.ZodType<JsonValue>;
|
|
23
|
+
/** Serialize `payload` and finish the response. The one place response JSON is written. */
|
|
24
|
+
export declare function sendJson(res: ServerResponse, status: number, payload: object, headers?: Record<string, string>): void;
|
|
25
|
+
/**
|
|
26
|
+
* Render any thrown value as the JSON error contract. `err` is `unknown` because this sits
|
|
27
|
+
* behind a catch boundary — the one place that type is unavoidable; it is narrowed immediately
|
|
28
|
+
* via instanceof. Internals (stacks, unexpected messages) go to `log`, never to the client.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sendError(res: ServerResponse, err: unknown, log: (line: string) => void): void;
|
|
31
|
+
/** Buffer a request body, rejecting 413 as soon as it exceeds `limitBytes`. */
|
|
32
|
+
export declare function readBody(req: IncomingMessage, limitBytes: number): Promise<Buffer>;
|
|
33
|
+
/** Parse a body as JSON and narrow it with `schema` — the trust boundary for HTTP input. */
|
|
34
|
+
export declare function parseJsonBody<T>(raw: Buffer, schema: z.ZodType<T>, what: string): T;
|
|
35
|
+
/** Parse `?name=<n>` as a non-negative integer, falling back when the parameter is absent. */
|
|
36
|
+
export declare function parseNonNegativeInt(url: URL, name: string, fallback: number): number;
|
|
37
|
+
/**
|
|
38
|
+
* The channel subscription for an event read (`/events` and `/stream` share this so a tail and
|
|
39
|
+
* its catch-up reads can never disagree): `?verbose=true` = everything, `?channels=a,b` = an
|
|
40
|
+
* explicit set, neither = MASTER_SPEC §2.5's default of lifecycle + phase + output.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseChannelSelection(url: URL): readonly Channel[];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Shared HTTP plumbing for the engine server: the error type every route throws, JSON
|
|
2
|
+
// request/response helpers, and query/body parsing. Bare node:http is deliberate (no
|
|
3
|
+
// third-party HTTP framework anywhere in the Boardwalk stack) — these helpers are the entire
|
|
4
|
+
// "framework", so every trust boundary (bodies, query params, headers) is narrowed with Zod
|
|
5
|
+
// or a type predicate before any engine call sees the data.
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { CHANNELS, DEFAULT_CHANNELS } from "@boardwalk-labs/workflow";
|
|
8
|
+
import { EngineError } from "../errors.js";
|
|
9
|
+
/** Bodies above this reject with 413 — nothing on this surface needs more than 1 MiB. */
|
|
10
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* An error that already knows its HTTP response. Routes throw these (or EngineErrors) and the
|
|
13
|
+
* top-level dispatcher renders them, so every endpoint shares one wire shape:
|
|
14
|
+
* `{ error: { code, message, hint? } }`.
|
|
15
|
+
*/
|
|
16
|
+
export class HttpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
code;
|
|
19
|
+
/** A one-line pointer at the fix (env var to set, valid values) — safe to show anywhere. */
|
|
20
|
+
hint;
|
|
21
|
+
constructor(status, code, message, hint) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "HttpError";
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.hint = hint;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* JSON values as a Zod schema, for narrowing parsed request bodies. The store keeps its own
|
|
31
|
+
* private copy — two private copies beat widening the store's public surface for one schema.
|
|
32
|
+
*/
|
|
33
|
+
export const jsonValueSchema = z.lazy(() => z.union([
|
|
34
|
+
z.string(),
|
|
35
|
+
z.number(),
|
|
36
|
+
z.boolean(),
|
|
37
|
+
z.null(),
|
|
38
|
+
z.array(jsonValueSchema),
|
|
39
|
+
z.record(z.string(), jsonValueSchema),
|
|
40
|
+
]));
|
|
41
|
+
/** Serialize `payload` and finish the response. The one place response JSON is written. */
|
|
42
|
+
export function sendJson(res, status, payload, headers = {}) {
|
|
43
|
+
// Why stringify before writeHead: a serialization failure must not escape after the status
|
|
44
|
+
// line is on the wire, where the JSON error contract is unreachable.
|
|
45
|
+
const body = JSON.stringify(payload);
|
|
46
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
|
|
47
|
+
res.end(body);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render any thrown value as the JSON error contract. `err` is `unknown` because this sits
|
|
51
|
+
* behind a catch boundary — the one place that type is unavoidable; it is narrowed immediately
|
|
52
|
+
* via instanceof. Internals (stacks, unexpected messages) go to `log`, never to the client.
|
|
53
|
+
*/
|
|
54
|
+
export function sendError(res, err, log) {
|
|
55
|
+
if (res.headersSent || res.destroyed) {
|
|
56
|
+
// Mid-stream failure (e.g. SSE): the JSON contract is unreachable; drop the connection.
|
|
57
|
+
res.destroy();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (err instanceof HttpError) {
|
|
61
|
+
// Why connection: close on 413: the client may still be sending the oversized body; close
|
|
62
|
+
// tells it to stop instead of stalling the kept-alive socket on unread data.
|
|
63
|
+
const headers = err.status === 413 ? { connection: "close" } : {};
|
|
64
|
+
sendJson(res, err.status, { error: { code: err.code, message: err.message, hint: err.hint } }, headers);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (err instanceof EngineError) {
|
|
68
|
+
const status = engineErrorStatus(err);
|
|
69
|
+
if (status !== null) {
|
|
70
|
+
sendJson(res, status, { error: { code: err.code, message: err.message, hint: err.hint } });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
log(`internal error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
75
|
+
sendJson(res, 500, { error: { code: "INTERNAL", message: "Internal server error." } });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The HTTP status an EngineError code implies, or null for codes that must render as an
|
|
79
|
+
* opaque 500 (anything unexpected at this surface could leak engine internals).
|
|
80
|
+
*/
|
|
81
|
+
function engineErrorStatus(err) {
|
|
82
|
+
switch (err.code) {
|
|
83
|
+
case "NOT_FOUND":
|
|
84
|
+
return 404;
|
|
85
|
+
case "VALIDATION":
|
|
86
|
+
return 400;
|
|
87
|
+
case "CONFLICT":
|
|
88
|
+
return 409;
|
|
89
|
+
case "UNSUPPORTED":
|
|
90
|
+
return 400;
|
|
91
|
+
default:
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Buffer a request body, rejecting 413 as soon as it exceeds `limitBytes`. */
|
|
96
|
+
export function readBody(req, limitBytes) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let received = 0;
|
|
100
|
+
let settled = false;
|
|
101
|
+
const fail = (failure) => {
|
|
102
|
+
if (settled)
|
|
103
|
+
return;
|
|
104
|
+
settled = true;
|
|
105
|
+
chunks.length = 0;
|
|
106
|
+
reject(failure);
|
|
107
|
+
};
|
|
108
|
+
// Why the Buffer annotation: no encoding is set on the stream, so chunks are Buffers per
|
|
109
|
+
// the node:http contract; the generic listener type erases that to `any`.
|
|
110
|
+
req.on("data", (chunk) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
received += chunk.length;
|
|
114
|
+
if (received > limitBytes) {
|
|
115
|
+
fail(new HttpError(413, "PAYLOAD_TOO_LARGE", `Request body exceeds the ${String(limitBytes)}-byte limit.`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(chunk);
|
|
119
|
+
});
|
|
120
|
+
req.on("end", () => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
124
|
+
resolve(Buffer.concat(chunks));
|
|
125
|
+
});
|
|
126
|
+
req.on("error", () => {
|
|
127
|
+
fail(new HttpError(400, "VALIDATION", "Request body could not be read."));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Parse a body as JSON and narrow it with `schema` — the trust boundary for HTTP input. */
|
|
132
|
+
export function parseJsonBody(raw, schema, what) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new HttpError(400, "VALIDATION", `${what} is not valid JSON.`);
|
|
139
|
+
}
|
|
140
|
+
const result = schema.safeParse(parsed);
|
|
141
|
+
if (!result.success) {
|
|
142
|
+
throw new HttpError(400, "VALIDATION", `${what} failed validation: ${result.error.message}`);
|
|
143
|
+
}
|
|
144
|
+
return result.data;
|
|
145
|
+
}
|
|
146
|
+
/** Parse `?name=<n>` as a non-negative integer, falling back when the parameter is absent. */
|
|
147
|
+
export function parseNonNegativeInt(url, name, fallback) {
|
|
148
|
+
const raw = url.searchParams.get(name);
|
|
149
|
+
if (raw === null)
|
|
150
|
+
return fallback;
|
|
151
|
+
const value = Number(raw);
|
|
152
|
+
if (raw.trim() === "" || !Number.isInteger(value) || value < 0) {
|
|
153
|
+
throw new HttpError(400, "VALIDATION", `Query parameter "${name}" must be a non-negative integer (got "${raw}").`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function isChannel(value) {
|
|
158
|
+
return CHANNELS.some((channel) => channel === value);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The channel subscription for an event read (`/events` and `/stream` share this so a tail and
|
|
162
|
+
* its catch-up reads can never disagree): `?verbose=true` = everything, `?channels=a,b` = an
|
|
163
|
+
* explicit set, neither = MASTER_SPEC §2.5's default of lifecycle + phase + output.
|
|
164
|
+
*/
|
|
165
|
+
export function parseChannelSelection(url) {
|
|
166
|
+
const verbose = url.searchParams.get("verbose");
|
|
167
|
+
if (verbose !== null && verbose !== "true" && verbose !== "false") {
|
|
168
|
+
throw new HttpError(400, "VALIDATION", `Query parameter "verbose" must be "true" or "false" (got "${verbose}").`);
|
|
169
|
+
}
|
|
170
|
+
if (verbose === "true")
|
|
171
|
+
return CHANNELS;
|
|
172
|
+
const raw = url.searchParams.get("channels");
|
|
173
|
+
if (raw === null)
|
|
174
|
+
return DEFAULT_CHANNELS;
|
|
175
|
+
const channels = [];
|
|
176
|
+
for (const name of raw.split(",").map((part) => part.trim())) {
|
|
177
|
+
if (!isChannel(name)) {
|
|
178
|
+
throw new HttpError(400, "VALIDATION", `Unknown channel "${name}".`, `Valid channels: ${CHANNELS.join(", ")}.`);
|
|
179
|
+
}
|
|
180
|
+
channels.push(name);
|
|
181
|
+
}
|
|
182
|
+
return channels;
|
|
183
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RouteContext } from "./router.js";
|
|
2
|
+
/** GET /api/workflows — names + manifest-derived fields, enough to render a picker. */
|
|
3
|
+
export declare function handleListWorkflows(ctx: RouteContext): void;
|
|
4
|
+
/** GET /api/runs?workflow=&status=&limit=&offset= — newest first, full RunRow shape. */
|
|
5
|
+
export declare function handleListRuns(ctx: RouteContext): void;
|
|
6
|
+
/** POST /api/workflows/:name/runs — start a manual run; 201 with the queued row. */
|
|
7
|
+
export declare function handleStartRun(ctx: RouteContext, workflowName: string): Promise<void>;
|
|
8
|
+
/** GET /api/runs/:id */
|
|
9
|
+
export declare function handleGetRun(ctx: RouteContext, runId: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/runs/:id/events?after=&channels=|verbose= — persisted events after a cursor,
|
|
12
|
+
* filtered server-side by channel. Cursors are run-global and untouched by filtering, so a
|
|
13
|
+
* client can resume here (or on /stream) with any channel set (MASTER_SPEC §2.5).
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleListEvents(ctx: RouteContext, runId: string): void;
|
|
16
|
+
/** POST /api/runs/:id/cancel — 202: accepted now, completes after the cooperative grace. */
|
|
17
|
+
export declare function handleCancelRun(ctx: RouteContext, runId: string): void;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The JSON API (SPEC §2.4): list workflows/runs, trigger a manual run, read a run + its
|
|
2
|
+
// events, cancel. Handlers translate HTTP into engine/store calls and nothing else — all SQL
|
|
3
|
+
// lives in the store, all run semantics in the engine.
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { matchesChannels } from "@boardwalk-labs/workflow";
|
|
6
|
+
import { HttpError, MAX_BODY_BYTES, jsonValueSchema, parseChannelSelection, parseJsonBody, parseNonNegativeInt, readBody, sendJson, } from "../http.js";
|
|
7
|
+
/**
|
|
8
|
+
* Default page size for run listings. Unbounded-by-default would make the run-log UI's
|
|
9
|
+
* "recent runs" fetch grow forever; callers page with limit/offset for more.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_RUNS_LIMIT = 100;
|
|
12
|
+
const startRunBodySchema = z.strictObject({ input: jsonValueSchema.optional() });
|
|
13
|
+
// Why a Record and not an array: the Record<RunStatus, true> shape makes the value list
|
|
14
|
+
// provably complete — a status added to the union without a flag here is a compile error, so
|
|
15
|
+
// the filter can never reject a status the store legitimately writes.
|
|
16
|
+
const RUN_STATUS_FLAGS = {
|
|
17
|
+
queued: true,
|
|
18
|
+
pending: true,
|
|
19
|
+
running: true,
|
|
20
|
+
completed: true,
|
|
21
|
+
failed: true,
|
|
22
|
+
cancelled: true,
|
|
23
|
+
cancelling: true,
|
|
24
|
+
};
|
|
25
|
+
function isRunStatus(value) {
|
|
26
|
+
return Object.hasOwn(RUN_STATUS_FLAGS, value);
|
|
27
|
+
}
|
|
28
|
+
/** GET /api/workflows — names + manifest-derived fields, enough to render a picker. */
|
|
29
|
+
export function handleListWorkflows(ctx) {
|
|
30
|
+
const workflows = ctx.engine.store.listWorkflows().map((row) => ({
|
|
31
|
+
name: row.name,
|
|
32
|
+
description: row.manifest.description ?? null,
|
|
33
|
+
triggers: row.manifest.triggers,
|
|
34
|
+
createdAt: row.createdAt,
|
|
35
|
+
updatedAt: row.updatedAt,
|
|
36
|
+
}));
|
|
37
|
+
sendJson(ctx.res, 200, { workflows });
|
|
38
|
+
}
|
|
39
|
+
/** GET /api/runs?workflow=&status=&limit=&offset= — newest first, full RunRow shape. */
|
|
40
|
+
export function handleListRuns(ctx) {
|
|
41
|
+
const filter = {
|
|
42
|
+
limit: parseNonNegativeInt(ctx.url, "limit", DEFAULT_RUNS_LIMIT),
|
|
43
|
+
offset: parseNonNegativeInt(ctx.url, "offset", 0),
|
|
44
|
+
};
|
|
45
|
+
const workflowName = ctx.url.searchParams.get("workflow");
|
|
46
|
+
if (workflowName !== null) {
|
|
47
|
+
const workflow = ctx.engine.store.getWorkflow(workflowName);
|
|
48
|
+
if (workflow === null) {
|
|
49
|
+
throw new HttpError(404, "NOT_FOUND", `Workflow "${workflowName}" is not deployed on this engine.`);
|
|
50
|
+
}
|
|
51
|
+
filter.workflowId = workflow.id;
|
|
52
|
+
}
|
|
53
|
+
const status = ctx.url.searchParams.get("status");
|
|
54
|
+
if (status !== null) {
|
|
55
|
+
if (!isRunStatus(status)) {
|
|
56
|
+
throw new HttpError(400, "VALIDATION", `Unknown run status "${status}".`, `Valid statuses: ${Object.keys(RUN_STATUS_FLAGS).join(", ")}.`);
|
|
57
|
+
}
|
|
58
|
+
filter.statuses = [status];
|
|
59
|
+
}
|
|
60
|
+
sendJson(ctx.res, 200, { runs: ctx.engine.store.listRuns(filter) });
|
|
61
|
+
}
|
|
62
|
+
/** POST /api/workflows/:name/runs — start a manual run; 201 with the queued row. */
|
|
63
|
+
export async function handleStartRun(ctx, workflowName) {
|
|
64
|
+
const raw = await readBody(ctx.req, MAX_BODY_BYTES);
|
|
65
|
+
// An empty body means "no input" — the curl-without-data ergonomics of a run-now button.
|
|
66
|
+
const body = raw.length === 0 ? {} : parseJsonBody(raw, startRunBodySchema, "run-start body");
|
|
67
|
+
const run = ctx.engine.startRun(workflowName, {
|
|
68
|
+
triggerKind: "manual",
|
|
69
|
+
...(body.input !== undefined ? { input: body.input } : {}),
|
|
70
|
+
});
|
|
71
|
+
sendJson(ctx.res, 201, { run });
|
|
72
|
+
}
|
|
73
|
+
/** GET /api/runs/:id */
|
|
74
|
+
export function handleGetRun(ctx, runId) {
|
|
75
|
+
const run = ctx.engine.store.getRun(runId);
|
|
76
|
+
if (run === null)
|
|
77
|
+
throw new HttpError(404, "NOT_FOUND", `Unknown run: ${runId}`);
|
|
78
|
+
sendJson(ctx.res, 200, { run });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* GET /api/runs/:id/events?after=&channels=|verbose= — persisted events after a cursor,
|
|
82
|
+
* filtered server-side by channel. Cursors are run-global and untouched by filtering, so a
|
|
83
|
+
* client can resume here (or on /stream) with any channel set (MASTER_SPEC §2.5).
|
|
84
|
+
*/
|
|
85
|
+
export function handleListEvents(ctx, runId) {
|
|
86
|
+
if (ctx.engine.store.getRun(runId) === null) {
|
|
87
|
+
throw new HttpError(404, "NOT_FOUND", `Unknown run: ${runId}`);
|
|
88
|
+
}
|
|
89
|
+
const channels = parseChannelSelection(ctx.url);
|
|
90
|
+
const afterCursor = parseNonNegativeInt(ctx.url, "after", 0);
|
|
91
|
+
const events = ctx.engine.store
|
|
92
|
+
.listEvents(runId, { afterCursor })
|
|
93
|
+
.filter((row) => matchesChannels(row.event, channels));
|
|
94
|
+
sendJson(ctx.res, 200, { events });
|
|
95
|
+
}
|
|
96
|
+
/** POST /api/runs/:id/cancel — 202: accepted now, completes after the cooperative grace. */
|
|
97
|
+
export function handleCancelRun(ctx, runId) {
|
|
98
|
+
if (ctx.engine.store.getRun(runId) === null) {
|
|
99
|
+
throw new HttpError(404, "NOT_FOUND", `Unknown run: ${runId}`);
|
|
100
|
+
}
|
|
101
|
+
// Why not awaited: cancellation holds the SIGTERM→SIGKILL grace window open (seconds); 202
|
|
102
|
+
// promises "accepted", and the caller observes completion via the run's status.
|
|
103
|
+
void ctx.engine.cancelRun(runId).catch((err) => {
|
|
104
|
+
ctx.log(`cancel of run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
+
});
|
|
106
|
+
sendJson(ctx.res, 202, {});
|
|
107
|
+
}
|