@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,203 @@
1
+ // The composition root for the `boardwalk-server` binary (SPEC §2.4 + §5): parse config,
2
+ // construct the Engine, mount the HTTP surface, wire graceful shutdown. Everything here is
3
+ // glue — run semantics live in the engine, routing in the server, so this file stays thin
4
+ // enough that config parsing (tested below) plus the already-tested pieces carry the risk.
5
+ //
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 (CODE_QUALITY §10)
8
+ // beats a hand-rolled parser. Env vars are also what Docker/systemd operators reach for
9
+ // first, so the deferral costs nothing in practice.
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { parseEnv } from "node:util";
13
+ import { z } from "zod";
14
+ import { Engine } from "./engine.js";
15
+ import { EngineError } from "./errors.js";
16
+ import { createEngineServer } from "./server/server.js";
17
+ const DEFAULT_PORT = 8080;
18
+ const DEFAULT_HOST = "127.0.0.1";
19
+ // Why strictObject: a typo'd provider key ("apikey_env") silently doing nothing is exactly
20
+ // the config bug an operator can't see — fail loudly at boot instead.
21
+ // Header values: most-specific-first — the { from_env } object before the bare string, so the
22
+ // object variant can't be string-coerced (zod unions are first-match-wins).
23
+ const headerValueSchema = z.union([
24
+ z.strictObject({ from_env: z.string().min(1) }),
25
+ z.string().min(1),
26
+ ]);
27
+ const providerEntrySchema = z.strictObject({
28
+ base_url: z.url(),
29
+ api_key_env: z.string().min(1).optional(),
30
+ protocol: z.enum(["anthropic", "openai"]).optional(),
31
+ headers: z.record(z.string().min(1), headerValueSchema).optional(),
32
+ });
33
+ const providersSchema = z.record(z.string().min(1), providerEntrySchema);
34
+ const PROVIDERS_HINT = "Expected a JSON object of named providers, e.g. " +
35
+ '{"ollama":{"base_url":"http://localhost:11434/v1"},' +
36
+ '"groq":{"base_url":"https://api.groq.com/openai/v1","api_key_env":"GROQ_API_KEY"}}.';
37
+ /** One line per Zod issue, path-prefixed, so the operator sees which provider field is wrong. */
38
+ function formatIssues(error) {
39
+ return error.issues
40
+ .map((issue) => issue.path.length > 0
41
+ ? `${issue.path.map(String).join(".")}: ${issue.message}`
42
+ : issue.message)
43
+ .join("; ");
44
+ }
45
+ // Why allow 0: `listen(0)` binds an ephemeral port, which embedders and tests rely on; the
46
+ // engine server reports the resolved port, so it is never a silent surprise.
47
+ const portSchema = z.coerce.number().int().min(0).max(65535);
48
+ function parsePort(raw) {
49
+ if (raw === undefined)
50
+ return DEFAULT_PORT;
51
+ const parsed = portSchema.safeParse(raw);
52
+ if (!parsed.success) {
53
+ throw new EngineError("VALIDATION", `BOARDWALK_PORT must be an integer between 0 and 65535, got "${raw}".`, "Unset BOARDWALK_PORT to use the default (8080); 0 binds an ephemeral port.");
54
+ }
55
+ return parsed.data;
56
+ }
57
+ function parseProviders(raw) {
58
+ if (raw === undefined)
59
+ return undefined;
60
+ let json;
61
+ try {
62
+ json = JSON.parse(raw);
63
+ }
64
+ catch (err) {
65
+ throw new EngineError("VALIDATION", `BOARDWALK_PROVIDERS is not valid JSON: ${err instanceof Error ? err.message : String(err)}.`, PROVIDERS_HINT);
66
+ }
67
+ const parsed = providersSchema.safeParse(json);
68
+ if (!parsed.success) {
69
+ throw new EngineError("VALIDATION", `BOARDWALK_PROVIDERS is malformed: ${formatIssues(parsed.error)}.`, PROVIDERS_HINT);
70
+ }
71
+ // Why rebuild instead of returning parsed.data: under exactOptionalPropertyTypes, Zod's
72
+ // optional fields type as `string | undefined` while ProviderConfig declares plain optional
73
+ // keys — constructing entries keyless-when-absent satisfies the stricter shape with no cast.
74
+ const providers = {};
75
+ for (const [name, entry] of Object.entries(parsed.data)) {
76
+ providers[name] = {
77
+ base_url: entry.base_url,
78
+ ...(entry.api_key_env !== undefined ? { api_key_env: entry.api_key_env } : {}),
79
+ ...(entry.protocol !== undefined ? { protocol: entry.protocol } : {}),
80
+ ...(entry.headers !== undefined ? { headers: entry.headers } : {}),
81
+ };
82
+ }
83
+ return providers;
84
+ }
85
+ /**
86
+ * Parse server config from an environment map. Pure (no filesystem, no process globals) so
87
+ * every default and failure mode is unit-testable; `main()` passes `process.env`.
88
+ */
89
+ export function loadServerConfig(env) {
90
+ // Why empty string counts as unset: `docker run -e BOARDWALK_PORT=` (and compose files with
91
+ // blank values) produce empty strings, and nobody means "the empty data dir" by that.
92
+ const get = (name) => {
93
+ const value = env[name];
94
+ return value === undefined || value === "" ? undefined : value;
95
+ };
96
+ // BOARDWALK_IN_DOCKER is set by the Dockerfile so the image defaults to the conventional
97
+ // volume mount point without baking container assumptions into bare-metal installs.
98
+ const inDocker = get("BOARDWALK_IN_DOCKER") === "1";
99
+ const dataDir = get("BOARDWALK_DATA_DIR") ?? (inDocker ? "/data" : "./boardwalk-data");
100
+ const defaultModel = get("BOARDWALK_DEFAULT_MODEL");
101
+ const providers = parseProviders(get("BOARDWALK_PROVIDERS"));
102
+ // BOARDWALK_INFERENCE_URL overrides the managed-inference gateway; BOARDWALK_API_KEY (the
103
+ // managed credential) is read directly from the environment at resolve time, not here.
104
+ const boardwalkBaseUrl = get("BOARDWALK_INFERENCE_URL");
105
+ const inference = defaultModel === undefined && providers === undefined && boardwalkBaseUrl === undefined
106
+ ? undefined
107
+ : {
108
+ ...(defaultModel !== undefined ? { default_model: defaultModel } : {}),
109
+ ...(providers !== undefined ? { providers } : {}),
110
+ ...(boardwalkBaseUrl !== undefined ? { boardwalk_base_url: boardwalkBaseUrl } : {}),
111
+ };
112
+ return {
113
+ dataDir,
114
+ host: get("BOARDWALK_HOST") ?? DEFAULT_HOST,
115
+ port: parsePort(get("BOARDWALK_PORT")),
116
+ inference,
117
+ envFile: get("BOARDWALK_ENV_FILE"),
118
+ };
119
+ }
120
+ /**
121
+ * Resolve the engine's secret/env source (SPEC §2.3 `secrets.get`): the configured
122
+ * BOARDWALK_ENV_FILE, else `<dataDir>/.env` when present. An explicitly named file that does
123
+ * not exist fails closed — a typo'd path silently falling back to process.env would make
124
+ * `secrets.get` read the wrong values with no warning.
125
+ */
126
+ export function resolveEngineEnv(config) {
127
+ const explicit = config.envFile !== undefined;
128
+ const path = config.envFile ?? join(config.dataDir, ".env");
129
+ if (!existsSync(path)) {
130
+ if (explicit) {
131
+ throw new EngineError("VALIDATION", `BOARDWALK_ENV_FILE points at "${path}" but no file exists there.`, "Create the file, or unset BOARDWALK_ENV_FILE to fall back to the process environment.");
132
+ }
133
+ return null;
134
+ }
135
+ const parsed = parseEnv(readFileSync(path, "utf8"));
136
+ const env = {};
137
+ for (const [key, value] of Object.entries(parsed)) {
138
+ if (value !== undefined)
139
+ env[key] = value;
140
+ }
141
+ return { env, envLabel: path };
142
+ }
143
+ /**
144
+ * Boot the engine + HTTP surface from a resolved config. Split from `main()` so the whole
145
+ * boot path (sweep, listen, startup logging, teardown) is testable without touching process
146
+ * signals or `process.exit`.
147
+ */
148
+ export async function startServer(config, log) {
149
+ const engineEnv = resolveEngineEnv(config);
150
+ const engine = new Engine({
151
+ dataDir: config.dataDir,
152
+ log,
153
+ ...(engineEnv !== null ? { env: engineEnv.env, envLabel: engineEnv.envLabel } : {}),
154
+ ...(config.inference !== undefined ? { inference: config.inference } : {}),
155
+ });
156
+ try {
157
+ const swept = engine.start();
158
+ log(`recovery sweep: restarted ${String(swept.resumed.length)} run(s), ` +
159
+ `cancelled ${String(swept.cancelled.length)}`);
160
+ const server = createEngineServer(engine, { host: config.host, log });
161
+ const { port } = await server.listen(config.port);
162
+ log(`data dir: ${resolve(config.dataDir)}`);
163
+ log(`listening on http://${config.host}:${String(port)}`);
164
+ log(`workflows deployed: ${String(engine.store.listWorkflows().length)}`);
165
+ let closed = false;
166
+ return {
167
+ port,
168
+ shutdown: async () => {
169
+ if (closed)
170
+ return;
171
+ closed = true;
172
+ // Server first so no new runs arrive while the engine is releasing the scheduler.
173
+ await server.close();
174
+ engine.close();
175
+ },
176
+ };
177
+ }
178
+ catch (err) {
179
+ // The engine owns the SQLite handle from construction; a failed listen must not leak it.
180
+ engine.close();
181
+ throw err;
182
+ }
183
+ }
184
+ /**
185
+ * The `boardwalk-server` entrypoint (invoked by bin/boardwalk-server.js). Owns the only
186
+ * process-global concerns in the package: process.env, signal handlers, and exit codes.
187
+ */
188
+ export async function main() {
189
+ const log = (line) => {
190
+ process.stderr.write(`${line}\n`);
191
+ };
192
+ const config = loadServerConfig(process.env);
193
+ const running = await startServer(config, log);
194
+ const onSignal = (signal) => {
195
+ log(`${signal} received — shutting down`);
196
+ void running.shutdown().then(() => process.exit(0), (err) => {
197
+ log(`shutdown failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ process.exit(1);
199
+ });
200
+ };
201
+ process.on("SIGINT", onSignal);
202
+ process.on("SIGTERM", onSignal);
203
+ }
@@ -0,0 +1,21 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ export interface Migration {
3
+ /** The schema version this migration produces — `PRAGMA user_version` after it applies. */
4
+ readonly version: number;
5
+ /**
6
+ * The DDL/DML for this migration. Must not contain transaction control (BEGIN/COMMIT) —
7
+ * {@link migrate} wraps each migration and its version bump in one transaction.
8
+ */
9
+ readonly sql: string;
10
+ }
11
+ /** Every migration the engine knows, ascending. Append-only — never edit a shipped entry. */
12
+ export declare const MIGRATIONS: readonly Migration[];
13
+ /**
14
+ * Bring `db` up to the latest schema version. Idempotent: already-applied versions are
15
+ * skipped via `PRAGMA user_version`, so calling this on every open is the deployment story —
16
+ * there is no separate "migrate" command to forget. Each pending migration applies in its own
17
+ * transaction together with the version bump (all-or-nothing per version).
18
+ *
19
+ * The `migrations` parameter exists for tests; production callers use the default.
20
+ */
21
+ export declare function migrate(db: DatabaseSync, migrations?: readonly Migration[]): void;
@@ -0,0 +1,159 @@
1
+ // Versioned, forward-only schema migrations for the engine database.
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.
12
+ import { EngineError } from "../errors.js";
13
+ // v1 — the full SPEC §4 schema. STRICT tables so SQLite enforces the declared column types
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 (CODE_QUALITY §2.2).
16
+ const V1_SQL = `
17
+ -- Workflows: the deployed unit. \`manifest\` is the validated JSON projection of the program's
18
+ -- pure-literal meta; \`program\` is the bundled ESM source the run host executes; \`config\` is
19
+ -- the per-deploy JSON config object.
20
+ CREATE TABLE workflows (
21
+ id TEXT PRIMARY KEY,
22
+ name TEXT NOT NULL UNIQUE,
23
+ manifest TEXT NOT NULL,
24
+ program TEXT NOT NULL,
25
+ config TEXT NOT NULL,
26
+ created_at INTEGER NOT NULL,
27
+ updated_at INTEGER NOT NULL
28
+ ) STRICT;
29
+
30
+ -- Runs: one row per run, owned by run-lifecycle code. \`status\` holds the SDK RunStatus values;
31
+ -- \`error\` is a JSON {code,message}; usage tallies accumulate from leaf usage reports.
32
+ CREATE TABLE runs (
33
+ id TEXT PRIMARY KEY,
34
+ workflow_id TEXT NOT NULL REFERENCES workflows (id),
35
+ status TEXT NOT NULL,
36
+ trigger_kind TEXT NOT NULL,
37
+ input TEXT,
38
+ output TEXT,
39
+ error TEXT,
40
+ parent_run_id TEXT REFERENCES runs (id),
41
+ idempotency_key TEXT,
42
+ restarts INTEGER NOT NULL DEFAULT 0,
43
+ tokens_in INTEGER NOT NULL DEFAULT 0,
44
+ tokens_out INTEGER NOT NULL DEFAULT 0,
45
+ usd_micros INTEGER NOT NULL DEFAULT 0,
46
+ created_at INTEGER NOT NULL,
47
+ started_at INTEGER,
48
+ ended_at INTEGER
49
+ ) STRICT;
50
+
51
+ -- The workflows.call idempotent re-attach guarantee: a restarted parent re-running the same
52
+ -- call site finds the child it already spawned instead of spawning a second one. Partial so
53
+ -- runs without a key are unconstrained.
54
+ CREATE UNIQUE INDEX runs_parent_idempotency_key
55
+ ON runs (parent_run_id, idempotency_key)
56
+ WHERE idempotency_key IS NOT NULL;
57
+
58
+ -- The recovery sweep ("which runs are still running?") and per-workflow run lists.
59
+ CREATE INDEX runs_workflow_id_status ON runs (workflow_id, status);
60
+ CREATE INDEX runs_status ON runs (status);
61
+
62
+ -- Run events: the append-only event log implementing the SDK wire format. The (run_id, cursor)
63
+ -- primary key IS the append-only guarantee — a cursor can never be rewritten.
64
+ CREATE TABLE run_events (
65
+ run_id TEXT NOT NULL REFERENCES runs (id),
66
+ cursor INTEGER NOT NULL,
67
+ event TEXT NOT NULL,
68
+ PRIMARY KEY (run_id, cursor)
69
+ ) STRICT, WITHOUT ROWID;
70
+
71
+ -- Cron fires: the exactly-once guarantee. The scheduler records the fire in the same
72
+ -- transaction as the run it creates; a duplicate (workflow, trigger, fire-time) is a CONFLICT,
73
+ -- so a restarted scheduler can never double-fire a tick it already handled.
74
+ CREATE TABLE cron_fires (
75
+ workflow_id TEXT NOT NULL REFERENCES workflows (id),
76
+ trigger_index INTEGER NOT NULL,
77
+ fire_time INTEGER NOT NULL,
78
+ run_id TEXT NOT NULL REFERENCES runs (id),
79
+ created_at INTEGER NOT NULL,
80
+ PRIMARY KEY (workflow_id, trigger_index, fire_time)
81
+ ) STRICT, WITHOUT ROWID;
82
+
83
+ -- Artifacts: metadata for files written via artifacts.write; \`path\` points into the engine's
84
+ -- content-addressed store on disk (the bytes never live in SQLite).
85
+ CREATE TABLE artifacts (
86
+ id TEXT PRIMARY KEY,
87
+ run_id TEXT NOT NULL REFERENCES runs (id),
88
+ name TEXT NOT NULL,
89
+ content_type TEXT NOT NULL,
90
+ path TEXT NOT NULL,
91
+ size INTEGER NOT NULL,
92
+ metadata TEXT,
93
+ created_at INTEGER NOT NULL
94
+ ) STRICT;
95
+
96
+ CREATE INDEX artifacts_run_id ON artifacts (run_id);
97
+ `;
98
+ /** Every migration the engine knows, ascending. Append-only — never edit a shipped entry. */
99
+ export const MIGRATIONS = [{ version: 1, sql: V1_SQL }];
100
+ /**
101
+ * Bring `db` up to the latest schema version. Idempotent: already-applied versions are
102
+ * skipped via `PRAGMA user_version`, so calling this on every open is the deployment story —
103
+ * there is no separate "migrate" command to forget. Each pending migration applies in its own
104
+ * transaction together with the version bump (all-or-nothing per version).
105
+ *
106
+ * The `migrations` parameter exists for tests; production callers use the default.
107
+ */
108
+ export function migrate(db, migrations = MIGRATIONS) {
109
+ assertWellFormed(migrations);
110
+ const latest = migrations.at(-1)?.version ?? 0;
111
+ const current = readUserVersion(db);
112
+ if (current > latest) {
113
+ throw new EngineError("INTERNAL", `database schema version ${String(current)} is newer than this engine understands ` +
114
+ `(latest known: ${String(latest)})`, "upgrade the engine, or point it at a database created by this engine version");
115
+ }
116
+ for (const migration of migrations) {
117
+ if (migration.version <= current)
118
+ continue;
119
+ db.exec("BEGIN IMMEDIATE");
120
+ try {
121
+ db.exec(migration.sql);
122
+ // Why interpolation: PRAGMA statements cannot take bound parameters. Safe — the version
123
+ // is validated as a positive integer by assertWellFormed above.
124
+ db.exec(`PRAGMA user_version = ${String(migration.version)}`);
125
+ db.exec("COMMIT");
126
+ }
127
+ catch (err) {
128
+ // Why best-effort: some SQLite errors abort the transaction themselves, making an
129
+ // explicit ROLLBACK fail with "no transaction is active"; the original error is the one
130
+ // that matters either way.
131
+ try {
132
+ db.exec("ROLLBACK");
133
+ }
134
+ catch {
135
+ // Intentionally ignored — see above.
136
+ }
137
+ throw err;
138
+ }
139
+ }
140
+ }
141
+ // Why validate the list (it's a compile-time constant): a typo'd or reordered version number
142
+ // would silently skip or re-apply DDL on user databases — fail loudly at boot instead.
143
+ function assertWellFormed(migrations) {
144
+ let previous = 0;
145
+ for (const migration of migrations) {
146
+ if (!Number.isInteger(migration.version) || migration.version <= previous) {
147
+ throw new EngineError("INTERNAL", `migration versions must be strictly ascending positive integers ` +
148
+ `(got ${String(migration.version)} after ${String(previous)})`);
149
+ }
150
+ previous = migration.version;
151
+ }
152
+ }
153
+ function readUserVersion(db) {
154
+ const row = db.prepare("PRAGMA user_version").get();
155
+ const value = row?.user_version;
156
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0)
157
+ return value;
158
+ throw new EngineError("INTERNAL", "could not read PRAGMA user_version from the database");
159
+ }
@@ -0,0 +1,194 @@
1
+ import type { JsonValue, RunEvent, RunStatus, WorkflowManifest } from "@boardwalk-labs/workflow";
2
+ export type { RunStatus };
3
+ /** A deployed workflow: validated manifest + bundled program source + per-deploy config. */
4
+ export interface WorkflowRow {
5
+ id: string;
6
+ name: string;
7
+ manifest: WorkflowManifest;
8
+ program: string;
9
+ config: Record<string, JsonValue>;
10
+ createdAt: number;
11
+ updatedAt: number;
12
+ }
13
+ export type TriggerKind = "cron" | "manual" | "webhook";
14
+ /** The persisted form of a run failure — mirrors `toErrorShape` in errors.ts. */
15
+ export interface RunErrorShape {
16
+ code: string;
17
+ message: string;
18
+ }
19
+ /** One run of a workflow, including its budget tallies and the call-tree linkage. */
20
+ export interface RunRow {
21
+ id: string;
22
+ workflowId: string;
23
+ status: RunStatus;
24
+ triggerKind: TriggerKind;
25
+ input: unknown;
26
+ output: JsonValue | null;
27
+ error: RunErrorShape | null;
28
+ parentRunId: string | null;
29
+ idempotencyKey: string | null;
30
+ restarts: number;
31
+ tokensIn: number;
32
+ tokensOut: number;
33
+ usdMicros: number;
34
+ createdAt: number;
35
+ startedAt: number | null;
36
+ endedAt: number | null;
37
+ }
38
+ /** One entry in a run's append-only event log (the SDK wire format, cursor-indexed). */
39
+ export interface EventRow {
40
+ runId: string;
41
+ cursor: number;
42
+ event: RunEvent;
43
+ }
44
+ /** Metadata for a file written via `artifacts.write`; `path` points into the on-disk store. */
45
+ export interface ArtifactRow {
46
+ id: string;
47
+ runId: string;
48
+ name: string;
49
+ contentType: string;
50
+ path: string;
51
+ size: number;
52
+ metadata: Record<string, unknown> | null;
53
+ createdAt: number;
54
+ }
55
+ export interface StoreOptions {
56
+ /** Injectable time source (ms since epoch) so tests can pin timestamps. Default: Date.now. */
57
+ now?: () => number;
58
+ }
59
+ /**
60
+ * The engine database. One instance per engine process; all access is synchronous and goes
61
+ * through this class — no other module writes SQL. Opening migrates the schema to the latest
62
+ * version, so "open the database" and "deploy the schema" are the same operation and can never
63
+ * drift apart.
64
+ */
65
+ export declare class Store {
66
+ private readonly db;
67
+ private readonly now;
68
+ private readonly statements;
69
+ private inTransaction;
70
+ /** Open (or create) the engine database at `path` (`":memory:"` for tests) and migrate. */
71
+ constructor(path: string, options?: StoreOptions);
72
+ close(): void;
73
+ /**
74
+ * Run `fn` inside a single SQLite transaction (BEGIN IMMEDIATE … COMMIT/ROLLBACK).
75
+ * Composable: the scheduler wraps createRun + recordCronFire in one transaction so the
76
+ * exactly-once fire record and the run it spawned commit or vanish together. Nested calls
77
+ * join the outer transaction — an inner throw propagates and rolls back the whole thing
78
+ * (SQLite has no real nested transactions; partial inner commits would break the outer
79
+ * invariant anyway).
80
+ */
81
+ transaction<T>(fn: () => T): T;
82
+ /**
83
+ * Insert a workflow or update it by name (deploying again is always an update — the name is
84
+ * the user-facing identity, so the id stays stable across redeploys and existing runs keep
85
+ * their foreign keys). `updated_at` bumps on update; `created_at` and `id` never change.
86
+ */
87
+ upsertWorkflow(args: {
88
+ name: string;
89
+ manifest: WorkflowManifest;
90
+ program: string;
91
+ config?: Record<string, JsonValue>;
92
+ }): WorkflowRow;
93
+ getWorkflow(name: string): WorkflowRow | null;
94
+ getWorkflowById(id: string): WorkflowRow | null;
95
+ listWorkflows(): WorkflowRow[];
96
+ /**
97
+ * Create a run in status `queued`. With an `idempotencyKey` this is an atomic
98
+ * find-or-create on (parentRunId, idempotencyKey): a restarted parent re-running the same
99
+ * `workflows.call` site re-attaches to the child it already spawned (`created: false`)
100
+ * instead of spawning a duplicate — the heart of restart-on-crash semantics.
101
+ */
102
+ createRun(args: {
103
+ workflowId: string;
104
+ triggerKind: TriggerKind;
105
+ input?: unknown;
106
+ parentRunId?: string;
107
+ idempotencyKey?: string;
108
+ }): {
109
+ run: RunRow;
110
+ created: boolean;
111
+ };
112
+ getRun(id: string): RunRow | null;
113
+ /** List runs newest first, optionally filtered — the shape the run-log UI and sweeps need. */
114
+ listRuns(filter?: {
115
+ workflowId?: string;
116
+ statuses?: readonly RunStatus[];
117
+ limit?: number;
118
+ offset?: number;
119
+ }): RunRow[];
120
+ /**
121
+ * Transition a run's status, optionally recording the outcome (`output` on completion,
122
+ * `error` on failure) and lifecycle timestamps in the same write — a crash can never leave
123
+ * a terminal status without its outcome.
124
+ */
125
+ updateRunStatus(id: string, status: RunStatus, opts?: {
126
+ error?: RunErrorShape;
127
+ output?: JsonValue;
128
+ startedAt?: number;
129
+ endedAt?: number;
130
+ }): void;
131
+ /**
132
+ * Bump the restart counter and return the new value in one statement, so the supervisor's
133
+ * "have we exhausted restarts?" check is race-free even across its own crash-recovery.
134
+ */
135
+ incrementRestarts(id: string): number;
136
+ /**
137
+ * Accumulate leaf usage onto the run's tallies. Additive (not a set) because each `agent()`
138
+ * leaf reports independently and budgets are checked against the running total.
139
+ */
140
+ addRunUsage(id: string, usage: {
141
+ tokensIn?: number;
142
+ tokensOut?: number;
143
+ usdMicros?: number;
144
+ }): void;
145
+ /** Current usage tallies for budget enforcement. Throws NOT_FOUND on an unknown run. */
146
+ getRunUsage(id: string): {
147
+ tokensIn: number;
148
+ tokensOut: number;
149
+ usdMicros: number;
150
+ };
151
+ /**
152
+ * Append a batch of events in one transaction: all rows land or none do, so a consumer can
153
+ * never observe a half-written batch and cursor resumption stays gap-free. A duplicate
154
+ * cursor throws CONFLICT — the log is append-only and a cursor is never rewritten.
155
+ */
156
+ appendEvents(runId: string, rows: readonly {
157
+ cursor: number;
158
+ event: RunEvent;
159
+ }[]): void;
160
+ /** Events in cursor order, optionally resuming after a cursor (SSE `Last-Event-ID`). */
161
+ listEvents(runId: string, opts?: {
162
+ afterCursor?: number;
163
+ limit?: number;
164
+ }): EventRow[];
165
+ /** The run's latest cursor, or 0 when it has no events (cursors are 1-based). */
166
+ maxCursor(runId: string): number;
167
+ /**
168
+ * Record that a cron tick fired. The (workflowId, triggerIndex, fireTime) primary key is the
169
+ * exactly-once guarantee: a scheduler that crashed after firing and restarted gets CONFLICT
170
+ * here instead of silently double-running the workflow. Callers wrap this with createRun in
171
+ * one {@link transaction} so the fire record and the run commit together.
172
+ */
173
+ recordCronFire(args: {
174
+ workflowId: string;
175
+ triggerIndex: number;
176
+ fireTime: number;
177
+ runId: string;
178
+ }): void;
179
+ /** The latest recorded fire time for a trigger, or null — the catch-up policy's anchor. */
180
+ lastCronFire(workflowId: string, triggerIndex: number): number | null;
181
+ /** Record an artifact's metadata (the bytes live on disk at `path`, never in SQLite). */
182
+ createArtifact(args: {
183
+ runId: string;
184
+ name: string;
185
+ contentType: string;
186
+ path: string;
187
+ size: number;
188
+ metadata?: Record<string, unknown>;
189
+ }): ArtifactRow;
190
+ /** A run's artifacts in creation order (ULIDs sort by time). */
191
+ listArtifacts(runId: string): ArtifactRow[];
192
+ private prepare;
193
+ private getRunOrThrow;
194
+ }