@barnum/barnum 0.0.0-main-ef6df91f → 0.0.0-main-e8b82cff

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/src/run.ts CHANGED
@@ -9,12 +9,17 @@ import { existsSync } from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
- import type { Config } from "./ast.js";
12
+ import type { Action, Config, Pipeable } from "./ast.js";
13
+ import { chain } from "./chain.js";
14
+ import { constant } from "./builtins.js";
13
15
 
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
17
 
16
- /** Resolve the tsx executor from the caller's node_modules. */
18
+ /** Resolve the TypeScript executor. Uses bun if the workflow was launched with bun, otherwise tsx. */
17
19
  function resolveExecutor(): string {
20
+ if (process.versions.bun) {
21
+ return "bun";
22
+ }
18
23
  const callerRequire = createRequire(process.argv[1] || import.meta.url);
19
24
  const tsxPath = callerRequire.resolve("tsx/cli");
20
25
  return `node ${tsxPath}`;
@@ -28,18 +33,35 @@ function resolveInstalledBinary(): string | undefined {
28
33
  let artifactDir: string;
29
34
  let binaryName = "barnum";
30
35
 
31
- if (platform === "darwin" && arch === "arm64") artifactDir = "macos-arm64";
32
- else if (platform === "darwin") artifactDir = "macos-x64";
33
- else if (platform === "linux" && arch === "arm64") artifactDir = "linux-arm64";
34
- else if (platform === "linux") artifactDir = "linux-x64";
35
- else if (platform === "win32") { artifactDir = "win-x64"; binaryName = "barnum.exe"; }
36
- else return undefined;
36
+ if (platform === "darwin" && arch === "arm64") {
37
+ artifactDir = "macos-arm64";
38
+ } else if (platform === "darwin") {
39
+ artifactDir = "macos-x64";
40
+ } else if (platform === "linux" && arch === "arm64") {
41
+ artifactDir = "linux-arm64";
42
+ } else if (platform === "linux") {
43
+ artifactDir = "linux-x64";
44
+ } else if (platform === "win32") {
45
+ artifactDir = "win-x64";
46
+ binaryName = "barnum.exe";
47
+ } else {
48
+ return undefined;
49
+ }
37
50
 
38
51
  const callerRequire = createRequire(process.argv[1] || import.meta.url);
39
52
  try {
40
- const packageDir = path.dirname(callerRequire.resolve("@barnum/barnum/package.json"));
41
- const binaryPath = path.join(packageDir, "artifacts", artifactDir, binaryName);
42
- if (existsSync(binaryPath)) return binaryPath;
53
+ const packageDir = path.dirname(
54
+ callerRequire.resolve("@barnum/barnum/package.json"),
55
+ );
56
+ const binaryPath = path.join(
57
+ packageDir,
58
+ "artifacts",
59
+ artifactDir,
60
+ binaryName,
61
+ );
62
+ if (existsSync(binaryPath)) {
63
+ return binaryPath;
64
+ }
43
65
  } catch {
44
66
  // Package not installed
45
67
  }
@@ -89,8 +111,20 @@ function buildBinary(): void {
89
111
  });
90
112
  }
91
113
 
92
- /** Run a workflow config to completion. Prints result to stdout. */
93
- export async function run(config: Config): Promise<void> {
114
+ /** Run a pipeline to completion. Optionally provide input (prepended as a constant node). */
115
+ export async function runPipeline(
116
+ pipeline: Action,
117
+ input?: unknown,
118
+ ): Promise<void> {
119
+ const workflow =
120
+ input === undefined
121
+ ? pipeline
122
+ : (chain(constant(input) as Pipeable, pipeline as Pipeable) as Action);
123
+ await spawnBarnum({ workflow });
124
+ }
125
+
126
+ /** Spawn the barnum CLI with the given config. */
127
+ function spawnBarnum(config: Config): Promise<void> {
94
128
  const binaryResolution = resolveBinary();
95
129
  if (binaryResolution.kind === "Local") {
96
130
  buildBinary();
@@ -100,18 +134,25 @@ export async function run(config: Config): Promise<void> {
100
134
  const configJson = JSON.stringify(config);
101
135
 
102
136
  return new Promise<void>((resolve, reject) => {
103
- const child = nodeSpawn(binaryResolution.path, [
104
- "run",
105
- "--config", configJson,
106
- "--executor", executor,
107
- "--worker", worker,
108
- ], {
109
- stdio: ["inherit", "inherit", "pipe"],
110
- });
137
+ const child = nodeSpawn(
138
+ binaryResolution.path,
139
+ [
140
+ "run",
141
+ "--config",
142
+ configJson,
143
+ "--executor",
144
+ executor,
145
+ "--worker",
146
+ worker,
147
+ ],
148
+ {
149
+ stdio: ["inherit", "inherit", "pipe"],
150
+ },
151
+ );
111
152
 
112
153
  const stderrChunks: Buffer[] = [];
113
154
 
114
- child.stderr!.on("data", (chunk: Buffer) => {
155
+ child.stderr?.on("data", (chunk: Buffer) => {
115
156
  stderrChunks.push(chunk);
116
157
  process.stderr.write(chunk);
117
158
  });
@@ -122,7 +163,7 @@ export async function run(config: Config): Promise<void> {
122
163
 
123
164
  child.on("close", (code) => {
124
165
  if (code !== 0) {
125
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
166
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
126
167
  const message = stderr
127
168
  ? `barnum exited with code ${code}:\n${stderr}`
128
169
  : `barnum exited with code ${code} (no stderr output)`;
package/src/schema.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { JSONSchema7 } from "json-schema";
2
+ import { type z, toJSONSchema } from "zod";
3
+
4
+ // Zod v4 schema def types that have child schemas.
5
+ // Verified against Zod 4.3.6 internals — every compound type's def
6
+ // shape and child property name is listed here.
7
+ const CHILD_ACCESSORS: Record<string, (def: any) => z.ZodType[]> = {
8
+ object: (def) => Object.values(def.shape),
9
+ array: (def) => [def.element],
10
+ tuple: (def) => [...def.items, ...(def.rest ? [def.rest] : [])],
11
+ union: (def) => def.options,
12
+ intersection: (def) => [def.left, def.right],
13
+ record: (def) => [def.keyType, def.valueType],
14
+ // Wrappers with a single inner type
15
+ nullable: (def) => [def.innerType],
16
+ optional: (def) => [def.innerType],
17
+ nonoptional: (def) => [def.innerType],
18
+ default: (def) => [def.innerType],
19
+ catch: (def) => [def.innerType],
20
+ readonly: (def) => [def.innerType],
21
+ promise: (def) => [def.innerType],
22
+ // Pipe has two children
23
+ pipe: (def) => [def.in, def.out],
24
+ // Lazy resolves to inner schema
25
+ lazy: (def) => [def.getter()],
26
+ };
27
+
28
+ /**
29
+ * Walk the Zod schema tree and reject patterns that `toJSONSchema()`
30
+ * handles incorrectly or silently drops:
31
+ *
32
+ * - `z.intersection()` — produces `allOf` with `additionalProperties: false`
33
+ * on both sides (from `io: "output"`), making the intersection unmatchable
34
+ * on Draft 7.
35
+ *
36
+ * - `.refine()` / `.superRefine()` — silently stripped from JSON Schema
37
+ * output, so the Rust side would accept values that fail the refinement.
38
+ * Detected by checking for custom checks (`check._zod.def.check === "custom"`)
39
+ * in the schema's checks array.
40
+ */
41
+ function assertNoUnsupportedPatterns(
42
+ schema: z.ZodType,
43
+ label: string,
44
+ visited = new WeakSet<z.ZodType>(),
45
+ ): void {
46
+ if (visited.has(schema)) {
47
+ return;
48
+ }
49
+ visited.add(schema);
50
+
51
+ const def = (schema as any)._zod.def;
52
+
53
+ // Reject intersections
54
+ if (def.type === "intersection") {
55
+ throw new Error(
56
+ `Handler "${label}": z.intersection() is not supported. ` +
57
+ `It produces broken JSON Schema on Draft 7 because both sides ` +
58
+ `get additionalProperties: false. Use z.object().extend() or ` +
59
+ `z.object().merge() instead.`,
60
+ );
61
+ }
62
+
63
+ // Reject custom checks (from .refine() and .superRefine())
64
+ const checks: any[] | undefined = def.checks;
65
+ if (checks) {
66
+ for (const check of checks) {
67
+ if (check._zod.def.check === "custom") {
68
+ throw new Error(
69
+ `Handler "${label}": .refine() and .superRefine() are not ` +
70
+ `supported. Custom validations cannot be expressed in JSON ` +
71
+ `Schema and would be silently dropped.`,
72
+ );
73
+ }
74
+ }
75
+ }
76
+
77
+ // Recurse into children
78
+ const getChildren = CHILD_ACCESSORS[def.type];
79
+ if (getChildren) {
80
+ for (const child of getChildren(def)) {
81
+ assertNoUnsupportedPatterns(child, label, visited);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Convert a Zod schema to a JSON Schema document suitable for embedding
88
+ * in the serialized AST. Throws if the schema contains types that can't
89
+ * survive the TS → JSON → Rust boundary.
90
+ */
91
+ export function zodToCheckedJsonSchema(
92
+ schema: z.ZodType,
93
+ label: string,
94
+ ): JSONSchema7 {
95
+ // Pre-validate: catch patterns that toJSONSchema() handles incorrectly
96
+ assertNoUnsupportedPatterns(schema, label);
97
+
98
+ let raw: Record<string, unknown>;
99
+ try {
100
+ raw = toJSONSchema(schema, {
101
+ target: "draft-07",
102
+ unrepresentable: "throw",
103
+ io: "output",
104
+ cycles: "throw",
105
+ reused: "inline",
106
+ }) as Record<string, unknown>;
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ throw new Error(
110
+ `Handler "${label}": Zod schema cannot be converted to JSON Schema: ${message}`,
111
+ { cause: error },
112
+ );
113
+ }
114
+
115
+ // Strip $schema — embedded schemas don't need the draft URI.
116
+ const { $schema: _, ...rest } = raw;
117
+ return rest as JSONSchema7;
118
+ }
package/src/try-catch.ts CHANGED
@@ -1,54 +1,53 @@
1
- import { type Action, type Pipeable, type TypedAction, typedAction } from "./ast.js";
2
- import { allocateEffectId } from "./effect-id.js";
1
+ import {
2
+ type Action,
3
+ type Pipeable,
4
+ type TypedAction,
5
+ typedAction,
6
+ buildRestartBranchAction,
7
+ TAG_BREAK,
8
+ } from "./ast.js";
9
+ import { allocateRestartHandlerId } from "./effect-id.js";
3
10
 
4
11
  // ---------------------------------------------------------------------------
5
- // tryCatch — type-level error handling via Handle/Perform
12
+ // tryCatch — type-level error handling via restart+Branch
6
13
  // ---------------------------------------------------------------------------
7
14
 
8
15
  /**
9
16
  * HOAS combinator for type-level error handling. The body callback receives
10
17
  * a `throwError` token — a `TypedAction<TError, never>` that, when placed
11
- * in the pipeline, causes the Handle frame to discard the continuation and
12
- * run the recovery branch with the error payload.
18
+ * in the pipeline, tags the error as Break, performs to the handler, which
19
+ * restarts the body. The body-level Branch routes to the recovery arm.
13
20
  *
14
21
  * This handles **type-level errors only** — values returned by handlers via
15
22
  * the `Result` type. If a handler panics, throws a JavaScript exception, or
16
23
  * the runtime crashes, the existing error propagation path handles it.
17
24
  * tryCatch does not catch those. Analogous to Rust's `Result` vs `panic!`.
18
25
  *
19
- * Compiles to:
20
- * Handle(effectId, handlerDag, body)
26
+ * Compiled form (restart+Branch, same substrate as loop/earlyReturn):
27
+ * `Chain(Tag("Continue"),`
28
+ * `RestartHandle(id, ExtractIndex(0),`
29
+ * `Branch({ Continue: body, Break: recovery })))`
21
30
  *
22
- * Handler DAG:
23
- * Chain(ExtractField("payload"), Chain(recovery, Tag("Discard")))
31
+ * throwError = `Chain(Tag("Break"), RestartPerform(id))`
24
32
  *
25
- * The handler extracts the error payload, runs recovery, and tags the result
26
- * as Discard the Handle frame tears down the body and exits with recovery's
27
- * result.
33
+ * When throwError fires: error tagged Break `RestartPerform` handler extracts
34
+ * payload body restarts Branch takes Break arm recovery receives error.
28
35
  */
29
36
  export function tryCatch<TIn, TOut, TError>(
30
37
  body: (throwError: TypedAction<TError, never>) => Pipeable<TIn, TOut>,
31
38
  recovery: Pipeable<TError, TOut>,
32
39
  ): TypedAction<TIn, TOut> {
33
- const effectId = allocateEffectId();
34
- const throwError = typedAction<TError, never>({ kind: "Perform", effect_id: effectId });
35
- const bodyAction = body(throwError) as Action;
40
+ const restartHandlerId = allocateRestartHandlerId();
36
41
 
37
- const handlerDag: Action = {
42
+ const throwError = typedAction<TError, never>({
38
43
  kind: "Chain",
39
- first: { kind: "Invoke", handler: { kind: "Builtin", builtin: { kind: "ExtractField", value: "payload" } } },
40
- rest: {
41
- kind: "Chain",
42
- first: recovery as Action,
43
- rest: { kind: "Invoke", handler: { kind: "Builtin", builtin: { kind: "Tag", value: "Discard" } } },
44
- },
45
- };
46
-
47
- return typedAction({
48
- kind: "Handle",
49
- effect_id: effectId,
50
- handler: handlerDag,
51
- body: bodyAction,
44
+ first: TAG_BREAK,
45
+ rest: { kind: "RestartPerform", restart_handler_id: restartHandlerId },
52
46
  });
53
- }
54
47
 
48
+ const bodyAction = body(throwError) as Action;
49
+
50
+ return typedAction(
51
+ buildRestartBranchAction(restartHandlerId, bodyAction, recovery as Action),
52
+ );
53
+ }
package/src/worker.ts CHANGED
@@ -9,6 +9,15 @@
9
9
  * process exits non-zero. Rust interprets that as a fatal workflow error.
10
10
  */
11
11
 
12
+ // Suppress EPIPE — when the Rust binary exits (e.g., a race was resolved),
13
+ // orphan workers get broken pipe on stdout. This is expected, not an error.
14
+ process.stdout.on("error", (error: NodeJS.ErrnoException) => {
15
+ if (error.code === "EPIPE") {
16
+ process.exit(0);
17
+ }
18
+ throw error;
19
+ });
20
+
12
21
  async function main(): Promise<void> {
13
22
  const [modulePath, exportName = "default"] = process.argv.slice(2);
14
23
 
@@ -38,7 +47,7 @@ async function main(): Promise<void> {
38
47
  const result = await handler.__definition.handle({ value: input.value });
39
48
 
40
49
  // Write result to stdout
41
- process.stdout.write(JSON.stringify(result));
50
+ process.stdout.write(JSON.stringify(result) ?? "null");
42
51
  }
43
52
 
44
53
  main().catch((error) => {