@fkws/klonk 0.0.19 → 0.0.22

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 CHANGED
@@ -48,20 +48,65 @@ A `Task` is the smallest unit of work. It's an abstract class with two main meth
48
48
  - `validateInput(input)`: Runtime validation of the task's input (on top of strong typing).
49
49
  - `run(input)`: Executes the task's logic.
50
50
 
51
- Tasks use a `Railroad` return type, which is a simple discriminated union for handling success and error states without throwing exceptions. Shoutout to Rust!
51
+ Tasks use a `Railroad` return type - a discriminated union for handling success and error states without throwing exceptions. Inspired by Rust's `Result<T, E>` type, it comes with familiar helper functions like `unwrap()`, `unwrapOr()`, and more.
52
+
53
+ ### Railroad (Rust-inspired Result Type)
54
+
55
+ `Railroad<T>` is Klonk's version of Rust's `Result<T, E>`. It's a discriminated union that forces you to handle both success and error cases:
56
+
57
+ ```typescript
58
+ type Railroad<T> =
59
+ | { success: true, data: T }
60
+ | { success: false, error: Error }
61
+ ```
62
+
63
+ #### Helper Functions
64
+
65
+ Klonk provides Rust-inspired helper functions for working with `Railroad`:
66
+
67
+ ```typescript
68
+ import { unwrap, unwrapOr, unwrapOrElse, isOk, isErr } from "@fkws/klonk";
69
+
70
+ // unwrap: Get data or throw error (like Rust's .unwrap())
71
+ const data = unwrap(result); // Returns T or throws
72
+
73
+ // unwrapOr: Get data or return a default value
74
+ const data = unwrapOr(result, defaultValue); // Returns T
75
+
76
+ // unwrapOrElse: Get data or compute a fallback from the error
77
+ const data = unwrapOrElse(result, (err) => computeFallback(err));
78
+
79
+ // isOk / isErr: Type guards for narrowing
80
+ if (isOk(result)) {
81
+ console.log(result.data); // TypeScript knows it's success
82
+ }
83
+ if (isErr(result)) {
84
+ console.log(result.error); // TypeScript knows it's error
85
+ }
86
+ ```
87
+
88
+ #### Why Railroad?
89
+
90
+ The name "Railroad" comes from Railway Oriented Programming - a functional approach where success travels the "happy path" and errors get shunted to the "error track". Combined with TypeScript's type narrowing, you get:
91
+
92
+ - **No uncaught exceptions** - errors are values, not thrown
93
+ - **Explicit error handling** - the type system forces you to handle failures
94
+ - **Familiar patterns** - if you love Rust's `Result`, you'll feel right at home
52
95
 
53
96
  ### Playlist
54
97
 
55
98
  A `Playlist` is a sequence of `Tasks` executed in order. The magic of a `Playlist` is that each task has access to the outputs of all previous tasks, in a fully type-safe way. You build a `Playlist` by chaining `.addTask().input()` calls:
56
99
 
57
100
  ```typescript
101
+ import { isOk } from "@fkws/klonk";
102
+
58
103
  playlist
59
104
  .addTask(new FetchTask("fetch"))
60
105
  .input((source) => ({ url: source.targetUrl }))
61
106
  .addTask(new ParseTask("parse"))
62
107
  .input((source, outputs) => ({
63
- // Full autocomplete! outputs.fetch?.success, outputs.fetch?.data, etc.
64
- html: outputs.fetch?.success ? outputs.fetch.data.body : ""
108
+ // Use isOk for Rust-style type narrowing!
109
+ html: outputs.fetch && isOk(outputs.fetch) ? outputs.fetch.data.body : ""
65
110
  }))
66
111
  ```
67
112
 
@@ -72,11 +117,13 @@ playlist
72
117
  Need to conditionally skip a task? Just return `null` from the input builder:
73
118
 
74
119
  ```typescript
120
+ import { isOk } from "@fkws/klonk";
121
+
75
122
  playlist
76
123
  .addTask(new NotifyTask("notify"))
77
124
  .input((source, outputs) => {
78
- // Skip notification if previous task failed
79
- if (!outputs.fetch?.success) {
125
+ // Skip notification if previous task failed - using isOk!
126
+ if (!outputs.fetch || !isOk(outputs.fetch)) {
80
127
  return null; // Task will be skipped!
81
128
  }
82
129
  return { message: "Success!", level: "info" };
@@ -90,6 +137,32 @@ When a task is skipped:
90
137
 
91
138
  This gives you Rust-like `Option` semantics using TypeScript's native `null` - no extra types needed!
92
139
 
140
+ #### Task Retries
141
+
142
+ When a task fails (`success: false`), it can be automatically retried. Retry behavior is configured on the `Machine` state or `Workflow`:
143
+
144
+ ```typescript
145
+ // On a Machine state:
146
+ Machine.create<MyState>()
147
+ .addState("fetch-data", node => node
148
+ .setPlaylist(p => p.addTask(...))
149
+ .retryDelayMs(500) // Retry every 500ms
150
+ .retryLimit(3) // Max 3 retries, then throw
151
+ )
152
+
153
+ // On a Workflow:
154
+ Workflow.create()
155
+ .addTrigger(myTrigger)
156
+ .retryDelayMs(1000) // Retry every 1s (default)
157
+ .retryLimit(5) // Max 5 retries
158
+ .setPlaylist(p => p.addTask(...))
159
+
160
+ // Disable retries entirely:
161
+ node.preventRetry() // Task failures throw immediately
162
+ ```
163
+
164
+ Default behavior: infinite retries at 1000ms delay. Use `.preventRetry()` to fail fast, or `.retryLimit(n)` to cap attempts.
165
+
93
166
  ### Trigger
94
167
 
95
168
  A `Trigger` is what kicks off a `Workflow`. It's an event source. Klonk can be extended with triggers for anything: file system events, webhooks, new database entries, messages in a queue, etc.
@@ -116,7 +189,7 @@ Machine.create<MyStateData>()
116
189
  Each state has:
117
190
  1. A `Playlist` that runs when the machine enters that state.
118
191
  2. A set of conditional `Transitions` to other states (with autocomplete!).
119
- 3. Retry rules for when a transition fails to resolve.
192
+ 3. Retry rules for failed tasks and when no transition is available.
120
193
 
121
194
  The `Machine` carries a mutable `stateData` object that can be read from and written to by playlists and transition conditions throughout its execution.
122
195
 
@@ -128,7 +201,8 @@ The `Machine` carries a mutable `stateData` object that can be read from and wri
128
201
 
129
202
  Notes:
130
203
  - `stopAfter` counts states entered, including the initial state. For example, `stopAfter: 1` will run the initial state's playlist once and then stop; `stopAfter: 0` stops before entering the initial state.
131
- - Retries are independent of `stopAfter`. A state can retry its transition condition (with optional delay) without affecting the `stopAfter` count until a state transition actually occurs.
204
+ - Transition retries are independent of `stopAfter`. A state can retry its transition condition (with optional delay) without affecting the `stopAfter` count until a state transition actually occurs.
205
+ - Task retries use the same settings as transition retries. If a task fails and retries are enabled, it will retry until success or the limit is reached.
132
206
 
133
207
  ## Features
134
208
 
@@ -256,7 +330,7 @@ Notice the fluent `.addTask(task).input(builder)` syntax - each task's input bui
256
330
 
257
331
  ```typescript
258
332
  import { z } from 'zod';
259
- import { Workflow } from '@fkws/klonk';
333
+ import { Workflow, isOk } from '@fkws/klonk';
260
334
 
261
335
  // The following example requires tasks, integrations and a trigger.
262
336
  // Soon, you will be able to import these from @fkws/klonkworks.
@@ -310,17 +384,17 @@ const workflow = Workflow.create()
310
384
  // Access outputs of previous tasks - fully typed!
311
385
  // Check for null (skipped) and success
312
386
  const downloadResult = outputs['download-invoice-pdf'];
313
- if (!downloadResult?.success) {
387
+ if (!downloadResult || !isOk(downloadResult)) {
314
388
  throw downloadResult?.error ?? new Error('Failed to download invoice PDF');
315
389
  }
316
390
 
317
391
  const payeesResult = outputs['get-payees'];
318
- if (!payeesResult?.success) {
392
+ if (!payeesResult || !isOk(payeesResult)) {
319
393
  throw payeesResult?.error ?? new Error('Failed to load payees');
320
394
  }
321
395
 
322
396
  const expenseTypesResult = outputs['get-expense-types'];
323
- if (!expenseTypesResult?.success) {
397
+ if (!expenseTypesResult || !isOk(expenseTypesResult)) {
324
398
  throw expenseTypesResult?.error ?? new Error('Failed to load expense types');
325
399
  }
326
400
 
@@ -345,7 +419,7 @@ const workflow = Workflow.create()
345
419
  .addTask(new TACreateNotionDatabaseItem("create-notion-invoice", notionProvider))
346
420
  .input((source, outputs) => {
347
421
  const invoiceResult = outputs['parse-invoice'];
348
- if (!invoiceResult?.success) {
422
+ if (!invoiceResult || !isOk(invoiceResult)) {
349
423
  throw invoiceResult?.error ?? new Error('Failed to parse invoice');
350
424
  }
351
425
  const invoiceData = invoiceResult.data;
@@ -381,7 +455,7 @@ workflow.start({
381
455
  The `Machine` manages a `StateData` object. Each `StateNode`'s `Playlist` can modify this state, and the `Transitions` between states use it to decide which state to move to next.
382
456
 
383
457
  ```typescript
384
- import { Machine } from "@fkws/klonk"
458
+ import { Machine, isOk } from "@fkws/klonk"
385
459
  import { OpenRouterClient } from "./tasks/common/OpenrouterClient"
386
460
  import { Model } from "./tasks/common/models"
387
461
  import { TABasicTextInference } from "./tasks/TABasicTextInference"
@@ -430,17 +504,17 @@ const webSearchAgent = Machine
430
504
  // Extract search terms from refined input
431
505
  .addTask(new TABasicTextInference("extract_search_terms", client))
432
506
  .input((state, outputs) => ({
433
- inputText: `Original: ${state.input}\n\nRefined: ${outputs.refine?.success ? outputs.refine.data.text : state.input}`,
507
+ inputText: `Original: ${state.input}\n\nRefined: ${outputs.refine && isOk(outputs.refine) ? outputs.refine.data.text : state.input}`,
434
508
  model: state.model ?? "openai/gpt-5.2",
435
509
  instructions: `Extract one short web search query from the user request and refined prompt.`
436
510
  }))
437
511
 
438
- // Update state with results
512
+ // Update state with results - using isOk for type narrowing
439
513
  .finally((state, outputs) => {
440
- if (outputs.refine?.success) {
514
+ if (outputs.refine && isOk(outputs.refine)) {
441
515
  state.refinedInput = outputs.refine.data.text;
442
516
  }
443
- if (outputs.extract_search_terms?.success) {
517
+ if (outputs.extract_search_terms && isOk(outputs.extract_search_terms)) {
444
518
  state.searchTerm = outputs.extract_search_terms.data.text;
445
519
  }
446
520
  })
@@ -465,7 +539,7 @@ const webSearchAgent = Machine
465
539
  query: state.searchTerm!
466
540
  }))
467
541
  .finally((state, outputs) => {
468
- if (outputs.search?.success) {
542
+ if (outputs.search && isOk(outputs.search)) {
469
543
  state.searchResults = outputs.search.data;
470
544
  }
471
545
  })
@@ -488,7 +562,7 @@ const webSearchAgent = Machine
488
562
  Write a professional response.`
489
563
  }))
490
564
  .finally((state, outputs) => {
491
- state.finalResponse = outputs.generate_response?.success
565
+ state.finalResponse = outputs.generate_response && isOk(outputs.generate_response)
492
566
  ? outputs.generate_response.data.text
493
567
  : "Sorry, an error occurred: " + (outputs.generate_response?.error ?? "unknown");
494
568
  })
@@ -522,13 +596,23 @@ Klonk's type system is designed to be minimal yet powerful. Here's what makes it
522
596
  | Type | Parameters | Purpose |
523
597
  |------|------------|---------|
524
598
  | `Task<Input, Output, Ident>` | Input shape, output shape, string literal ident | Base class for all tasks |
525
- | `Railroad<Output>` | Success data type | Discriminated union for success/error results |
599
+ | `Railroad<Output>` | Success data type | Discriminated union for success/error results (like Rust's `Result`) |
526
600
  | `Playlist<AllOutputs, Source>` | Accumulated output map, source data type | Ordered task sequence with typed chaining |
527
601
  | `Trigger<Ident, Data>` | String literal ident, event payload type | Event source for workflows |
528
602
  | `Workflow<Events>` | Union of trigger event types | Connects triggers to playlists |
529
603
  | `Machine<StateData, AllStateIdents>` | Mutable state shape, union of state idents | Finite state machine with typed transitions |
530
604
  | `StateNode<StateData, Ident, AllStateIdents>` | State shape, this node's ident, all valid transition targets | Individual state with playlist and transitions |
531
605
 
606
+ ### Railroad Helper Functions
607
+
608
+ | Function | Signature | Behavior |
609
+ |----------|-----------|----------|
610
+ | `unwrap(r)` | `Railroad<T> → T` | Returns data or throws error |
611
+ | `unwrapOr(r, default)` | `Railroad<T>, T → T` | Returns data or default value |
612
+ | `unwrapOrElse(r, fn)` | `Railroad<T>, (E) → T → T` | Returns data or result of fn(error) |
613
+ | `isOk(r)` | `Railroad<T> → boolean` | Type guard for success case |
614
+ | `isErr(r)` | `Railroad<T> → boolean` | Type guard for error case |
615
+
532
616
  ### How Output Chaining Works
533
617
 
534
618
  When you add a task to a playlist, Klonk extends the output type:
@@ -543,7 +627,7 @@ Playlist<{ fetch: Railroad<FetchOutput> | null }, Source>
543
627
  // Now outputs include both: { fetch: ..., parse: Railroad<ParseOutput> | null }
544
628
  ```
545
629
 
546
- The `| null` accounts for the possibility that a task was skipped (when its input builder returns `null`). This is why you'll use optional chaining like `outputs.fetch?.success` - TypeScript knows the output could be `null` if the task was skipped!
630
+ The `| null` accounts for the possibility that a task was skipped (when its input builder returns `null`). This is why you'll check for null before using `isOk()` - for example: `outputs.fetch && isOk(outputs.fetch)`. TypeScript then narrows the type so you can safely access `.data`!
547
631
 
548
632
  This maps cleanly to Rust's types:
549
633
  | Rust | Klonk (TypeScript) |
package/dist/index.cjs CHANGED
@@ -30,6 +30,11 @@ var __export = (target, all) => {
30
30
  // src/index.ts
31
31
  var exports_src = {};
32
32
  __export(exports_src, {
33
+ unwrapOrElse: () => unwrapOrElse,
34
+ unwrapOr: () => unwrapOr,
35
+ unwrap: () => unwrap,
36
+ isOk: () => isOk,
37
+ isErr: () => isErr,
33
38
  Workflow: () => Workflow,
34
39
  Trigger: () => Trigger,
35
40
  Task: () => Task,
@@ -60,7 +65,11 @@ class Playlist {
60
65
  this.finalizer = finalizer;
61
66
  return this;
62
67
  }
63
- async run(source) {
68
+ sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+ async run(source, options = {}) {
72
+ const { retryDelay = 1000, maxRetries = false } = options;
64
73
  const outputs = {};
65
74
  for (const bundle of this.bundles) {
66
75
  const input = bundle.builder(source, outputs);
@@ -72,7 +81,21 @@ class Playlist {
72
81
  if (!isValid) {
73
82
  throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
74
83
  }
75
- const result = await bundle.task.run(input);
84
+ let result = await bundle.task.run(input);
85
+ if (!result.success) {
86
+ if (retryDelay === false) {
87
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed and retries are disabled`);
88
+ }
89
+ let retries = 0;
90
+ while (!result.success) {
91
+ if (maxRetries !== false && retries >= maxRetries) {
92
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed after ${retries} retries`);
93
+ }
94
+ await this.sleep(retryDelay);
95
+ retries++;
96
+ result = await bundle.task.run(input);
97
+ }
98
+ }
76
99
  outputs[bundle.task.ident] = result;
77
100
  }
78
101
  if (this.finalizer) {
@@ -86,19 +109,32 @@ class Playlist {
86
109
  class Workflow {
87
110
  playlist;
88
111
  triggers;
89
- constructor(triggers, playlist) {
112
+ retry;
113
+ maxRetries;
114
+ constructor(triggers, playlist, retry = 1000, maxRetries = false) {
90
115
  this.triggers = triggers;
91
116
  this.playlist = playlist;
117
+ this.retry = retry;
118
+ this.maxRetries = maxRetries;
92
119
  }
93
120
  addTrigger(trigger) {
94
121
  const newTriggers = [...this.triggers, trigger];
95
122
  const newPlaylist = this.playlist;
96
- return new Workflow(newTriggers, newPlaylist);
123
+ return new Workflow(newTriggers, newPlaylist, this.retry, this.maxRetries);
124
+ }
125
+ preventRetry() {
126
+ return new Workflow(this.triggers, this.playlist, false, this.maxRetries);
127
+ }
128
+ retryDelayMs(delayMs) {
129
+ return new Workflow(this.triggers, this.playlist, delayMs, this.maxRetries);
130
+ }
131
+ retryLimit(maxRetries) {
132
+ return new Workflow(this.triggers, this.playlist, this.retry, maxRetries);
97
133
  }
98
134
  setPlaylist(builder) {
99
135
  const initialPlaylist = new Playlist;
100
136
  const finalPlaylist = builder(initialPlaylist);
101
- return new Workflow(this.triggers, finalPlaylist);
137
+ return new Workflow(this.triggers, finalPlaylist, this.retry, this.maxRetries);
102
138
  }
103
139
  async start({ interval = 5000, callback } = {}) {
104
140
  if (!this.playlist) {
@@ -107,12 +143,16 @@ class Workflow {
107
143
  for (const trigger of this.triggers) {
108
144
  await trigger.start();
109
145
  }
146
+ const runOptions = {
147
+ retryDelay: this.retry,
148
+ maxRetries: this.maxRetries
149
+ };
110
150
  const runTick = async () => {
111
151
  for (const trigger of this.triggers) {
112
152
  const event = trigger.poll();
113
153
  if (event) {
114
154
  try {
115
- const outputs = await this.playlist.run(event);
155
+ const outputs = await this.playlist.run(event, runOptions);
116
156
  if (callback) {
117
157
  callback(event, outputs);
118
158
  }
@@ -130,6 +170,28 @@ class Workflow {
130
170
  }
131
171
  }
132
172
  // src/prototypes/Task.ts
173
+ function isOk(r) {
174
+ return r.success === true;
175
+ }
176
+ function isErr(r) {
177
+ return r.success === false;
178
+ }
179
+ function unwrap(r) {
180
+ if (r.success)
181
+ return r.data;
182
+ throw r.error;
183
+ }
184
+ function unwrapOr(r, defaultValue) {
185
+ if (r.success)
186
+ return r.data;
187
+ return defaultValue;
188
+ }
189
+ function unwrapOrElse(r, fn) {
190
+ if (r.success)
191
+ return r.data;
192
+ return fn(r.error);
193
+ }
194
+
133
195
  class Task {
134
196
  ident;
135
197
  constructor(ident) {
@@ -398,7 +460,10 @@ class Machine {
398
460
  }
399
461
  let current = this.initialState;
400
462
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
401
- await current.playlist.run(stateData);
463
+ await current.playlist.run(stateData, {
464
+ retryDelay: current.retry,
465
+ maxRetries: current.maxRetries
466
+ });
402
467
  transitionsCount = 1;
403
468
  visitedIdents.add(current.ident);
404
469
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
@@ -445,7 +510,10 @@ class Machine {
445
510
  }
446
511
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
447
512
  current = resolvedNext;
448
- await current.playlist.run(stateData);
513
+ await current.playlist.run(stateData, {
514
+ retryDelay: current.retry,
515
+ maxRetries: current.maxRetries
516
+ });
449
517
  visitedIdents.add(current.ident);
450
518
  transitionsCount++;
451
519
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
package/dist/index.d.ts CHANGED
@@ -19,6 +19,37 @@ type Railroad<
19
19
  readonly success: false
20
20
  readonly error: ErrorType
21
21
  };
22
+ /** Type guard: returns true if the Railroad is a success */
23
+ declare function isOk<
24
+ T,
25
+ E
26
+ >(r: Railroad<T, E>): r is {
27
+ success: true
28
+ data: T
29
+ };
30
+ /** Type guard: returns true if the Railroad is an error */
31
+ declare function isErr<
32
+ T,
33
+ E
34
+ >(r: Railroad<T, E>): r is {
35
+ success: false
36
+ error: E
37
+ };
38
+ /** Returns the data if success, throws the error if failure (like Rust's unwrap) */
39
+ declare function unwrap<
40
+ T,
41
+ E
42
+ >(r: Railroad<T, E>): T;
43
+ /** Returns the data if success, or the default value if failure */
44
+ declare function unwrapOr<
45
+ T,
46
+ E
47
+ >(r: Railroad<T, E>, defaultValue: T): T;
48
+ /** Returns the data if success, or calls the function with the error if failure */
49
+ declare function unwrapOrElse<
50
+ T,
51
+ E
52
+ >(r: Railroad<T, E>, fn: (error: E) => T): T;
22
53
  /**
23
54
  * Base class for all executable units in Klonk.
24
55
  * Implement `validateInput` for runtime checks and `run` for the actual work.
@@ -61,6 +92,15 @@ interface TaskBundle {
61
92
  builder: (source: any, outputs: any) => any;
62
93
  }
63
94
  /**
95
+ * Options for controlling task retry behavior during playlist execution.
96
+ */
97
+ type PlaylistRunOptions = {
98
+ /** Delay in ms between retries, or false to disable retries (fail immediately on task failure). */
99
+ retryDelay?: number | false
100
+ /** Maximum number of retries per task, or false for unlimited retries. */
101
+ maxRetries?: number | false
102
+ };
103
+ /**
64
104
  * Returned by `Playlist.addTask()` - you must call `.input()` to provide the task's input builder.
65
105
  *
66
106
  * If you see this type in an error message, it means you forgot to call `.input()` after `.addTask()`.
@@ -148,15 +188,25 @@ declare class Playlist<
148
188
  */
149
189
  finally(finalizer: (source: SourceType, outputs: AllOutputTypes) => void | Promise<void>): this;
150
190
  /**
191
+ * Sleep helper for retry delays.
192
+ */
193
+ private sleep;
194
+ /**
151
195
  * Execute all tasks in order, building each task's input via its builder
152
196
  * and storing each result under the task's ident in the outputs map.
153
197
  * If a builder returns `null`, the task is skipped and its output is `null`.
154
198
  * If a task's `validateInput` returns false, execution stops with an error.
199
+ *
200
+ * When a task fails (`success: false`):
201
+ * - If `retryDelay` is false, throws immediately
202
+ * - Otherwise, retries after `retryDelay` ms until success or `maxRetries` exhausted
203
+ * - If `maxRetries` is exhausted, throws an error
155
204
  *
156
205
  * @param source - The source object for this run (e.g., trigger event or machine state).
206
+ * @param options - Optional retry settings for failed tasks.
157
207
  * @returns The aggregated, strongly-typed outputs map.
158
208
  */
159
- run(source: SourceType): Promise<AllOutputTypes>;
209
+ run(source: SourceType, options?: PlaylistRunOptions): Promise<AllOutputTypes>;
160
210
  }
161
211
  /**
162
212
  * Event object produced by a `Trigger` and consumed by a `Workflow`.
@@ -223,6 +273,7 @@ declare abstract class Trigger<
223
273
  *
224
274
  * - Add triggers with `addTrigger`.
225
275
  * - Configure the playlist using `setPlaylist(p => p.addTask(...))`.
276
+ * - Configure retry behavior with `retryDelayMs`, `retryLimit`, or `preventRetry`.
226
277
  * - Start polling with `start`, optionally receiving a callback when a run completes.
227
278
  *
228
279
  * See README Code Examples for building a full workflow.
@@ -232,7 +283,9 @@ declare abstract class Trigger<
232
283
  declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
233
284
  playlist: Playlist<any, AllTriggerEvents> | null;
234
285
  triggers: Trigger<string, any>[];
235
- constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null);
286
+ retry: false | number;
287
+ maxRetries: false | number;
288
+ constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null, retry?: false | number, maxRetries?: false | number);
236
289
  /**
237
290
  * Register a new trigger to feed events into the workflow.
238
291
  * The resulting workflow type widens its `AllTriggerEvents` union accordingly.
@@ -247,6 +300,27 @@ declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
247
300
  TData
248
301
  >(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData>>;
249
302
  /**
303
+ * Disable retry behavior for failed tasks. Tasks that fail will throw immediately.
304
+ *
305
+ * @returns This workflow for chaining.
306
+ */
307
+ preventRetry(): Workflow<AllTriggerEvents>;
308
+ /**
309
+ * Set the delay between retry attempts for failed tasks.
310
+ *
311
+ * @param delayMs - Delay in milliseconds between retries.
312
+ * @returns This workflow for chaining.
313
+ */
314
+ retryDelayMs(delayMs: number): Workflow<AllTriggerEvents>;
315
+ /**
316
+ * Set the maximum number of retries for failed tasks.
317
+ * Use `preventRetry()` to disable retries entirely.
318
+ *
319
+ * @param maxRetries - Maximum number of retry attempts before throwing.
320
+ * @returns This workflow for chaining.
321
+ */
322
+ retryLimit(maxRetries: number): Workflow<AllTriggerEvents>;
323
+ /**
250
324
  * Configure the playlist by providing a builder that starts from an empty
251
325
  * `Playlist<{}, AllTriggerEvents>` and returns your fully configured playlist.
252
326
  *
@@ -532,4 +606,4 @@ type RunOptions = {
532
606
  mode: "infinitely"
533
607
  interval?: number
534
608
  });
535
- export { Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, Playlist, Machine };
609
+ export { unwrapOrElse, unwrapOr, unwrap, isOk, isErr, Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, PlaylistRunOptions, Playlist, Machine };
package/dist/index.js CHANGED
@@ -19,7 +19,11 @@ class Playlist {
19
19
  this.finalizer = finalizer;
20
20
  return this;
21
21
  }
22
- async run(source) {
22
+ sleep(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+ async run(source, options = {}) {
26
+ const { retryDelay = 1000, maxRetries = false } = options;
23
27
  const outputs = {};
24
28
  for (const bundle of this.bundles) {
25
29
  const input = bundle.builder(source, outputs);
@@ -31,7 +35,21 @@ class Playlist {
31
35
  if (!isValid) {
32
36
  throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
33
37
  }
34
- const result = await bundle.task.run(input);
38
+ let result = await bundle.task.run(input);
39
+ if (!result.success) {
40
+ if (retryDelay === false) {
41
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed and retries are disabled`);
42
+ }
43
+ let retries = 0;
44
+ while (!result.success) {
45
+ if (maxRetries !== false && retries >= maxRetries) {
46
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed after ${retries} retries`);
47
+ }
48
+ await this.sleep(retryDelay);
49
+ retries++;
50
+ result = await bundle.task.run(input);
51
+ }
52
+ }
35
53
  outputs[bundle.task.ident] = result;
36
54
  }
37
55
  if (this.finalizer) {
@@ -45,19 +63,32 @@ class Playlist {
45
63
  class Workflow {
46
64
  playlist;
47
65
  triggers;
48
- constructor(triggers, playlist) {
66
+ retry;
67
+ maxRetries;
68
+ constructor(triggers, playlist, retry = 1000, maxRetries = false) {
49
69
  this.triggers = triggers;
50
70
  this.playlist = playlist;
71
+ this.retry = retry;
72
+ this.maxRetries = maxRetries;
51
73
  }
52
74
  addTrigger(trigger) {
53
75
  const newTriggers = [...this.triggers, trigger];
54
76
  const newPlaylist = this.playlist;
55
- return new Workflow(newTriggers, newPlaylist);
77
+ return new Workflow(newTriggers, newPlaylist, this.retry, this.maxRetries);
78
+ }
79
+ preventRetry() {
80
+ return new Workflow(this.triggers, this.playlist, false, this.maxRetries);
81
+ }
82
+ retryDelayMs(delayMs) {
83
+ return new Workflow(this.triggers, this.playlist, delayMs, this.maxRetries);
84
+ }
85
+ retryLimit(maxRetries) {
86
+ return new Workflow(this.triggers, this.playlist, this.retry, maxRetries);
56
87
  }
57
88
  setPlaylist(builder) {
58
89
  const initialPlaylist = new Playlist;
59
90
  const finalPlaylist = builder(initialPlaylist);
60
- return new Workflow(this.triggers, finalPlaylist);
91
+ return new Workflow(this.triggers, finalPlaylist, this.retry, this.maxRetries);
61
92
  }
62
93
  async start({ interval = 5000, callback } = {}) {
63
94
  if (!this.playlist) {
@@ -66,12 +97,16 @@ class Workflow {
66
97
  for (const trigger of this.triggers) {
67
98
  await trigger.start();
68
99
  }
100
+ const runOptions = {
101
+ retryDelay: this.retry,
102
+ maxRetries: this.maxRetries
103
+ };
69
104
  const runTick = async () => {
70
105
  for (const trigger of this.triggers) {
71
106
  const event = trigger.poll();
72
107
  if (event) {
73
108
  try {
74
- const outputs = await this.playlist.run(event);
109
+ const outputs = await this.playlist.run(event, runOptions);
75
110
  if (callback) {
76
111
  callback(event, outputs);
77
112
  }
@@ -89,6 +124,28 @@ class Workflow {
89
124
  }
90
125
  }
91
126
  // src/prototypes/Task.ts
127
+ function isOk(r) {
128
+ return r.success === true;
129
+ }
130
+ function isErr(r) {
131
+ return r.success === false;
132
+ }
133
+ function unwrap(r) {
134
+ if (r.success)
135
+ return r.data;
136
+ throw r.error;
137
+ }
138
+ function unwrapOr(r, defaultValue) {
139
+ if (r.success)
140
+ return r.data;
141
+ return defaultValue;
142
+ }
143
+ function unwrapOrElse(r, fn) {
144
+ if (r.success)
145
+ return r.data;
146
+ return fn(r.error);
147
+ }
148
+
92
149
  class Task {
93
150
  ident;
94
151
  constructor(ident) {
@@ -357,7 +414,10 @@ class Machine {
357
414
  }
358
415
  let current = this.initialState;
359
416
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
360
- await current.playlist.run(stateData);
417
+ await current.playlist.run(stateData, {
418
+ retryDelay: current.retry,
419
+ maxRetries: current.maxRetries
420
+ });
361
421
  transitionsCount = 1;
362
422
  visitedIdents.add(current.ident);
363
423
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
@@ -404,7 +464,10 @@ class Machine {
404
464
  }
405
465
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
406
466
  current = resolvedNext;
407
- await current.playlist.run(stateData);
467
+ await current.playlist.run(stateData, {
468
+ retryDelay: current.retry,
469
+ maxRetries: current.maxRetries
470
+ });
408
471
  visitedIdents.add(current.ident);
409
472
  transitionsCount++;
410
473
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
@@ -424,6 +487,11 @@ class Machine {
424
487
  }
425
488
  }
426
489
  export {
490
+ unwrapOrElse,
491
+ unwrapOr,
492
+ unwrap,
493
+ isOk,
494
+ isErr,
427
495
  Workflow,
428
496
  Trigger,
429
497
  Task,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fkws/klonk",
3
- "version": "0.0.19",
3
+ "version": "0.0.22",
4
4
  "description": "A lightweight, extensible workflow automation engine for Node.js and Bun",
5
5
  "repository": "https://github.com/klar-web-services/klonk",
6
6
  "homepage": "https://klonk.dev",