@magic-marker/nurt 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.
package/README.md ADDED
@@ -0,0 +1,551 @@
1
+ # Nurt
2
+
3
+ > [!CAUTION]
4
+ > This documentation and library is mostly Claude generated. It does have plenty of tests and is used in minimal scope but further cleanup is required at some point.
5
+
6
+ A type-safe, zero-dependency DAG flow execution engine for TypeScript. Nurt lets you define directed acyclic graphs of async steps, execute them with automatic parallelism, and observe progress in real time. It supports dynamic step spawning, nested subgraphs, cross-boundary dependencies, and fine-grained error handling.
7
+
8
+ The name comes from the Polish word for "flow" (nurt).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @marker/nurt
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```typescript
19
+ import { flow } from "@marker/nurt";
20
+
21
+ const result = await flow("my-flow")
22
+ .step("fetch", async () => {
23
+ const data = await fetchData();
24
+ return { items: data };
25
+ })
26
+ .step("process", ["fetch"], async (input) => {
27
+ // input.fetch is typed as { items: ... }
28
+ return { count: input.fetch.items.length };
29
+ })
30
+ .step("save", ["process"], async (input) => {
31
+ await saveResult(input.process.count);
32
+ return { saved: true };
33
+ })
34
+ .build()
35
+ .run().result;
36
+
37
+ console.log(result.status); // "success"
38
+ ```
39
+
40
+ ## Core Concepts
41
+
42
+ ### Flow
43
+
44
+ An immutable DAG blueprint created by the builder. Defines steps, their dependencies, and groups. Validated at build time (cycle detection, parent existence). Can spawn multiple concurrent runs.
45
+
46
+ ### FlowRun
47
+
48
+ A single execution of a flow. Tracks step statuses, outputs, and timing. Provides hooks for observability and a `snapshot()` method for serialization.
49
+
50
+ ### Steps
51
+
52
+ Async functions that receive their parents' outputs as typed input. Steps execute as soon as all their dependencies complete. Multiple independent steps run in parallel automatically.
53
+
54
+ ### Groups
55
+
56
+ Typed collection points for dynamically-added members. An arbiter step can decide at runtime which members to spawn into a group. Downstream steps wait for all group members to complete.
57
+
58
+ ### Subgraphs
59
+
60
+ Group members can be entire nested flows (DAGs within DAGs). A subgraph member contains its own `Flow` that executes as a child `FlowRun`. Subgraphs can reference steps outside their boundary via `externalDeps`.
61
+
62
+ ## API Reference
63
+
64
+ ### `flow(name)`
65
+
66
+ Creates a new `FlowBuilder`.
67
+
68
+ ```typescript
69
+ import { flow } from "@marker/nurt";
70
+
71
+ const builder = flow("my-flow");
72
+ ```
73
+
74
+ ### FlowBuilder
75
+
76
+ Chainable builder. Each `.step()` call returns a new builder with an expanded type registry.
77
+
78
+ #### `.step(name, handler)` - Root step
79
+
80
+ ```typescript
81
+ .step("start", async () => {
82
+ return { documentId: "doc-1" };
83
+ })
84
+ ```
85
+
86
+ #### `.step(name, parents, handler)` - Step with dependencies
87
+
88
+ ```typescript
89
+ .step("process", ["start"], async (input) => {
90
+ // input.start is typed from the "start" step's return type
91
+ return { processed: true };
92
+ })
93
+ ```
94
+
95
+ #### `.step(name, parents, options)` - Step with options
96
+
97
+ ```typescript
98
+ .step("save", ["process"], {
99
+ execute: async (input) => ({ saved: true }),
100
+ terminal: true, // determines run success/failure
101
+ allowFailures: true, // runs even if parents fail
102
+ transform: (raw) => transform(raw), // transform parent outputs
103
+ })
104
+ ```
105
+
106
+ #### `.group<T>(name, options)` - Declare a group
107
+
108
+ ```typescript
109
+ .group<ReviewOutput>("reviews", { dependsOn: ["arbiter"] })
110
+ ```
111
+
112
+ The group adds `T[]` to the type registry. Downstream steps receive an array of member outputs.
113
+
114
+ #### `.step(name, [group(...)], handler)` - Depend on a group
115
+
116
+ ```typescript
117
+ import { group } from "@marker/nurt";
118
+
119
+ .step("merge", [group("reviews")], async (input) => {
120
+ // input.reviews is ReviewOutput[]
121
+ return { total: input.reviews.length };
122
+ })
123
+ ```
124
+
125
+ #### `.build()` - Create the Flow
126
+
127
+ Validates the DAG (cycle detection, parent existence) and returns an immutable `Flow`.
128
+
129
+ ### Flow
130
+
131
+ ```typescript
132
+ const myFlow = flow("example").step(...).build();
133
+
134
+ // Create a run
135
+ const run = myFlow.run();
136
+ const run2 = myFlow.run({ failFast: true }); // multiple concurrent runs OK
137
+ ```
138
+
139
+ ### RunOptions
140
+
141
+ ```typescript
142
+ interface RunOptions {
143
+ failFast?: boolean; // abort entire run on first error (default: false)
144
+ hooks?: FlowHooks; // lifecycle callbacks
145
+ injectedSteps?: Map<string, unknown>; // pre-resolved step outputs
146
+ }
147
+ ```
148
+
149
+ ### FlowRun
150
+
151
+ The execution instance. Created by `flow.run()`.
152
+
153
+ ```typescript
154
+ const run = myFlow.run({ hooks: { ... } });
155
+
156
+ run.runId; // "run-1" (unique per run)
157
+ run.result; // Promise<FlowRunResult> - resolves when done
158
+ run.steps; // readonly StepRecord[] - current state snapshot
159
+
160
+ run.snapshot(); // FlowSnapshot - JSON-serializable state
161
+ run.abort(); // signal all steps to stop
162
+
163
+ // Dynamic group control
164
+ run.spawnGroup("reviews", members); // add members + auto-seal
165
+ run.addGroupMember("reviews", member); // add one member
166
+ run.sealGroup("reviews"); // seal (no more members)
167
+ ```
168
+
169
+ ### StepContext
170
+
171
+ Available inside every step handler as the second argument.
172
+
173
+ ```typescript
174
+ .step("my-step", async (input, ctx) => {
175
+ ctx.runId; // current run ID
176
+ ctx.signal; // AbortSignal (check ctx.signal.aborted)
177
+ ctx.history; // shared History store
178
+ ctx.run; // RunHandle for dynamic control
179
+
180
+ ctx.history.set("key", "value");
181
+ ctx.history.get<string>("key"); // "value"
182
+
183
+ // Spawn group members from inside a step
184
+ ctx.run.spawnGroup("reviews", [
185
+ { name: "grammar", execute: async () => ({ ... }) },
186
+ ]);
187
+ })
188
+ ```
189
+
190
+ ### FlowHooks
191
+
192
+ ```typescript
193
+ const run = myFlow.run({
194
+ hooks: {
195
+ onChange: () => {
196
+ // fires on ANY state change (including subgraph events)
197
+ updateUI(run.snapshot());
198
+ },
199
+ onStepStart: (step) => console.log(`started: ${step.name}`),
200
+ onStepComplete: (step) =>
201
+ console.log(`done: ${step.name} in ${step.durationMs}ms`),
202
+ onStepError: (step) => console.log(`failed: ${step.name}: ${step.error}`),
203
+ onStepAdded: (step) => console.log(`dynamic step: ${step.name}`),
204
+ onRunComplete: (result) => console.log(`run ${result.status}`),
205
+ },
206
+ });
207
+ ```
208
+
209
+ Hooks are isolated from execution -- if a hook throws, the run continues unaffected.
210
+
211
+ ### FlowRunResult
212
+
213
+ ```typescript
214
+ const result = await run.result;
215
+
216
+ result.runId; // "run-1"
217
+ result.status; // "success" | "error"
218
+ result.startedAt; // timestamp
219
+ result.completedAt; // timestamp
220
+ result.steps; // StepRecord[] with status, output, timing
221
+ result.history; // ReadonlyMap<string, unknown>
222
+ ```
223
+
224
+ ### StepRecord
225
+
226
+ ```typescript
227
+ interface StepRecord {
228
+ name: string;
229
+ parentNames: string[];
230
+ status: "pending" | "running" | "success" | "error" | "skipped";
231
+ startedAt?: number;
232
+ completedAt?: number;
233
+ durationMs?: number;
234
+ output?: unknown;
235
+ error?: string;
236
+ }
237
+ ```
238
+
239
+ ## Patterns
240
+
241
+ ### Linear Pipeline
242
+
243
+ ```typescript
244
+ const result = await flow("pipeline")
245
+ .step("extract", async () => ({ text: "hello world" }))
246
+ .step("transform", ["extract"], async (input) => ({
247
+ upper: input.extract.text.toUpperCase(),
248
+ }))
249
+ .step("load", ["transform"], async (input) => ({
250
+ saved: true,
251
+ text: input.transform.upper,
252
+ }))
253
+ .build()
254
+ .run().result;
255
+ ```
256
+
257
+ ### Parallel Fan-Out / Fan-In
258
+
259
+ Steps with the same parent run in parallel automatically.
260
+
261
+ ```typescript
262
+ const result = await flow("parallel")
263
+ .step("start", async () => ({ data: [1, 2, 3] }))
264
+ .step("branch-a", ["start"], async (input) => ({
265
+ sum: input.start.data.reduce((a, b) => a + b, 0),
266
+ }))
267
+ .step("branch-b", ["start"], async (input) => ({
268
+ count: input.start.data.length,
269
+ }))
270
+ .step("merge", ["branch-a", "branch-b"], async (input) => ({
271
+ average: input["branch-a"].sum / input["branch-b"].count,
272
+ }))
273
+ .build()
274
+ .run().result;
275
+ // branch-a and branch-b execute concurrently
276
+ ```
277
+
278
+ ### Dynamic Group Spawning (Arbiter Pattern)
279
+
280
+ An arbiter step decides at runtime which members to add to a group.
281
+
282
+ ```typescript
283
+ type ReviewOutput = { tool: string; comments: string[] };
284
+
285
+ const reviewFlow = flow("review")
286
+ .step("start", async () => ({ wordCount: 1200 }))
287
+ .step("arbiter", ["start"], async (input, ctx) => {
288
+ const tools =
289
+ input.start.wordCount > 500
290
+ ? ["grammar", "tone", "clarity"]
291
+ : ["grammar"];
292
+
293
+ ctx.run.spawnGroup(
294
+ "reviews",
295
+ tools.map((tool) => ({
296
+ name: `review-${tool}`,
297
+ execute: async () => ({
298
+ tool,
299
+ comments: [`Found issue in ${tool}`],
300
+ }),
301
+ })),
302
+ );
303
+
304
+ return { selectedTools: tools };
305
+ })
306
+ .group<ReviewOutput>("reviews", { dependsOn: ["arbiter"] })
307
+ .step("synthesize", [group("reviews")], async (input) => ({
308
+ total: input.reviews.flatMap((r) => r.comments).length,
309
+ }))
310
+ .build();
311
+
312
+ const result = await reviewFlow.run().result;
313
+ ```
314
+
315
+ ### Subgraph Members (Nested DAGs)
316
+
317
+ A group member can contain an entire flow with branching and parallelism.
318
+
319
+ ```typescript
320
+ const analysisFlow = flow("deep-analysis")
321
+ .step("extract", async () => ({ claims: ["A", "B"] }))
322
+ .step("verify", ["extract"], async (input) => ({
323
+ verified: input.extract.claims.length,
324
+ }))
325
+ .step("check-dates", ["extract"], async () => ({
326
+ issues: 0,
327
+ }))
328
+ .step("report", ["verify", "check-dates"], {
329
+ execute: async (input) => ({
330
+ result: `${input.verify.verified} verified, ${input["check-dates"].issues} date issues`,
331
+ }),
332
+ terminal: true,
333
+ })
334
+ .build();
335
+ // DAG: extract -> [verify, check-dates] -> report
336
+
337
+ run.spawnGroup("reviews", [
338
+ { name: "grammar", execute: async () => ({ ... }) }, // single member
339
+ { name: "deep-analysis", flow: analysisFlow }, // subgraph member
340
+ ]);
341
+ ```
342
+
343
+ ### Pipeline Helper
344
+
345
+ Shorthand for linear subgraphs.
346
+
347
+ ```typescript
348
+ import { pipeline } from "@marker/nurt";
349
+
350
+ run.spawnGroup("reviews", [
351
+ pipeline("tone-check", [
352
+ { name: "detect", execute: async () => ({ issues: ["too formal"] }) },
353
+ { name: "classify", execute: async (input) => ({ severity: "medium" }) },
354
+ {
355
+ name: "suggest",
356
+ execute: async (input) => ({ fix: "use simpler words" }),
357
+ },
358
+ ]),
359
+ ]);
360
+ // Creates: detect -> classify -> suggest (terminal)
361
+ ```
362
+
363
+ ### Cross-Boundary Dependencies
364
+
365
+ A subgraph step can depend on a step outside the subgraph via `externalDeps`.
366
+
367
+ ```typescript
368
+ const clarityFlow = flow("clarity")
369
+ .step("extract", async () => ({ issues: ["vague intro"] }))
370
+ .step("nlp-data", async () => ({})) // placeholder for external injection
371
+ .step("assess", ["extract"], async (input) => ({ ... }))
372
+ .step("cross-ref", ["nlp-data"], async (input) => ({
373
+ // input["nlp-data"] will contain the NLP step's output
374
+ refs: input["nlp-data"].entities.length,
375
+ }))
376
+ .step("refine", ["assess", "cross-ref"], {
377
+ execute: async (input) => ({ ... }),
378
+ terminal: true,
379
+ })
380
+ .build();
381
+
382
+ // In the parent flow, nlp-process runs at the top level
383
+ // externalDeps maps the subgraph's "nlp-data" step to the parent's "nlp-process" step
384
+ run.spawnGroup("reviews", [
385
+ {
386
+ name: "clarity",
387
+ flow: clarityFlow,
388
+ externalDeps: { "nlp-data": "nlp-process" },
389
+ },
390
+ ]);
391
+ // The subgraph waits for "nlp-process" to complete, then injects its output
392
+ // as the pre-resolved "nlp-data" step inside the child run
393
+ ```
394
+
395
+ ### Error Handling with allowFailures
396
+
397
+ By default, if a step fails, its dependents are skipped. With `allowFailures: true`, a step runs even if parents failed, receiving `StepResult<T>` wrappers.
398
+
399
+ ```typescript
400
+ import type { StepResult } from "@marker/nurt";
401
+
402
+ const result = await flow("resilient")
403
+ .step("risky", async () => {
404
+ throw new Error("network timeout");
405
+ })
406
+ .step("handler", ["risky"], {
407
+ allowFailures: true,
408
+ execute: async (input) => {
409
+ // input.risky is StepResult<T>, not T
410
+ const result = input.risky as StepResult<unknown>;
411
+ if (result.status === "error") {
412
+ return { fallback: true, error: result.error };
413
+ }
414
+ return { fallback: false, value: result.value };
415
+ },
416
+ })
417
+ .build()
418
+ .run().result;
419
+
420
+ // result.steps[0].status = "error"
421
+ // result.steps[1].status = "success" (ran despite parent failure)
422
+ ```
423
+
424
+ ### Terminal Steps
425
+
426
+ Terminal steps determine the run's final status. If no steps are marked terminal, all steps are considered.
427
+
428
+ ```typescript
429
+ const result = await flow("with-terminal")
430
+ .step("main", async () => ({ data: "ok" }))
431
+ .step("save", ["main"], {
432
+ execute: async () => ({ saved: true }),
433
+ terminal: true,
434
+ })
435
+ .step("notify", async () => {
436
+ throw new Error("email service down");
437
+ })
438
+ .build()
439
+ .run().result;
440
+
441
+ // result.status = "success"
442
+ // Only "save" (terminal) determines status. "notify" failed but doesn't affect it.
443
+ ```
444
+
445
+ ### Shared State via History
446
+
447
+ Steps can share data through the `ctx.history` store, accessible across all steps in a run.
448
+
449
+ ```typescript
450
+ const result = await flow("with-history")
451
+ .step("producer", async (_, ctx) => {
452
+ ctx.history.set("config", { maxRetries: 3 });
453
+ return { produced: true };
454
+ })
455
+ .step("consumer", ["producer"], async (_, ctx) => {
456
+ const config = ctx.history.get<{ maxRetries: number }>("config");
457
+ return { retries: config?.maxRetries };
458
+ })
459
+ .build()
460
+ .run().result;
461
+
462
+ // result.history.get("config") = { maxRetries: 3 }
463
+ ```
464
+
465
+ ### Snapshots for Serialization
466
+
467
+ `run.snapshot()` returns a JSON-serializable representation of the entire flow state, including nested subgraphs. Useful for sending state to a frontend for visualization.
468
+
469
+ ```typescript
470
+ const run = myFlow.run({
471
+ hooks: {
472
+ onChange: () => {
473
+ const snapshot = run.snapshot();
474
+ // snapshot.flow.name, snapshot.flow.steps, snapshot.flow.groups
475
+ // snapshot.run.status, snapshot.run.runId
476
+ // Each step has: name, status, output, durationMs, error, parentNames
477
+ // Groups have: members with type, status, subgraph (recursive FlowSnapshot)
478
+ sendToFrontend(JSON.stringify(snapshot));
479
+ },
480
+ },
481
+ });
482
+ ```
483
+
484
+ ### Executable Classes
485
+
486
+ Steps can be class instances implementing the `Executable` interface.
487
+
488
+ ```typescript
489
+ import type { Executable, StepContext } from "@marker/nurt";
490
+
491
+ class MyTool implements Executable<{ data: string }, { result: number }> {
492
+ async execute(
493
+ input: { data: string },
494
+ ctx: StepContext,
495
+ ): Promise<{ result: number }> {
496
+ return { result: input.data.length };
497
+ }
498
+ }
499
+
500
+ flow("with-class")
501
+ .step("start", async () => ({ data: "hello" }))
502
+ .step("tool", ["start"], new MyTool())
503
+ .build();
504
+ ```
505
+
506
+ ## Error Types
507
+
508
+ | Error | When |
509
+ | -------------------- | --------------------------------------------------------------------- |
510
+ | `CycleDetectedError` | `.build()` detects a cycle in the DAG |
511
+ | `DuplicateStepError` | `.step()` or `.group()` uses an already-registered name |
512
+ | `UnknownParentError` | `.step()` references a parent that doesn't exist |
513
+ | `UnsealedGroupError` | Run completes with a group that was never sealed |
514
+ | `UnfilledSlotError` | A pipeline slot was not provided an implementation |
515
+ | `StepExecutionError` | Wraps an error thrown by a step handler. Has `.stepName` and `.cause` |
516
+
517
+ ## Graph Utilities
518
+
519
+ Low-level DAG utilities, useful for custom tooling or analysis.
520
+
521
+ ```typescript
522
+ import {
523
+ validateAcyclic,
524
+ topologicalSort,
525
+ getReadySteps,
526
+ getSkippableSteps,
527
+ getReadyWithFailures,
528
+ } from "@marker/nurt";
529
+
530
+ const nodes = [
531
+ { name: "a", parentNames: [] },
532
+ { name: "b", parentNames: ["a"] },
533
+ { name: "c", parentNames: ["a"] },
534
+ { name: "d", parentNames: ["b", "c"] },
535
+ ];
536
+
537
+ validateAcyclic(nodes); // throws CycleDetectedError if cyclic
538
+ topologicalSort(nodes); // ["a", "b", "c", "d"]
539
+
540
+ const statuses = new Map([
541
+ ["a", "success"],
542
+ ["b", "success"],
543
+ ["c", "running"],
544
+ ["d", "pending"],
545
+ ]);
546
+ getReadySteps(nodes, statuses); // [] (c still running, d waits)
547
+ ```
548
+
549
+ ## License
550
+
551
+ MIT