@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 +105 -21
- package/dist/index.cjs +76 -8
- package/dist/index.d.ts +77 -3
- package/dist/index.js +76 -8
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
64
|
-
html: outputs.fetch
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
514
|
+
if (outputs.refine && isOk(outputs.refine)) {
|
|
441
515
|
state.refinedInput = outputs.refine.data.text;
|
|
442
516
|
}
|
|
443
|
-
if (outputs.extract_search_terms
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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