@fkws/klonk 0.0.19 → 0.0.21

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
@@ -90,6 +90,32 @@ When a task is skipped:
90
90
 
91
91
  This gives you Rust-like `Option` semantics using TypeScript's native `null` - no extra types needed!
92
92
 
93
+ #### Task Retries
94
+
95
+ When a task fails (`success: false`), it can be automatically retried. Retry behavior is configured on the `Machine` state or `Workflow`:
96
+
97
+ ```typescript
98
+ // On a Machine state:
99
+ Machine.create<MyState>()
100
+ .addState("fetch-data", node => node
101
+ .setPlaylist(p => p.addTask(...))
102
+ .retryDelayMs(500) // Retry every 500ms
103
+ .retryLimit(3) // Max 3 retries, then throw
104
+ )
105
+
106
+ // On a Workflow:
107
+ Workflow.create()
108
+ .addTrigger(myTrigger)
109
+ .retryDelayMs(1000) // Retry every 1s (default)
110
+ .retryLimit(5) // Max 5 retries
111
+ .setPlaylist(p => p.addTask(...))
112
+
113
+ // Disable retries entirely:
114
+ node.preventRetry() // Task failures throw immediately
115
+ ```
116
+
117
+ Default behavior: infinite retries at 1000ms delay. Use `.preventRetry()` to fail fast, or `.retryLimit(n)` to cap attempts.
118
+
93
119
  ### Trigger
94
120
 
95
121
  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 +142,7 @@ Machine.create<MyStateData>()
116
142
  Each state has:
117
143
  1. A `Playlist` that runs when the machine enters that state.
118
144
  2. A set of conditional `Transitions` to other states (with autocomplete!).
119
- 3. Retry rules for when a transition fails to resolve.
145
+ 3. Retry rules for failed tasks and when no transition is available.
120
146
 
121
147
  The `Machine` carries a mutable `stateData` object that can be read from and written to by playlists and transition conditions throughout its execution.
122
148
 
@@ -128,7 +154,8 @@ The `Machine` carries a mutable `stateData` object that can be read from and wri
128
154
 
129
155
  Notes:
130
156
  - `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.
157
+ - 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.
158
+ - 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
159
 
133
160
  ## Features
134
161
 
package/dist/index.cjs CHANGED
@@ -60,7 +60,11 @@ class Playlist {
60
60
  this.finalizer = finalizer;
61
61
  return this;
62
62
  }
63
- async run(source) {
63
+ sleep(ms) {
64
+ return new Promise((resolve) => setTimeout(resolve, ms));
65
+ }
66
+ async run(source, options = {}) {
67
+ const { retryDelay = 1000, maxRetries = false } = options;
64
68
  const outputs = {};
65
69
  for (const bundle of this.bundles) {
66
70
  const input = bundle.builder(source, outputs);
@@ -72,7 +76,21 @@ class Playlist {
72
76
  if (!isValid) {
73
77
  throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
74
78
  }
75
- const result = await bundle.task.run(input);
79
+ let result = await bundle.task.run(input);
80
+ if (!result.success) {
81
+ if (retryDelay === false) {
82
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed and retries are disabled`);
83
+ }
84
+ let retries = 0;
85
+ while (!result.success) {
86
+ if (maxRetries !== false && retries >= maxRetries) {
87
+ throw result.error ?? new Error(`Task '${bundle.task.ident}' failed after ${retries} retries`);
88
+ }
89
+ await this.sleep(retryDelay);
90
+ retries++;
91
+ result = await bundle.task.run(input);
92
+ }
93
+ }
76
94
  outputs[bundle.task.ident] = result;
77
95
  }
78
96
  if (this.finalizer) {
@@ -86,19 +104,32 @@ class Playlist {
86
104
  class Workflow {
87
105
  playlist;
88
106
  triggers;
89
- constructor(triggers, playlist) {
107
+ retry;
108
+ maxRetries;
109
+ constructor(triggers, playlist, retry = 1000, maxRetries = false) {
90
110
  this.triggers = triggers;
91
111
  this.playlist = playlist;
112
+ this.retry = retry;
113
+ this.maxRetries = maxRetries;
92
114
  }
93
115
  addTrigger(trigger) {
94
116
  const newTriggers = [...this.triggers, trigger];
95
117
  const newPlaylist = this.playlist;
96
- return new Workflow(newTriggers, newPlaylist);
118
+ return new Workflow(newTriggers, newPlaylist, this.retry, this.maxRetries);
119
+ }
120
+ preventRetry() {
121
+ return new Workflow(this.triggers, this.playlist, false, this.maxRetries);
122
+ }
123
+ retryDelayMs(delayMs) {
124
+ return new Workflow(this.triggers, this.playlist, delayMs, this.maxRetries);
125
+ }
126
+ retryLimit(maxRetries) {
127
+ return new Workflow(this.triggers, this.playlist, this.retry, maxRetries);
97
128
  }
98
129
  setPlaylist(builder) {
99
130
  const initialPlaylist = new Playlist;
100
131
  const finalPlaylist = builder(initialPlaylist);
101
- return new Workflow(this.triggers, finalPlaylist);
132
+ return new Workflow(this.triggers, finalPlaylist, this.retry, this.maxRetries);
102
133
  }
103
134
  async start({ interval = 5000, callback } = {}) {
104
135
  if (!this.playlist) {
@@ -107,12 +138,16 @@ class Workflow {
107
138
  for (const trigger of this.triggers) {
108
139
  await trigger.start();
109
140
  }
141
+ const runOptions = {
142
+ retryDelay: this.retry,
143
+ maxRetries: this.maxRetries
144
+ };
110
145
  const runTick = async () => {
111
146
  for (const trigger of this.triggers) {
112
147
  const event = trigger.poll();
113
148
  if (event) {
114
149
  try {
115
- const outputs = await this.playlist.run(event);
150
+ const outputs = await this.playlist.run(event, runOptions);
116
151
  if (callback) {
117
152
  callback(event, outputs);
118
153
  }
@@ -398,7 +433,10 @@ class Machine {
398
433
  }
399
434
  let current = this.initialState;
400
435
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
401
- await current.playlist.run(stateData);
436
+ await current.playlist.run(stateData, {
437
+ retryDelay: current.retry,
438
+ maxRetries: current.maxRetries
439
+ });
402
440
  transitionsCount = 1;
403
441
  visitedIdents.add(current.ident);
404
442
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
@@ -445,7 +483,10 @@ class Machine {
445
483
  }
446
484
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
447
485
  current = resolvedNext;
448
- await current.playlist.run(stateData);
486
+ await current.playlist.run(stateData, {
487
+ retryDelay: current.retry,
488
+ maxRetries: current.maxRetries
489
+ });
449
490
  visitedIdents.add(current.ident);
450
491
  transitionsCount++;
451
492
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
package/dist/index.d.ts CHANGED
@@ -61,6 +61,15 @@ interface TaskBundle {
61
61
  builder: (source: any, outputs: any) => any;
62
62
  }
63
63
  /**
64
+ * Options for controlling task retry behavior during playlist execution.
65
+ */
66
+ type PlaylistRunOptions = {
67
+ /** Delay in ms between retries, or false to disable retries (fail immediately on task failure). */
68
+ retryDelay?: number | false
69
+ /** Maximum number of retries per task, or false for unlimited retries. */
70
+ maxRetries?: number | false
71
+ };
72
+ /**
64
73
  * Returned by `Playlist.addTask()` - you must call `.input()` to provide the task's input builder.
65
74
  *
66
75
  * If you see this type in an error message, it means you forgot to call `.input()` after `.addTask()`.
@@ -148,15 +157,25 @@ declare class Playlist<
148
157
  */
149
158
  finally(finalizer: (source: SourceType, outputs: AllOutputTypes) => void | Promise<void>): this;
150
159
  /**
160
+ * Sleep helper for retry delays.
161
+ */
162
+ private sleep;
163
+ /**
151
164
  * Execute all tasks in order, building each task's input via its builder
152
165
  * and storing each result under the task's ident in the outputs map.
153
166
  * If a builder returns `null`, the task is skipped and its output is `null`.
154
167
  * If a task's `validateInput` returns false, execution stops with an error.
168
+ *
169
+ * When a task fails (`success: false`):
170
+ * - If `retryDelay` is false, throws immediately
171
+ * - Otherwise, retries after `retryDelay` ms until success or `maxRetries` exhausted
172
+ * - If `maxRetries` is exhausted, throws an error
155
173
  *
156
174
  * @param source - The source object for this run (e.g., trigger event or machine state).
175
+ * @param options - Optional retry settings for failed tasks.
157
176
  * @returns The aggregated, strongly-typed outputs map.
158
177
  */
159
- run(source: SourceType): Promise<AllOutputTypes>;
178
+ run(source: SourceType, options?: PlaylistRunOptions): Promise<AllOutputTypes>;
160
179
  }
161
180
  /**
162
181
  * Event object produced by a `Trigger` and consumed by a `Workflow`.
@@ -223,6 +242,7 @@ declare abstract class Trigger<
223
242
  *
224
243
  * - Add triggers with `addTrigger`.
225
244
  * - Configure the playlist using `setPlaylist(p => p.addTask(...))`.
245
+ * - Configure retry behavior with `retryDelayMs`, `retryLimit`, or `preventRetry`.
226
246
  * - Start polling with `start`, optionally receiving a callback when a run completes.
227
247
  *
228
248
  * See README Code Examples for building a full workflow.
@@ -232,7 +252,9 @@ declare abstract class Trigger<
232
252
  declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
233
253
  playlist: Playlist<any, AllTriggerEvents> | null;
234
254
  triggers: Trigger<string, any>[];
235
- constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null);
255
+ retry: false | number;
256
+ maxRetries: false | number;
257
+ constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null, retry?: false | number, maxRetries?: false | number);
236
258
  /**
237
259
  * Register a new trigger to feed events into the workflow.
238
260
  * The resulting workflow type widens its `AllTriggerEvents` union accordingly.
@@ -247,6 +269,27 @@ declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
247
269
  TData
248
270
  >(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData>>;
249
271
  /**
272
+ * Disable retry behavior for failed tasks. Tasks that fail will throw immediately.
273
+ *
274
+ * @returns This workflow for chaining.
275
+ */
276
+ preventRetry(): Workflow<AllTriggerEvents>;
277
+ /**
278
+ * Set the delay between retry attempts for failed tasks.
279
+ *
280
+ * @param delayMs - Delay in milliseconds between retries.
281
+ * @returns This workflow for chaining.
282
+ */
283
+ retryDelayMs(delayMs: number): Workflow<AllTriggerEvents>;
284
+ /**
285
+ * Set the maximum number of retries for failed tasks.
286
+ * Use `preventRetry()` to disable retries entirely.
287
+ *
288
+ * @param maxRetries - Maximum number of retry attempts before throwing.
289
+ * @returns This workflow for chaining.
290
+ */
291
+ retryLimit(maxRetries: number): Workflow<AllTriggerEvents>;
292
+ /**
250
293
  * Configure the playlist by providing a builder that starts from an empty
251
294
  * `Playlist<{}, AllTriggerEvents>` and returns your fully configured playlist.
252
295
  *
@@ -532,4 +575,4 @@ type RunOptions = {
532
575
  mode: "infinitely"
533
576
  interval?: number
534
577
  });
535
- export { Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, Playlist, Machine };
578
+ export { 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
  }
@@ -357,7 +392,10 @@ class Machine {
357
392
  }
358
393
  let current = this.initialState;
359
394
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
360
- await current.playlist.run(stateData);
395
+ await current.playlist.run(stateData, {
396
+ retryDelay: current.retry,
397
+ maxRetries: current.maxRetries
398
+ });
361
399
  transitionsCount = 1;
362
400
  visitedIdents.add(current.ident);
363
401
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
@@ -404,7 +442,10 @@ class Machine {
404
442
  }
405
443
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
406
444
  current = resolvedNext;
407
- await current.playlist.run(stateData);
445
+ await current.playlist.run(stateData, {
446
+ retryDelay: current.retry,
447
+ maxRetries: current.maxRetries
448
+ });
408
449
  visitedIdents.add(current.ident);
409
450
  transitionsCount++;
410
451
  if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fkws/klonk",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
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",