@boardwalk-labs/engine 0.1.0 → 0.1.1
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/dist/agent/leaf.js +1 -1
- package/dist/agent/providers.js +2 -2
- package/dist/agent/redact.js +1 -1
- package/dist/agent/resolve.js +1 -1
- package/dist/agent/tools.js +1 -1
- package/dist/cron/cron.js +2 -2
- package/dist/errors.js +1 -1
- package/dist/ids.js +1 -1
- package/dist/json_value.js +1 -1
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/jsonrpc.js +1 -1
- package/dist/mcp/oauth.js +1 -1
- package/dist/mcp/token_store.js +1 -1
- package/dist/run/child.js +1 -1
- package/dist/run/child_host.js +1 -1
- package/dist/run/ipc.js +1 -1
- package/dist/run/run_dir.js +1 -1
- package/dist/run/supervisor.js +5 -5
- package/dist/scheduler/scheduler.d.ts +1 -1
- package/dist/scheduler/scheduler.js +1 -1
- package/dist/server/http.d.ts +1 -1
- package/dist/server/http.js +1 -1
- package/dist/server/routes/api.d.ts +1 -1
- package/dist/server/routes/api.js +1 -1
- package/dist/server/routes/hooks.js +1 -1
- package/dist/server/routes/stream.js +1 -1
- package/dist/server_main.d.ts +6 -0
- package/dist/server_main.js +36 -2
- package/dist/store/migrations.js +2 -2
- package/dist/store/store.js +3 -3
- package/package.json +1 -1
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/dist/agent/leaf.js
CHANGED
|
@@ -150,7 +150,7 @@ async function executeToolCall(call, tools, io, turnId) {
|
|
|
150
150
|
}
|
|
151
151
|
io.emit(turnId, { kind: "tool_call_executing", toolCallId: call.id });
|
|
152
152
|
try {
|
|
153
|
-
// Tool results enter model context → redact (
|
|
153
|
+
// Tool results enter model context → redact (redaction covers tool results too).
|
|
154
154
|
const content = io.redactor.redact(await tool.execute(call.input));
|
|
155
155
|
io.emit(turnId, {
|
|
156
156
|
kind: "tool_call_result",
|
package/dist/agent/providers.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// model turn; the tool loop itself lives in leaf.ts.
|
|
6
6
|
//
|
|
7
7
|
// Zero SDK dependencies: plain fetch + Zod-validated responses (a provider's response is a
|
|
8
|
-
// trust boundary like any other). Retry policy
|
|
8
|
+
// trust boundary like any other). Retry policy: exponential backoff with
|
|
9
9
|
// jitter on 429/5xx/network errors; a non-rate-limit 4xx never retries.
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { EngineError } from "../errors.js";
|
|
@@ -300,7 +300,7 @@ export async function chatOpenAi(args, io = {}) {
|
|
|
300
300
|
// ----------------------------------------------------------------------------
|
|
301
301
|
// Shared plumbing
|
|
302
302
|
// ----------------------------------------------------------------------------
|
|
303
|
-
/** Model-produced tool input is untrusted
|
|
303
|
+
/** Model-produced tool input is untrusted: parse, demand a JSON object. */
|
|
304
304
|
function parseToolInput(raw, toolName) {
|
|
305
305
|
const value = raw.trim().length === 0 ? {} : safeJson(raw);
|
|
306
306
|
if (isPlainObject(value) && isJsonValue(value)) {
|
package/dist/agent/redact.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Secret redaction for the agent() leaf
|
|
1
|
+
// Secret redaction for the agent() leaf.
|
|
2
2
|
//
|
|
3
3
|
// The invariant: secret VALUES live only in deterministic program code; everything bound for a
|
|
4
4
|
// model — prompts now, tool args/results/MCP traffic/skills/memory later — is scrubbed of every
|
package/dist/agent/resolve.js
CHANGED
|
@@ -22,7 +22,7 @@ export const BOARDWALK_PROVIDER = "boardwalk";
|
|
|
22
22
|
// Boardwalk inference gateway API; this engine sends model: "auto".
|
|
23
23
|
const AUTO_MODEL = "auto";
|
|
24
24
|
// The Boardwalk managed-inference gateway (OpenAI-compatible). `boardwalk.sh` is the placeholder
|
|
25
|
-
// domain
|
|
25
|
+
// domain; override with BOARDWALK_INFERENCE_URL or config. The Auto
|
|
26
26
|
// ROUTER itself lives in hosted Boardwalk — this engine only forwards to the gateway.
|
|
27
27
|
const DEFAULT_BOARDWALK_INFERENCE_URL = "https://api.boardwalk.sh/v1";
|
|
28
28
|
// Built-in direct-call providers — used only when NAMED explicitly; key from the conventional
|
package/dist/agent/tools.js
CHANGED
|
@@ -93,7 +93,7 @@ function wrapProgramTool(def) {
|
|
|
93
93
|
// Server names prefix tool names (`<server>__<tool>`) — keep them tool-name-shaped.
|
|
94
94
|
const MCP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
95
95
|
// 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
|
|
96
|
+
// runtime, so each ref is Zod-checked before anything spawns or connects.
|
|
97
97
|
const mcpServerRefSchema = z.discriminatedUnion("transport", [
|
|
98
98
|
z.strictObject({
|
|
99
99
|
name: z.string().regex(MCP_NAME_RE),
|
package/dist/cron/cron.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// Plus the modern (cronie/Quartz/AWS) extension `N/step` = `N-max/step`, because it is what
|
|
15
15
|
// authors coming from any contemporary cron expect and accepting it loses nothing.
|
|
16
16
|
//
|
|
17
|
-
// Timezones use Intl only — this package takes no runtime dependency for cron (
|
|
18
|
-
//
|
|
17
|
+
// Timezones use Intl only — this package takes no runtime dependency for cron (every dependency is
|
|
18
|
+
// supply-chain surface). DST policy: a wall time erased by
|
|
19
19
|
// spring-forward is skipped (it never occurs, so it never fires); a wall time repeated by
|
|
20
20
|
// fall-back fires once, at its first (earlier-UTC) occurrence.
|
|
21
21
|
import { EngineError } from "../errors.js";
|
package/dist/errors.js
CHANGED
|
@@ -12,7 +12,7 @@ export const ENGINE_ERROR_CODES = [
|
|
|
12
12
|
"PROGRAM_ERROR", // the workflow program threw
|
|
13
13
|
"CRASHED", // run process died and restarts were exhausted
|
|
14
14
|
"CANCELLED",
|
|
15
|
-
"UNSUPPORTED", // capability not present on this engine
|
|
15
|
+
"UNSUPPORTED", // capability not present on this engine
|
|
16
16
|
"INTERNAL",
|
|
17
17
|
];
|
|
18
18
|
/** 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,4 @@
|
|
|
1
|
-
// ULIDs — the engine's primary-key format
|
|
1
|
+
// ULIDs — the engine's primary-key format: time-sortable, URL-safe,
|
|
2
2
|
// no auto-increment integers. Implemented in-house: 26 chars of Crockford base32 over
|
|
3
3
|
// 48 bits of timestamp + 80 bits of crypto randomness. Zero dependencies on purpose —
|
|
4
4
|
// every dependency in a public package is supply-chain surface.
|
package/dist/json_value.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runtime narrowing for JsonValue. Values arriving over the run process's JSON-serialized IPC
|
|
2
2
|
// channel are JSON by construction, but "by construction" is exactly what trust boundaries
|
|
3
|
-
// don't get to assume
|
|
3
|
+
// don't get to assume — so narrow structurally instead of casting.
|
|
4
4
|
import { EngineError } from "./errors.js";
|
|
5
5
|
/** True when `value` is a plain JSON tree (no functions, symbols, bigints, class instances). */
|
|
6
6
|
export function isJsonValue(value) {
|
package/dist/mcp/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// tools/call) over any transport. Lives in the RUN PROCESS — tool execution must happen where
|
|
3
3
|
// the program runs — while OAuth token state stays parent-side (the transport's hook brokers
|
|
4
4
|
// it over IPC). Every server response is Zod-validated: an MCP server's output is untrusted
|
|
5
|
-
// input like any provider's
|
|
5
|
+
// input like any provider's.
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { EngineError } from "../errors.js";
|
|
8
8
|
import { JsonRpcClient } from "./jsonrpc.js";
|
package/dist/mcp/jsonrpc.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @modelcontextprotocol/sdk dependency tree was rejected for the flagship — zero new deps).
|
|
3
3
|
// The transport moves frames; this layer owns ids, correlation, timeouts, and the trust
|
|
4
4
|
// boundary: every inbound frame is Zod-validated before anything dereferences it
|
|
5
|
-
// (
|
|
5
|
+
// (an MCP server is as untrusted as any provider).
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { EngineError } from "../errors.js";
|
|
8
8
|
// One loose schema for every inbound frame; classification happens after validation. A frame
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// resource indicators, and the refresh grant. All of it runs in the ENGINE process: token
|
|
5
5
|
// state never belongs to the run process, and the one interactive step is an explicit public
|
|
6
6
|
// API (`Engine.authorizeMcpServer`) — a headless run that would need a human fails loudly
|
|
7
|
-
// instead of prompting. Every external response is Zod-validated
|
|
7
|
+
// instead of prompting. Every external response is Zod-validated.
|
|
8
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
9
|
import http from "node:http";
|
|
10
10
|
import { z } from "zod";
|
package/dist/mcp/token_store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// PARENT-side MCP OAuth token persistence. Tokens live with the engine — never in the run
|
|
2
2
|
// process beyond the single brokered value a request needs — in one JSON file under the data
|
|
3
|
-
// dir, mode 0600 (they are credentials
|
|
3
|
+
// dir, mode 0600 (they are credentials, treated like secrets: values
|
|
4
4
|
// never logged). Zod-validated on every read because a disk file is a trust boundary even
|
|
5
5
|
// when we wrote it.
|
|
6
6
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
package/dist/run/child.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The run-process entry point. Spawned by the supervisor with an IPC channel; never run
|
|
2
2
|
// directly. Protocol: wait for `init`, install the SDK host + run inputs, then IMPORT the
|
|
3
3
|
// program bundle — the module body is the program, so importing the file IS running it
|
|
4
|
-
// (
|
|
4
|
+
// (no entrypoint convention). Report `done`/`failed`, exit. A thrown error
|
|
5
5
|
// anywhere is reported over IPC when possible — the supervisor treats an exit without a
|
|
6
6
|
// report as a crash (which triggers restart-from-the-top, the documented semantics).
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
package/dist/run/child_host.js
CHANGED
|
@@ -103,7 +103,7 @@ export function createChildHost(io, capabilities) {
|
|
|
103
103
|
/** setTimeout's max delay (2^31-1 ms ≈ 24.8 days); longer sleeps are chunked. */
|
|
104
104
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
105
105
|
// Supervisor responses are validated like any other boundary input — the channel being ours
|
|
106
|
-
// doesn't exempt it
|
|
106
|
+
// doesn't exempt it.
|
|
107
107
|
const secretValueSchema = z.string();
|
|
108
108
|
const runIdSchema = z.string().min(1);
|
|
109
109
|
const artifactRefSchema = z.strictObject({
|
package/dist/run/ipc.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// One run = one spawned Node process (SPEC §2.2). The child executes the user's program and
|
|
4
4
|
// brokers its SDK hook calls back to the supervisor over Node's built-in IPC channel. Every
|
|
5
5
|
// message is Zod-validated on receipt — the child runs user code, so everything it sends is a
|
|
6
|
-
// trust boundary
|
|
6
|
+
// trust boundary.
|
|
7
7
|
//
|
|
8
8
|
// Envelope authority: the child sends event BODIES (no runId/turnId/seq/t); the supervisor is
|
|
9
9
|
// the single place envelopes are stamped and cursors allocated, so cursor monotonicity holds
|
package/dist/run/run_dir.js
CHANGED
|
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { EngineError } from "../errors.js";
|
|
19
|
-
// A file from disk is a trust boundary — parse, don't cast
|
|
19
|
+
// A file from disk is a trust boundary — parse, don't cast.
|
|
20
20
|
const packageNameSchema = z.looseObject({ name: z.string().optional() });
|
|
21
21
|
/** Lay out (or re-lay-out, on restart) the run directory for a program bundle. Idempotent. */
|
|
22
22
|
export function prepareRunDir(dataDir, runId, program) {
|
package/dist/run/supervisor.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The run supervisor — owns run-lifecycle state transitions and process supervision
|
|
2
|
-
// (SPEC §2.2). Layering
|
|
2
|
+
// (SPEC §2.2). Layering: knows nothing about HTTP or the CLI; persistence
|
|
3
3
|
// goes through the Store; what workflows *do* lives in the child process.
|
|
4
4
|
//
|
|
5
|
-
// Semantics implemented here, identical in every engine
|
|
5
|
+
// Semantics implemented here, identical in every engine:
|
|
6
6
|
// - one run = one spawned process, isolated working directory
|
|
7
7
|
// - hold-and-pay: sleep holds the child; nothing here checkpoints
|
|
8
8
|
// - restart-on-crash: child death without a done/failed report restarts the run from the
|
|
@@ -308,7 +308,7 @@ export class RunSupervisor {
|
|
|
308
308
|
if (deadline !== null) {
|
|
309
309
|
budgetTimer = setTimeout(() => {
|
|
310
310
|
entry.budgetReason ??= durationBudgetMessage(workflow.manifest);
|
|
311
|
-
// Budget breach terminates immediately — enforced, not advisory
|
|
311
|
+
// Budget breach terminates immediately — enforced, not advisory.
|
|
312
312
|
child.kill("SIGKILL");
|
|
313
313
|
}, deadline - this.clock.now());
|
|
314
314
|
}
|
|
@@ -377,7 +377,7 @@ export class RunSupervisor {
|
|
|
377
377
|
break;
|
|
378
378
|
case "memory_used":
|
|
379
379
|
// The child validated the path, but the parent persists it — re-check the shape
|
|
380
|
-
// before it can ever reach a filesystem copy
|
|
380
|
+
// before it can ever reach a filesystem copy.
|
|
381
381
|
if (MEMORY_PATH_RE.test(msg.dir) && !msg.dir.includes("\\")) {
|
|
382
382
|
entry.memoryDirs.add(msg.dir);
|
|
383
383
|
}
|
|
@@ -511,7 +511,7 @@ export class RunSupervisor {
|
|
|
511
511
|
if (target === null) {
|
|
512
512
|
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
513
|
}
|
|
514
|
-
// Crossed the JSON IPC channel, but narrow instead of assuming
|
|
514
|
+
// Crossed the JSON IPC channel, but narrow instead of assuming — and
|
|
515
515
|
// the canonical default key requires a JSON tree anyway.
|
|
516
516
|
const jsonInput = input === undefined ? null : asJsonValue(input, "workflows.call input");
|
|
517
517
|
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 {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The cron scheduler (SPEC §2.1). Layering: it fires runs; it knows nothing about what a
|
|
2
|
-
// workflow does
|
|
2
|
+
// workflow does. Execution is injected (`dispatch`) so this module never
|
|
3
3
|
// touches processes, and time is injected (`Clock`) so tests drive it deterministically.
|
|
4
4
|
//
|
|
5
5
|
// Correctness rules implemented here:
|
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
|
@@ -160,7 +160,7 @@ function isChannel(value) {
|
|
|
160
160
|
/**
|
|
161
161
|
* The channel subscription for an event read (`/events` and `/stream` share this so a tail and
|
|
162
162
|
* its catch-up reads can never disagree): `?verbose=true` = everything, `?channels=a,b` = an
|
|
163
|
-
* explicit set, neither =
|
|
163
|
+
* explicit set, neither = the default of lifecycle + phase + output.
|
|
164
164
|
*/
|
|
165
165
|
export function parseChannelSelection(url) {
|
|
166
166
|
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. */
|
|
@@ -80,7 +80,7 @@ export function handleGetRun(ctx, runId) {
|
|
|
80
80
|
/**
|
|
81
81
|
* GET /api/runs/:id/events?after=&channels=|verbose= — persisted events after a cursor,
|
|
82
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
|
|
83
|
+
* client can resume here (or on /stream) with any channel set.
|
|
84
84
|
*/
|
|
85
85
|
export function handleListEvents(ctx, runId) {
|
|
86
86
|
if (ctx.engine.store.getRun(runId) === null) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// POST /hooks/:workflow/:triggerIndex — the webhook trigger endpoint, and this engine's v0
|
|
2
|
-
// answer to
|
|
2
|
+
// answer to the open webhook-auth question (documented in SPEC §2.4):
|
|
3
3
|
// per-workflow credentials live in *server* environment variables —
|
|
4
4
|
// token auth: BOARDWALK_WEBHOOK_TOKEN__<NAME> vs `Authorization: Bearer <token>`
|
|
5
5
|
// signature auth: BOARDWALK_WEBHOOK_SECRET__<NAME> vs `X-Boardwalk-Signature: sha256=<hex>`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GET /api/runs/:id/stream — the SSE live tail (SPEC §2.4
|
|
1
|
+
// GET /api/runs/:id/stream — the SSE live tail (SPEC §2.4): replay
|
|
2
2
|
// persisted events after the resume cursor, then follow live ones. Every frame carries
|
|
3
3
|
// `id: <cursor>`, so a dropped client reconnects with Last-Event-ID and misses nothing —
|
|
4
4
|
// cursors are run-global and independent of channel filtering, which keeps filtered resumes
|
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
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// enough that config parsing (tested below) plus the already-tested pieces carry the risk.
|
|
5
5
|
//
|
|
6
6
|
// 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
|
|
7
|
+
// is deferred: Node has no TOML built-in and the zero-dependency rule
|
|
8
8
|
// beats a hand-rolled parser. Env vars are also what Docker/systemd operators reach for
|
|
9
9
|
// first, so the deferral costs nothing in practice.
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { join, resolve } from "node:path";
|
|
12
12
|
import { parseEnv } from "node:util";
|
|
13
13
|
import { z } from "zod";
|
|
@@ -115,8 +115,40 @@ export function loadServerConfig(env) {
|
|
|
115
115
|
port: parsePort(get("BOARDWALK_PORT")),
|
|
116
116
|
inference,
|
|
117
117
|
envFile: get("BOARDWALK_ENV_FILE"),
|
|
118
|
+
workflowsDir: get("BOARDWALK_WORKFLOWS_DIR") ?? join(dataDir, "workflows"),
|
|
118
119
|
};
|
|
119
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Deploy every built workflow in `dir` on boot — the self-host deploy mechanism. Each `.mjs`/`.js`
|
|
123
|
+
* file is one workflow's program (single-file, `@boardwalk-labs/workflow` external — what
|
|
124
|
+
* `boardwalk build` emits). Idempotent by manifest name (re-boot re-syncs the dir into the store);
|
|
125
|
+
* a removed file leaves its last-deployed workflow in place (no un-deploy in v0). A missing dir is
|
|
126
|
+
* fine (an operator may deploy by other means); a bad file is logged and skipped, never fatal.
|
|
127
|
+
*/
|
|
128
|
+
function deployWorkflowsFromDir(engine, dir, log) {
|
|
129
|
+
if (!existsSync(dir))
|
|
130
|
+
return;
|
|
131
|
+
let files;
|
|
132
|
+
try {
|
|
133
|
+
files = readdirSync(dir)
|
|
134
|
+
.filter((name) => name.endsWith(".mjs") || name.endsWith(".js"))
|
|
135
|
+
.sort();
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
log(`workflows dir ${dir} could not be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
try {
|
|
143
|
+
const program = readFileSync(join(dir, file), "utf8");
|
|
144
|
+
const workflow = engine.deployWorkflow({ program });
|
|
145
|
+
log(`deployed "${workflow.name}" from ${file}`);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
log(`skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
120
152
|
/**
|
|
121
153
|
* Resolve the engine's secret/env source (SPEC §2.3 `secrets.get`): the configured
|
|
122
154
|
* BOARDWALK_ENV_FILE, else `<dataDir>/.env` when present. An explicitly named file that does
|
|
@@ -157,6 +189,8 @@ export async function startServer(config, log) {
|
|
|
157
189
|
const swept = engine.start();
|
|
158
190
|
log(`recovery sweep: restarted ${String(swept.resumed.length)} run(s), ` +
|
|
159
191
|
`cancelled ${String(swept.cancelled.length)}`);
|
|
192
|
+
// Self-host deploy: sync the workflows dir into the store before serving (idempotent).
|
|
193
|
+
deployWorkflowsFromDir(engine, config.workflowsDir, log);
|
|
160
194
|
const server = createEngineServer(engine, { host: config.host, log });
|
|
161
195
|
const { port } = await server.listen(config.port);
|
|
162
196
|
log(`data dir: ${resolve(config.dataDir)}`);
|
package/dist/store/migrations.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// header): reading it costs nothing, needs no bookkeeping table, and — crucially — setting it
|
|
5
5
|
// participates in the same transaction as the migration's DDL. A crash mid-migration therefore
|
|
6
6
|
// leaves the database exactly at the previous version with none of the new schema applied
|
|
7
|
-
// (
|
|
7
|
+
// (multi-row writes are transactional; a half-migrated database is a state
|
|
8
8
|
// the engine could not recover from).
|
|
9
9
|
//
|
|
10
10
|
// Forward-only: an older engine refuses a newer database instead of guessing what future
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { EngineError } from "../errors.js";
|
|
13
13
|
// v1 — the full SPEC §4 schema. STRICT tables so SQLite enforces the declared column types
|
|
14
14
|
// (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
|
|
15
|
+
// All timestamps are integer milliseconds since epoch; all ids are ULIDs.
|
|
16
16
|
const V1_SQL = `
|
|
17
17
|
-- Workflows: the deployed unit. \`manifest\` is the validated JSON projection of the program's
|
|
18
18
|
-- pure-literal meta; \`program\` is the bundled ESM source the run host executes; \`config\` is
|
package/dist/store/store.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The engine's one persistence module — every SQL statement in the engine lives here
|
|
2
|
-
// (
|
|
2
|
+
// (storage access goes through one persistence module). The backend is
|
|
3
3
|
// node:sqlite, synchronous on purpose: a single-node engine gains nothing from an async
|
|
4
4
|
// driver, and synchronous statements make multi-row invariants trivially transactional.
|
|
5
5
|
//
|
|
6
|
-
// Conventions
|
|
6
|
+
// Conventions: ULID primary keys, integer-ms timestamps, and JSON columns
|
|
7
7
|
// Zod-validated on READ — a row that fails validation throws EngineError("INTERNAL") naming
|
|
8
8
|
// the table and column, so corrupt state surfaces as a loud error instead of flowing into the
|
|
9
9
|
// scheduler as data.
|
|
@@ -14,7 +14,7 @@ import { EngineError } from "../errors.js";
|
|
|
14
14
|
import { ulid } from "../ids.js";
|
|
15
15
|
import { migrate } from "./migrations.js";
|
|
16
16
|
// ============================================================================
|
|
17
|
-
// Column schemas — every JSON/enum column has exactly one validator
|
|
17
|
+
// Column schemas — every JSON/enum column has exactly one validator
|
|
18
18
|
// ============================================================================
|
|
19
19
|
/**
|
|
20
20
|
* 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.1",
|
|
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": {
|