@boardwalk-labs/engine 0.1.0 → 0.1.2
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/README.md +17 -0
- package/bin/boardwalk-server.js +2 -0
- package/dist/agent/conversation.js +1 -3
- package/dist/agent/leaf.js +2 -1
- package/dist/agent/providers.js +3 -2
- package/dist/agent/rates.js +1 -7
- package/dist/agent/redact.js +2 -1
- package/dist/agent/resolve.js +2 -1
- package/dist/agent/sse.js +1 -0
- package/dist/agent/tools.js +2 -1
- package/dist/clock.js +1 -3
- package/dist/cron/cron.js +3 -2
- package/dist/engine.js +1 -0
- package/dist/errors.js +2 -1
- package/dist/ids.js +2 -1
- package/dist/index.js +1 -0
- package/dist/json_value.js +1 -3
- package/dist/mcp/client.js +2 -1
- package/dist/mcp/jsonrpc.js +2 -1
- package/dist/mcp/oauth.js +2 -1
- package/dist/mcp/token_store.js +2 -1
- package/dist/mcp/transport_http.js +1 -0
- package/dist/mcp/transport_stdio.js +1 -0
- package/dist/run/child.js +2 -1
- package/dist/run/child_host.js +2 -1
- package/dist/run/idempotency.js +1 -0
- package/dist/run/ipc.js +2 -1
- package/dist/run/run_dir.js +2 -1
- package/dist/run/supervisor.js +6 -5
- package/dist/scheduler/scheduler.d.ts +1 -1
- package/dist/scheduler/scheduler.js +0 -0
- package/dist/server/http.d.ts +1 -1
- package/dist/server/http.js +2 -6
- package/dist/server/routes/api.d.ts +1 -1
- package/dist/server/routes/api.js +2 -1
- package/dist/server/routes/hooks.js +2 -1
- package/dist/server/routes/router.js +1 -4
- package/dist/server/routes/stream.js +2 -1
- package/dist/server/routes/ui.js +1 -4
- package/dist/server/server.js +1 -0
- package/dist/server_main.d.ts +6 -0
- package/dist/server_main.js +37 -2
- package/dist/store/migrations.js +2 -12
- package/dist/store/store.js +4 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,6 +32,22 @@ docker run -v ./data:/data -p 8080:8080 ghcr.io/boardwalk-labs/boardwalk
|
|
|
32
32
|
Then open `http://localhost:8080` for the run log, or hit the JSON API
|
|
33
33
|
(`/api/workflows`, `/api/runs`). Webhook triggers land on `/hooks/<workflow>/<trigger-id>`.
|
|
34
34
|
|
|
35
|
+
### Deploying a workflow
|
|
36
|
+
|
|
37
|
+
Build your workflow to a single file and drop it in the engine's **workflows directory** — it's
|
|
38
|
+
deployed on boot (re-synced every boot; idempotent by manifest name):
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx @boardwalk-labs/cli build index.ts --out ./data/workflows/my-routine.mjs
|
|
42
|
+
docker run -v ./data:/data -p 8080:8080 ghcr.io/boardwalk-labs/boardwalk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The default workflows directory is `<data-dir>/workflows` (`/data/workflows` in Docker); override
|
|
46
|
+
it with `BOARDWALK_WORKFLOWS_DIR`. Each `.mjs`/`.js` file is one workflow — single-file, with
|
|
47
|
+
`@boardwalk-labs/workflow` left external (exactly what `boardwalk build` emits). From there the
|
|
48
|
+
manifest's triggers take over: cron fires on schedule, `POST /api/workflows/<name>/runs` triggers
|
|
49
|
+
a manual run, and webhooks land on `/hooks/<workflow>/<trigger-id>`.
|
|
50
|
+
|
|
35
51
|
### Configuration
|
|
36
52
|
|
|
37
53
|
All configuration is environment variables (a `boardwalk.toml` file is deferred — see
|
|
@@ -40,6 +56,7 @@ All configuration is environment variables (a `boardwalk.toml` file is deferred
|
|
|
40
56
|
| Variable | Default | What it does |
|
|
41
57
|
| ------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
42
58
|
| `BOARDWALK_DATA_DIR` | `/data` in Docker, else `./boardwalk-data` | Where everything lives: SQLite DB, run dirs, artifacts |
|
|
59
|
+
| `BOARDWALK_WORKFLOWS_DIR` | `<data-dir>/workflows` | Directory of built workflows (`.mjs`/`.js`) deployed on boot |
|
|
43
60
|
| `BOARDWALK_HOST` | `127.0.0.1` (`0.0.0.0` in Docker) | Bind address — this surface has no auth beyond webhook auth, so binding wider logs a warning |
|
|
44
61
|
| `BOARDWALK_PORT` | `8080` | Listen port (`0` picks a free port) |
|
|
45
62
|
| `BOARDWALK_DEFAULT_MODEL` | — | Model used when `agent()` omits one, e.g. `anthropic/claude-sonnet-4-5` |
|
package/bin/boardwalk-server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
2
4
|
// Thin launcher for `boardwalk-server` (SPEC §5). All real logic lives in compiled,
|
|
3
5
|
// type-checked, tested TypeScript (src/server_main.ts) — this shim only exists so npm `bin`
|
|
4
6
|
// and the Docker CMD share one entrypoint that resolves dist/ relative to the package.
|
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
// each protocol adapter maps them to its wire format. Keeping the loop neutral is what lets
|
|
3
|
-
// two adapters (Anthropic + OpenAI-compatible) cover every supported endpoint.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
2
|
export {};
|
package/dist/agent/leaf.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The agent() leaf: a real agentic loop (SDK SPEC §2.1.1) — streamed model turns with tool
|
|
2
3
|
// use (program-defined ToolDefs + memory file tools + MCP server tools), skills loaded into
|
|
3
4
|
// context, schema output, secret redaction, and usage reporting. Runs IN THE PROGRAM PROCESS
|
|
@@ -150,7 +151,7 @@ async function executeToolCall(call, tools, io, turnId) {
|
|
|
150
151
|
}
|
|
151
152
|
io.emit(turnId, { kind: "tool_call_executing", toolCallId: call.id });
|
|
152
153
|
try {
|
|
153
|
-
// Tool results enter model context → redact (
|
|
154
|
+
// Tool results enter model context → redact (redaction covers tool results too).
|
|
154
155
|
const content = io.redactor.redact(await tool.execute(call.input));
|
|
155
156
|
io.emit(turnId, {
|
|
156
157
|
kind: "tool_call_result",
|
package/dist/agent/providers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Provider adapters for the agent() leaf — two wire protocols cover everything (SPEC §2.3):
|
|
2
3
|
// Anthropic's Messages API (streamed) and OpenAI-style chat completions (the lingua franca of
|
|
3
4
|
// OpenAI, Google's compat surface, vLLM, Ollama, Together, Fireworks, Groq…). Each adapter
|
|
@@ -5,7 +6,7 @@
|
|
|
5
6
|
// model turn; the tool loop itself lives in leaf.ts.
|
|
6
7
|
//
|
|
7
8
|
// Zero SDK dependencies: plain fetch + Zod-validated responses (a provider's response is a
|
|
8
|
-
// trust boundary like any other). Retry policy
|
|
9
|
+
// trust boundary like any other). Retry policy: exponential backoff with
|
|
9
10
|
// jitter on 429/5xx/network errors; a non-rate-limit 4xx never retries.
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import { EngineError } from "../errors.js";
|
|
@@ -300,7 +301,7 @@ export async function chatOpenAi(args, io = {}) {
|
|
|
300
301
|
// ----------------------------------------------------------------------------
|
|
301
302
|
// Shared plumbing
|
|
302
303
|
// ----------------------------------------------------------------------------
|
|
303
|
-
/** Model-produced tool input is untrusted
|
|
304
|
+
/** Model-produced tool input is untrusted: parse, demand a JSON object. */
|
|
304
305
|
function parseToolInput(raw, toolName) {
|
|
305
306
|
const value = raw.trim().length === 0 ? {} : safeJson(raw);
|
|
306
307
|
if (isPlainObject(value) && isJsonValue(value)) {
|
package/dist/agent/rates.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// approximate rate table, documented as approximate").
|
|
3
|
-
//
|
|
4
|
-
// This is a GUARDRAIL, not a bill: the engine never charges anyone — it terminates a run that
|
|
5
|
-
// crosses the author's declared ceiling. Prices drift; entries here are deliberately coarse
|
|
6
|
-
// pattern matches with a conservative default, so an unknown model still consumes budget
|
|
7
|
-
// rather than running unmetered.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
8
2
|
// Order matters: first match wins, most-specific first.
|
|
9
3
|
const RULES = [
|
|
10
4
|
{ pattern: /claude-opus/i, rate: { inUsdPerMtok: 15, outUsdPerMtok: 75 } },
|
package/dist/agent/redact.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Secret redaction for the agent() leaf.
|
|
2
3
|
//
|
|
3
4
|
// The invariant: secret VALUES live only in deterministic program code; everything bound for a
|
|
4
5
|
// model — prompts now, tool args/results/MCP traffic/skills/memory later — is scrubbed of every
|
package/dist/agent/resolve.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Model + provider resolution for the agent() leaf (SPEC §2.3 "model resolution").
|
|
2
3
|
//
|
|
3
4
|
// `provider` and `model` are ORTHOGONAL (decided 2026-06-12):
|
|
@@ -22,7 +23,7 @@ export const BOARDWALK_PROVIDER = "boardwalk";
|
|
|
22
23
|
// Boardwalk inference gateway API; this engine sends model: "auto".
|
|
23
24
|
const AUTO_MODEL = "auto";
|
|
24
25
|
// The Boardwalk managed-inference gateway (OpenAI-compatible). `boardwalk.sh` is the placeholder
|
|
25
|
-
// domain
|
|
26
|
+
// domain; override with BOARDWALK_INFERENCE_URL or config. The Auto
|
|
26
27
|
// ROUTER itself lives in hosted Boardwalk — this engine only forwards to the gateway.
|
|
27
28
|
const DEFAULT_BOARDWALK_INFERENCE_URL = "https://api.boardwalk.sh/v1";
|
|
28
29
|
// Built-in direct-call providers — used only when NAMED explicitly; key from the conventional
|
package/dist/agent/sse.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Server-Sent Events parsing, shared by the provider adapters (streamed model turns) and the
|
|
2
3
|
// MCP streamable-HTTP transport (a server may answer any POST with an SSE stream). One parser
|
|
3
4
|
// so the two consumers can't drift on framing edge cases (CRLF, split chunks, [DONE]).
|
package/dist/agent/tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Capability assembly for an agent() call (SDK SPEC §2.1.1). Capabilities are PER-AGENT
|
|
2
3
|
// (decided 2026-06-11): each call brings its own tools/skills/memory — there is nothing to
|
|
3
4
|
// check against the manifest, but everything the call names must RESOLVE (fail loudly —
|
|
@@ -93,7 +94,7 @@ function wrapProgramTool(def) {
|
|
|
93
94
|
// Server names prefix tool names (`<server>__<tool>`) — keep them tool-name-shaped.
|
|
94
95
|
const MCP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
95
96
|
// AgentOptions comes straight from user program code — the TS types are aspirational at
|
|
96
|
-
// runtime, so each ref is Zod-checked before anything spawns or connects
|
|
97
|
+
// runtime, so each ref is Zod-checked before anything spawns or connects.
|
|
97
98
|
const mcpServerRefSchema = z.discriminatedUnion("transport", [
|
|
98
99
|
z.strictObject({
|
|
99
100
|
name: z.string().regex(MCP_NAME_RE),
|
package/dist/clock.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Date.now()/setTimeout directly so tests can drive time deterministically (scheduler clock
|
|
3
|
-
// tests, DST cases, catch-up policy) without real waits.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
2
|
/** The real wall clock. */
|
|
5
3
|
export const systemClock = {
|
|
6
4
|
now: () => Date.now(),
|
package/dist/cron/cron.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Cron parsing + next-fire computation for the scheduler (SPEC §2.1).
|
|
2
3
|
//
|
|
3
4
|
// One-validator philosophy: the SDK manifest schema only checks a cron trigger shallowly
|
|
@@ -14,8 +15,8 @@
|
|
|
14
15
|
// Plus the modern (cronie/Quartz/AWS) extension `N/step` = `N-max/step`, because it is what
|
|
15
16
|
// authors coming from any contemporary cron expect and accepting it loses nothing.
|
|
16
17
|
//
|
|
17
|
-
// Timezones use Intl only — this package takes no runtime dependency for cron (
|
|
18
|
-
//
|
|
18
|
+
// Timezones use Intl only — this package takes no runtime dependency for cron (every dependency is
|
|
19
|
+
// supply-chain surface). DST policy: a wall time erased by
|
|
19
20
|
// spring-forward is skipped (it never occurs, so it never fires); a wall time repeated by
|
|
20
21
|
// fall-back fires once, at its first (earlier-UTC) occurrence.
|
|
21
22
|
import { EngineError } from "../errors.js";
|
package/dist/engine.js
CHANGED
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Engine errors carry a stable machine-readable code (surfaced in run_status `failed` events
|
|
2
3
|
// and API responses) plus an actionable message. Messages NEVER contain secret values.
|
|
3
4
|
export const ENGINE_ERROR_CODES = [
|
|
@@ -12,7 +13,7 @@ export const ENGINE_ERROR_CODES = [
|
|
|
12
13
|
"PROGRAM_ERROR", // the workflow program threw
|
|
13
14
|
"CRASHED", // run process died and restarts were exhausted
|
|
14
15
|
"CANCELLED",
|
|
15
|
-
"UNSUPPORTED", // capability not present on this engine
|
|
16
|
+
"UNSUPPORTED", // capability not present on this engine
|
|
16
17
|
"INTERNAL",
|
|
17
18
|
];
|
|
18
19
|
/** Narrow a string (e.g. an error code off the IPC wire) to a known engine code. */
|
package/dist/ids.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// ULIDs — the engine's primary-key format: time-sortable, URL-safe,
|
|
2
3
|
// no auto-increment integers. Implemented in-house: 26 chars of Crockford base32 over
|
|
3
4
|
// 48 bits of timestamp + 80 bits of crypto randomness. Zero dependencies on purpose —
|
|
4
5
|
// every dependency in a public package is supply-chain surface.
|
package/dist/index.js
CHANGED
package/dist/json_value.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// channel are JSON by construction, but "by construction" is exactly what trust boundaries
|
|
3
|
-
// don't get to assume (CODE_QUALITY §2.1) — so narrow structurally instead of casting.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
2
|
import { EngineError } from "./errors.js";
|
|
5
3
|
/** True when `value` is a plain JSON tree (no functions, symbols, bigints, class instances). */
|
|
6
4
|
export function isJsonValue(value) {
|
package/dist/mcp/client.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The MCP connection: the protocol conversation (initialize handshake, tools/list pagination,
|
|
2
3
|
// tools/call) over any transport. Lives in the RUN PROCESS — tool execution must happen where
|
|
3
4
|
// the program runs — while OAuth token state stays parent-side (the transport's hook brokers
|
|
4
5
|
// it over IPC). Every server response is Zod-validated: an MCP server's output is untrusted
|
|
5
|
-
// input like any provider's
|
|
6
|
+
// input like any provider's.
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { EngineError } from "../errors.js";
|
|
8
9
|
import { JsonRpcClient } from "./jsonrpc.js";
|
package/dist/mcp/jsonrpc.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// JSON-RPC 2.0 request/notification correlation for the hand-rolled MCP client (the
|
|
2
3
|
// @modelcontextprotocol/sdk dependency tree was rejected for the flagship — zero new deps).
|
|
3
4
|
// The transport moves frames; this layer owns ids, correlation, timeouts, and the trust
|
|
4
5
|
// boundary: every inbound frame is Zod-validated before anything dereferences it
|
|
5
|
-
// (
|
|
6
|
+
// (an MCP server is as untrusted as any provider).
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { EngineError } from "../errors.js";
|
|
8
9
|
// One loose schema for every inbound frame; classification happens after validation. A frame
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// PARENT-side OAuth 2.1 for MCP servers (MCP authorization spec, 2025-06-18): discovery
|
|
2
3
|
// (RFC 9728 protected-resource metadata → RFC 8414 AS metadata), RFC 7591 dynamic client
|
|
3
4
|
// registration, the authorization-code + PKCE (S256) grant with a loopback redirect, RFC 8707
|
|
4
5
|
// resource indicators, and the refresh grant. All of it runs in the ENGINE process: token
|
|
5
6
|
// state never belongs to the run process, and the one interactive step is an explicit public
|
|
6
7
|
// API (`Engine.authorizeMcpServer`) — a headless run that would need a human fails loudly
|
|
7
|
-
// instead of prompting. Every external response is Zod-validated
|
|
8
|
+
// instead of prompting. Every external response is Zod-validated.
|
|
8
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
10
|
import http from "node:http";
|
|
10
11
|
import { z } from "zod";
|
package/dist/mcp/token_store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// PARENT-side MCP OAuth token persistence. Tokens live with the engine — never in the run
|
|
2
3
|
// process beyond the single brokered value a request needs — in one JSON file under the data
|
|
3
|
-
// dir, mode 0600 (they are credentials
|
|
4
|
+
// dir, mode 0600 (they are credentials, treated like secrets: values
|
|
4
5
|
// never logged). Zod-validated on every read because a disk file is a trust boundary even
|
|
5
6
|
// when we wrote it.
|
|
6
7
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// MCP streamable-HTTP transport (spec rev 2025-06-18 §Transports): every client message is a
|
|
2
3
|
// POST to the server URL; the response is either a single JSON body or an SSE stream of
|
|
3
4
|
// JSON-RPC messages (one shared parser with the provider adapters — src/agent/sse.ts). The
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// MCP stdio transport: spawn the server command and speak newline-delimited JSON over its
|
|
2
3
|
// stdin/stdout (the MCP stdio framing). Runs in the RUN PROCESS — the program is the trusted
|
|
3
4
|
// layer, so its inline `command`/`env` are honored as-is; the server's stderr is inherited so
|
package/dist/run/child.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The run-process entry point. Spawned by the supervisor with an IPC channel; never run
|
|
2
3
|
// directly. Protocol: wait for `init`, install the SDK host + run inputs, then IMPORT the
|
|
3
4
|
// program bundle — the module body is the program, so importing the file IS running it
|
|
4
|
-
// (
|
|
5
|
+
// (no entrypoint convention). Report `done`/`failed`, exit. A thrown error
|
|
5
6
|
// anywhere is reported over IPC when possible — the supervisor treats an exit without a
|
|
6
7
|
// report as a crash (which triggers restart-from-the-top, the documented semantics).
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
package/dist/run/child_host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The WorkflowHost installed in the run process.
|
|
2
3
|
//
|
|
3
4
|
// Split of responsibilities (SPEC §2.3): anything that only needs the local process happens
|
|
@@ -103,7 +104,7 @@ export function createChildHost(io, capabilities) {
|
|
|
103
104
|
/** setTimeout's max delay (2^31-1 ms ≈ 24.8 days); longer sleeps are chunked. */
|
|
104
105
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
105
106
|
// Supervisor responses are validated like any other boundary input — the channel being ours
|
|
106
|
-
// doesn't exempt it
|
|
107
|
+
// doesn't exempt it.
|
|
107
108
|
const secretValueSchema = z.string();
|
|
108
109
|
const runIdSchema = z.string().min(1);
|
|
109
110
|
const artifactRefSchema = z.strictObject({
|
package/dist/run/idempotency.js
CHANGED
package/dist/run/ipc.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The supervisor ⇄ run-process IPC protocol.
|
|
2
3
|
//
|
|
3
4
|
// One run = one spawned Node process (SPEC §2.2). The child executes the user's program and
|
|
4
5
|
// brokers its SDK hook calls back to the supervisor over Node's built-in IPC channel. Every
|
|
5
6
|
// message is Zod-validated on receipt — the child runs user code, so everything it sends is a
|
|
6
|
-
// trust boundary
|
|
7
|
+
// trust boundary.
|
|
7
8
|
//
|
|
8
9
|
// Envelope authority: the child sends event BODIES (no runId/turnId/seq/t); the supervisor is
|
|
9
10
|
// the single place envelopes are stamped and cursors allocated, so cursor monotonicity holds
|
package/dist/run/run_dir.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// Per-run on-disk layout + the SDK-sharing symlink.
|
|
2
3
|
//
|
|
3
4
|
// <dataDir>/runs/<runId>/
|
|
@@ -16,7 +17,7 @@ import { createRequire } from "node:module";
|
|
|
16
17
|
import { dirname, join } from "node:path";
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { EngineError } from "../errors.js";
|
|
19
|
-
// A file from disk is a trust boundary — parse, don't cast
|
|
20
|
+
// A file from disk is a trust boundary — parse, don't cast.
|
|
20
21
|
const packageNameSchema = z.looseObject({ name: z.string().optional() });
|
|
21
22
|
/** Lay out (or re-lay-out, on restart) the run directory for a program bundle. Idempotent. */
|
|
22
23
|
export function prepareRunDir(dataDir, runId, program) {
|
package/dist/run/supervisor.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The run supervisor — owns run-lifecycle state transitions and process supervision
|
|
2
|
-
// (SPEC §2.2). Layering
|
|
3
|
+
// (SPEC §2.2). Layering: knows nothing about HTTP or the CLI; persistence
|
|
3
4
|
// goes through the Store; what workflows *do* lives in the child process.
|
|
4
5
|
//
|
|
5
|
-
// Semantics implemented here, identical in every engine
|
|
6
|
+
// Semantics implemented here, identical in every engine:
|
|
6
7
|
// - one run = one spawned process, isolated working directory
|
|
7
8
|
// - hold-and-pay: sleep holds the child; nothing here checkpoints
|
|
8
9
|
// - restart-on-crash: child death without a done/failed report restarts the run from the
|
|
@@ -308,7 +309,7 @@ export class RunSupervisor {
|
|
|
308
309
|
if (deadline !== null) {
|
|
309
310
|
budgetTimer = setTimeout(() => {
|
|
310
311
|
entry.budgetReason ??= durationBudgetMessage(workflow.manifest);
|
|
311
|
-
// Budget breach terminates immediately — enforced, not advisory
|
|
312
|
+
// Budget breach terminates immediately — enforced, not advisory.
|
|
312
313
|
child.kill("SIGKILL");
|
|
313
314
|
}, deadline - this.clock.now());
|
|
314
315
|
}
|
|
@@ -377,7 +378,7 @@ export class RunSupervisor {
|
|
|
377
378
|
break;
|
|
378
379
|
case "memory_used":
|
|
379
380
|
// The child validated the path, but the parent persists it — re-check the shape
|
|
380
|
-
// before it can ever reach a filesystem copy
|
|
381
|
+
// before it can ever reach a filesystem copy.
|
|
381
382
|
if (MEMORY_PATH_RE.test(msg.dir) && !msg.dir.includes("\\")) {
|
|
382
383
|
entry.memoryDirs.add(msg.dir);
|
|
383
384
|
}
|
|
@@ -511,7 +512,7 @@ export class RunSupervisor {
|
|
|
511
512
|
if (target === null) {
|
|
512
513
|
throw new EngineError("NOT_FOUND", `workflows.call target "${slug}" is not deployed on this engine.`, `Deploy it first — the engine only runs workflows it knows by name.`);
|
|
513
514
|
}
|
|
514
|
-
// Crossed the JSON IPC channel, but narrow instead of assuming
|
|
515
|
+
// Crossed the JSON IPC channel, but narrow instead of assuming — and
|
|
515
516
|
// the canonical default key requires a JSON tree anyway.
|
|
516
517
|
const jsonInput = input === undefined ? null : asJsonValue(input, "workflows.call input");
|
|
517
518
|
const key = idempotencyKey ?? defaultIdempotencyKey(parentRunId, slug, jsonInput);
|
|
@@ -11,7 +11,7 @@ export interface SchedulerOptions {
|
|
|
11
11
|
log?: (line: string) => void;
|
|
12
12
|
/** Tick cadence of the background loop. Default 1s. */
|
|
13
13
|
tickIntervalMs?: number;
|
|
14
|
-
/** Ticks slower than this are logged
|
|
14
|
+
/** Ticks slower than this are logged. Default 250ms. */
|
|
15
15
|
tickBudgetMs?: number;
|
|
16
16
|
}
|
|
17
17
|
export declare class Scheduler {
|
|
Binary file
|
package/dist/server/http.d.ts
CHANGED
|
@@ -37,6 +37,6 @@ export declare function parseNonNegativeInt(url: URL, name: string, fallback: nu
|
|
|
37
37
|
/**
|
|
38
38
|
* The channel subscription for an event read (`/events` and `/stream` share this so a tail and
|
|
39
39
|
* its catch-up reads can never disagree): `?verbose=true` = everything, `?channels=a,b` = an
|
|
40
|
-
* explicit set, neither =
|
|
40
|
+
* explicit set, neither = the default of lifecycle + phase + output.
|
|
41
41
|
*/
|
|
42
42
|
export declare function parseChannelSelection(url: URL): readonly Channel[];
|
package/dist/server/http.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
//
|
|
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.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
6
2
|
import { z } from "zod";
|
|
7
3
|
import { CHANNELS, DEFAULT_CHANNELS } from "@boardwalk-labs/workflow";
|
|
8
4
|
import { EngineError } from "../errors.js";
|
|
@@ -160,7 +156,7 @@ function isChannel(value) {
|
|
|
160
156
|
/**
|
|
161
157
|
* The channel subscription for an event read (`/events` and `/stream` share this so a tail and
|
|
162
158
|
* its catch-up reads can never disagree): `?verbose=true` = everything, `?channels=a,b` = an
|
|
163
|
-
* explicit set, neither =
|
|
159
|
+
* explicit set, neither = the default of lifecycle + phase + output.
|
|
164
160
|
*/
|
|
165
161
|
export function parseChannelSelection(url) {
|
|
166
162
|
const verbose = url.searchParams.get("verbose");
|
|
@@ -10,7 +10,7 @@ export declare function handleGetRun(ctx: RouteContext, runId: string): void;
|
|
|
10
10
|
/**
|
|
11
11
|
* GET /api/runs/:id/events?after=&channels=|verbose= — persisted events after a cursor,
|
|
12
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
|
|
13
|
+
* client can resume here (or on /stream) with any channel set.
|
|
14
14
|
*/
|
|
15
15
|
export declare function handleListEvents(ctx: RouteContext, runId: string): void;
|
|
16
16
|
/** POST /api/runs/:id/cancel — 202: accepted now, completes after the cooperative grace. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The JSON API (SPEC §2.4): list workflows/runs, trigger a manual run, read a run + its
|
|
2
3
|
// events, cancel. Handlers translate HTTP into engine/store calls and nothing else — all SQL
|
|
3
4
|
// lives in the store, all run semantics in the engine.
|
|
@@ -80,7 +81,7 @@ export function handleGetRun(ctx, runId) {
|
|
|
80
81
|
/**
|
|
81
82
|
* GET /api/runs/:id/events?after=&channels=|verbose= — persisted events after a cursor,
|
|
82
83
|
* 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
|
|
84
|
+
* client can resume here (or on /stream) with any channel set.
|
|
84
85
|
*/
|
|
85
86
|
export function handleListEvents(ctx, runId) {
|
|
86
87
|
if (ctx.engine.store.getRun(runId) === null) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// POST /hooks/:workflow/:triggerIndex — the webhook trigger endpoint, and this engine's v0
|
|
2
|
-
// answer to
|
|
3
|
+
// answer to the open webhook-auth question (documented in SPEC §2.4):
|
|
3
4
|
// per-workflow credentials live in *server* environment variables —
|
|
4
5
|
// token auth: BOARDWALK_WEBHOOK_TOKEN__<NAME> vs `Authorization: Bearer <token>`
|
|
5
6
|
// signature auth: BOARDWALK_WEBHOOK_SECRET__<NAME> vs `X-Boardwalk-Signature: sha256=<hex>`
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
//
|
|
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".
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
5
2
|
import { HttpError } from "../http.js";
|
|
6
3
|
import { handleCancelRun, handleGetRun, handleListEvents, handleListRuns, handleListWorkflows, handleStartRun, } from "./api.js";
|
|
7
4
|
import { handleWebhook } from "./hooks.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// GET /api/runs/:id/stream — the SSE live tail (SPEC §2.4): replay
|
|
2
3
|
// persisted events after the resume cursor, then follow live ones. Every frame carries
|
|
3
4
|
// `id: <cursor>`, so a dropped client reconnects with Last-Event-ID and misses nothing —
|
|
4
5
|
// cursors are run-global and independent of channel filtering, which keeps filtered resumes
|
package/dist/server/routes/ui.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
//
|
|
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.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
5
2
|
export function handleUiPage(ctx) {
|
|
6
3
|
ctx.res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
7
4
|
ctx.res.end(RUN_LOG_PAGE);
|
package/dist/server/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The engine's HTTP surface (SPEC §2.4): JSON API + SSE live tail + webhook triggers + the
|
|
2
3
|
// local run-log page, on bare node:http. This file owns the socket lifecycle only — routing
|
|
3
4
|
// lives in routes/router.ts, and every handler goes through engine/store methods, never SQL.
|
package/dist/server_main.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface ServerConfig {
|
|
|
11
11
|
inference: InferenceConfig | undefined;
|
|
12
12
|
/** Explicit BOARDWALK_ENV_FILE path; undefined means "use <dataDir>/.env if it exists". */
|
|
13
13
|
envFile: string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Directory of built workflow programs deployed on boot (the self-host deploy mechanism):
|
|
16
|
+
* each `.mjs`/`.js` file is one workflow (single-file, `@boardwalk-labs/workflow` external —
|
|
17
|
+
* what `boardwalk build` emits). Defaults to `<dataDir>/workflows`.
|
|
18
|
+
*/
|
|
19
|
+
workflowsDir: string;
|
|
14
20
|
}
|
|
15
21
|
/**
|
|
16
22
|
* Parse server config from an environment map. Pure (no filesystem, no process globals) so
|
package/dist/server_main.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The composition root for the `boardwalk-server` binary (SPEC §2.4 + §5): parse config,
|
|
2
3
|
// construct the Engine, mount the HTTP surface, wire graceful shutdown. Everything here is
|
|
3
4
|
// glue — run semantics live in the engine, routing in the server, so this file stays thin
|
|
4
5
|
// enough that config parsing (tested below) plus the already-tested pieces carry the risk.
|
|
5
6
|
//
|
|
6
7
|
// Config is ENVIRONMENT VARIABLES ONLY in v0 (`BOARDWALK_` prefix). A `boardwalk.toml` file
|
|
7
|
-
// is deferred: Node has no TOML built-in and the zero-dependency rule
|
|
8
|
+
// is deferred: Node has no TOML built-in and the zero-dependency rule
|
|
8
9
|
// beats a hand-rolled parser. Env vars are also what Docker/systemd operators reach for
|
|
9
10
|
// first, so the deferral costs nothing in practice.
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
12
|
import { join, resolve } from "node:path";
|
|
12
13
|
import { parseEnv } from "node:util";
|
|
13
14
|
import { z } from "zod";
|
|
@@ -115,8 +116,40 @@ export function loadServerConfig(env) {
|
|
|
115
116
|
port: parsePort(get("BOARDWALK_PORT")),
|
|
116
117
|
inference,
|
|
117
118
|
envFile: get("BOARDWALK_ENV_FILE"),
|
|
119
|
+
workflowsDir: get("BOARDWALK_WORKFLOWS_DIR") ?? join(dataDir, "workflows"),
|
|
118
120
|
};
|
|
119
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Deploy every built workflow in `dir` on boot — the self-host deploy mechanism. Each `.mjs`/`.js`
|
|
124
|
+
* file is one workflow's program (single-file, `@boardwalk-labs/workflow` external — what
|
|
125
|
+
* `boardwalk build` emits). Idempotent by manifest name (re-boot re-syncs the dir into the store);
|
|
126
|
+
* a removed file leaves its last-deployed workflow in place (no un-deploy in v0). A missing dir is
|
|
127
|
+
* fine (an operator may deploy by other means); a bad file is logged and skipped, never fatal.
|
|
128
|
+
*/
|
|
129
|
+
function deployWorkflowsFromDir(engine, dir, log) {
|
|
130
|
+
if (!existsSync(dir))
|
|
131
|
+
return;
|
|
132
|
+
let files;
|
|
133
|
+
try {
|
|
134
|
+
files = readdirSync(dir)
|
|
135
|
+
.filter((name) => name.endsWith(".mjs") || name.endsWith(".js"))
|
|
136
|
+
.sort();
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
log(`workflows dir ${dir} could not be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
try {
|
|
144
|
+
const program = readFileSync(join(dir, file), "utf8");
|
|
145
|
+
const workflow = engine.deployWorkflow({ program });
|
|
146
|
+
log(`deployed "${workflow.name}" from ${file}`);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
log(`skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
120
153
|
/**
|
|
121
154
|
* Resolve the engine's secret/env source (SPEC §2.3 `secrets.get`): the configured
|
|
122
155
|
* BOARDWALK_ENV_FILE, else `<dataDir>/.env` when present. An explicitly named file that does
|
|
@@ -157,6 +190,8 @@ export async function startServer(config, log) {
|
|
|
157
190
|
const swept = engine.start();
|
|
158
191
|
log(`recovery sweep: restarted ${String(swept.resumed.length)} run(s), ` +
|
|
159
192
|
`cancelled ${String(swept.cancelled.length)}`);
|
|
193
|
+
// Self-host deploy: sync the workflows dir into the store before serving (idempotent).
|
|
194
|
+
deployWorkflowsFromDir(engine, config.workflowsDir, log);
|
|
160
195
|
const server = createEngineServer(engine, { host: config.host, log });
|
|
161
196
|
const { port } = await server.listen(config.port);
|
|
162
197
|
log(`data dir: ${resolve(config.dataDir)}`);
|
package/dist/store/migrations.js
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// The schema version lives in SQLite's `PRAGMA user_version` (an integer in the database
|
|
4
|
-
// header): reading it costs nothing, needs no bookkeeping table, and — crucially — setting it
|
|
5
|
-
// participates in the same transaction as the migration's DDL. A crash mid-migration therefore
|
|
6
|
-
// leaves the database exactly at the previous version with none of the new schema applied
|
|
7
|
-
// (CODE_QUALITY §2.2: multi-row writes are transactional; a half-migrated database is a state
|
|
8
|
-
// the engine could not recover from).
|
|
9
|
-
//
|
|
10
|
-
// Forward-only: an older engine refuses a newer database instead of guessing what future
|
|
11
|
-
// columns mean. Downgrades are restore-from-backup, not code.
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
12
2
|
import { EngineError } from "../errors.js";
|
|
13
3
|
// v1 — the full SPEC §4 schema. STRICT tables so SQLite enforces the declared column types
|
|
14
4
|
// (a TEXT primary key can never silently hold an integer; INTEGER columns reject REALs).
|
|
15
|
-
// All timestamps are integer milliseconds since epoch; all ids are ULIDs
|
|
5
|
+
// All timestamps are integer milliseconds since epoch; all ids are ULIDs.
|
|
16
6
|
const V1_SQL = `
|
|
17
7
|
-- Workflows: the deployed unit. \`manifest\` is the validated JSON projection of the program's
|
|
18
8
|
-- pure-literal meta; \`program\` is the bundled ESM source the run host executes; \`config\` is
|
package/dist/store/store.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
1
2
|
// The engine's one persistence module — every SQL statement in the engine lives here
|
|
2
|
-
// (
|
|
3
|
+
// (storage access goes through one persistence module). The backend is
|
|
3
4
|
// node:sqlite, synchronous on purpose: a single-node engine gains nothing from an async
|
|
4
5
|
// driver, and synchronous statements make multi-row invariants trivially transactional.
|
|
5
6
|
//
|
|
6
|
-
// Conventions
|
|
7
|
+
// Conventions: ULID primary keys, integer-ms timestamps, and JSON columns
|
|
7
8
|
// Zod-validated on READ — a row that fails validation throws EngineError("INTERNAL") naming
|
|
8
9
|
// the table and column, so corrupt state surfaces as a loud error instead of flowing into the
|
|
9
10
|
// scheduler as data.
|
|
@@ -14,7 +15,7 @@ import { EngineError } from "../errors.js";
|
|
|
14
15
|
import { ulid } from "../ids.js";
|
|
15
16
|
import { migrate } from "./migrations.js";
|
|
16
17
|
// ============================================================================
|
|
17
|
-
// Column schemas — every JSON/enum column has exactly one validator
|
|
18
|
+
// Column schemas — every JSON/enum column has exactly one validator
|
|
18
19
|
// ============================================================================
|
|
19
20
|
/**
|
|
20
21
|
* Build a Zod enum from an exhaustive flag record. Why a Record and not a plain array: the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The Boardwalk single-node engine: cron scheduling, run lifecycle, SQLite state, and the local run log. Powers `boardwalk dev` and the self-hosted server.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"coverage": "vitest run --coverage"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@boardwalk-labs/workflow": "^0.1.
|
|
43
|
+
"@boardwalk-labs/workflow": "^0.1.2",
|
|
44
44
|
"zod": "^4.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|