@boardwalk-labs/engine 0.1.4 → 0.1.6

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/dist/engine.d.ts CHANGED
@@ -65,7 +65,7 @@ export declare class Engine {
65
65
  /** Subscribe to every stamped run event (feeds SSE, the CLI renderer, the log UI). */
66
66
  onEvent(listener: (row: EventRow) => void): () => void;
67
67
  /**
68
- * Deploy (or redeploy, by manifest name) a workflow from its bundled program source. The
68
+ * Deploy (or redeploy, by manifest slug) a workflow from its bundled program source. The
69
69
  * manifest is DERIVED from the program's pure-literal `meta` — the program file is the
70
70
  * author's source of truth, manifest drift is impossible by construction.
71
71
  */
@@ -74,7 +74,7 @@ export declare class Engine {
74
74
  * Queue a run and dispatch it through the concurrency gate. Returns the queued row
75
75
  * immediately; `waitForRun` for the terminal row.
76
76
  */
77
- startRun(workflowName: string, opts?: {
77
+ startRun(slug: string, opts?: {
78
78
  input?: JsonValue;
79
79
  triggerKind?: TriggerKind;
80
80
  }): RunRow;
package/dist/engine.js CHANGED
@@ -75,7 +75,7 @@ export class Engine {
75
75
  return this.supervisor.onEvent(listener);
76
76
  }
77
77
  /**
78
- * Deploy (or redeploy, by manifest name) a workflow from its bundled program source. The
78
+ * Deploy (or redeploy, by manifest slug) a workflow from its bundled program source. The
79
79
  * manifest is DERIVED from the program's pure-literal `meta` — the program file is the
80
80
  * author's source of truth, manifest drift is impossible by construction.
81
81
  */
@@ -83,7 +83,7 @@ export class Engine {
83
83
  this.assertOpen();
84
84
  const manifest = extractManifest(args.program, { fileName: "index.mjs" });
85
85
  const workflow = this.store.upsertWorkflow({
86
- name: manifest.slug,
86
+ slug: manifest.slug,
87
87
  manifest,
88
88
  program: args.program,
89
89
  ...(args.config !== undefined ? { config: args.config } : {}),
@@ -106,11 +106,11 @@ export class Engine {
106
106
  * Queue a run and dispatch it through the concurrency gate. Returns the queued row
107
107
  * immediately; `waitForRun` for the terminal row.
108
108
  */
109
- startRun(workflowName, opts = {}) {
109
+ startRun(slug, opts = {}) {
110
110
  this.assertOpen();
111
- const workflow = this.store.getWorkflow(workflowName);
111
+ const workflow = this.store.getWorkflow(slug);
112
112
  if (workflow === null) {
113
- throw new EngineError("NOT_FOUND", `Workflow "${workflowName}" is not deployed on this engine.`);
113
+ throw new EngineError("NOT_FOUND", `Workflow "${slug}" is not deployed on this engine.`);
114
114
  }
115
115
  const { run } = this.store.createRun({
116
116
  workflowId: workflow.id,
@@ -168,7 +168,7 @@ export class Engine {
168
168
  */
169
169
  async runOnce(args) {
170
170
  const workflow = this.deployWorkflow(args);
171
- const run = this.startRun(workflow.name, {
171
+ const run = this.startRun(workflow.slug, {
172
172
  ...(args.input !== undefined ? { input: args.input } : {}),
173
173
  });
174
174
  return await this.waitForRun(run.id);
@@ -510,7 +510,7 @@ export class RunSupervisor {
510
510
  startChildRun(parentRunId, slug, input, idempotencyKey) {
511
511
  const target = this.store.getWorkflow(slug);
512
512
  if (target === null) {
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
+ 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 slug.`);
514
514
  }
515
515
  // Crossed the JSON IPC channel, but narrow instead of assuming — and
516
516
  // the canonical default key requires a JSON tree anyway.
Binary file
@@ -3,8 +3,8 @@ import type { RouteContext } from "./router.js";
3
3
  export declare function handleListWorkflows(ctx: RouteContext): void;
4
4
  /** GET /api/runs?workflow=&status=&limit=&offset= — newest first, full RunRow shape. */
5
5
  export declare function handleListRuns(ctx: RouteContext): void;
6
- /** POST /api/workflows/:name/runs — start a manual run; 201 with the queued row. */
7
- export declare function handleStartRun(ctx: RouteContext, workflowName: string): Promise<void>;
6
+ /** POST /api/workflows/:slug/runs — start a manual run; 201 with the queued row. */
7
+ export declare function handleStartRun(ctx: RouteContext, slug: string): Promise<void>;
8
8
  /** GET /api/runs/:id */
9
9
  export declare function handleGetRun(ctx: RouteContext, runId: string): void;
10
10
  /**
@@ -29,7 +29,7 @@ function isRunStatus(value) {
29
29
  /** GET /api/workflows — names + manifest-derived fields, enough to render a picker. */
30
30
  export function handleListWorkflows(ctx) {
31
31
  const workflows = ctx.engine.store.listWorkflows().map((row) => ({
32
- name: row.name,
32
+ slug: row.slug,
33
33
  description: row.manifest.description ?? null,
34
34
  triggers: row.manifest.triggers,
35
35
  createdAt: row.createdAt,
@@ -43,11 +43,11 @@ export function handleListRuns(ctx) {
43
43
  limit: parseNonNegativeInt(ctx.url, "limit", DEFAULT_RUNS_LIMIT),
44
44
  offset: parseNonNegativeInt(ctx.url, "offset", 0),
45
45
  };
46
- const workflowName = ctx.url.searchParams.get("workflow");
47
- if (workflowName !== null) {
48
- const workflow = ctx.engine.store.getWorkflow(workflowName);
46
+ const slug = ctx.url.searchParams.get("workflow");
47
+ if (slug !== null) {
48
+ const workflow = ctx.engine.store.getWorkflow(slug);
49
49
  if (workflow === null) {
50
- throw new HttpError(404, "NOT_FOUND", `Workflow "${workflowName}" is not deployed on this engine.`);
50
+ throw new HttpError(404, "NOT_FOUND", `Workflow "${slug}" is not deployed on this engine.`);
51
51
  }
52
52
  filter.workflowId = workflow.id;
53
53
  }
@@ -60,12 +60,12 @@ export function handleListRuns(ctx) {
60
60
  }
61
61
  sendJson(ctx.res, 200, { runs: ctx.engine.store.listRuns(filter) });
62
62
  }
63
- /** POST /api/workflows/:name/runs — start a manual run; 201 with the queued row. */
64
- export async function handleStartRun(ctx, workflowName) {
63
+ /** POST /api/workflows/:slug/runs — start a manual run; 201 with the queued row. */
64
+ export async function handleStartRun(ctx, slug) {
65
65
  const raw = await readBody(ctx.req, MAX_BODY_BYTES);
66
66
  // An empty body means "no input" — the curl-without-data ergonomics of a run-now button.
67
67
  const body = raw.length === 0 ? {} : parseJsonBody(raw, startRunBodySchema, "run-start body");
68
- const run = ctx.engine.startRun(workflowName, {
68
+ const run = ctx.engine.startRun(slug, {
69
69
  triggerKind: "manual",
70
70
  ...(body.input !== undefined ? { input: body.input } : {}),
71
71
  });
@@ -1,2 +1,2 @@
1
1
  import type { RouteContext } from "./router.js";
2
- export declare function handleWebhook(ctx: RouteContext, workflowName: string, triggerIndexRaw: string): Promise<void>;
2
+ export declare function handleWebhook(ctx: RouteContext, slug: string, triggerIndexRaw: string): Promise<void>;
@@ -5,19 +5,19 @@
5
5
  // token auth: BOARDWALK_WEBHOOK_TOKEN__<NAME> vs `Authorization: Bearer <token>`
6
6
  // signature auth: BOARDWALK_WEBHOOK_SECRET__<NAME> vs `X-Boardwalk-Signature: sha256=<hex>`
7
7
  // (HMAC-SHA256 over the raw request body)
8
- // where <NAME> is the workflow name upper-cased with `-` → `_`. Missing variable = 503 (fail
8
+ // where <NAME> is the workflow slug upper-cased with `-` → `_`. Missing variable = 503 (fail
9
9
  // closed, hint names the variable); bad credential = 401. These are server config, not
10
10
  // workflow secrets, so they resolve from process.env — never from the engine's env map.
11
11
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
12
12
  import { HttpError, MAX_BODY_BYTES, jsonValueSchema, parseJsonBody, readBody, sendJson, } from "../http.js";
13
- export async function handleWebhook(ctx, workflowName, triggerIndexRaw) {
13
+ export async function handleWebhook(ctx, slug, triggerIndexRaw) {
14
14
  // The raw body is read up front: signature auth signs the exact bytes on the wire, before
15
15
  // any JSON parsing can normalize them away.
16
16
  const rawBody = await readBody(ctx.req, MAX_BODY_BYTES);
17
17
  // One identical 404 for "no workflow", "no such trigger index", and "not a webhook
18
18
  // trigger" — an unauthenticated caller learns nothing about what is deployed here.
19
- const notFound = new HttpError(404, "NOT_FOUND", `No webhook trigger at /hooks/${workflowName}/${triggerIndexRaw}.`);
20
- const workflow = ctx.engine.store.getWorkflow(workflowName);
19
+ const notFound = new HttpError(404, "NOT_FOUND", `No webhook trigger at /hooks/${slug}/${triggerIndexRaw}.`);
20
+ const workflow = ctx.engine.store.getWorkflow(slug);
21
21
  if (workflow === null)
22
22
  throw notFound;
23
23
  if (!/^\d+$/.test(triggerIndexRaw))
@@ -26,27 +26,27 @@ export async function handleWebhook(ctx, workflowName, triggerIndexRaw) {
26
26
  if (trigger === undefined || trigger.kind !== "webhook")
27
27
  throw notFound;
28
28
  if (trigger.auth === "token")
29
- authorizeToken(ctx.req, workflowName);
29
+ authorizeToken(ctx.req, slug);
30
30
  else
31
- authorizeSignature(ctx.req, workflowName, rawBody);
31
+ authorizeSignature(ctx.req, slug, rawBody);
32
32
  const input = rawBody.length === 0 ? null : parseJsonBody(rawBody, jsonValueSchema, "webhook payload");
33
- const run = ctx.engine.startRun(workflowName, { input, triggerKind: "webhook" });
33
+ const run = ctx.engine.startRun(slug, { input, triggerKind: "webhook" });
34
34
  sendJson(ctx.res, 201, { run: { id: run.id, status: run.status } });
35
35
  }
36
- /** `BOARDWALK_WEBHOOK_<kind>__<NAME>`: the workflow name upper-cased, hyphens → underscores. */
37
- function webhookEnvVarName(kind, workflowName) {
38
- return `BOARDWALK_WEBHOOK_${kind}__${workflowName.toUpperCase().replaceAll("-", "_")}`;
36
+ /** `BOARDWALK_WEBHOOK_<kind>__<NAME>`: the workflow slug upper-cased, hyphens → underscores. */
37
+ function webhookEnvVarName(kind, slug) {
38
+ return `BOARDWALK_WEBHOOK_${kind}__${slug.toUpperCase().replaceAll("-", "_")}`;
39
39
  }
40
40
  /**
41
41
  * Read the trigger's credential from the server environment, failing CLOSED when unset: a
42
42
  * webhook that nobody configured must never become an open trigger. Read lazily per request
43
43
  * so an operator can fix the environment without redeploying workflows.
44
44
  */
45
- function requiredCredential(kind, workflowName) {
46
- const name = webhookEnvVarName(kind, workflowName);
45
+ function requiredCredential(kind, slug) {
46
+ const name = webhookEnvVarName(kind, slug);
47
47
  const value = process.env[name];
48
48
  if (value === undefined || value === "") {
49
- throw new HttpError(503, "WEBHOOK_UNCONFIGURED", `Webhook auth for workflow "${workflowName}" is not configured on this server.`, `Set the environment variable ${name} and restart the server.`);
49
+ throw new HttpError(503, "WEBHOOK_UNCONFIGURED", `Webhook auth for workflow "${slug}" is not configured on this server.`, `Set the environment variable ${name} and restart the server.`);
50
50
  }
51
51
  return value;
52
52
  }
@@ -54,16 +54,16 @@ function requiredCredential(kind, workflowName) {
54
54
  function unauthorized() {
55
55
  return new HttpError(401, "UNAUTHORIZED", "Invalid webhook credentials.");
56
56
  }
57
- function authorizeToken(req, workflowName) {
58
- const expected = requiredCredential("TOKEN", workflowName);
57
+ function authorizeToken(req, slug) {
58
+ const expected = requiredCredential("TOKEN", slug);
59
59
  const header = req.headers.authorization;
60
60
  if (header === undefined || !header.startsWith("Bearer "))
61
61
  throw unauthorized();
62
62
  if (!constantTimeEquals(header.slice("Bearer ".length), expected))
63
63
  throw unauthorized();
64
64
  }
65
- function authorizeSignature(req, workflowName, rawBody) {
66
- const secret = requiredCredential("SECRET", workflowName);
65
+ function authorizeSignature(req, slug, rawBody) {
66
+ const secret = requiredCredential("SECRET", slug);
67
67
  const header = req.headers["x-boardwalk-signature"];
68
68
  if (typeof header !== "string")
69
69
  throw unauthorized();
@@ -70,7 +70,7 @@ async function loadWorkflows() {
70
70
  }
71
71
  box.append(rowButton("all workflows", () => { selectedWorkflow = null; loadRuns(); }));
72
72
  for (const workflow of workflows) {
73
- box.append(rowButton(workflow.name, () => { selectedWorkflow = workflow.name; loadRuns(); }));
73
+ box.append(rowButton(workflow.slug, () => { selectedWorkflow = workflow.slug; loadRuns(); }));
74
74
  }
75
75
  }
76
76
 
@@ -122,7 +122,7 @@ export function loadServerConfig(env) {
122
122
  /**
123
123
  * Deploy every built workflow in `dir` on boot — the self-host deploy mechanism. Each `.mjs`/`.js`
124
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);
125
+ * `boardwalk build` emits). Idempotent by manifest slug (re-boot re-syncs the dir into the store);
126
126
  * a removed file leaves its last-deployed workflow in place (no un-deploy in v0). A missing dir is
127
127
  * fine (an operator may deploy by other means); a bad file is logged and skipped, never fatal.
128
128
  */
@@ -143,7 +143,7 @@ function deployWorkflowsFromDir(engine, dir, log) {
143
143
  try {
144
144
  const program = readFileSync(join(dir, file), "utf8");
145
145
  const workflow = engine.deployWorkflow({ program });
146
- log(`deployed "${workflow.name}" from ${file}`);
146
+ log(`deployed "${workflow.slug}" from ${file}`);
147
147
  }
148
148
  catch (err) {
149
149
  log(`skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
@@ -9,7 +9,7 @@ const V1_SQL = `
9
9
  -- the per-deploy JSON config object.
10
10
  CREATE TABLE workflows (
11
11
  id TEXT PRIMARY KEY,
12
- name TEXT NOT NULL UNIQUE,
12
+ slug TEXT NOT NULL UNIQUE,
13
13
  manifest TEXT NOT NULL,
14
14
  program TEXT NOT NULL,
15
15
  config TEXT NOT NULL,
@@ -3,7 +3,7 @@ export type { RunStatus };
3
3
  /** A deployed workflow: validated manifest + bundled program source + per-deploy config. */
4
4
  export interface WorkflowRow {
5
5
  id: string;
6
- name: string;
6
+ slug: string;
7
7
  manifest: WorkflowManifest;
8
8
  program: string;
9
9
  config: Record<string, JsonValue>;
@@ -80,17 +80,17 @@ export declare class Store {
80
80
  */
81
81
  transaction<T>(fn: () => T): T;
82
82
  /**
83
- * Insert a workflow or update it by name (deploying again is always an update — the name is
83
+ * Insert a workflow or update it by slug (deploying again is always an update — the slug is
84
84
  * the user-facing identity, so the id stays stable across redeploys and existing runs keep
85
85
  * their foreign keys). `updated_at` bumps on update; `created_at` and `id` never change.
86
86
  */
87
87
  upsertWorkflow(args: {
88
- name: string;
88
+ slug: string;
89
89
  manifest: WorkflowManifest;
90
90
  program: string;
91
91
  config?: Record<string, JsonValue>;
92
92
  }): WorkflowRow;
93
- getWorkflow(name: string): WorkflowRow | null;
93
+ getWorkflow(slug: string): WorkflowRow | null;
94
94
  getWorkflowById(id: string): WorkflowRow | null;
95
95
  listWorkflows(): WorkflowRow[];
96
96
  /**
@@ -113,7 +113,7 @@ function readEnum(row, table, column, schema) {
113
113
  function mapWorkflow(row) {
114
114
  return {
115
115
  id: readText(row, "workflows", "id"),
116
- name: readText(row, "workflows", "name"),
116
+ slug: readText(row, "workflows", "slug"),
117
117
  manifest: readJson(row, "workflows", "manifest", workflowManifestSchema),
118
118
  program: readText(row, "workflows", "program"),
119
119
  config: readJson(row, "workflows", "config", configSchema),
@@ -267,7 +267,7 @@ export class Store {
267
267
  // Workflows
268
268
  // --------------------------------------------------------------------------
269
269
  /**
270
- * Insert a workflow or update it by name (deploying again is always an update — the name is
270
+ * Insert a workflow or update it by slug (deploying again is always an update — the slug is
271
271
  * the user-facing identity, so the id stays stable across redeploys and existing runs keep
272
272
  * their foreign keys). `updated_at` bumps on update; `created_at` and `id` never change.
273
273
  */
@@ -276,29 +276,29 @@ export class Store {
276
276
  // caller bug is better rejected here than persisted and discovered as INTERNAL on read.
277
277
  const manifest = workflowManifestSchema.safeParse(args.manifest);
278
278
  if (!manifest.success) {
279
- throw new EngineError("VALIDATION", `manifest for workflow "${args.name}" failed validation: ${manifest.error.message}`);
279
+ throw new EngineError("VALIDATION", `manifest for workflow "${args.slug}" failed validation: ${manifest.error.message}`);
280
280
  }
281
281
  const manifestJson = JSON.stringify(manifest.data);
282
282
  const configJson = JSON.stringify(args.config ?? {});
283
283
  return this.transaction(() => {
284
284
  const t = this.now();
285
- const existing = this.prepare("SELECT id FROM workflows WHERE name = ?").get(args.name);
285
+ const existing = this.prepare("SELECT id FROM workflows WHERE slug = ?").get(args.slug);
286
286
  if (existing === undefined) {
287
- this.prepare(`INSERT INTO workflows (id, name, manifest, program, config, created_at, updated_at)
288
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(ulid(t), args.name, manifestJson, args.program, configJson, t, t);
287
+ this.prepare(`INSERT INTO workflows (id, slug, manifest, program, config, created_at, updated_at)
288
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(ulid(t), args.slug, manifestJson, args.program, configJson, t, t);
289
289
  }
290
290
  else {
291
- this.prepare("UPDATE workflows SET manifest = ?, program = ?, config = ?, updated_at = ? WHERE name = ?").run(manifestJson, args.program, configJson, t, args.name);
291
+ this.prepare("UPDATE workflows SET manifest = ?, program = ?, config = ?, updated_at = ? WHERE slug = ?").run(manifestJson, args.program, configJson, t, args.slug);
292
292
  }
293
- const row = this.getWorkflow(args.name);
293
+ const row = this.getWorkflow(args.slug);
294
294
  if (row === null) {
295
- throw new EngineError("INTERNAL", `workflow "${args.name}" vanished mid-upsert`);
295
+ throw new EngineError("INTERNAL", `workflow "${args.slug}" vanished mid-upsert`);
296
296
  }
297
297
  return row;
298
298
  });
299
299
  }
300
- getWorkflow(name) {
301
- const row = this.prepare("SELECT * FROM workflows WHERE name = ?").get(name);
300
+ getWorkflow(slug) {
301
+ const row = this.prepare("SELECT * FROM workflows WHERE slug = ?").get(slug);
302
302
  return row === undefined ? null : mapWorkflow(row);
303
303
  }
304
304
  getWorkflowById(id) {
@@ -306,7 +306,7 @@ export class Store {
306
306
  return row === undefined ? null : mapWorkflow(row);
307
307
  }
308
308
  listWorkflows() {
309
- return this.prepare("SELECT * FROM workflows ORDER BY name ASC").all().map(mapWorkflow);
309
+ return this.prepare("SELECT * FROM workflows ORDER BY slug ASC").all().map(mapWorkflow);
310
310
  }
311
311
  // --------------------------------------------------------------------------
312
312
  // Runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/engine",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {