blokctl 0.7.0 → 1.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.
@@ -1,21 +1,25 @@
1
- const node_file = `import ApiCall from "@blokjs/api-call";
1
+ const node_file = `import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import ApiCall from "@blokjs/api-call";
2
4
  import IfElse from "@blokjs/if-else";
5
+ import { discoverNodes } from "@blokjs/runner";
3
6
  import type { NodeBase } from "@blokjs/shared";
4
- import ChainInit from "./nodes/chain-init/index";
5
- import ChainVerify from "./nodes/chain-verify/index";
6
7
  import ExampleNodes from "./nodes/examples/index";
7
- import RuntimeBridge from "./nodes/runtime-bridge/index";
8
-
9
- const nodes: {
10
- [key: string]: NodeBase;
11
- } = {
12
- "@blokjs/api-call": ApiCall,
13
- "@blokjs/if-else": IfElse,
14
- "chain-init": ChainInit,
15
- "chain-verify": ChainVerify,
16
- "runtime-bridge": RuntimeBridge,
17
- ...ExampleNodes,
18
- };
8
+
9
+ // Published nodes (npm) + the example bundle are registered explicitly below.
10
+ // Your OWN nodes under 'nodes/<name>/index.ts' are AUTO-DISCOVERED and registered
11
+ // by their defineNode({ name }) — you never edit this file to add a node.
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const local = await discoverNodes(join(here, "nodes"));
14
+
15
+ const explicit: NodeBase[] = [ApiCall, IfElse, ...(Object.values(ExampleNodes) as NodeBase[])];
16
+
17
+ // Map keys are cosmetic — the runner registers each node under its own node.name
18
+ // (the canonical 'use:' ref). Duplicate refs throw at startup.
19
+ const nodes: { [key: string]: NodeBase } = {};
20
+ for (const node of [...explicit, ...local]) {
21
+ nodes[(node as { name: string }).name] = node;
22
+ }
19
23
 
20
24
  export default nodes;
21
25
  `;
@@ -349,7 +353,52 @@ Blok is a **multi-trigger, multi-runtime workflow framework**. A workflow is a d
349
353
  The 9 trigger types: \`http\`, \`worker\`, \`cron\`, \`pubsub\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\`, \`grpc\`.
350
354
  The 8 runtimes: \`typescript\` (in-process), \`go\`, \`rust\`, \`java\`, \`csharp\`, \`php\`, \`ruby\`, \`python3\`.
351
355
 
352
- The canonical workflow form is \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\`. The same shape works for all 9 triggers — only the \`trigger:\` block changes.
356
+ The canonical TypeScript form is the **typed-handle DSL** from \`@blokjs/core\`: \`workflow(name, { version, trigger }, (entry) => { ... })\`, where each \`step()\` returns a typed handle you reference directly — **no \`$\`, no \`js/\`, no raw \`ctx\` strings.** The object-style \`workflow({ name, version, trigger, steps: [...] })\` from \`@blokjs/helper\` and JSON workflows are equivalent (all three compile to the same IR) and remain fully supported. The same shape works for all 9 triggers — only the \`trigger:\` block changes.
357
+
358
+ ---
359
+
360
+ ## 0. THE TYPED-HANDLE DSL (\`@blokjs/core\`) — preferred for TypeScript
361
+
362
+ \`\`\`ts
363
+ import { workflow, step, branch, forEach, switchOn, tryCatch, http, tpl, gt } from "@blokjs/core";
364
+
365
+ export default workflow("order-intake", { version: "1.0.0", trigger: http.post("/orders") }, (req) => {
366
+ // step(id, node, inputs) → a TYPED handle shaped like the node's output.
367
+ const order = step("validate", validateOrder, { qty: req.body.qty });
368
+
369
+ // Reference a handle field anywhere downstream — it records a ref the runner resolves.
370
+ step("summary", summarize, { line: tpl\`order of \${order.qty} item(s)\` });
371
+
372
+ // Control flow: a comparator (gt/eq/lt/...) builds the condition; arms are callbacks.
373
+ branch("lane", gt(order.qty, 10), {
374
+ then: () => { step("bulk", routeOrder, { lane: "bulk" }); },
375
+ else: () => { step("standard", routeOrder, { lane: "standard" }); },
376
+ });
377
+ });
378
+ \`\`\`
379
+
380
+ **Trigger config:** \`http.{get,post,put,delete,patch,any}(path?, opts?)\` for HTTP (omit \`path\` for file-based routing). For any other trigger pass the raw block as \`trigger:\` — e.g. \`{ worker: { queue: "jobs" } }\`, \`{ cron: { schedule: "0 2 * * *" } }\`, \`{ webhook: { source: "stripe" } }\`.
381
+
382
+ **The entry handle** (the callback's argument) is the trigger payload — typed and conventionally named per trigger: \`http\`→\`req\`, \`webhook\`→\`event\`, \`cron\`→\`tick\`, \`worker\`→\`job\`, \`pubsub\`→\`msg\`, \`grpc\`→\`rpc\` (others get a loose handle). Read \`req.body\`, \`req.params.id\`, \`req.query.q\`, \`req.headers["x-…"]\`.
383
+
384
+ **Handles & persistence:**
385
+ - \`const h = step("id", node, inputs)\` — every step auto-persists to \`ctx.state["id"]\` on success; \`h\` / \`h.field\` is how you reference it later (never \`$.state.id\`).
386
+ - 4th arg \`opts\`: \`{ as: "name" }\` roots the handle at \`state["name"]\`; \`{ spread: true }\` flattens \`result.data\` into state (mutually exclusive with \`as\`); \`{ ephemeral: true }\` skips persistence (handle then unreadable downstream); also \`idempotencyKey\`, \`retry\`, \`maxDuration\`, \`type: "runtime.<lang>"\`.
387
+ - \`tpl\`…\${h.field}…\`\` builds a string with embedded handle refs.
388
+ - Comparators (typed): \`eq, ne, gt, gte, lt, lte, not\`. A bare boolean handle is a truthiness check.
389
+ - \`defineNode\` is re-exported from \`@blokjs/core\` — author nodes exactly as before; just RETURN output (never write \`ctx.state\`/\`ctx.vars\`).
390
+
391
+ **Control-flow primitives** (all from \`@blokjs/core\`):
392
+ - \`branch(id, cond, { then: () => {…}, else?: () => {…} })\` — \`cond\` is a comparator or a boolean handle.
393
+ - \`forEach(iterable, (item, index) => { step(…) }, { as?, mode?, concurrency? })\` — \`iterable\` is a handle; \`item\`/\`index\` are per-iteration handles; \`mode: "parallel"\` bounds with \`concurrency\` (default 10).
394
+ - \`switchOn(discriminant, { cases: [{ when: "push", do: () => {…} }], default?: () => {…} }, { id })\` — \`when\` labels are STATIC literals (a handle never matches).
395
+ - \`tryCatch(id, { try: () => {…}, catch: (error) => { step("log", logger, { msg: error.message }); }, finally?: () => {…} })\` — \`error\` is a typed handle (\`.message\`/\`.name\`/\`.stack\`/\`.code\`/\`.stepId\`).
396
+
397
+ **The four footguns** (full detail: \`docs/d/primitives/handles-and-footguns.mdx\`):
398
+ 1. **Arm-scoped handles don't escape their arm.** A handle minted inside a \`branch\`/\`switchOn\`/\`tryCatch\`/\`forEach\` arm is unreadable outside it. Return a value from the control-flow step, or have both arms write one \`as:\` key.
399
+ 2. **Ephemeral handles are unreadable.** Reading an \`{ ephemeral: true }\` step's handle resolves to \`undefined\`.
400
+ 3. **Never reuse a step \`id\`** — including across mutually-exclusive arms. Ids are a flat per-workflow map; the runner throws at load time. Use \`as:\` when two arms must write the same downstream key.
401
+ 4. **Never name a \`forEach\` \`as:\`/\`asIndex\` after an existing step id** (or another loop's \`as\`) — they share \`ctx.state\`; the runner throws at load time.
353
402
 
354
403
  ---
355
404
 
@@ -858,7 +907,7 @@ Process-global middleware: \`WorkflowRegistry.getInstance().setGlobalMiddleware(
858
907
  Always \`export default defineNode(...)\`. Never class-based \`BlokService\`. Zod input/output are mandatory.
859
908
 
860
909
  \`\`\`ts
861
- import { defineNode } from "@blokjs/runner";
910
+ import { defineNode } from "@blokjs/core";
862
911
  import { z } from "zod";
863
912
 
864
913
  export default defineNode({
@@ -1211,12 +1260,12 @@ Plus the cross-runtime rules: **the wrong input source** (typed sidecar nodes re
1211
1260
 
1212
1261
  ## 8. TESTING
1213
1262
 
1214
- Use the \`@blokjs/runner\` testing utilities with Vitest.
1263
+ Use the \`@blokjs/core/testing\` utilities with Vitest (same package you author against).
1215
1264
 
1216
1265
  **Unit-test a node** with \`NodeTestHarness\`:
1217
1266
 
1218
1267
  \`\`\`ts
1219
- import { NodeTestHarness } from "@blokjs/runner";
1268
+ import { NodeTestHarness } from "@blokjs/core/testing";
1220
1269
  import myNode from "../src/nodes/my-node";
1221
1270
 
1222
1271
  const harness = new NodeTestHarness(myNode);
@@ -1228,7 +1277,7 @@ harness.assertOutput(result, { user: { id: "abc-123" } });
1228
1277
  **Integration-test a workflow** with \`WorkflowTestRunner\`:
1229
1278
 
1230
1279
  \`\`\`ts
1231
- import { WorkflowTestRunner } from "@blokjs/runner";
1280
+ import { WorkflowTestRunner } from "@blokjs/core/testing";
1232
1281
 
1233
1282
  const runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });
1234
1283
  runner.registerNode("validate", ValidateNode);
@@ -1334,7 +1383,7 @@ Per-step reliability lives on the step: \`idempotencyKey\` (cache by \`(workflow
1334
1383
  Always \`export default defineNode(...)\` (TS) — never class-based \`BlokService\`. Zod input/output are mandatory. Never write \`ctx.state\` from a node — return your output and let the runner persist it (use \`ctx.publish(name, value)\` for a true side-channel). No \`any\` types — use \`z.unknown()\`.
1335
1384
 
1336
1385
  \`\`\`typescript
1337
- import { defineNode } from "@blokjs/runner";
1386
+ import { defineNode } from "@blokjs/core";
1338
1387
  import { z } from "zod";
1339
1388
 
1340
1389
  export default defineNode({
@@ -1388,7 +1437,21 @@ Registration is **manual** in non-TS runtimes — importing the module runs the
1388
1437
 
1389
1438
  ## 4. Generating Workflows
1390
1439
 
1391
- Canonical form: \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\` one object literal, no chained builder, no separate \`nodes{}\` map. \`name\` 3 chars, \`version\` 5 chars (semver). Reference earlier outputs with \`$.state.<id>\` / \`$.req.body\`. Use \`branch\`, \`switchOn\`, \`forEach\`, \`loop\`, \`tryCatch\` (all from \`@blokjs/helper\`) for control flow.
1440
+ **Preferred (TypeScript): the typed-handle DSL from \`@blokjs/core\`.** \`workflow(name, { version, trigger }, (entry) => { ... })\` each \`step(id, node, inputs)\` returns a typed handle you reference directly; no \`$\`, no \`js/\`, no raw \`ctx\` strings. \`http.{get,post,…}(path?)\` builds the trigger; other triggers pass the raw block (\`{ worker: { queue } }\`, \`{ cron: { schedule } }\`, …). Entry name per trigger: \`http\`→\`req\`, \`worker\`→\`job\`, \`cron\`→\`tick\`, \`webhook\`→\`event\`, \`pubsub\`→\`msg\`, \`grpc\`→\`rpc\`.
1441
+
1442
+ \`\`\`typescript
1443
+ import { workflow, step, branch, gt, http } from "@blokjs/core";
1444
+
1445
+ export default workflow("Process Order", { version: "1.0.0", trigger: http.post("/orders") }, (req) => {
1446
+ const order = step("validate", orderValidator, { order: req.body }); // typed handle
1447
+ step("save", orderStore, { data: order }); // reference it directly
1448
+ branch("big", gt(order.total, 100), { then: () => { step("vip", flagVip, { id: order.id }); } });
1449
+ });
1450
+ \`\`\`
1451
+
1452
+ Control flow (all from \`@blokjs/core\`): \`branch(id, cond, {then,else?})\`, \`forEach(iterable, (item,i)=>{}, {as?,mode?})\`, \`switchOn(disc, {cases:[{when,do}],default?}, {id})\`, \`tryCatch(id, {try,catch:(err)=>{},finally?})\`; comparators \`eq/ne/gt/gte/lt/lte/not\`; \`tpl\`…\${h.x}…\`\` for strings. The **four footguns**: arm-scoped handles don't escape their arm; ephemeral handles read \`undefined\`; never reuse a step \`id\`; never name a \`forEach\` \`as:\` after an existing id. See \`docs/d/primitives/handles-and-footguns.mdx\`.
1453
+
1454
+ **Equivalent object-style form** (\`@blokjs/helper\`, also valid; JSON mirrors it): one object literal, \`steps: [...]\`, reference outputs with \`$.state.<id>\` / \`$.req.body\`.
1392
1455
 
1393
1456
  \`\`\`typescript
1394
1457
  import { workflow, $ } from "@blokjs/helper";
@@ -1461,7 +1524,7 @@ branch({ id: "route",
1461
1524
  - Do NOT generate class-based \`BlokService\` nodes or use \`any\` types — always \`defineNode()\` (TS) / \`@node\` (Python) with Zod/Pydantic schemas.
1462
1525
  - Do NOT use ESLint/Prettier — this project uses Biome. Do NOT edit auto-generated files in \`.blok/runtimes/\`.
1463
1526
  `;
1464
- const function_first_node_file = `import { defineNode } from "@blokjs/runner";
1527
+ const function_first_node_file = `import { defineNode } from "@blokjs/core";
1465
1528
  import { z } from "zod";
1466
1529
 
1467
1530
  /**
@@ -1486,13 +1549,13 @@ export default defineNode({
1486
1549
  // Execute function - type-safe with inferred types from Zod schemas
1487
1550
  async execute(ctx, input) {
1488
1551
  // Your business logic here
1489
- // - ctx.vars: Access workflow variables
1490
1552
  // - ctx.request: Access HTTP request data
1491
1553
  // - ctx.logger: Log messages
1492
1554
  // - ctx.env: Access environment variables
1493
-
1494
- // Example: Store data for downstream nodes
1495
- ctx.vars["processed-message"] = input.message;
1555
+ //
1556
+ // Just RETURN your output the runner auto-persists it to
1557
+ // ctx.state["{{NODE_NAME}}"], readable from any later step's handle.
1558
+ // Do NOT write to ctx.state / ctx.vars inside a node.
1496
1559
 
1497
1560
  // Return type-safe output (validated automatically)
1498
1561
  return {
@@ -101,11 +101,11 @@ export default class WorkflowGenerator {
101
101
  "```",
102
102
  "",
103
103
  "Please fix these errors and regenerate the workflow JSON. Common fixes:",
104
- "- Ensure every step name has a matching entry in the nodes object",
104
+ "- Ensure every step has a unique `id` and a `use` (or a `branch`) — inputs live inline; there is NO `nodes` map",
105
105
  "- Ensure the trigger has exactly one trigger type with valid configuration",
106
- "- Ensure condition expressions use ctx.* properties",
107
- "- Ensure all conditional branches have name, node, and type fields",
108
- "- Add an else branch to conditional routing for error handling",
106
+ "- Ensure branch `when` is a raw `ctx.*` expression (never `js/` or `$.`)",
107
+ "- Reference earlier outputs with `$.state.<id>` and request data with `$.req.*`",
108
+ "- Use a single step with `branch: { when, then, else }` for conditionals (not a `conditions` array)",
109
109
  ].join("\n");
110
110
  return feedback;
111
111
  }
@@ -70,8 +70,8 @@ describe("WorkflowGenerator", () => {
70
70
  });
71
71
  it("should include common fix suggestions", () => {
72
72
  const result = createFeedback("Test", "{}", ["Missing node entry"]);
73
- expect(result).toContain("step name has a matching entry");
74
- expect(result).toContain("else branch");
73
+ expect(result).toContain("unique `id`");
74
+ expect(result).toContain("branch: { when, then, else }");
75
75
  });
76
76
  });
77
77
  });
@@ -9,7 +9,7 @@ What to return:
9
9
  1. Proper imports:
10
10
  * \`z\` from \`zod\`
11
11
  * \`Context\` from \`@blokjs/shared\`
12
- * \`defineNode\` from \`@blokjs/runner\`
12
+ * \`defineNode\` from \`@blokjs/core\`
13
13
  2. A clear and structured \`input\` schema using Zod (z.object with proper types).
14
14
  3. A matching \`output\` schema using Zod.
15
15
  4. A single exported node instance created via \`defineNode\` with:
@@ -26,11 +26,11 @@ Constraints:
26
26
  * The Zod \`output\` schema must fully describe the object returned by \`execute\`.
27
27
  * Inside \`execute(ctx, input)\`:
28
28
  * Use the strongly-typed \`input\`, which is automatically inferred from the Zod schema.
29
- * Use \`ctx\` to access request data, configuration, and cross-node state when needed:
29
+ * Use \`ctx\` to access request data, configuration, and logging when needed:
30
30
  * \`ctx.request.body\`, \`ctx.request.query\`, \`ctx.request.params\` for HTTP data
31
- * \`ctx.vars\` for reading/writing values shared between nodes
32
31
  * \`ctx.logger\` for logging
33
32
  * \`ctx.env\` for environment variables
33
+ * Do **not** write to \`ctx.state\`/\`ctx.vars\` from a node — just RETURN your output; the runner auto-persists it to \`ctx.state[<step-id>]\` for downstream steps. Upstream values arrive as \`input.*\` (mapped in the workflow), not by reading \`ctx.vars\`.
34
34
  * Do **not** construct or return \`BlokResponse\` here; just return a plain object matching the output schema. The wrapper created by \`defineNode\` will call \`setSuccess\` / \`setError\` and handle \`GlobalError\`.
35
35
  * On validation errors or runtime errors, you do NOT manually throw \`GlobalError\`; throw/rethrow normal errors. The \`defineNode\` wrapper will catch them and map them to \`GlobalError\` consistently with proper error codes:
36
36
  * Zod validation errors → 400 Bad Request
@@ -52,7 +52,7 @@ Real-World Examples to Guide You:
52
52
  \`\`\`typescript
53
53
  import type { Context } from "@blokjs/shared";
54
54
  import { z } from "zod";
55
- import { defineNode } from "@blokjs/runner";
55
+ import { defineNode } from "@blokjs/core";
56
56
 
57
57
  export default defineNode({
58
58
  name: "api-call",
@@ -102,10 +102,6 @@ export default defineNode({
102
102
 
103
103
  ctx.logger.log(\`Request completed in \${duration.toFixed(2)}ms with status \${response.status}\`);
104
104
 
105
- if (ctx.vars) {
106
- ctx.vars["api-response"] = { status: response.status, data };
107
- }
108
-
109
105
  return {
110
106
  status: response.status,
111
107
  statusText: response.statusText,
@@ -122,7 +118,7 @@ export default defineNode({
122
118
  \`\`\`typescript
123
119
  import type { Context } from "@blokjs/shared";
124
120
  import { z } from "zod";
125
- import { defineNode } from "@blokjs/runner";
121
+ import { defineNode } from "@blokjs/core";
126
122
 
127
123
  export default defineNode({
128
124
  name: "fetch-user",
@@ -149,11 +145,8 @@ export default defineNode({
149
145
  // Simulate database fetch
150
146
  const user = await fetchUserFromDatabase(input.userId, input.includeMetadata);
151
147
 
152
- // Store in context for downstream nodes
153
- if (ctx.vars) {
154
- ctx.vars["current-user"] = user;
155
- }
156
-
148
+ // Just return the runner persists this to ctx.state["fetch-user"]
149
+ // for any downstream step that maps it in as an input.
157
150
  return { user };
158
151
  },
159
152
  });
@@ -176,7 +169,7 @@ Template to follow (adapt and fill based on the user's request):
176
169
 
177
170
  import type { Context } from "@blokjs/shared";
178
171
  import { z } from "zod";
179
- import { defineNode } from "@blokjs/runner";
172
+ import { defineNode } from "@blokjs/core";
180
173
 
181
174
  /**
182
175
  * [Brief description of what this node does]
@@ -210,11 +203,11 @@ export default defineNode({
210
203
  //
211
204
  // Common patterns:
212
205
  // - Read HTTP params: const id = ctx.request.params.id;
213
- // - Read previous node output: const prev = ctx.vars["previous-node-key"];
214
- // - Write for future nodes: ctx.vars["this-node-key"] = someValue;
206
+ // - Read upstream values via input.* (mapped from prior steps in the workflow) — NOT ctx.vars
215
207
  // - Use input.* fields that match the input schema (TypeScript infers the type automatically)
216
208
  // - Log messages: ctx.logger.log("Processing request");
217
209
  // - Access environment: const apiKey = ctx.env.API_KEY;
210
+ // - Do NOT write ctx.state/ctx.vars here — just return; the runner persists your output.
218
211
 
219
212
  // TODO: Implement business logic here
220
213