@fkws/klonk 0.0.18 → 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 +29 -2
- package/dist/index.cjs +50 -9
- package/dist/index.d.ts +46 -3
- package/dist/index.js +50 -9
- package/package.json +1 -1
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -438,14 +476,17 @@ class Machine {
|
|
|
438
476
|
}
|
|
439
477
|
const resolvedNext = next;
|
|
440
478
|
if (resolvedNext === this.initialState) {
|
|
441
|
-
if (options.mode
|
|
479
|
+
if (options.mode === "roundtrip" || options.mode === "any") {
|
|
442
480
|
logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
|
|
443
481
|
return stateData;
|
|
444
482
|
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -397,14 +435,17 @@ class Machine {
|
|
|
397
435
|
}
|
|
398
436
|
const resolvedNext = next;
|
|
399
437
|
if (resolvedNext === this.initialState) {
|
|
400
|
-
if (options.mode
|
|
438
|
+
if (options.mode === "roundtrip" || options.mode === "any") {
|
|
401
439
|
logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
|
|
402
440
|
return stateData;
|
|
403
441
|
}
|
|
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