@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,88 @@
1
+ // POST /hooks/:workflow/:triggerIndex — the webhook trigger endpoint, and this engine's v0
2
+ // answer to MASTER_SPEC §10's open webhook-auth question (documented in SPEC §2.4):
3
+ // per-workflow credentials live in *server* environment variables —
4
+ // token auth: BOARDWALK_WEBHOOK_TOKEN__<NAME> vs `Authorization: Bearer <token>`
5
+ // signature auth: BOARDWALK_WEBHOOK_SECRET__<NAME> vs `X-Boardwalk-Signature: sha256=<hex>`
6
+ // (HMAC-SHA256 over the raw request body)
7
+ // where <NAME> is the workflow name upper-cased with `-` → `_`. Missing variable = 503 (fail
8
+ // closed, hint names the variable); bad credential = 401. These are server config, not
9
+ // workflow secrets, so they resolve from process.env — never from the engine's env map.
10
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
11
+ import { HttpError, MAX_BODY_BYTES, jsonValueSchema, parseJsonBody, readBody, sendJson, } from "../http.js";
12
+ export async function handleWebhook(ctx, workflowName, triggerIndexRaw) {
13
+ // The raw body is read up front: signature auth signs the exact bytes on the wire, before
14
+ // any JSON parsing can normalize them away.
15
+ const rawBody = await readBody(ctx.req, MAX_BODY_BYTES);
16
+ // One identical 404 for "no workflow", "no such trigger index", and "not a webhook
17
+ // trigger" — an unauthenticated caller learns nothing about what is deployed here.
18
+ const notFound = new HttpError(404, "NOT_FOUND", `No webhook trigger at /hooks/${workflowName}/${triggerIndexRaw}.`);
19
+ const workflow = ctx.engine.store.getWorkflow(workflowName);
20
+ if (workflow === null)
21
+ throw notFound;
22
+ if (!/^\d+$/.test(triggerIndexRaw))
23
+ throw notFound;
24
+ const trigger = workflow.manifest.triggers[Number(triggerIndexRaw)];
25
+ if (trigger === undefined || trigger.kind !== "webhook")
26
+ throw notFound;
27
+ if (trigger.auth === "token")
28
+ authorizeToken(ctx.req, workflowName);
29
+ else
30
+ authorizeSignature(ctx.req, workflowName, rawBody);
31
+ const input = rawBody.length === 0 ? null : parseJsonBody(rawBody, jsonValueSchema, "webhook payload");
32
+ const run = ctx.engine.startRun(workflowName, { input, triggerKind: "webhook" });
33
+ sendJson(ctx.res, 201, { run: { id: run.id, status: run.status } });
34
+ }
35
+ /** `BOARDWALK_WEBHOOK_<kind>__<NAME>`: the workflow name upper-cased, hyphens → underscores. */
36
+ function webhookEnvVarName(kind, workflowName) {
37
+ return `BOARDWALK_WEBHOOK_${kind}__${workflowName.toUpperCase().replaceAll("-", "_")}`;
38
+ }
39
+ /**
40
+ * Read the trigger's credential from the server environment, failing CLOSED when unset: a
41
+ * webhook that nobody configured must never become an open trigger. Read lazily per request
42
+ * so an operator can fix the environment without redeploying workflows.
43
+ */
44
+ function requiredCredential(kind, workflowName) {
45
+ const name = webhookEnvVarName(kind, workflowName);
46
+ const value = process.env[name];
47
+ if (value === undefined || value === "") {
48
+ throw new HttpError(503, "WEBHOOK_UNCONFIGURED", `Webhook auth for workflow "${workflowName}" is not configured on this server.`, `Set the environment variable ${name} and restart the server.`);
49
+ }
50
+ return value;
51
+ }
52
+ /** One generic 401 for every credential failure — no oracle for which part was wrong. */
53
+ function unauthorized() {
54
+ return new HttpError(401, "UNAUTHORIZED", "Invalid webhook credentials.");
55
+ }
56
+ function authorizeToken(req, workflowName) {
57
+ const expected = requiredCredential("TOKEN", workflowName);
58
+ const header = req.headers.authorization;
59
+ if (header === undefined || !header.startsWith("Bearer "))
60
+ throw unauthorized();
61
+ if (!constantTimeEquals(header.slice("Bearer ".length), expected))
62
+ throw unauthorized();
63
+ }
64
+ function authorizeSignature(req, workflowName, rawBody) {
65
+ const secret = requiredCredential("SECRET", workflowName);
66
+ const header = req.headers["x-boardwalk-signature"];
67
+ if (typeof header !== "string")
68
+ throw unauthorized();
69
+ const match = /^sha256=([0-9a-f]{64})$/i.exec(header);
70
+ const presentedHex = match?.[1];
71
+ if (presentedHex === undefined)
72
+ throw unauthorized();
73
+ const presented = Buffer.from(presentedHex, "hex");
74
+ const computed = createHmac("sha256", secret).update(rawBody).digest();
75
+ // The regex pins 64 hex chars = 32 bytes = SHA-256 output, so the lengths already match;
76
+ // the explicit check keeps timingSafeEqual's equal-length precondition locally provable.
77
+ if (presented.length !== computed.length || !timingSafeEqual(presented, computed)) {
78
+ throw unauthorized();
79
+ }
80
+ }
81
+ /**
82
+ * Constant-time string equality. Why hash-then-compare: timingSafeEqual demands equal-length
83
+ * inputs, and comparing fixed-size digests both satisfies that and avoids leaking the
84
+ * expected token's length through an early length check.
85
+ */
86
+ function constantTimeEquals(a, b) {
87
+ return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
88
+ }
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Engine } from "../../engine.js";
3
+ /** Everything a route handler may touch. The server only calls engine/store methods. */
4
+ export interface RouteContext {
5
+ engine: Engine;
6
+ req: IncomingMessage;
7
+ res: ServerResponse;
8
+ url: URL;
9
+ log: (line: string) => void;
10
+ }
11
+ /**
12
+ * Resolve and run the handler for one request. Throws HttpError for routing failures; the
13
+ * server's top-level catch renders them, so handlers never touch error formatting.
14
+ */
15
+ export declare function dispatchRequest(engine: Engine, req: IncomingMessage, res: ServerResponse, log: (line: string) => void): Promise<void>;
@@ -0,0 +1,75 @@
1
+ // URL → handler resolution for the engine server. One flat match over decoded path segments —
2
+ // the route table is small enough that a real trie/middleware stack would be pure ceremony.
3
+ // Method mismatches on a known path get 405 + Allow (not 404), so clients can tell "wrong
4
+ // verb" from "no such thing".
5
+ import { HttpError } from "../http.js";
6
+ import { handleCancelRun, handleGetRun, handleListEvents, handleListRuns, handleListWorkflows, handleStartRun, } from "./api.js";
7
+ import { handleWebhook } from "./hooks.js";
8
+ import { handleStreamRun } from "./stream.js";
9
+ import { handleUiPage } from "./ui.js";
10
+ /**
11
+ * Resolve and run the handler for one request. Throws HttpError for routing failures; the
12
+ * server's top-level catch renders them, so handlers never touch error formatting.
13
+ */
14
+ export async function dispatchRequest(engine, req, res, log) {
15
+ const method = req.method ?? "GET";
16
+ // The base never matters — routes only read pathname + searchParams.
17
+ const url = new URL(req.url ?? "/", "http://localhost");
18
+ const methods = matchRoute(decodePathSegments(url.pathname));
19
+ if (methods === null) {
20
+ throw new HttpError(404, "NOT_FOUND", `No such route: ${url.pathname}`);
21
+ }
22
+ const handler = methods[method];
23
+ if (handler === undefined) {
24
+ // Allow rides on setHeader so the shared error renderer needs no special 405 case.
25
+ res.setHeader("allow", Object.keys(methods).join(", "));
26
+ throw new HttpError(405, "METHOD_NOT_ALLOWED", `${method} is not allowed for ${url.pathname}.`);
27
+ }
28
+ await handler({ engine, req, res, url, log });
29
+ }
30
+ function decodePathSegments(pathname) {
31
+ return pathname
32
+ .split("/")
33
+ .filter((segment) => segment !== "")
34
+ .map((segment) => {
35
+ try {
36
+ return decodeURIComponent(segment);
37
+ }
38
+ catch {
39
+ throw new HttpError(400, "VALIDATION", "Malformed percent-encoding in URL path.");
40
+ }
41
+ });
42
+ }
43
+ /** The route table: path segments → method → handler. Null means no such resource (404). */
44
+ function matchRoute(segments) {
45
+ const [first, second, third, fourth] = segments;
46
+ if (segments.length === 0)
47
+ return { GET: handleUiPage };
48
+ if (first === "api" && second === "workflows") {
49
+ if (segments.length === 2)
50
+ return { GET: handleListWorkflows };
51
+ if (segments.length === 4 && third !== undefined && fourth === "runs") {
52
+ return { POST: (ctx) => handleStartRun(ctx, third) };
53
+ }
54
+ }
55
+ if (first === "api" && second === "runs") {
56
+ if (segments.length === 2)
57
+ return { GET: handleListRuns };
58
+ if (third === undefined)
59
+ return null;
60
+ if (segments.length === 3)
61
+ return { GET: (ctx) => handleGetRun(ctx, third) };
62
+ if (segments.length === 4) {
63
+ if (fourth === "events")
64
+ return { GET: (ctx) => handleListEvents(ctx, third) };
65
+ if (fourth === "cancel")
66
+ return { POST: (ctx) => handleCancelRun(ctx, third) };
67
+ if (fourth === "stream")
68
+ return { GET: (ctx) => handleStreamRun(ctx, third) };
69
+ }
70
+ }
71
+ if (first === "hooks" && segments.length === 3 && second !== undefined && third !== undefined) {
72
+ return { POST: (ctx) => handleWebhook(ctx, second, third) };
73
+ }
74
+ return null;
75
+ }
@@ -0,0 +1,2 @@
1
+ import type { RouteContext } from "./router.js";
2
+ export declare function handleStreamRun(ctx: RouteContext, runId: string): void;
@@ -0,0 +1,79 @@
1
+ // GET /api/runs/:id/stream — the SSE live tail (SPEC §2.4, MASTER_SPEC §2.5): replay
2
+ // persisted events after the resume cursor, then follow live ones. Every frame carries
3
+ // `id: <cursor>`, so a dropped client reconnects with Last-Event-ID and misses nothing —
4
+ // cursors are run-global and independent of channel filtering, which keeps filtered resumes
5
+ // gap-free.
6
+ import { matchesChannels } from "@boardwalk-labs/workflow";
7
+ import { HttpError, parseChannelSelection, parseNonNegativeInt } from "../http.js";
8
+ /** SSE comment frames keep intermediaries from idling out a quiet tail. */
9
+ const PING_INTERVAL_MS = 15_000;
10
+ export function handleStreamRun(ctx, runId) {
11
+ // All validation happens before headers go out — after writeHead the JSON error contract
12
+ // is unreachable.
13
+ if (ctx.engine.store.getRun(runId) === null) {
14
+ throw new HttpError(404, "NOT_FOUND", `Unknown run: ${runId}`);
15
+ }
16
+ const channels = parseChannelSelection(ctx.url);
17
+ const afterCursor = resolveResumeCursor(ctx.req, ctx.url);
18
+ ctx.res.writeHead(200, {
19
+ "content-type": "text/event-stream",
20
+ "cache-control": "no-cache",
21
+ connection: "keep-alive",
22
+ });
23
+ // High-water mark of cursors already handled. It advances even for events the channel
24
+ // filter drops: a filtered-out cursor is "covered", and skipping it is not a gap.
25
+ let delivered = afterCursor;
26
+ const deliver = (row) => {
27
+ if (row.cursor <= delivered)
28
+ return;
29
+ delivered = row.cursor;
30
+ if (!matchesChannels(row.event, channels))
31
+ return;
32
+ if (ctx.res.writableEnded || ctx.res.destroyed)
33
+ return;
34
+ ctx.res.write(`id: ${String(row.cursor)}\ndata: ${JSON.stringify(row.event)}\n\n`);
35
+ };
36
+ // Why subscribe BEFORE reading the store: an event appended between "read store" and
37
+ // "subscribe" would be lost forever. Subscribing first means such events land in the
38
+ // backlog instead, and the high-water mark dedupes any the replay also saw.
39
+ let replaying = true;
40
+ const backlog = [];
41
+ const unsubscribe = ctx.engine.onEvent((row) => {
42
+ if (row.runId !== runId)
43
+ return;
44
+ if (replaying)
45
+ backlog.push(row);
46
+ else
47
+ deliver(row);
48
+ });
49
+ for (const row of ctx.engine.store.listEvents(runId, { afterCursor }))
50
+ deliver(row);
51
+ replaying = false;
52
+ for (const row of backlog.splice(0))
53
+ deliver(row);
54
+ const ping = setInterval(() => {
55
+ ctx.res.write(": ping\n\n");
56
+ }, PING_INTERVAL_MS);
57
+ // Why unref: a lingering tail must never hold the process open past server close.
58
+ ping.unref();
59
+ ctx.res.on("close", () => {
60
+ clearInterval(ping);
61
+ unsubscribe();
62
+ });
63
+ }
64
+ /**
65
+ * Where to resume from: the SSE-standard Last-Event-ID header wins (it is what browsers send
66
+ * on automatic reconnect), then `?after=`, then 0 (everything).
67
+ */
68
+ function resolveResumeCursor(req, url) {
69
+ const header = req.headers["last-event-id"];
70
+ const value = Array.isArray(header) ? header[0] : header;
71
+ if (value !== undefined) {
72
+ const cursor = Number(value);
73
+ if (value.trim() === "" || !Number.isInteger(cursor) || cursor < 0) {
74
+ throw new HttpError(400, "VALIDATION", `Last-Event-ID must be a non-negative integer cursor (got "${value}").`);
75
+ }
76
+ return cursor;
77
+ }
78
+ return parseNonNegativeInt(url, "after", 0);
79
+ }
@@ -0,0 +1,2 @@
1
+ import type { RouteContext } from "./router.js";
2
+ export declare function handleUiPage(ctx: RouteContext): void;
@@ -0,0 +1,120 @@
1
+ // GET / — the local run-log page (SPEC §2.4): one self-contained HTML document, no build
2
+ // step, no external assets. It is a log viewer, not a console: list workflows, list recent
3
+ // runs, click a run to tail it over the SSE endpoint. All rendering uses textContent — no
4
+ // markup is ever built from API data, so nothing a workflow prints can inject into the page.
5
+ export function handleUiPage(ctx) {
6
+ ctx.res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
7
+ ctx.res.end(RUN_LOG_PAGE);
8
+ }
9
+ const RUN_LOG_PAGE = `<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <title>Boardwalk run log</title>
15
+ <style>
16
+ :root { color-scheme: light dark; }
17
+ body { font-family: system-ui, sans-serif; margin: 0; display: grid; grid-template-columns: 17rem 1fr; height: 100vh; }
18
+ aside { border-right: 1px solid #8884; padding: 1rem; overflow-y: auto; }
19
+ main { padding: 1rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; }
20
+ h1 { font-size: 1rem; margin: 0 0 0.75rem; }
21
+ h2 { font-size: 0.8rem; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
22
+ button.row { display: block; width: 100%; text-align: left; background: none; border: none; padding: 0.3rem 0.4rem; cursor: pointer; border-radius: 0.25rem; font: inherit; }
23
+ button.row:hover { background: #8882; }
24
+ #log { font-family: ui-monospace, monospace; font-size: 0.8rem; white-space: pre-wrap; flex: 1; border: 1px solid #8884; border-radius: 0.25rem; padding: 0.5rem; overflow-y: auto; min-height: 10rem; }
25
+ .muted { opacity: 0.6; }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <aside>
30
+ <h1>boardwalk</h1>
31
+ <h2>Workflows</h2>
32
+ <div id="workflows" class="muted">loading…</div>
33
+ </aside>
34
+ <main>
35
+ <section>
36
+ <h2>Recent runs</h2>
37
+ <div id="runs" class="muted">loading…</div>
38
+ </section>
39
+ <section style="display: flex; flex-direction: column; flex: 1;">
40
+ <h2 id="tail-title">Run log</h2>
41
+ <div id="log" class="muted">select a run to tail it</div>
42
+ </section>
43
+ </main>
44
+ <script>
45
+ "use strict";
46
+ let source = null;
47
+ let selectedWorkflow = null;
48
+ const el = (id) => document.getElementById(id);
49
+
50
+ async function getJson(path) {
51
+ const res = await fetch(path);
52
+ if (!res.ok) throw new Error(path + " -> " + res.status);
53
+ return res.json();
54
+ }
55
+
56
+ function rowButton(label, onClick) {
57
+ const button = document.createElement("button");
58
+ button.className = "row";
59
+ button.textContent = label;
60
+ button.addEventListener("click", onClick);
61
+ return button;
62
+ }
63
+
64
+ async function loadWorkflows() {
65
+ const { workflows } = await getJson("/api/workflows");
66
+ const box = el("workflows");
67
+ box.replaceChildren();
68
+ box.className = "";
69
+ if (workflows.length === 0) {
70
+ box.className = "muted";
71
+ box.textContent = "none deployed";
72
+ return;
73
+ }
74
+ box.append(rowButton("all workflows", () => { selectedWorkflow = null; loadRuns(); }));
75
+ for (const workflow of workflows) {
76
+ box.append(rowButton(workflow.name, () => { selectedWorkflow = workflow.name; loadRuns(); }));
77
+ }
78
+ }
79
+
80
+ async function loadRuns() {
81
+ const filter = selectedWorkflow === null ? "" : "&workflow=" + encodeURIComponent(selectedWorkflow);
82
+ const { runs } = await getJson("/api/runs?limit=50" + filter);
83
+ const box = el("runs");
84
+ box.replaceChildren();
85
+ box.className = runs.length === 0 ? "muted" : "";
86
+ if (runs.length === 0) box.textContent = "no runs yet";
87
+ for (const run of runs) {
88
+ const when = new Date(run.createdAt).toLocaleString();
89
+ box.append(rowButton(run.status + " · " + when + " · " + run.id, () => tail(run.id)));
90
+ }
91
+ }
92
+
93
+ function describeEvent(event) {
94
+ if (event.kind === "run_status") {
95
+ return "status: " + event.status + (event.error ? " (" + event.error.code + ": " + event.error.message + ")" : "");
96
+ }
97
+ if (event.kind === "phase") return "phase: " + event.name;
98
+ if (event.kind === "output") return "output: " + JSON.stringify(event.value);
99
+ if (event.kind === "program_output") return "[" + event.stream + "] " + event.text.replace(/\\n$/, "");
100
+ return event.kind;
101
+ }
102
+
103
+ function tail(runId) {
104
+ if (source !== null) source.close();
105
+ el("tail-title").textContent = "Run " + runId;
106
+ const log = el("log");
107
+ log.replaceChildren();
108
+ log.className = "";
109
+ source = new EventSource("/api/runs/" + encodeURIComponent(runId) + "/stream?verbose=true");
110
+ source.onmessage = (message) => log.append(describeEvent(JSON.parse(message.data)) + "\\n");
111
+ source.onerror = () => log.append("(stream interrupted; retrying)\\n");
112
+ }
113
+
114
+ loadWorkflows();
115
+ loadRuns();
116
+ setInterval(loadRuns, 5000);
117
+ </script>
118
+ </body>
119
+ </html>
120
+ `;
@@ -0,0 +1,25 @@
1
+ import type { Engine } from "../engine.js";
2
+ export interface EngineServerOptions {
3
+ /** Default bind host when `listen` doesn't name one. Default 127.0.0.1. */
4
+ host?: string;
5
+ /** Server diagnostics (bind warnings, internal errors kept off the wire). Default: stderr. */
6
+ log?: (line: string) => void;
7
+ }
8
+ export interface EngineServer {
9
+ /** Bind and start serving. `port` 0 picks a free port; the resolved value reports it. */
10
+ listen(port: number, host?: string): Promise<{
11
+ port: number;
12
+ }>;
13
+ /** Stop accepting and drop open connections (SSE tails would otherwise hold close forever). */
14
+ close(): Promise<void>;
15
+ }
16
+ /**
17
+ * Hosts only the local machine can reach. Exported for direct testing — the bind warning
18
+ * hinges on this judgement.
19
+ */
20
+ export declare function isLoopbackHost(host: string): boolean;
21
+ /**
22
+ * Build (but do not bind) the HTTP server for an engine. Separate from the Engine itself so
23
+ * embedded consumers (`boardwalk dev`) never pay for an HTTP layer they don't use.
24
+ */
25
+ export declare function createEngineServer(engine: Engine, opts?: EngineServerOptions): EngineServer;
@@ -0,0 +1,67 @@
1
+ // The engine's HTTP surface (SPEC §2.4): JSON API + SSE live tail + webhook triggers + the
2
+ // local run-log page, on bare node:http. This file owns the socket lifecycle only — routing
3
+ // lives in routes/router.ts, and every handler goes through engine/store methods, never SQL.
4
+ //
5
+ // Security posture (v1): bound to localhost by default; there is NO auth on this surface
6
+ // beyond webhook auth, so binding wider logs a prominent warning and is the operator's call.
7
+ import { createServer } from "node:http";
8
+ import { sendError } from "./http.js";
9
+ import { dispatchRequest } from "./routes/router.js";
10
+ /**
11
+ * Hosts only the local machine can reach. Exported for direct testing — the bind warning
12
+ * hinges on this judgement.
13
+ */
14
+ export function isLoopbackHost(host) {
15
+ return (host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "::ffff:127.0.0.1");
16
+ }
17
+ /**
18
+ * Build (but do not bind) the HTTP server for an engine. Separate from the Engine itself so
19
+ * embedded consumers (`boardwalk dev`) never pay for an HTTP layer they don't use.
20
+ */
21
+ export function createEngineServer(engine, opts = {}) {
22
+ const log = opts.log ??
23
+ ((line) => {
24
+ process.stderr.write(`${line}\n`);
25
+ });
26
+ const server = createServer((req, res) => {
27
+ // One catch for every route: handlers just throw, and the error contract stays uniform.
28
+ void dispatchRequest(engine, req, res, log).catch((err) => {
29
+ sendError(res, err, log);
30
+ });
31
+ });
32
+ return {
33
+ listen(port, host) {
34
+ const bindHost = host ?? opts.host ?? "127.0.0.1";
35
+ if (!isLoopbackHost(bindHost)) {
36
+ log(`WARNING: engine server binding to ${bindHost} — this surface has NO authentication ` +
37
+ `beyond webhook auth (SPEC §2.4). Anyone who can reach it can start, cancel, and ` +
38
+ `read runs. Only bind beyond loopback on a network you trust.`);
39
+ }
40
+ return new Promise((resolve, reject) => {
41
+ server.once("error", reject);
42
+ server.listen(port, bindHost, () => {
43
+ server.removeListener("error", reject);
44
+ const address = server.address();
45
+ // A TCP listen always yields an AddressInfo; a string means a pipe, which would be
46
+ // a programming error here.
47
+ if (address === null || typeof address === "string") {
48
+ reject(new Error("engine server did not bind to a TCP port"));
49
+ return;
50
+ }
51
+ resolve({ port: address.port });
52
+ });
53
+ });
54
+ },
55
+ close() {
56
+ return new Promise((resolve, reject) => {
57
+ if (!server.listening) {
58
+ resolve();
59
+ return;
60
+ }
61
+ server.close((err) => (err !== undefined ? reject(err) : resolve()));
62
+ // SSE tails are long-lived by design; without this, close() never settles.
63
+ server.closeAllConnections();
64
+ });
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,46 @@
1
+ import type { InferenceConfig } from "./agent/resolve.js";
2
+ /** Fully-resolved server configuration — every field present, defaults applied. */
3
+ export interface ServerConfig {
4
+ /** Where everything lives (SQLite DB, run dirs, artifacts). Created on boot if missing. */
5
+ dataDir: string;
6
+ /** Bind address. Loopback by default — the surface has no auth beyond webhook auth. */
7
+ host: string;
8
+ /** Listen port. 0 binds an ephemeral port (the resolved port is logged). */
9
+ port: number;
10
+ /** Default model + provider table for agent() leaves; undefined when none configured. */
11
+ inference: InferenceConfig | undefined;
12
+ /** Explicit BOARDWALK_ENV_FILE path; undefined means "use <dataDir>/.env if it exists". */
13
+ envFile: string | undefined;
14
+ }
15
+ /**
16
+ * Parse server config from an environment map. Pure (no filesystem, no process globals) so
17
+ * every default and failure mode is unit-testable; `main()` passes `process.env`.
18
+ */
19
+ export declare function loadServerConfig(env: Record<string, string | undefined>): ServerConfig;
20
+ /**
21
+ * Resolve the engine's secret/env source (SPEC §2.3 `secrets.get`): the configured
22
+ * BOARDWALK_ENV_FILE, else `<dataDir>/.env` when present. An explicitly named file that does
23
+ * not exist fails closed — a typo'd path silently falling back to process.env would make
24
+ * `secrets.get` read the wrong values with no warning.
25
+ */
26
+ export declare function resolveEngineEnv(config: Pick<ServerConfig, "envFile" | "dataDir">): {
27
+ env: Record<string, string>;
28
+ envLabel: string;
29
+ } | null;
30
+ /** A booted server: the resolved port plus an idempotent teardown for signal handlers/tests. */
31
+ export interface RunningServer {
32
+ port: number;
33
+ /** Stop accepting connections, then release the engine (scheduler loop + DB handle). */
34
+ shutdown(): Promise<void>;
35
+ }
36
+ /**
37
+ * Boot the engine + HTTP surface from a resolved config. Split from `main()` so the whole
38
+ * boot path (sweep, listen, startup logging, teardown) is testable without touching process
39
+ * signals or `process.exit`.
40
+ */
41
+ export declare function startServer(config: ServerConfig, log: (line: string) => void): Promise<RunningServer>;
42
+ /**
43
+ * The `boardwalk-server` entrypoint (invoked by bin/boardwalk-server.js). Owns the only
44
+ * process-global concerns in the package: process.env, signal handlers, and exit codes.
45
+ */
46
+ export declare function main(): Promise<void>;