@fkws/klonk 0.0.21 → 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 +76 -19
- package/dist/index.cjs +27 -0
- package/dist/index.d.ts +32 -1
- package/dist/index.js +27 -0
- 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" };
|
|
@@ -283,7 +330,7 @@ Notice the fluent `.addTask(task).input(builder)` syntax - each task's input bui
|
|
|
283
330
|
|
|
284
331
|
```typescript
|
|
285
332
|
import { z } from 'zod';
|
|
286
|
-
import { Workflow } from '@fkws/klonk';
|
|
333
|
+
import { Workflow, isOk } from '@fkws/klonk';
|
|
287
334
|
|
|
288
335
|
// The following example requires tasks, integrations and a trigger.
|
|
289
336
|
// Soon, you will be able to import these from @fkws/klonkworks.
|
|
@@ -337,17 +384,17 @@ const workflow = Workflow.create()
|
|
|
337
384
|
// Access outputs of previous tasks - fully typed!
|
|
338
385
|
// Check for null (skipped) and success
|
|
339
386
|
const downloadResult = outputs['download-invoice-pdf'];
|
|
340
|
-
if (!downloadResult
|
|
387
|
+
if (!downloadResult || !isOk(downloadResult)) {
|
|
341
388
|
throw downloadResult?.error ?? new Error('Failed to download invoice PDF');
|
|
342
389
|
}
|
|
343
390
|
|
|
344
391
|
const payeesResult = outputs['get-payees'];
|
|
345
|
-
if (!payeesResult
|
|
392
|
+
if (!payeesResult || !isOk(payeesResult)) {
|
|
346
393
|
throw payeesResult?.error ?? new Error('Failed to load payees');
|
|
347
394
|
}
|
|
348
395
|
|
|
349
396
|
const expenseTypesResult = outputs['get-expense-types'];
|
|
350
|
-
if (!expenseTypesResult
|
|
397
|
+
if (!expenseTypesResult || !isOk(expenseTypesResult)) {
|
|
351
398
|
throw expenseTypesResult?.error ?? new Error('Failed to load expense types');
|
|
352
399
|
}
|
|
353
400
|
|
|
@@ -372,7 +419,7 @@ const workflow = Workflow.create()
|
|
|
372
419
|
.addTask(new TACreateNotionDatabaseItem("create-notion-invoice", notionProvider))
|
|
373
420
|
.input((source, outputs) => {
|
|
374
421
|
const invoiceResult = outputs['parse-invoice'];
|
|
375
|
-
if (!invoiceResult
|
|
422
|
+
if (!invoiceResult || !isOk(invoiceResult)) {
|
|
376
423
|
throw invoiceResult?.error ?? new Error('Failed to parse invoice');
|
|
377
424
|
}
|
|
378
425
|
const invoiceData = invoiceResult.data;
|
|
@@ -408,7 +455,7 @@ workflow.start({
|
|
|
408
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.
|
|
409
456
|
|
|
410
457
|
```typescript
|
|
411
|
-
import { Machine } from "@fkws/klonk"
|
|
458
|
+
import { Machine, isOk } from "@fkws/klonk"
|
|
412
459
|
import { OpenRouterClient } from "./tasks/common/OpenrouterClient"
|
|
413
460
|
import { Model } from "./tasks/common/models"
|
|
414
461
|
import { TABasicTextInference } from "./tasks/TABasicTextInference"
|
|
@@ -457,17 +504,17 @@ const webSearchAgent = Machine
|
|
|
457
504
|
// Extract search terms from refined input
|
|
458
505
|
.addTask(new TABasicTextInference("extract_search_terms", client))
|
|
459
506
|
.input((state, outputs) => ({
|
|
460
|
-
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}`,
|
|
461
508
|
model: state.model ?? "openai/gpt-5.2",
|
|
462
509
|
instructions: `Extract one short web search query from the user request and refined prompt.`
|
|
463
510
|
}))
|
|
464
511
|
|
|
465
|
-
// Update state with results
|
|
512
|
+
// Update state with results - using isOk for type narrowing
|
|
466
513
|
.finally((state, outputs) => {
|
|
467
|
-
if (outputs.refine
|
|
514
|
+
if (outputs.refine && isOk(outputs.refine)) {
|
|
468
515
|
state.refinedInput = outputs.refine.data.text;
|
|
469
516
|
}
|
|
470
|
-
if (outputs.extract_search_terms
|
|
517
|
+
if (outputs.extract_search_terms && isOk(outputs.extract_search_terms)) {
|
|
471
518
|
state.searchTerm = outputs.extract_search_terms.data.text;
|
|
472
519
|
}
|
|
473
520
|
})
|
|
@@ -492,7 +539,7 @@ const webSearchAgent = Machine
|
|
|
492
539
|
query: state.searchTerm!
|
|
493
540
|
}))
|
|
494
541
|
.finally((state, outputs) => {
|
|
495
|
-
if (outputs.search
|
|
542
|
+
if (outputs.search && isOk(outputs.search)) {
|
|
496
543
|
state.searchResults = outputs.search.data;
|
|
497
544
|
}
|
|
498
545
|
})
|
|
@@ -515,7 +562,7 @@ const webSearchAgent = Machine
|
|
|
515
562
|
Write a professional response.`
|
|
516
563
|
}))
|
|
517
564
|
.finally((state, outputs) => {
|
|
518
|
-
state.finalResponse = outputs.generate_response
|
|
565
|
+
state.finalResponse = outputs.generate_response && isOk(outputs.generate_response)
|
|
519
566
|
? outputs.generate_response.data.text
|
|
520
567
|
: "Sorry, an error occurred: " + (outputs.generate_response?.error ?? "unknown");
|
|
521
568
|
})
|
|
@@ -549,13 +596,23 @@ Klonk's type system is designed to be minimal yet powerful. Here's what makes it
|
|
|
549
596
|
| Type | Parameters | Purpose |
|
|
550
597
|
|------|------------|---------|
|
|
551
598
|
| `Task<Input, Output, Ident>` | Input shape, output shape, string literal ident | Base class for all tasks |
|
|
552
|
-
| `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`) |
|
|
553
600
|
| `Playlist<AllOutputs, Source>` | Accumulated output map, source data type | Ordered task sequence with typed chaining |
|
|
554
601
|
| `Trigger<Ident, Data>` | String literal ident, event payload type | Event source for workflows |
|
|
555
602
|
| `Workflow<Events>` | Union of trigger event types | Connects triggers to playlists |
|
|
556
603
|
| `Machine<StateData, AllStateIdents>` | Mutable state shape, union of state idents | Finite state machine with typed transitions |
|
|
557
604
|
| `StateNode<StateData, Ident, AllStateIdents>` | State shape, this node's ident, all valid transition targets | Individual state with playlist and transitions |
|
|
558
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
|
+
|
|
559
616
|
### How Output Chaining Works
|
|
560
617
|
|
|
561
618
|
When you add a task to a playlist, Klonk extends the output type:
|
|
@@ -570,7 +627,7 @@ Playlist<{ fetch: Railroad<FetchOutput> | null }, Source>
|
|
|
570
627
|
// Now outputs include both: { fetch: ..., parse: Railroad<ParseOutput> | null }
|
|
571
628
|
```
|
|
572
629
|
|
|
573
|
-
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`!
|
|
574
631
|
|
|
575
632
|
This maps cleanly to Rust's types:
|
|
576
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,
|
|
@@ -165,6 +170,28 @@ class Workflow {
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
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
|
+
|
|
168
195
|
class Task {
|
|
169
196
|
ident;
|
|
170
197
|
constructor(ident) {
|
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.
|
|
@@ -575,4 +606,4 @@ type RunOptions = {
|
|
|
575
606
|
mode: "infinitely"
|
|
576
607
|
interval?: number
|
|
577
608
|
});
|
|
578
|
-
export { Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, PlaylistRunOptions, Playlist, Machine };
|
|
609
|
+
export { unwrapOrElse, unwrapOr, unwrap, isOk, isErr, Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, PlaylistRunOptions, Playlist, Machine };
|
package/dist/index.js
CHANGED
|
@@ -124,6 +124,28 @@ class Workflow {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
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
|
+
|
|
127
149
|
class Task {
|
|
128
150
|
ident;
|
|
129
151
|
constructor(ident) {
|
|
@@ -465,6 +487,11 @@ class Machine {
|
|
|
465
487
|
}
|
|
466
488
|
}
|
|
467
489
|
export {
|
|
490
|
+
unwrapOrElse,
|
|
491
|
+
unwrapOr,
|
|
492
|
+
unwrap,
|
|
493
|
+
isOk,
|
|
494
|
+
isErr,
|
|
468
495
|
Workflow,
|
|
469
496
|
Trigger,
|
|
470
497
|
Task,
|
package/package.json
CHANGED