flowneer 0.1.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephan Langeveld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Flowneer
2
2
 
3
+ <p>
4
+ <a href="https://www.npmjs.com/package/flowneer"><img src="https://badges.ws/npm/v/flowneer" /></a>
5
+ <a href="https://deno.bundlejs.com/badge?q=flowneer"><img src="https://deno.bundlejs.com/badge?q=flowneer" /></a>
6
+ <a href="https://www.npmjs.com/package/flowneer"><img src="https://badges.ws/npm/l/flowneer" /></a>
7
+ <a href="https://www.npmjs.com/package/flowneer"><img src="https://badges.ws/npm/dt/flowneer" /></a>
8
+ <a href="https://deepwiki.com/Fanna1119/flowneer"><img src="https://deepwiki.com/badge.svg" /></a>
9
+ </p>
10
+
3
11
  A tiny, zero-dependency fluent flow builder for TypeScript. Chain steps, branch on conditions, loop, batch-process, and run tasks in parallel — all through a single `FlowBuilder` class. Extend it with plugins.
4
12
 
5
13
  ## Install
@@ -121,7 +129,7 @@ await new FlowBuilder<SumState>()
121
129
  // → [2, 4, 6]
122
130
  ```
123
131
 
124
- ### `parallel(fns, options?)`
132
+ ### `parallel(fns, options?, reducer?)`
125
133
 
126
134
  Run multiple functions concurrently against the same shared state.
127
135
 
@@ -155,6 +163,67 @@ await new FlowBuilder<FetchState>()
155
163
  // → Fetched 100 posts and 10 users
156
164
  ```
157
165
 
166
+ When a `reducer` is provided each fn receives its own **shallow clone** of `shared`, preventing concurrent write races. After all fns settle the reducer merges the drafts back into the original:
167
+
168
+ ```typescript
169
+ interface ScoreState {
170
+ value: number;
171
+ }
172
+
173
+ await new FlowBuilder<ScoreState>()
174
+ .parallel(
175
+ [
176
+ async (s) => {
177
+ s.value += 10;
178
+ },
179
+ async (s) => {
180
+ s.value += 20;
181
+ },
182
+ ],
183
+ undefined,
184
+ (original, drafts) => {
185
+ // drafts[0].value === 10, drafts[1].value === 20 (each started at 0)
186
+ original.value = drafts.reduce((sum, d) => sum + d.value, 0);
187
+ },
188
+ )
189
+ .run({ value: 0 });
190
+ // original.value === 30
191
+ ```
192
+
193
+ See [`withAtomicUpdates`](#withatomicupdates) for the plugin shorthand.
194
+
195
+ ### `label(name)`
196
+
197
+ Insert a named marker in the step chain. Labels are no-ops during normal execution — they exist only as jump targets.
198
+
199
+ Any `NodeFn` can return `"→labelName"` to jump back (or forward) to that label, enabling iterative refinement and reflection loops without nesting:
200
+
201
+ ```typescript
202
+ interface RefineState {
203
+ draft: string;
204
+ passes: number;
205
+ quality: number;
206
+ }
207
+
208
+ await new FlowBuilder<RefineState>()
209
+ .startWith(async (s) => {
210
+ s.draft = await generateDraft(s);
211
+ })
212
+ .label("refine")
213
+ .then(async (s) => {
214
+ s.quality = await scoreDraft(s.draft);
215
+ if (s.quality < 0.8) {
216
+ s.draft = await improveDraft(s.draft);
217
+ s.passes++;
218
+ return "→refine"; // jump back to the label
219
+ }
220
+ })
221
+ .then(async (s) => console.log("Final draft after", s.passes, "passes"))
222
+ .run({ draft: "", passes: 0, quality: 0 });
223
+ ```
224
+
225
+ > **Tip:** Pair with [`withCycles`](#withcycles) to cap the maximum number of jumps.
226
+
158
227
  ### `run(shared, params?, options?)`
159
228
 
160
229
  Execute the flow. Optionally pass a `params` object that every step receives as a second argument.
@@ -210,12 +279,89 @@ FlowError: Flow failed at loop (step 1): exploded on tick 2
210
279
  FlowError: Flow failed at batch (step 0): bad item: 3
211
280
  ```
212
281
 
282
+ ### `InterruptError`
283
+
284
+ `InterruptError` is a special error that **bypasses `FlowError` wrapping** — it propagates directly to the caller. Use it for human-in-the-loop and approval patterns (via [`withInterrupts`](#withinterrupts)).
285
+
286
+ ```typescript
287
+ import { FlowBuilder, InterruptError } from "flowneer";
288
+
289
+ try {
290
+ await flow.run(shared);
291
+ } catch (err) {
292
+ if (err instanceof InterruptError) {
293
+ // err.savedShared is a deep clone of state at the interrupt point
294
+ const approval = await askHuman(err.savedShared);
295
+ if (approval) await flow.run(shared); // resume from scratch or use withReplay
296
+ }
297
+ }
298
+ ```
299
+
213
300
  ## Plugins
214
301
 
215
302
  The core is intentionally small. Use `FlowBuilder.use(plugin)` to add chain methods.
216
303
 
217
304
  A plugin is an object of functions that get copied onto `FlowBuilder.prototype`. Each function receives the builder as `this` and should return `this` for chaining.
218
305
 
306
+ ### Available plugins
307
+
308
+ | Category | Plugin | Method | Description |
309
+ | ----------------- | ------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------- |
310
+ | **Observability** | `withHistory` | `.withHistory()` | Appends a shallow state snapshot after each step to `shared.__history` |
311
+ | | `withTiming` | `.withTiming()` | Records wall-clock duration (ms) of each step in `shared.__timings[index]` |
312
+ | | `withVerbose` | `.withVerbose()` | Prints the full `shared` object to stdout after each step |
313
+ | | `withInterrupts` | `.interruptIf(condition)` | Pauses the flow by throwing an `InterruptError` (with a deep-clone of `shared`) when condition is true |
314
+ | **Persistence** | `withCheckpoint` | `.withCheckpoint(store)` | Saves `shared` to a store after each successful step |
315
+ | | `withAuditLog` | `.withAuditLog(store)` | Writes an immutable deep-clone audit entry to a store after every step (success and error) |
316
+ | | `withReplay` | `.withReplay(fromStep)` | Skips all steps before `fromStep`; combine with `.withCheckpoint()` to resume a failed flow |
317
+ | | `withVersionedCheckpoint` | `.withVersionedCheckpoint(store)` | Saves diff-based versioned checkpoints with parent pointers after each step that changes state |
318
+ | | | `.resumeFrom(version, store)` | Resolves a version id and skips all steps up to and including the saved step index |
319
+ | **Resilience** | `withCircuitBreaker` | `.withCircuitBreaker(opts?)` | Opens the circuit after `maxFailures` consecutive failures and rejects all steps until `resetMs` elapses |
320
+ | | `withFallback` | `.withFallback(fn)` | Catches any step error and calls `fn` instead of propagating, allowing the flow to continue |
321
+ | | `withTimeout` | `.withTimeout(ms)` | Aborts any step that exceeds `ms` milliseconds with a descriptive error |
322
+ | | `withCycles` | `.withCycles(maxJumps?)` | Throws if total step executions exceed `maxJumps` (default 100) — guards against infinite goto loops |
323
+ | **Messaging** | `withChannels` | `.withChannels()` | Initialises a `Map`-based message-channel system on `shared.__channels` |
324
+ | **LLM** | `withCostTracker` | `.withCostTracker()` | Accumulates per-step `shared.__stepCost` values into `shared.__cost` after each step |
325
+ | | `withRateLimit` | `.withRateLimit({ intervalMs })` | Enforces a minimum gap of `intervalMs` ms between steps to avoid hammering rate-limited APIs |
326
+ | | `withTokenBudget` | `.withTokenBudget(limit)` | Aborts the flow before any step where `shared.tokensUsed >= limit` |
327
+ | **Dev** | `withDryRun` | `.withDryRun()` | Skips all step bodies while still firing hooks — useful for validating observability wiring |
328
+ | | `withMocks` | `.withMocks(map)` | Replaces step bodies at specified indices with mock functions; all other steps run normally |
329
+ | | `withStepLimit` | `.withStepLimit(max?)` | Throws after `max` total step executions (default 1000); counter resets on each `run()` call |
330
+ | | `withAtomicUpdates` | `.parallelAtomic(fns, reducer, options?)` | Sugar over `parallel()` with a reducer — each fn runs on an isolated draft, reducer merges results |
331
+
332
+ Plugins are imported from `flowneer/plugins` (or their individual subpath) and registered once with `FlowBuilder.use()`:
333
+
334
+ ```typescript
335
+ import { withTiming, withCostTracker } from "flowneer/plugins";
336
+
337
+ FlowBuilder.use(withTiming);
338
+ FlowBuilder.use(withCostTracker);
339
+ ```
340
+
341
+ Messaging utilities are standalone functions — no need to register them as a plugin method:
342
+
343
+ ```typescript
344
+ import {
345
+ withChannels,
346
+ sendTo,
347
+ receiveFrom,
348
+ peekChannel,
349
+ } from "flowneer/plugins/messaging";
350
+
351
+ FlowBuilder.use(withChannels);
352
+
353
+ const flow = new FlowBuilder()
354
+ .withChannels()
355
+ .startWith(async (s) => {
356
+ sendTo(s, "results", { score: 42 });
357
+ })
358
+ .then(async (s) => {
359
+ const msgs = receiveFrom(s, "results"); // [{ score: 42 }]
360
+ });
361
+ ```
362
+
363
+ ---
364
+
219
365
  ### Writing a plugin
220
366
 
221
367
  ```typescript
@@ -257,23 +403,33 @@ const flow = new FlowBuilder<MyState>()
257
403
 
258
404
  ### Lifecycle hooks
259
405
 
260
- Plugins register hooks via `_setHooks()`. Three hook points are available:
406
+ Plugins register hooks via `_setHooks()`. The following hook points are available:
407
+
408
+ | Hook | Called | Arguments |
409
+ | ---------------- | --------------------------------------------------------- | --------------------------------------- |
410
+ | `beforeFlow` | Once before the first step | `(shared, params)` |
411
+ | `beforeStep` | Before each step executes | `(meta, shared, params)` |
412
+ | `wrapStep` | Wraps step execution — call `next()` to run the step body | `(meta, next, shared, params)` |
413
+ | `afterStep` | After each step completes | `(meta, shared, params)` |
414
+ | `wrapParallelFn` | Wraps each individual fn inside a `parallel()` step | `(meta, fnIndex, next, shared, params)` |
415
+ | `onError` | When a step throws (before re-throwing) | `(meta, error, shared, params)` |
416
+ | `afterFlow` | After the flow finishes (success or failure) | `(shared, params)` |
261
417
 
262
- | Hook | Called | Arguments |
263
- | ------------ | -------------------------------------------- | ------------------------------- |
264
- | `beforeStep` | Before each step executes | `(meta, shared, params)` |
265
- | `afterStep` | After each step completes | `(meta, shared, params)` |
266
- | `onError` | When a step throws (before re-throwing) | `(meta, error, shared, params)` |
267
- | `afterFlow` | After the flow finishes (success or failure) | `(shared, params)` |
418
+ Multiple `wrapStep` (or `wrapParallelFn`) registrations compose — the first registered is the outermost wrapper. Omitting `next()` skips the step body entirely (used by `withDryRun`, `withMocks`, `withReplay`).
268
419
 
269
420
  ### What plugins are for
270
421
 
271
- | Concern | Example plugin | Hook it uses |
272
- | --------------------------- | --------------- | -------------------------------------- |
273
- | Observability / tracing | `observePlugin` | `beforeStep` + `afterStep` + `onError` |
274
- | Persistence / checkpointing | `persistPlugin` | `afterStep` |
275
- | Timing / metrics | custom | `beforeStep` + `afterStep` |
276
- | Cleanup / teardown | custom | `afterFlow` |
422
+ | Concern | Plugin / hook | Hook(s) used |
423
+ | ---------------------------- | ----------------------------- | ----------------------------------- |
424
+ | Observability / tracing | `withHistory`, `withTiming` | `beforeStep` + `afterStep` |
425
+ | Persistence / checkpointing | `withCheckpoint` | `afterStep` |
426
+ | Versioned persistence | `withVersionedCheckpoint` | `beforeFlow` + `afterStep` |
427
+ | Step/execution skip | `withDryRun`, `withReplay` | `wrapStep` |
428
+ | Safe parallel isolation | `withAtomicUpdates` | `wrapParallelFn` (via core reducer) |
429
+ | Human-in-the-loop / approval | `withInterrupts` | `then()` + `InterruptError` |
430
+ | Message passing | `withChannels` | `beforeFlow` |
431
+ | Infinite-loop protection | `withCycles`, `withStepLimit` | `afterStep` / `beforeStep` |
432
+ | Cleanup / teardown | custom | `afterFlow` |
277
433
 
278
434
  See [examples/observePlugin.ts](examples/observePlugin.ts) and [examples/persistPlugin.ts](examples/persistPlugin.ts) for complete implementations.
279
435
 
@@ -380,15 +536,82 @@ const orchestrator = new FlowBuilder<AnalysisState>()
380
536
 
381
537
  All three sub-agents share the same `shared` object and run concurrently. Avoid writing to the same key from parallel sub-agents — writes are not synchronised.
382
538
 
539
+ To eliminate race conditions entirely, pass a `reducer` as the third argument to `.parallel()`, or use `.parallelAtomic()` from [`withAtomicUpdates`](#withatomicupdates). Each sub-agent then operates on its own isolated draft and the reducer decides how to merge.
540
+
541
+ ### Iterative refinement with `label` + goto
542
+
543
+ Use `label` / `→label` return values for reflection loops that don't need nesting:
544
+
545
+ ```typescript
546
+ const reactAgent = new FlowBuilder<AgentState>()
547
+ .startWith(think)
548
+ .label("act")
549
+ .then(async (s) => {
550
+ const result = await callTool(s.toolCall);
551
+ s.observations.push(result);
552
+ s.done = await shouldStop(s);
553
+ if (!s.done) return "→act";
554
+ })
555
+ .then(formatOutput);
556
+ ```
557
+
558
+ ### Human-in-the-loop with `interruptIf`
559
+
560
+ ```typescript
561
+ import { withInterrupts, InterruptError } from "flowneer/plugins/observability";
562
+ FlowBuilder.use(withInterrupts);
563
+
564
+ const flow = new FlowBuilder<DraftState>()
565
+ .startWith(generateDraft)
566
+ .interruptIf((s) => s.requiresApproval) // pauses here
567
+ .then(publishDraft);
568
+
569
+ try {
570
+ await flow.run(state);
571
+ } catch (err) {
572
+ if (err instanceof InterruptError) {
573
+ // err.savedShared holds state at the pause point
574
+ await showReviewUI(err.savedShared);
575
+ }
576
+ }
577
+ ```
578
+
383
579
  ## Project structure
384
580
 
385
581
  ```
386
- Flowneer.ts Core — FlowBuilder, FlowError, types (~380 lines)
387
- index.ts Public exports
582
+ Flowneer.ts Core — FlowBuilder, FlowError, InterruptError, types
583
+ index.ts Public exports
584
+ plugins/
585
+ observability/
586
+ withHistory.ts State snapshot history
587
+ withTiming.ts Per-step wall-clock timing
588
+ withVerbose.ts Stdout logging
589
+ withInterrupts.ts Human-in-the-loop / approval gates
590
+ persistence/
591
+ withCheckpoint.ts Post-step state saves
592
+ withAuditLog.ts Immutable audit trail
593
+ withReplay.ts Skip-to-step for crash recovery
594
+ withVersionedCheckpoint.ts Diff-based versioned saves + resumeFrom
595
+ resilience/
596
+ withCircuitBreaker.ts
597
+ withFallback.ts
598
+ withTimeout.ts
599
+ withCycles.ts Guard against infinite goto loops
600
+ llm/
601
+ withCostTracker.ts
602
+ withRateLimit.ts
603
+ withTokenBudget.ts
604
+ messaging/
605
+ withChannels.ts Map-based message channels (sendTo / receiveFrom)
606
+ dev/
607
+ withDryRun.ts
608
+ withMocks.ts
609
+ withStepLimit.ts Cap total step executions
610
+ withAtomicUpdates.ts parallelAtomic() shorthand
388
611
  examples/
389
- assistantFlow.ts Interactive LLM assistant with branching
390
- observePlugin.ts Tracing plugin example
391
- persistPlugin.ts Checkpoint plugin example
612
+ assistantFlow.ts Interactive LLM assistant with branching
613
+ observePlugin.ts Tracing plugin example
614
+ persistPlugin.ts Checkpoint plugin example
392
615
  ```
393
616
 
394
617
  ## License
package/dist/index.d.ts CHANGED
@@ -42,12 +42,18 @@ interface ParallelStep<S, P extends Record<string, unknown>> {
42
42
  retries: number;
43
43
  delaySec: number;
44
44
  timeoutMs: number;
45
+ reducer?: (shared: S, drafts: S[]) => void;
45
46
  }
46
- type Step<S, P extends Record<string, unknown>> = FnStep<S, P> | BranchStep<S, P> | LoopStep<S, P> | BatchStep<S, P> | ParallelStep<S, P>;
47
+ interface LabelStep {
48
+ type: "label";
49
+ name: string;
50
+ }
51
+ type Step<S, P extends Record<string, unknown>> = FnStep<S, P> | BranchStep<S, P> | LoopStep<S, P> | BatchStep<S, P> | ParallelStep<S, P> | LabelStep;
47
52
  /** Metadata exposed to hooks — intentionally minimal to avoid coupling. */
48
53
  interface StepMeta {
49
54
  index: number;
50
55
  type: Step<any, any>["type"];
56
+ label?: string;
51
57
  }
52
58
  /** Lifecycle hooks that plugins can register. */
53
59
  interface FlowHooks<S = any, P extends Record<string, unknown> = Record<string, unknown>> {
@@ -61,6 +67,11 @@ interface FlowHooks<S = any, P extends Record<string, unknown> = Record<string,
61
67
  */
62
68
  wrapStep?: (meta: StepMeta, next: () => Promise<void>, shared: S, params: P) => Promise<void>;
63
69
  afterStep?: (meta: StepMeta, shared: S, params: P) => void | Promise<void>;
70
+ /**
71
+ * Wraps individual functions within a `.parallel()` step.
72
+ * `fnIndex` is the position within the fns array.
73
+ */
74
+ wrapParallelFn?: (meta: StepMeta, fnIndex: number, next: () => Promise<void>, shared: S, params: P) => Promise<void>;
64
75
  onError?: (meta: StepMeta, error: unknown, shared: S, params: P) => void;
65
76
  afterFlow?: (shared: S, params: P) => void | Promise<void>;
66
77
  }
@@ -82,6 +93,14 @@ declare class FlowError extends Error {
82
93
  readonly cause: unknown;
83
94
  constructor(step: string, cause: unknown);
84
95
  }
96
+ /**
97
+ * Thrown by `interruptIf` to pause a flow.
98
+ * Catch this in your runner to save `savedShared` and resume later.
99
+ */
100
+ declare class InterruptError extends Error {
101
+ readonly savedShared: unknown;
102
+ constructor(shared: unknown);
103
+ }
85
104
  /**
86
105
  * Fluent builder for composable flows.
87
106
  *
@@ -119,8 +138,17 @@ declare class FlowBuilder<S = any, P extends Record<string, unknown> = Record<st
119
138
  /**
120
139
  * Append a parallel step.
121
140
  * Runs all `fns` concurrently against the same shared state.
141
+ *
142
+ * When `reducer` is provided each fn receives its own shallow clone of
143
+ * `shared`; after all fns complete the reducer merges the drafts back
144
+ * into the original shared object — preventing concurrent mutation races.
145
+ */
146
+ parallel(fns: NodeFn<S, P>[], options?: NodeOptions, reducer?: (shared: S, drafts: S[]) => void): this;
147
+ /**
148
+ * Insert a named label. Labels are no-op markers that can be jumped to
149
+ * from any `NodeFn` by returning `"→labelName"`.
122
150
  */
123
- parallel(fns: NodeFn<S, P>[], options?: NodeOptions): this;
151
+ label(name: string): this;
124
152
  /** Execute the flow. */
125
153
  run(shared: S, params?: P, options?: RunOptions): Promise<void>;
126
154
  protected _execute(shared: S, params: P, signal?: AbortSignal): Promise<void>;
@@ -130,4 +158,4 @@ declare class FlowBuilder<S = any, P extends Record<string, unknown> = Record<st
130
158
  private _withTimeout;
131
159
  }
132
160
 
133
- export { FlowBuilder, FlowError, type FlowHooks, type FlowneerPlugin, type NodeFn, type NodeOptions, type RunOptions, type StepMeta };
161
+ export { FlowBuilder, FlowError, type FlowHooks, type FlowneerPlugin, InterruptError, type NodeFn, type NodeOptions, type RunOptions, type StepMeta };
package/dist/index.js CHANGED
@@ -11,6 +11,14 @@ var FlowError = class extends Error {
11
11
  this.cause = cause;
12
12
  }
13
13
  };
14
+ var InterruptError = class extends Error {
15
+ savedShared;
16
+ constructor(shared) {
17
+ super("Flow interrupted");
18
+ this.name = "InterruptError";
19
+ this.savedShared = shared;
20
+ }
21
+ };
14
22
  var FlowBuilder = class _FlowBuilder {
15
23
  steps = [];
16
24
  _hooksList = [];
@@ -78,10 +86,29 @@ var FlowBuilder = class _FlowBuilder {
78
86
  /**
79
87
  * Append a parallel step.
80
88
  * Runs all `fns` concurrently against the same shared state.
89
+ *
90
+ * When `reducer` is provided each fn receives its own shallow clone of
91
+ * `shared`; after all fns complete the reducer merges the drafts back
92
+ * into the original shared object — preventing concurrent mutation races.
81
93
  */
82
- parallel(fns, options) {
94
+ parallel(fns, options, reducer) {
83
95
  const { retries = 1, delaySec = 0, timeoutMs = 0 } = options ?? {};
84
- this.steps.push({ type: "parallel", fns, retries, delaySec, timeoutMs });
96
+ this.steps.push({
97
+ type: "parallel",
98
+ fns,
99
+ retries,
100
+ delaySec,
101
+ timeoutMs,
102
+ reducer
103
+ });
104
+ return this;
105
+ }
106
+ /**
107
+ * Insert a named label. Labels are no-op markers that can be jumped to
108
+ * from any `NodeFn` by returning `"→labelName"`.
109
+ */
110
+ label(name) {
111
+ this.steps.push({ type: "label", name });
85
112
  return this;
86
113
  }
87
114
  /** Execute the flow. */
@@ -98,22 +125,32 @@ var FlowBuilder = class _FlowBuilder {
98
125
  // Internal execution
99
126
  // -----------------------------------------------------------------------
100
127
  async _execute(shared, params, signal) {
128
+ const labels = /* @__PURE__ */ new Map();
129
+ for (let j = 0; j < this.steps.length; j++) {
130
+ const s = this.steps[j];
131
+ if (s.type === "label") labels.set(s.name, j);
132
+ }
101
133
  for (let i = 0; i < this.steps.length; i++) {
102
134
  signal?.throwIfAborted();
103
135
  const step = this.steps[i];
136
+ if (step.type === "label") continue;
104
137
  const meta = { index: i, type: step.type };
105
138
  try {
106
139
  for (const h of this._hooksList)
107
140
  await h.beforeStep?.(meta, shared, params);
141
+ let gotoTarget;
108
142
  const runBody = async () => {
109
143
  switch (step.type) {
110
- case "fn":
111
- await this._retry(
144
+ case "fn": {
145
+ const result = await this._retry(
112
146
  step.retries,
113
147
  step.delaySec,
114
148
  () => step.fn(shared, params)
115
149
  );
150
+ if (typeof result === "string" && result.startsWith("\u2192"))
151
+ gotoTarget = result.slice(1);
116
152
  break;
153
+ }
117
154
  case "branch": {
118
155
  const action = await this._retry(
119
156
  step.retries,
@@ -122,12 +159,15 @@ var FlowBuilder = class _FlowBuilder {
122
159
  );
123
160
  const key = action ? String(action) : "default";
124
161
  const fn = step.branches[key] ?? step.branches["default"];
125
- if (fn)
126
- await this._retry(
162
+ if (fn) {
163
+ const branchResult = await this._retry(
127
164
  step.retries,
128
165
  step.delaySec,
129
166
  () => fn(shared, params)
130
167
  );
168
+ if (typeof branchResult === "string" && branchResult.startsWith("\u2192"))
169
+ gotoTarget = branchResult.slice(1);
170
+ }
131
171
  break;
132
172
  }
133
173
  case "loop":
@@ -151,17 +191,49 @@ var FlowBuilder = class _FlowBuilder {
151
191
  else shared.__batchItem = prev;
152
192
  break;
153
193
  }
154
- case "parallel":
155
- await Promise.all(
156
- step.fns.map(
157
- (fn) => this._retry(
158
- step.retries,
159
- step.delaySec,
160
- () => fn(shared, params)
161
- )
162
- )
163
- );
194
+ case "parallel": {
195
+ const pfnWrappers = this._hooksList.map((h) => h.wrapParallelFn).filter((w) => w != null);
196
+ if (step.reducer) {
197
+ const drafts = [];
198
+ await Promise.all(
199
+ step.fns.map(async (fn, fi) => {
200
+ const draft = { ...shared };
201
+ drafts[fi] = draft;
202
+ const exec = () => this._retry(
203
+ step.retries,
204
+ step.delaySec,
205
+ () => fn(draft, params)
206
+ );
207
+ const wrapped2 = pfnWrappers.reduceRight(
208
+ (next, wrap) => () => wrap(meta, fi, next, draft, params),
209
+ async () => {
210
+ await exec();
211
+ }
212
+ );
213
+ await wrapped2();
214
+ })
215
+ );
216
+ step.reducer(shared, drafts);
217
+ } else {
218
+ await Promise.all(
219
+ step.fns.map((fn, fi) => {
220
+ const exec = () => this._retry(
221
+ step.retries,
222
+ step.delaySec,
223
+ () => fn(shared, params)
224
+ );
225
+ const wrapped2 = pfnWrappers.reduceRight(
226
+ (next, wrap) => () => wrap(meta, fi, next, shared, params),
227
+ async () => {
228
+ await exec();
229
+ }
230
+ );
231
+ return wrapped2();
232
+ })
233
+ );
234
+ }
164
235
  break;
236
+ }
165
237
  }
166
238
  };
167
239
  const { timeoutMs } = step;
@@ -174,11 +246,18 @@ var FlowBuilder = class _FlowBuilder {
174
246
  await wrapped();
175
247
  for (const h of this._hooksList)
176
248
  await h.afterStep?.(meta, shared, params);
249
+ if (gotoTarget) {
250
+ const target = labels.get(gotoTarget);
251
+ if (target === void 0)
252
+ throw new Error(`goto target label "${gotoTarget}" not found`);
253
+ i = target;
254
+ }
177
255
  } catch (err) {
256
+ if (err instanceof InterruptError) throw err;
178
257
  for (const h of this._hooksList) h.onError?.(meta, err, shared, params);
179
258
  if (err instanceof FlowError) throw err;
180
- const label = step.type === "fn" ? `step ${i}` : `${step.type} (step ${i})`;
181
- throw new FlowError(label, err);
259
+ const stepLabel = step.type === "fn" ? `step ${i}` : `${step.type} (step ${i})`;
260
+ throw new FlowError(stepLabel, err);
182
261
  }
183
262
  }
184
263
  }
@@ -214,5 +293,6 @@ var FlowBuilder = class _FlowBuilder {
214
293
  };
215
294
  export {
216
295
  FlowBuilder,
217
- FlowError
296
+ FlowError,
297
+ InterruptError
218
298
  };
@@ -22,4 +22,24 @@ declare module "../../Flowneer" {
22
22
  }
23
23
  declare const withMocks: FlowneerPlugin;
24
24
 
25
- export { withDryRun, withMocks };
25
+ declare module "../../Flowneer" {
26
+ interface FlowBuilder<S, P> {
27
+ /** Throw if total step executions exceed `max` (default 1000). */
28
+ withStepLimit(max?: number): this;
29
+ }
30
+ }
31
+ declare const withStepLimit: FlowneerPlugin;
32
+
33
+ declare module "../../Flowneer" {
34
+ interface FlowBuilder<S, P> {
35
+ /**
36
+ * Safe parallel execution with a reducer.
37
+ * Each fn receives its own shallow draft of `shared`.
38
+ * After all fns complete, `reducer(shared, drafts)` merges results.
39
+ */
40
+ parallelAtomic(fns: NodeFn<S, P>[], reducer: (shared: S, drafts: S[]) => void, options?: NodeOptions): this;
41
+ }
42
+ }
43
+ declare const withAtomicUpdates: FlowneerPlugin;
44
+
45
+ export { withAtomicUpdates, withDryRun, withMocks, withStepLimit };
@@ -25,7 +25,33 @@ var withMocks = {
25
25
  return this;
26
26
  }
27
27
  };
28
+
29
+ // plugins/dev/withStepLimit.ts
30
+ var withStepLimit = {
31
+ withStepLimit(max = 1e3) {
32
+ let count = 0;
33
+ this._setHooks({
34
+ beforeFlow: () => {
35
+ count = 0;
36
+ },
37
+ beforeStep: () => {
38
+ if (++count > max)
39
+ throw new Error(`step limit exceeded: ${count} > ${max}`);
40
+ }
41
+ });
42
+ return this;
43
+ }
44
+ };
45
+
46
+ // plugins/dev/withAtomicUpdates.ts
47
+ var withAtomicUpdates = {
48
+ parallelAtomic(fns, reducer, options) {
49
+ return this.parallel(fns, options, reducer);
50
+ }
51
+ };
28
52
  export {
53
+ withAtomicUpdates,
29
54
  withDryRun,
30
- withMocks
55
+ withMocks,
56
+ withStepLimit
31
57
  };
@@ -1,6 +1,7 @@
1
- export { withHistory, withTiming, withVerbose } from './observability/index.js';
2
- export { CircuitBreakerOptions, withCircuitBreaker, withFallback, withTimeout } from './resilience/index.js';
3
- export { AuditEntry, AuditLogStore, CheckpointStore, withAuditLog, withCheckpoint, withReplay } from './persistence/index.js';
1
+ export { withHistory, withInterrupts, withTiming, withVerbose } from './observability/index.js';
2
+ export { CircuitBreakerOptions, withCircuitBreaker, withCycles, withFallback, withTimeout } from './resilience/index.js';
3
+ export { AuditEntry, AuditLogStore, CheckpointStore, VersionedCheckpointEntry, VersionedCheckpointStore, withAuditLog, withCheckpoint, withReplay, withVersionedCheckpoint } from './persistence/index.js';
4
4
  export { RateLimitOptions, withCostTracker, withRateLimit, withTokenBudget } from './llm/index.js';
5
- export { withDryRun, withMocks } from './dev/index.js';
5
+ export { withAtomicUpdates, withDryRun, withMocks, withStepLimit } from './dev/index.js';
6
+ export { StreamSubscriber, emit, peekChannel, receiveFrom, sendTo, withChannels, withStream } from './messaging/index.js';
6
7
  import '../index.js';
@@ -50,14 +50,43 @@ var withVerbose = {
50
50
  }
51
51
  };
52
52
 
53
+ // Flowneer.ts
54
+ var InterruptError = class extends Error {
55
+ savedShared;
56
+ constructor(shared) {
57
+ super("Flow interrupted");
58
+ this.name = "InterruptError";
59
+ this.savedShared = shared;
60
+ }
61
+ };
62
+
63
+ // plugins/observability/withInterrupts.ts
64
+ var withInterrupts = {
65
+ interruptIf(condition) {
66
+ const interruptFn = async (shared, params) => {
67
+ const shouldInterrupt = await condition(shared, params);
68
+ if (shouldInterrupt) {
69
+ throw new InterruptError(JSON.parse(JSON.stringify(shared)));
70
+ }
71
+ };
72
+ return this.then(interruptFn);
73
+ }
74
+ };
75
+
53
76
  // plugins/resilience/withFallback.ts
54
77
  var withFallback = {
55
78
  withFallback(fn) {
56
79
  this._setHooks({
57
- wrapStep: async (_meta, next, shared, params) => {
80
+ wrapStep: async (meta, next, shared, params) => {
58
81
  try {
59
82
  await next();
60
- } catch {
83
+ } catch (e) {
84
+ shared.__fallbackError = {
85
+ stepIndex: meta.index,
86
+ stepType: meta.type,
87
+ message: e instanceof Error ? e.message : String(e),
88
+ stack: e instanceof Error ? e.stack : void 0
89
+ };
61
90
  await fn(shared, params);
62
91
  }
63
92
  }
@@ -103,15 +132,44 @@ var withCircuitBreaker = {
103
132
  var withTimeout = {
104
133
  withTimeout(ms) {
105
134
  this._setHooks({
106
- wrapStep: (meta, next) => Promise.race([
107
- next(),
108
- new Promise(
109
- (_, reject) => setTimeout(
110
- () => reject(new Error(`step ${meta.index} timed out after ${ms}ms`)),
111
- ms
135
+ wrapStep: (meta, next) => {
136
+ let handle;
137
+ return Promise.race([
138
+ next().finally(() => clearTimeout(handle)),
139
+ new Promise(
140
+ (_, reject) => handle = setTimeout(
141
+ () => reject(
142
+ new Error(`step ${meta.index} timed out after ${ms}ms`)
143
+ ),
144
+ ms
145
+ )
112
146
  )
113
- )
114
- ])
147
+ ]);
148
+ }
149
+ });
150
+ return this;
151
+ }
152
+ };
153
+
154
+ // plugins/resilience/withCycles.ts
155
+ var withCycles = {
156
+ withCycles(maxJumps = 100) {
157
+ let jumps = 0;
158
+ this._setHooks({
159
+ beforeFlow: () => {
160
+ jumps = 0;
161
+ },
162
+ beforeStep: (meta) => {
163
+ if (meta.type === "fn" || meta.type === "branch") {
164
+ }
165
+ },
166
+ afterStep: (_meta, shared) => {
167
+ jumps++;
168
+ if (jumps > maxJumps)
169
+ throw new Error(
170
+ `cycle limit exceeded: ${jumps} step executions > maxJumps(${maxJumps})`
171
+ );
172
+ }
115
173
  });
116
174
  return this;
117
175
  }
@@ -169,6 +227,68 @@ var withReplay = {
169
227
  }
170
228
  };
171
229
 
230
+ // plugins/persistence/withVersionedCheckpoint.ts
231
+ function diffObjects(prev, curr) {
232
+ const diff = {};
233
+ for (const key of Object.keys(curr)) {
234
+ if (JSON.stringify(prev[key]) !== JSON.stringify(curr[key])) {
235
+ diff[key] = curr[key];
236
+ }
237
+ }
238
+ for (const key of Object.keys(prev)) {
239
+ if (!(key in curr)) {
240
+ diff[key] = void 0;
241
+ }
242
+ }
243
+ return diff;
244
+ }
245
+ var versionCounter = 0;
246
+ var withVersionedCheckpoint = {
247
+ withVersionedCheckpoint(store) {
248
+ let prevSnapshot = {};
249
+ let lastVersion = null;
250
+ this._setHooks({
251
+ beforeFlow: (shared) => {
252
+ prevSnapshot = JSON.parse(JSON.stringify(shared));
253
+ lastVersion = null;
254
+ },
255
+ afterStep: async (meta, shared) => {
256
+ const curr = JSON.parse(JSON.stringify(shared));
257
+ const diff = diffObjects(prevSnapshot, curr);
258
+ if (Object.keys(diff).length === 0) return;
259
+ const version = `v${++versionCounter}`;
260
+ const entry = {
261
+ version,
262
+ stepIndex: meta.index,
263
+ diff,
264
+ parentVersion: lastVersion,
265
+ timestamp: Date.now()
266
+ };
267
+ await store.save(entry);
268
+ lastVersion = version;
269
+ prevSnapshot = curr;
270
+ }
271
+ });
272
+ return this;
273
+ },
274
+ resumeFrom(version, store) {
275
+ let resolvedStep = null;
276
+ let resolved = false;
277
+ this._setHooks({
278
+ wrapStep: async (meta, next) => {
279
+ if (!resolved) {
280
+ const result = await store.resolve(version);
281
+ resolvedStep = result.stepIndex;
282
+ resolved = true;
283
+ }
284
+ if (resolvedStep !== null && meta.index <= resolvedStep) return;
285
+ await next();
286
+ }
287
+ });
288
+ return this;
289
+ }
290
+ };
291
+
172
292
  // plugins/llm/withTokenBudget.ts
173
293
  var withTokenBudget = {
174
294
  withTokenBudget(limit) {
@@ -247,19 +367,104 @@ var withMocks = {
247
367
  return this;
248
368
  }
249
369
  };
370
+
371
+ // plugins/dev/withStepLimit.ts
372
+ var withStepLimit = {
373
+ withStepLimit(max = 1e3) {
374
+ let count = 0;
375
+ this._setHooks({
376
+ beforeFlow: () => {
377
+ count = 0;
378
+ },
379
+ beforeStep: () => {
380
+ if (++count > max)
381
+ throw new Error(`step limit exceeded: ${count} > ${max}`);
382
+ }
383
+ });
384
+ return this;
385
+ }
386
+ };
387
+
388
+ // plugins/dev/withAtomicUpdates.ts
389
+ var withAtomicUpdates = {
390
+ parallelAtomic(fns, reducer, options) {
391
+ return this.parallel(fns, options, reducer);
392
+ }
393
+ };
394
+
395
+ // plugins/messaging/withChannels.ts
396
+ function sendTo(shared, channel, message) {
397
+ const channels = shared.__channels ?? /* @__PURE__ */ new Map();
398
+ if (!shared.__channels) shared.__channels = channels;
399
+ const queue = channels.get(channel) ?? [];
400
+ if (!channels.has(channel)) channels.set(channel, queue);
401
+ queue.push(message);
402
+ }
403
+ function receiveFrom(shared, channel) {
404
+ const channels = shared.__channels;
405
+ if (!channels) return [];
406
+ const queue = channels.get(channel);
407
+ if (!queue || queue.length === 0) return [];
408
+ const messages = [...queue];
409
+ queue.length = 0;
410
+ return messages;
411
+ }
412
+ function peekChannel(shared, channel) {
413
+ const channels = shared.__channels;
414
+ if (!channels) return [];
415
+ return [...channels.get(channel) ?? []];
416
+ }
417
+ var withChannels = {
418
+ withChannels() {
419
+ this._setHooks({
420
+ beforeFlow: (shared) => {
421
+ if (!shared.__channels) {
422
+ shared.__channels = /* @__PURE__ */ new Map();
423
+ }
424
+ }
425
+ });
426
+ return this;
427
+ }
428
+ };
429
+
430
+ // plugins/messaging/withStream.ts
431
+ var withStream = {
432
+ withStream(subscriber) {
433
+ this._setHooks({
434
+ beforeFlow: (shared) => {
435
+ shared.__stream = subscriber;
436
+ }
437
+ });
438
+ return this;
439
+ }
440
+ };
441
+ function emit(shared, chunk) {
442
+ shared.__stream?.(chunk);
443
+ }
250
444
  export {
445
+ emit,
446
+ peekChannel,
447
+ receiveFrom,
448
+ sendTo,
449
+ withAtomicUpdates,
251
450
  withAuditLog,
451
+ withChannels,
252
452
  withCheckpoint,
253
453
  withCircuitBreaker,
254
454
  withCostTracker,
455
+ withCycles,
255
456
  withDryRun,
256
457
  withFallback,
257
458
  withHistory,
459
+ withInterrupts,
258
460
  withMocks,
259
461
  withRateLimit,
260
462
  withReplay,
463
+ withStepLimit,
464
+ withStream,
261
465
  withTimeout,
262
466
  withTiming,
263
467
  withTokenBudget,
264
- withVerbose
468
+ withVerbose,
469
+ withVersionedCheckpoint
265
470
  };
@@ -0,0 +1,56 @@
1
+ import { FlowneerPlugin } from '../../index.js';
2
+
3
+ /** Send a message to a named channel on `shared.__channels`. */
4
+ declare function sendTo<S extends Record<string, any>>(shared: S, channel: string, message: unknown): void;
5
+ /** Receive (drain) all pending messages from a named channel. */
6
+ declare function receiveFrom<T = unknown>(shared: Record<string, any>, channel: string): T[];
7
+ /** Peek at pending messages without draining. */
8
+ declare function peekChannel<T = unknown>(shared: Record<string, any>, channel: string): T[];
9
+ declare module "../../Flowneer" {
10
+ interface FlowBuilder<S, P> {
11
+ /**
12
+ * Initialise a `Map`-based message channel system on `shared.__channels`.
13
+ * Nodes communicate via `sendTo(shared, ch, msg)` / `receiveFrom(shared, ch)`.
14
+ */
15
+ withChannels(): this;
16
+ }
17
+ }
18
+ declare const withChannels: FlowneerPlugin;
19
+
20
+ /** Callback invoked each time a step calls `emit()`. */
21
+ type StreamSubscriber<T = unknown> = (chunk: T) => void;
22
+ declare module "../../Flowneer" {
23
+ interface FlowBuilder<S, P> {
24
+ /**
25
+ * Registers a streaming subscriber for this flow.
26
+ * Steps call `emit(shared, chunk)` to push data to the subscriber in real-time.
27
+ * The subscriber is stored on `shared.__stream` before the first step runs
28
+ * so it is automatically inherited by sub-flows (loop, batch, etc.).
29
+ *
30
+ * @example
31
+ * const flow = new FlowBuilder<MyState>()
32
+ * .withStream((chunk) => console.log("[stream]", chunk))
33
+ * // ...
34
+ *
35
+ * // Inside a step:
36
+ * emit(s, { type: "draft", content: s.draft });
37
+ */
38
+ withStream<T = unknown>(subscriber: StreamSubscriber<T>): this;
39
+ }
40
+ }
41
+ declare const withStream: FlowneerPlugin;
42
+ /**
43
+ * Push a chunk to the subscriber registered via `.withStream()`.
44
+ * Safe to call unconditionally — silently no-ops when no subscriber is registered.
45
+ *
46
+ * @example
47
+ * async function refineDraft(s: MyState) {
48
+ * // ... produce s.draft ...
49
+ * emit(s, { type: "draft", round: s.round, content: s.draft });
50
+ * }
51
+ */
52
+ declare function emit<T = unknown>(shared: {
53
+ __stream?: StreamSubscriber<T>;
54
+ }, chunk: T): void;
55
+
56
+ export { type StreamSubscriber, emit, peekChannel, receiveFrom, sendTo, withChannels, withStream };
@@ -0,0 +1,57 @@
1
+ // plugins/messaging/withChannels.ts
2
+ function sendTo(shared, channel, message) {
3
+ const channels = shared.__channels ?? /* @__PURE__ */ new Map();
4
+ if (!shared.__channels) shared.__channels = channels;
5
+ const queue = channels.get(channel) ?? [];
6
+ if (!channels.has(channel)) channels.set(channel, queue);
7
+ queue.push(message);
8
+ }
9
+ function receiveFrom(shared, channel) {
10
+ const channels = shared.__channels;
11
+ if (!channels) return [];
12
+ const queue = channels.get(channel);
13
+ if (!queue || queue.length === 0) return [];
14
+ const messages = [...queue];
15
+ queue.length = 0;
16
+ return messages;
17
+ }
18
+ function peekChannel(shared, channel) {
19
+ const channels = shared.__channels;
20
+ if (!channels) return [];
21
+ return [...channels.get(channel) ?? []];
22
+ }
23
+ var withChannels = {
24
+ withChannels() {
25
+ this._setHooks({
26
+ beforeFlow: (shared) => {
27
+ if (!shared.__channels) {
28
+ shared.__channels = /* @__PURE__ */ new Map();
29
+ }
30
+ }
31
+ });
32
+ return this;
33
+ }
34
+ };
35
+
36
+ // plugins/messaging/withStream.ts
37
+ var withStream = {
38
+ withStream(subscriber) {
39
+ this._setHooks({
40
+ beforeFlow: (shared) => {
41
+ shared.__stream = subscriber;
42
+ }
43
+ });
44
+ return this;
45
+ }
46
+ };
47
+ function emit(shared, chunk) {
48
+ shared.__stream?.(chunk);
49
+ }
50
+ export {
51
+ emit,
52
+ peekChannel,
53
+ receiveFrom,
54
+ sendTo,
55
+ withChannels,
56
+ withStream
57
+ };
@@ -27,4 +27,19 @@ declare module "../../Flowneer" {
27
27
  }
28
28
  declare const withVerbose: FlowneerPlugin;
29
29
 
30
- export { withHistory, withTiming, withVerbose };
30
+ declare module "../../Flowneer" {
31
+ interface FlowBuilder<S, P> {
32
+ /**
33
+ * Insert an interrupt point.
34
+ * If `condition(shared, params)` returns true, the flow throws
35
+ * an `InterruptError` carrying a deep clone of `shared`.
36
+ *
37
+ * Catch `InterruptError` in your runner to implement human-in-the-loop,
38
+ * approval gates, or external-resume patterns.
39
+ */
40
+ interruptIf(condition: (shared: S, params: P) => boolean | Promise<boolean>): this;
41
+ }
42
+ }
43
+ declare const withInterrupts: FlowneerPlugin;
44
+
45
+ export { withHistory, withInterrupts, withTiming, withVerbose };
@@ -49,8 +49,32 @@ var withVerbose = {
49
49
  return this;
50
50
  }
51
51
  };
52
+
53
+ // Flowneer.ts
54
+ var InterruptError = class extends Error {
55
+ savedShared;
56
+ constructor(shared) {
57
+ super("Flow interrupted");
58
+ this.name = "InterruptError";
59
+ this.savedShared = shared;
60
+ }
61
+ };
62
+
63
+ // plugins/observability/withInterrupts.ts
64
+ var withInterrupts = {
65
+ interruptIf(condition) {
66
+ const interruptFn = async (shared, params) => {
67
+ const shouldInterrupt = await condition(shared, params);
68
+ if (shouldInterrupt) {
69
+ throw new InterruptError(JSON.parse(JSON.stringify(shared)));
70
+ }
71
+ };
72
+ return this.then(interruptFn);
73
+ }
74
+ };
52
75
  export {
53
76
  withHistory,
77
+ withInterrupts,
54
78
  withTiming,
55
79
  withVerbose
56
80
  };
@@ -48,4 +48,40 @@ declare module "../../Flowneer" {
48
48
  }
49
49
  declare const withReplay: FlowneerPlugin;
50
50
 
51
- export { type AuditEntry, type AuditLogStore, type CheckpointStore, withAuditLog, withCheckpoint, withReplay };
51
+ interface VersionedCheckpointEntry<S = any> {
52
+ version: string;
53
+ stepIndex: number;
54
+ /** JSON-serialisable diff — only keys that changed from the previous version. */
55
+ diff: Partial<S>;
56
+ parentVersion: string | null;
57
+ timestamp: number;
58
+ }
59
+ interface VersionedCheckpointStore<S = any> {
60
+ /** Save a versioned checkpoint. Implementation assigns version ids. */
61
+ save(entry: VersionedCheckpointEntry<S>): void | Promise<void>;
62
+ /** Resolve a version id to the full snapshot + step index. */
63
+ resolve(version: string): {
64
+ stepIndex: number;
65
+ snapshot: S;
66
+ } | Promise<{
67
+ stepIndex: number;
68
+ snapshot: S;
69
+ }>;
70
+ }
71
+ declare module "../../Flowneer" {
72
+ interface FlowBuilder<S, P> {
73
+ /**
74
+ * Save diff-based versioned checkpoints after each step.
75
+ * Each checkpoint records only the keys that changed, plus a parent pointer.
76
+ */
77
+ withVersionedCheckpoint(store: VersionedCheckpointStore<S>): this;
78
+ /**
79
+ * Resume execution from a previously saved version.
80
+ * Steps before the saved `stepIndex` are skipped.
81
+ */
82
+ resumeFrom(version: string, store: VersionedCheckpointStore<S>): this;
83
+ }
84
+ }
85
+ declare const withVersionedCheckpoint: FlowneerPlugin;
86
+
87
+ export { type AuditEntry, type AuditLogStore, type CheckpointStore, type VersionedCheckpointEntry, type VersionedCheckpointStore, withAuditLog, withCheckpoint, withReplay, withVersionedCheckpoint };
@@ -49,8 +49,71 @@ var withReplay = {
49
49
  return this;
50
50
  }
51
51
  };
52
+
53
+ // plugins/persistence/withVersionedCheckpoint.ts
54
+ function diffObjects(prev, curr) {
55
+ const diff = {};
56
+ for (const key of Object.keys(curr)) {
57
+ if (JSON.stringify(prev[key]) !== JSON.stringify(curr[key])) {
58
+ diff[key] = curr[key];
59
+ }
60
+ }
61
+ for (const key of Object.keys(prev)) {
62
+ if (!(key in curr)) {
63
+ diff[key] = void 0;
64
+ }
65
+ }
66
+ return diff;
67
+ }
68
+ var versionCounter = 0;
69
+ var withVersionedCheckpoint = {
70
+ withVersionedCheckpoint(store) {
71
+ let prevSnapshot = {};
72
+ let lastVersion = null;
73
+ this._setHooks({
74
+ beforeFlow: (shared) => {
75
+ prevSnapshot = JSON.parse(JSON.stringify(shared));
76
+ lastVersion = null;
77
+ },
78
+ afterStep: async (meta, shared) => {
79
+ const curr = JSON.parse(JSON.stringify(shared));
80
+ const diff = diffObjects(prevSnapshot, curr);
81
+ if (Object.keys(diff).length === 0) return;
82
+ const version = `v${++versionCounter}`;
83
+ const entry = {
84
+ version,
85
+ stepIndex: meta.index,
86
+ diff,
87
+ parentVersion: lastVersion,
88
+ timestamp: Date.now()
89
+ };
90
+ await store.save(entry);
91
+ lastVersion = version;
92
+ prevSnapshot = curr;
93
+ }
94
+ });
95
+ return this;
96
+ },
97
+ resumeFrom(version, store) {
98
+ let resolvedStep = null;
99
+ let resolved = false;
100
+ this._setHooks({
101
+ wrapStep: async (meta, next) => {
102
+ if (!resolved) {
103
+ const result = await store.resolve(version);
104
+ resolvedStep = result.stepIndex;
105
+ resolved = true;
106
+ }
107
+ if (resolvedStep !== null && meta.index <= resolvedStep) return;
108
+ await next();
109
+ }
110
+ });
111
+ return this;
112
+ }
113
+ };
52
114
  export {
53
115
  withAuditLog,
54
116
  withCheckpoint,
55
- withReplay
117
+ withReplay,
118
+ withVersionedCheckpoint
56
119
  };
@@ -43,4 +43,16 @@ declare module "../../Flowneer" {
43
43
  }
44
44
  declare const withTimeout: FlowneerPlugin;
45
45
 
46
- export { type CircuitBreakerOptions, withCircuitBreaker, withFallback, withTimeout };
46
+ declare module "../../Flowneer" {
47
+ interface FlowBuilder<S, P> {
48
+ /**
49
+ * Guard against infinite goto loops.
50
+ * Tracks total label jumps per `run()` and throws if `maxJumps` is exceeded.
51
+ * Default: 100.
52
+ */
53
+ withCycles(maxJumps?: number): this;
54
+ }
55
+ }
56
+ declare const withCycles: FlowneerPlugin;
57
+
58
+ export { type CircuitBreakerOptions, withCircuitBreaker, withCycles, withFallback, withTimeout };
@@ -2,10 +2,16 @@
2
2
  var withFallback = {
3
3
  withFallback(fn) {
4
4
  this._setHooks({
5
- wrapStep: async (_meta, next, shared, params) => {
5
+ wrapStep: async (meta, next, shared, params) => {
6
6
  try {
7
7
  await next();
8
- } catch {
8
+ } catch (e) {
9
+ shared.__fallbackError = {
10
+ stepIndex: meta.index,
11
+ stepType: meta.type,
12
+ message: e instanceof Error ? e.message : String(e),
13
+ stack: e instanceof Error ? e.stack : void 0
14
+ };
9
15
  await fn(shared, params);
10
16
  }
11
17
  }
@@ -51,21 +57,51 @@ var withCircuitBreaker = {
51
57
  var withTimeout = {
52
58
  withTimeout(ms) {
53
59
  this._setHooks({
54
- wrapStep: (meta, next) => Promise.race([
55
- next(),
56
- new Promise(
57
- (_, reject) => setTimeout(
58
- () => reject(new Error(`step ${meta.index} timed out after ${ms}ms`)),
59
- ms
60
+ wrapStep: (meta, next) => {
61
+ let handle;
62
+ return Promise.race([
63
+ next().finally(() => clearTimeout(handle)),
64
+ new Promise(
65
+ (_, reject) => handle = setTimeout(
66
+ () => reject(
67
+ new Error(`step ${meta.index} timed out after ${ms}ms`)
68
+ ),
69
+ ms
70
+ )
60
71
  )
61
- )
62
- ])
72
+ ]);
73
+ }
74
+ });
75
+ return this;
76
+ }
77
+ };
78
+
79
+ // plugins/resilience/withCycles.ts
80
+ var withCycles = {
81
+ withCycles(maxJumps = 100) {
82
+ let jumps = 0;
83
+ this._setHooks({
84
+ beforeFlow: () => {
85
+ jumps = 0;
86
+ },
87
+ beforeStep: (meta) => {
88
+ if (meta.type === "fn" || meta.type === "branch") {
89
+ }
90
+ },
91
+ afterStep: (_meta, shared) => {
92
+ jumps++;
93
+ if (jumps > maxJumps)
94
+ throw new Error(
95
+ `cycle limit exceeded: ${jumps} step executions > maxJumps(${maxJumps})`
96
+ );
97
+ }
63
98
  });
64
99
  return this;
65
100
  }
66
101
  };
67
102
  export {
68
103
  withCircuitBreaker,
104
+ withCycles,
69
105
  withFallback,
70
106
  withTimeout
71
107
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowneer",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-dependency fluent flow builder for AI agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,7 +13,8 @@
13
13
  "./plugins/resilience": "./dist/plugins/resilience/index.js",
14
14
  "./plugins/persistence": "./dist/plugins/persistence/index.js",
15
15
  "./plugins/llm": "./dist/plugins/llm/index.js",
16
- "./plugins/dev": "./dist/plugins/dev/index.js"
16
+ "./plugins/dev": "./dist/plugins/dev/index.js",
17
+ "./plugins/messaging": "./dist/plugins/messaging/index.js"
17
18
  },
18
19
  "sideEffects": false,
19
20
  "files": [