@fkws/klonk 0.0.17 → 0.0.19
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 +260 -187
- package/dist/index.cjs +27 -21
- package/dist/index.d.ts +137 -108
- package/dist/index.js +27 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*A code-first, type-safe automation engine for TypeScript.*
|
|
20
20
|
|
|
21
21
|
## Introduction
|
|
22
|
+
|
|
22
23
|
Klonk is a code-first, type-safe automation engine designed with developer experience as a top priority. It provides powerful, composable primitives to build complex workflows and state machines with world-class autocomplete and type inference. If you've ever wanted to build event-driven automations or a stateful agent, but in code, with all the benefits of TypeScript, Klonk is for you.
|
|
23
24
|
|
|
24
25
|

|
|
@@ -30,6 +31,7 @@ The two main features are **Workflows** and **Machines**.
|
|
|
30
31
|
- **Machines**: Create finite state machines where each state has its own `Playlist` of tasks and conditional transitions to other states. Ideal for building agents, multi-step processes, or any system with complex, stateful logic.
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
34
|
+
|
|
33
35
|
```bash
|
|
34
36
|
bun add @fkws/klonk
|
|
35
37
|
# or
|
|
@@ -41,26 +43,80 @@ npm i @fkws/klonk
|
|
|
41
43
|
At the heart of Klonk are a few key concepts that work together.
|
|
42
44
|
|
|
43
45
|
### Task
|
|
46
|
+
|
|
44
47
|
A `Task` is the smallest unit of work. It's an abstract class with two main methods you need to implement:
|
|
45
48
|
- `validateInput(input)`: Runtime validation of the task's input (on top of strong typing).
|
|
46
49
|
- `run(input)`: Executes the task's logic.
|
|
47
50
|
|
|
48
|
-
Tasks use a `Railroad` return type, which is a simple
|
|
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!
|
|
49
52
|
|
|
50
53
|
### Playlist
|
|
51
|
-
|
|
54
|
+
|
|
55
|
+
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
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
playlist
|
|
59
|
+
.addTask(new FetchTask("fetch"))
|
|
60
|
+
.input((source) => ({ url: source.targetUrl }))
|
|
61
|
+
.addTask(new ParseTask("parse"))
|
|
62
|
+
.input((source, outputs) => ({
|
|
63
|
+
// Full autocomplete! outputs.fetch?.success, outputs.fetch?.data, etc.
|
|
64
|
+
html: outputs.fetch?.success ? outputs.fetch.data.body : ""
|
|
65
|
+
}))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> **Note**: If you forget to call `.input()`, TypeScript will show an error mentioning `TaskInputRequired` - this is your hint that you need to provide the input builder!
|
|
69
|
+
|
|
70
|
+
#### Skipping Tasks
|
|
71
|
+
|
|
72
|
+
Need to conditionally skip a task? Just return `null` from the input builder:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
playlist
|
|
76
|
+
.addTask(new NotifyTask("notify"))
|
|
77
|
+
.input((source, outputs) => {
|
|
78
|
+
// Skip notification if previous task failed
|
|
79
|
+
if (!outputs.fetch?.success) {
|
|
80
|
+
return null; // Task will be skipped!
|
|
81
|
+
}
|
|
82
|
+
return { message: "Success!", level: "info" };
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When a task is skipped:
|
|
87
|
+
- Its output in the `outputs` map is `null` (not a `Railroad`)
|
|
88
|
+
- The playlist continues to the next task
|
|
89
|
+
- Subsequent tasks can check `if (outputs.notify === null)` to know it was skipped
|
|
90
|
+
|
|
91
|
+
This gives you Rust-like `Option` semantics using TypeScript's native `null` - no extra types needed!
|
|
52
92
|
|
|
53
93
|
### Trigger
|
|
94
|
+
|
|
54
95
|
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.
|
|
55
96
|
|
|
56
97
|
### Workflow
|
|
98
|
+
|
|
57
99
|
A `Workflow` connects one or more `Triggers` to a `Playlist`. When a trigger fires an event, the workflow runs the playlist, passing the event data as the initial input. This allows you to create powerful, event-driven automations.
|
|
58
100
|
|
|
59
101
|
### Machine
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
|
|
103
|
+
A `Machine` is a finite state machine. You build it by declaring all state identifiers upfront with `.withStates<...>()`, then adding states with `.addState()`:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
Machine.create<MyStateData>()
|
|
107
|
+
.withStates<"idle" | "running" | "complete">() // Declare all states
|
|
108
|
+
.addState("idle", node => node
|
|
109
|
+
.setPlaylist(p => p.addTask(...).input(...))
|
|
110
|
+
.addTransition({ to: "running", condition: ..., weight: 1 }) // Autocomplete!
|
|
111
|
+
, { initial: true })
|
|
112
|
+
.addState("running", node => node...)
|
|
113
|
+
.finalize({ ident: "my-machine" });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Each state has:
|
|
117
|
+
1. A `Playlist` that runs when the machine enters that state.
|
|
118
|
+
2. A set of conditional `Transitions` to other states (with autocomplete!).
|
|
119
|
+
3. Retry rules for when a transition fails to resolve.
|
|
64
120
|
|
|
65
121
|
The `Machine` carries a mutable `stateData` object that can be read from and written to by playlists and transition conditions throughout its execution.
|
|
66
122
|
|
|
@@ -75,15 +131,18 @@ Notes:
|
|
|
75
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.
|
|
76
132
|
|
|
77
133
|
## Features
|
|
134
|
+
|
|
78
135
|
- **Type-Safe & Autocompleted**: Klonk leverages TypeScript's inference to provide a world-class developer experience. The inputs and outputs of every step are strongly typed, so you'll know at compile time if your logic is sound.
|
|
79
136
|
- **Code-First**: Define your automations directly in TypeScript. No YAML, no drag-and-drop UIs. Just the full power of a real programming language.
|
|
80
137
|
- **Composable & Extensible**: The core primitives (`Task`, `Trigger`) are simple abstract classes, making it easy to create your own reusable components and integrations.
|
|
81
138
|
- **Flexible Execution**: `Machines` run with configurable modes via `run(state, options)`: `any`, `leaf`, `roundtrip`, or `infinitely` (with optional `interval`).
|
|
82
139
|
|
|
83
140
|
## Klonkworks: Pre-built Components
|
|
141
|
+
|
|
84
142
|
Coming soon(ish)! Klonkworks will be a large collection of pre-built Tasks, Triggers, and integrations. This will allow you to quickly assemble powerful automations that connect to a wide variety of services, often without needing to build your own components from scratch.
|
|
85
143
|
|
|
86
144
|
## Code Examples
|
|
145
|
+
|
|
87
146
|
<details>
|
|
88
147
|
<summary><b>Creating a Task</b></summary>
|
|
89
148
|
|
|
@@ -106,9 +165,9 @@ type TABasicTextInferenceOutput = {
|
|
|
106
165
|
|
|
107
166
|
// A Task is a generic class. You provide the Input, Output, and an Ident (a unique string literal for the task).
|
|
108
167
|
export class TABasicTextInference<IdentType extends string> extends Task<
|
|
109
|
-
TABasicTextInferenceInput, //
|
|
110
|
-
TABasicTextInferenceOutput, //
|
|
111
|
-
IdentType
|
|
168
|
+
TABasicTextInferenceInput, // Input Type
|
|
169
|
+
TABasicTextInferenceOutput, // Output Type
|
|
170
|
+
IdentType // Ident Type (string literal for type-safe output keys)
|
|
112
171
|
> {
|
|
113
172
|
constructor(ident: IdentType, public client: OpenRouterClient) {
|
|
114
173
|
super(ident);
|
|
@@ -135,13 +194,11 @@ export class TABasicTextInference<IdentType extends string> extends Task<
|
|
|
135
194
|
});
|
|
136
195
|
// On success, return a success object with your data.
|
|
137
196
|
return {
|
|
138
|
-
success: true,
|
|
139
|
-
data: {
|
|
140
|
-
text: result
|
|
141
|
-
}
|
|
197
|
+
success: true,
|
|
198
|
+
data: { text: result }
|
|
142
199
|
};
|
|
143
200
|
} catch (error) {
|
|
144
|
-
// On failure, return an error object.
|
|
201
|
+
// On failure, return an error object.
|
|
145
202
|
return {
|
|
146
203
|
success: false,
|
|
147
204
|
error: error instanceof Error ? error : new Error(String(error))
|
|
@@ -174,7 +231,7 @@ export class IntervalTrigger<TIdent extends string> extends Trigger<TIdent, { no
|
|
|
174
231
|
if (this.intervalId) return; // Prevent multiple intervals.
|
|
175
232
|
|
|
176
233
|
this.intervalId = setInterval(() => {
|
|
177
|
-
// When an event occurs, use pushEvent to add it to the internal queue
|
|
234
|
+
// When an event occurs, use pushEvent to add it to the internal queue.
|
|
178
235
|
this.pushEvent({ now: new Date() });
|
|
179
236
|
}, this.intervalMs);
|
|
180
237
|
}
|
|
@@ -195,21 +252,21 @@ export class IntervalTrigger<TIdent extends string> extends Trigger<TIdent, { no
|
|
|
195
252
|
|
|
196
253
|
Workflows are perfect for event-driven automations. This example creates a workflow that triggers when a new invoice PDF is added to a Dropbox folder. It then parses the invoice and creates a new item in a Notion database.
|
|
197
254
|
|
|
198
|
-
Notice
|
|
255
|
+
Notice the fluent `.addTask(task).input(builder)` syntax - each task's input builder has access to `source` (trigger data) and `outputs` (all previous task results), with full type inference!
|
|
199
256
|
|
|
200
257
|
```typescript
|
|
201
258
|
import { z } from 'zod';
|
|
202
259
|
import { Workflow } from '@fkws/klonk';
|
|
203
260
|
|
|
204
|
-
// The following example requires
|
|
261
|
+
// The following example requires tasks, integrations and a trigger.
|
|
205
262
|
// Soon, you will be able to import these from @fkws/klonkworks.
|
|
206
263
|
import { TACreateNotionDatabaseItem, TANotionGetTitlesAndIdsForDatabase, TAParsePdfAi, TADropboxDownloadFile } from '@fkws/klonkworks/tasks';
|
|
207
264
|
import { INotion, IOpenRouter, IDropbox } from '@fkws/klonkworks/integrations';
|
|
208
265
|
import { TRDropboxFileAdded } from '@fkws/klonkworks/triggers';
|
|
209
266
|
|
|
210
267
|
// Providers and clients are instantiated as usual.
|
|
211
|
-
const notionProvider = new INotion({apiKey: process.env.NOTION_API_KEY!});
|
|
212
|
-
const openrouterProvider = new IOpenRouter({apiKey: process.env.OPENROUTER_API_KEY!});
|
|
268
|
+
const notionProvider = new INotion({ apiKey: process.env.NOTION_API_KEY! });
|
|
269
|
+
const openrouterProvider = new IOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });
|
|
213
270
|
const dropboxProvider = new IDropbox({
|
|
214
271
|
appKey: process.env.DROPBOX_APP_KEY!,
|
|
215
272
|
appSecret: process.env.DROPBOX_APP_SECRET!,
|
|
@@ -217,108 +274,100 @@ const dropboxProvider = new IDropbox({
|
|
|
217
274
|
});
|
|
218
275
|
|
|
219
276
|
// Start building a workflow.
|
|
220
|
-
const workflow = Workflow.create()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
new TANotionGetTitlesAndIdsForDatabase("get-expense-types", notionProvider)
|
|
236
|
-
(source, outputs) => {
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
throw new Error(`Trigger ${source.triggerIdent} is not implemented for task download-invoice-pdf.`)
|
|
277
|
+
const workflow = Workflow.create()
|
|
278
|
+
.addTrigger(
|
|
279
|
+
new TRDropboxFileAdded("dropbox-trigger", {
|
|
280
|
+
client: dropboxProvider,
|
|
281
|
+
folderPath: process.env.DROPBOX_INVOICES_FOLDER_PATH ?? "",
|
|
282
|
+
})
|
|
283
|
+
)
|
|
284
|
+
.setPlaylist(p => p
|
|
285
|
+
// Get payees from Notion
|
|
286
|
+
.addTask(new TANotionGetTitlesAndIdsForDatabase("get-payees", notionProvider))
|
|
287
|
+
.input((source, outputs) => ({
|
|
288
|
+
database_id: process.env.NOTION_PAYEES_DATABASE_ID!
|
|
289
|
+
}))
|
|
290
|
+
|
|
291
|
+
// Get expense types from Notion
|
|
292
|
+
.addTask(new TANotionGetTitlesAndIdsForDatabase("get-expense-types", notionProvider))
|
|
293
|
+
.input((source, outputs) => ({
|
|
294
|
+
database_id: process.env.NOTION_EXPENSE_TYPES_DATABASE_ID!
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
// Download the invoice PDF from Dropbox
|
|
298
|
+
.addTask(new TADropboxDownloadFile("download-invoice-pdf", dropboxProvider))
|
|
299
|
+
.input((source, outputs) => {
|
|
300
|
+
// The `source` object contains the trigger ident for discrimination
|
|
301
|
+
if (source.triggerIdent === "dropbox-trigger") {
|
|
302
|
+
return { file_metadata: source.data }
|
|
247
303
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
304
|
+
throw new Error(`Trigger ${source.triggerIdent} not implemented`);
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// Parse the PDF with AI
|
|
308
|
+
.addTask(new TAParsePdfAi("parse-invoice", openrouterProvider))
|
|
309
|
+
.input((source, outputs) => {
|
|
310
|
+
// Access outputs of previous tasks - fully typed!
|
|
311
|
+
// Check for null (skipped) and success
|
|
254
312
|
const downloadResult = outputs['download-invoice-pdf'];
|
|
255
|
-
if (!downloadResult
|
|
256
|
-
throw downloadResult
|
|
313
|
+
if (!downloadResult?.success) {
|
|
314
|
+
throw downloadResult?.error ?? new Error('Failed to download invoice PDF');
|
|
257
315
|
}
|
|
258
316
|
|
|
259
317
|
const payeesResult = outputs['get-payees'];
|
|
260
|
-
if (!payeesResult
|
|
261
|
-
throw payeesResult
|
|
318
|
+
if (!payeesResult?.success) {
|
|
319
|
+
throw payeesResult?.error ?? new Error('Failed to load payees');
|
|
262
320
|
}
|
|
263
321
|
|
|
264
322
|
const expenseTypesResult = outputs['get-expense-types'];
|
|
265
|
-
if (!expenseTypesResult
|
|
266
|
-
throw expenseTypesResult
|
|
323
|
+
if (!expenseTypesResult?.success) {
|
|
324
|
+
throw expenseTypesResult?.error ?? new Error('Failed to load expense types');
|
|
267
325
|
}
|
|
268
326
|
|
|
269
|
-
const payees = payeesResult.data;
|
|
270
|
-
const expenseTypes = expenseTypesResult.data;
|
|
271
|
-
|
|
272
327
|
return {
|
|
273
328
|
pdf: downloadResult.data.file,
|
|
274
329
|
instructions: "Extract data from the invoice",
|
|
275
330
|
schema: z.object({
|
|
276
|
-
payee: z.enum(
|
|
277
|
-
.describe("The payee id
|
|
331
|
+
payee: z.enum(payeesResult.data.map(p => p.id) as [string, ...string[]])
|
|
332
|
+
.describe("The payee id"),
|
|
278
333
|
total: z.number()
|
|
279
|
-
.describe("The total amount
|
|
334
|
+
.describe("The total amount"),
|
|
280
335
|
invoice_date: z.string()
|
|
281
336
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
282
|
-
.describe("
|
|
283
|
-
expense_type: z.enum(
|
|
284
|
-
.describe("The expense type id
|
|
337
|
+
.describe("Date as YYYY-MM-DD"),
|
|
338
|
+
expense_type: z.enum(expenseTypesResult.data.map(e => e.id) as [string, ...string[]])
|
|
339
|
+
.describe("The expense type id")
|
|
285
340
|
})
|
|
286
341
|
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
(
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// Create the invoice entry in Notion
|
|
345
|
+
.addTask(new TACreateNotionDatabaseItem("create-notion-invoice", notionProvider))
|
|
346
|
+
.input((source, outputs) => {
|
|
291
347
|
const invoiceResult = outputs['parse-invoice'];
|
|
292
|
-
if (!invoiceResult
|
|
293
|
-
throw invoiceResult
|
|
348
|
+
if (!invoiceResult?.success) {
|
|
349
|
+
throw invoiceResult?.error ?? new Error('Failed to parse invoice');
|
|
294
350
|
}
|
|
295
351
|
const invoiceData = invoiceResult.data;
|
|
296
|
-
const properties = {
|
|
297
|
-
'Name': { 'title': [{ 'text': { 'content': 'Invoice' } }] },
|
|
298
|
-
'Payee': { 'relation': [{ 'id': invoiceData.payee }] },
|
|
299
|
-
'Total': { 'number': invoiceData.total },
|
|
300
|
-
'Invoice Date': { 'date': { 'start': invoiceData.invoice_date } },
|
|
301
|
-
'Expense Type': { 'relation': [{ 'id': invoiceData.expense_type }] }
|
|
302
|
-
};
|
|
303
352
|
return {
|
|
304
353
|
database_id: process.env.NOTION_INVOICES_DATABASE_ID!,
|
|
305
|
-
properties:
|
|
354
|
+
properties: {
|
|
355
|
+
'Name': { 'title': [{ 'text': { 'content': 'Invoice' } }] },
|
|
356
|
+
'Payee': { 'relation': [{ 'id': invoiceData.payee }] },
|
|
357
|
+
'Total': { 'number': invoiceData.total },
|
|
358
|
+
'Invoice Date': { 'date': { 'start': invoiceData.invoice_date } },
|
|
359
|
+
'Expense Type': { 'relation': [{ 'id': invoiceData.expense_type }] }
|
|
360
|
+
}
|
|
306
361
|
}
|
|
307
|
-
}
|
|
308
|
-
)
|
|
309
|
-
)
|
|
362
|
+
})
|
|
363
|
+
);
|
|
310
364
|
|
|
311
365
|
// Run the workflow
|
|
312
366
|
console.log('[WCreateNotionInvoiceFromFile] Starting workflow...');
|
|
313
|
-
// .start() begins the workflow's trigger polling loop.
|
|
314
367
|
workflow.start({
|
|
315
|
-
// The callback is executed every time the playlist successfully completes.
|
|
316
368
|
callback: (source, outputs) => {
|
|
317
369
|
console.log('[WCreateNotionInvoiceFromFile] Workflow completed');
|
|
318
|
-
console.dir({
|
|
319
|
-
source,
|
|
320
|
-
outputs
|
|
321
|
-
}, { depth: null });
|
|
370
|
+
console.dir({ source, outputs }, { depth: null });
|
|
322
371
|
}
|
|
323
372
|
});
|
|
324
373
|
```
|
|
@@ -327,12 +376,12 @@ workflow.start({
|
|
|
327
376
|
<details>
|
|
328
377
|
<summary><b>Building a Machine</b></summary>
|
|
329
378
|
|
|
330
|
-
`Machines` are ideal for building complex, stateful agents. This example shows
|
|
379
|
+
`Machines` are ideal for building complex, stateful agents. This example shows an AI agent that takes a user's query, refines it, performs a web search, and generates a final response.
|
|
331
380
|
|
|
332
|
-
The `Machine` manages a `StateData` object. Each `StateNode`'s `Playlist` can modify this state, and the `Transitions` between states
|
|
381
|
+
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.
|
|
333
382
|
|
|
334
383
|
```typescript
|
|
335
|
-
import { Machine
|
|
384
|
+
import { Machine } from "@fkws/klonk"
|
|
336
385
|
import { OpenRouterClient } from "./tasks/common/OpenrouterClient"
|
|
337
386
|
import { Model } from "./tasks/common/models"
|
|
338
387
|
import { TABasicTextInference } from "./tasks/TABasicTextInference"
|
|
@@ -349,15 +398,15 @@ type StateData = {
|
|
|
349
398
|
url: string;
|
|
350
399
|
title: string;
|
|
351
400
|
content: string;
|
|
352
|
-
raw_content?: string
|
|
401
|
+
raw_content?: string;
|
|
353
402
|
score: string;
|
|
354
403
|
}[];
|
|
355
404
|
query: string;
|
|
356
|
-
answer?: string
|
|
357
|
-
images?: string[]
|
|
358
|
-
follow_up_questions?: string[]
|
|
405
|
+
answer?: string;
|
|
406
|
+
images?: string[];
|
|
407
|
+
follow_up_questions?: string[];
|
|
359
408
|
response_time: string;
|
|
360
|
-
}
|
|
409
|
+
};
|
|
361
410
|
finalResponse?: string;
|
|
362
411
|
}
|
|
363
412
|
|
|
@@ -365,116 +414,140 @@ const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY!)
|
|
|
365
414
|
|
|
366
415
|
const webSearchAgent = Machine
|
|
367
416
|
.create<StateData>()
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
.setPlaylist(p => p
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
instructions: instructions
|
|
381
|
-
}
|
|
382
|
-
})
|
|
383
|
-
.addTask(new TABasicTextInference("extract_search_terms", client),
|
|
384
|
-
(state, outputs) => {
|
|
385
|
-
const input = `Original request: ${state.input}\n\nRefined prompt: ${state.refinedInput}`;
|
|
386
|
-
const model = state.model ? state.model : "openai/gpt-5"
|
|
387
|
-
const instructions = `You will receive the original user request AND an LLM refined version of the prompt. Please use both to extract one short web search query that will retrieve useful results.`;
|
|
388
|
-
return {
|
|
389
|
-
inputText: input,
|
|
390
|
-
model: model,
|
|
391
|
-
instructions: instructions
|
|
392
|
-
}
|
|
393
|
-
})
|
|
394
|
-
.finally((state, outputs) => { // The finally block allows the playlist to react to the last task and to modify state data before the run ends.
|
|
395
|
-
if (outputs.refine.success) {
|
|
396
|
-
state.refinedInput = outputs.refine.data.text
|
|
397
|
-
} else {
|
|
398
|
-
state.refinedInput = "Sorry, an error occurred: " + outputs.refine.error
|
|
399
|
-
}
|
|
417
|
+
// Declare all states upfront for transition autocomplete
|
|
418
|
+
.withStates<"refine_and_extract" | "search_web" | "generate_response">()
|
|
419
|
+
.addState("refine_and_extract", node => node
|
|
420
|
+
.setPlaylist(p => p
|
|
421
|
+
// Refine the user's input
|
|
422
|
+
.addTask(new TABasicTextInference("refine", client))
|
|
423
|
+
.input((state, outputs) => ({
|
|
424
|
+
inputText: state.input,
|
|
425
|
+
model: state.model ?? "openai/gpt-5.2",
|
|
426
|
+
instructions: `You are a prompt refiner. Refine the prompt to improve LLM performance.
|
|
427
|
+
Break down by Intent, Mood, and Instructions. Do NOT answer - ONLY refine.`
|
|
428
|
+
}))
|
|
400
429
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
430
|
+
// Extract search terms from refined input
|
|
431
|
+
.addTask(new TABasicTextInference("extract_search_terms", client))
|
|
432
|
+
.input((state, outputs) => ({
|
|
433
|
+
inputText: `Original: ${state.input}\n\nRefined: ${outputs.refine?.success ? outputs.refine.data.text : state.input}`,
|
|
434
|
+
model: state.model ?? "openai/gpt-5.2",
|
|
435
|
+
instructions: `Extract one short web search query from the user request and refined prompt.`
|
|
404
436
|
}))
|
|
405
|
-
|
|
437
|
+
|
|
438
|
+
// Update state with results
|
|
439
|
+
.finally((state, outputs) => {
|
|
440
|
+
if (outputs.refine?.success) {
|
|
441
|
+
state.refinedInput = outputs.refine.data.text;
|
|
442
|
+
}
|
|
443
|
+
if (outputs.extract_search_terms?.success) {
|
|
444
|
+
state.searchTerm = outputs.extract_search_terms.data.text;
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
)
|
|
448
|
+
.retryLimit(3) // Retry up to 3 times if no transition available
|
|
406
449
|
.addTransition({
|
|
407
|
-
to: "search_web",
|
|
408
|
-
condition: async (
|
|
409
|
-
weight: 2 //
|
|
450
|
+
to: "search_web", // Autocomplete works!
|
|
451
|
+
condition: async (state) => !!state.searchTerm,
|
|
452
|
+
weight: 2 // Higher weight = higher priority
|
|
410
453
|
})
|
|
411
454
|
.addTransition({
|
|
412
|
-
to: "generate_response",
|
|
413
|
-
condition: async (
|
|
455
|
+
to: "generate_response", // Autocomplete works!
|
|
456
|
+
condition: async () => true, // Fallback
|
|
414
457
|
weight: 1
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
.addState(
|
|
419
|
-
.setIdent("search_web")
|
|
458
|
+
})
|
|
459
|
+
, { initial: true })
|
|
460
|
+
|
|
461
|
+
.addState("search_web", node => node
|
|
420
462
|
.setPlaylist(p => p
|
|
421
|
-
.addTask(new TASearchOnline("search")
|
|
422
|
-
(state, outputs) => {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
})
|
|
463
|
+
.addTask(new TASearchOnline("search"))
|
|
464
|
+
.input((state, outputs) => ({
|
|
465
|
+
query: state.searchTerm!
|
|
466
|
+
}))
|
|
427
467
|
.finally((state, outputs) => {
|
|
428
|
-
if(outputs.search
|
|
429
|
-
state.searchResults = outputs.search.data
|
|
468
|
+
if (outputs.search?.success) {
|
|
469
|
+
state.searchResults = outputs.search.data;
|
|
430
470
|
}
|
|
431
|
-
})
|
|
471
|
+
})
|
|
472
|
+
)
|
|
432
473
|
.addTransition({
|
|
433
474
|
to: "generate_response",
|
|
434
|
-
condition: async (
|
|
475
|
+
condition: async () => true,
|
|
435
476
|
weight: 1
|
|
436
477
|
})
|
|
437
478
|
)
|
|
438
|
-
|
|
439
|
-
|
|
479
|
+
|
|
480
|
+
.addState("generate_response", node => node
|
|
440
481
|
.setPlaylist(p => p
|
|
441
|
-
.addTask(new TABasicTextInference("generate_response", client)
|
|
442
|
-
(state, outputs) => {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
})
|
|
482
|
+
.addTask(new TABasicTextInference("generate_response", client))
|
|
483
|
+
.input((state, outputs) => ({
|
|
484
|
+
inputText: state.input,
|
|
485
|
+
model: state.model ?? "openai/gpt-5.2",
|
|
486
|
+
instructions: `You received a user request and refined prompt.
|
|
487
|
+
${state.searchResults ? 'Search results are also available.' : ''}
|
|
488
|
+
Write a professional response.`
|
|
489
|
+
}))
|
|
449
490
|
.finally((state, outputs) => {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
else {
|
|
454
|
-
state.finalResponse = "Sorry, an error occurred: " + outputs.generate_response.error
|
|
455
|
-
}
|
|
491
|
+
state.finalResponse = outputs.generate_response?.success
|
|
492
|
+
? outputs.generate_response.data.text
|
|
493
|
+
: "Sorry, an error occurred: " + (outputs.generate_response?.error ?? "unknown");
|
|
456
494
|
})
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
.finalize({
|
|
461
|
-
ident: "web-search-agent"
|
|
462
|
-
})
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
.addLogger(pino()) // Optional: Add structured logging (pino recommended)
|
|
498
|
+
.finalize({ ident: "web-search-agent" });
|
|
463
499
|
|
|
464
|
-
// ------------- EXECUTION
|
|
500
|
+
// ------------- EXECUTION -------------
|
|
465
501
|
|
|
466
|
-
const state: StateData = {
|
|
502
|
+
const state: StateData = {
|
|
467
503
|
input: "How do I update AMD graphic driver?",
|
|
468
|
-
model: "openai/gpt-
|
|
469
|
-
}
|
|
504
|
+
model: "openai/gpt-5.2-mini"
|
|
505
|
+
};
|
|
470
506
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
// to the initial state. The original state object is also mutated.
|
|
474
|
-
const finalState = await webSearchAgent.run(state, { mode: 'roundtrip' })
|
|
507
|
+
// Run until it completes a roundtrip to the initial state
|
|
508
|
+
const finalState = await webSearchAgent.run(state, { mode: 'roundtrip' });
|
|
475
509
|
|
|
476
|
-
console.log(finalState.finalResponse)
|
|
477
|
-
//
|
|
478
|
-
console.log(state.finalResponse)
|
|
510
|
+
console.log(finalState.finalResponse);
|
|
511
|
+
// The original state object is also mutated:
|
|
512
|
+
console.log(state.finalResponse);
|
|
479
513
|
```
|
|
480
514
|
</details>
|
|
515
|
+
|
|
516
|
+
## Type System
|
|
517
|
+
|
|
518
|
+
Klonk's type system is designed to be minimal yet powerful. Here's what makes it tick:
|
|
519
|
+
|
|
520
|
+
### Core Types
|
|
521
|
+
|
|
522
|
+
| Type | Parameters | Purpose |
|
|
523
|
+
|------|------------|---------|
|
|
524
|
+
| `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 |
|
|
526
|
+
| `Playlist<AllOutputs, Source>` | Accumulated output map, source data type | Ordered task sequence with typed chaining |
|
|
527
|
+
| `Trigger<Ident, Data>` | String literal ident, event payload type | Event source for workflows |
|
|
528
|
+
| `Workflow<Events>` | Union of trigger event types | Connects triggers to playlists |
|
|
529
|
+
| `Machine<StateData, AllStateIdents>` | Mutable state shape, union of state idents | Finite state machine with typed transitions |
|
|
530
|
+
| `StateNode<StateData, Ident, AllStateIdents>` | State shape, this node's ident, all valid transition targets | Individual state with playlist and transitions |
|
|
531
|
+
|
|
532
|
+
### How Output Chaining Works
|
|
533
|
+
|
|
534
|
+
When you add a task to a playlist, Klonk extends the output type:
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
// Start with empty outputs
|
|
538
|
+
Playlist<{}, Source>
|
|
539
|
+
.addTask(new FetchTask("fetch")).input(...)
|
|
540
|
+
// Now outputs include: { fetch: Railroad<FetchOutput> | null }
|
|
541
|
+
Playlist<{ fetch: Railroad<FetchOutput> | null }, Source>
|
|
542
|
+
.addTask(new ParseTask("parse")).input(...)
|
|
543
|
+
// Now outputs include both: { fetch: ..., parse: Railroad<ParseOutput> | null }
|
|
544
|
+
```
|
|
545
|
+
|
|
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!
|
|
547
|
+
|
|
548
|
+
This maps cleanly to Rust's types:
|
|
549
|
+
| Rust | Klonk (TypeScript) |
|
|
550
|
+
|------|-------------------|
|
|
551
|
+
| `Option<T>` | `T \| null` |
|
|
552
|
+
| `Result<T, E>` | `Railroad<T>` |
|
|
553
|
+
| `Option<Result<T, E>>` | `Railroad<T> \| null` |
|
package/dist/index.cjs
CHANGED
|
@@ -47,10 +47,14 @@ class Playlist {
|
|
|
47
47
|
this.bundles = bundles;
|
|
48
48
|
this.finalizer = finalizer;
|
|
49
49
|
}
|
|
50
|
-
addTask(task
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
addTask(task) {
|
|
51
|
+
return {
|
|
52
|
+
input: (builder) => {
|
|
53
|
+
const bundle = { task, builder };
|
|
54
|
+
const newBundles = [...this.bundles, bundle];
|
|
55
|
+
return new Playlist(newBundles, this.finalizer);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
54
58
|
}
|
|
55
59
|
finally(finalizer) {
|
|
56
60
|
this.finalizer = finalizer;
|
|
@@ -60,6 +64,10 @@ class Playlist {
|
|
|
60
64
|
const outputs = {};
|
|
61
65
|
for (const bundle of this.bundles) {
|
|
62
66
|
const input = bundle.builder(source, outputs);
|
|
67
|
+
if (input === null) {
|
|
68
|
+
outputs[bundle.task.ident] = null;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
63
71
|
const isValid = await bundle.task.validateInput(input);
|
|
64
72
|
if (!isValid) {
|
|
65
73
|
throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
|
|
@@ -177,16 +185,13 @@ class StateNode {
|
|
|
177
185
|
retry;
|
|
178
186
|
maxRetries;
|
|
179
187
|
logger;
|
|
180
|
-
constructor(
|
|
181
|
-
this.transitions =
|
|
182
|
-
this.playlist =
|
|
183
|
-
this.ident =
|
|
188
|
+
constructor(ident) {
|
|
189
|
+
this.transitions = [];
|
|
190
|
+
this.playlist = new Playlist;
|
|
191
|
+
this.ident = ident;
|
|
184
192
|
this.retry = 1000;
|
|
185
193
|
this.maxRetries = false;
|
|
186
194
|
}
|
|
187
|
-
static create() {
|
|
188
|
-
return new StateNode([], new Playlist);
|
|
189
|
-
}
|
|
190
195
|
addTransition({ to, condition, weight }) {
|
|
191
196
|
if (!this.tempTransitions) {
|
|
192
197
|
this.tempTransitions = [];
|
|
@@ -216,10 +221,6 @@ class StateNode {
|
|
|
216
221
|
this.maxRetries = maxRetries;
|
|
217
222
|
return this;
|
|
218
223
|
}
|
|
219
|
-
setIdent(ident) {
|
|
220
|
-
this.ident = ident;
|
|
221
|
-
return this;
|
|
222
|
-
}
|
|
223
224
|
getByIdent(ident, visited = []) {
|
|
224
225
|
if (this.ident === ident) {
|
|
225
226
|
return this;
|
|
@@ -294,6 +295,9 @@ class Machine {
|
|
|
294
295
|
static create() {
|
|
295
296
|
return new Machine;
|
|
296
297
|
}
|
|
298
|
+
withStates() {
|
|
299
|
+
return this;
|
|
300
|
+
}
|
|
297
301
|
finalize({
|
|
298
302
|
ident
|
|
299
303
|
} = {}) {
|
|
@@ -342,14 +346,16 @@ class Machine {
|
|
|
342
346
|
logger?.info({ phase: "end" }, `Machine ${this.ident} finalized.`);
|
|
343
347
|
return this;
|
|
344
348
|
}
|
|
345
|
-
addState(
|
|
349
|
+
addState(ident, builder, options = {}) {
|
|
346
350
|
const logger = this.logger?.child?.({ path: "machine.addState", instance: this.ident }) ?? this.logger;
|
|
347
|
-
logger?.info({ phase: "start", state:
|
|
348
|
-
|
|
351
|
+
logger?.info({ phase: "start", state: ident, isInitial: !!options.initial }, "Adding state");
|
|
352
|
+
const node = new StateNode(ident);
|
|
353
|
+
const configuredNode = builder(node);
|
|
354
|
+
this.statesToCreate.push(configuredNode);
|
|
349
355
|
if (options.initial) {
|
|
350
|
-
this.initialState =
|
|
356
|
+
this.initialState = configuredNode;
|
|
351
357
|
}
|
|
352
|
-
logger?.info({ phase: "end", state:
|
|
358
|
+
logger?.info({ phase: "end", state: ident }, "State added");
|
|
353
359
|
return this;
|
|
354
360
|
}
|
|
355
361
|
addLogger(logger) {
|
|
@@ -432,7 +438,7 @@ class Machine {
|
|
|
432
438
|
}
|
|
433
439
|
const resolvedNext = next;
|
|
434
440
|
if (resolvedNext === this.initialState) {
|
|
435
|
-
if (options.mode
|
|
441
|
+
if (options.mode === "roundtrip" || options.mode === "any") {
|
|
436
442
|
logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
|
|
437
443
|
return stateData;
|
|
438
444
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -54,36 +54,32 @@ declare abstract class Task<
|
|
|
54
54
|
abstract run(input: InputType): Promise<Railroad<OutputType>>;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* Function used to build the input for a Task from the Playlist context.
|
|
58
|
-
*
|
|
59
|
-
* @template SourceType - The source object passed to `run` (e.g., trigger event or machine state).
|
|
60
|
-
* @template AllOutputTypes - Accumulated outputs from previously executed tasks.
|
|
61
|
-
* @template TaskInputType - Concrete input type required by the target Task.
|
|
62
|
-
*/
|
|
63
|
-
type InputBuilder<
|
|
64
|
-
SourceType,
|
|
65
|
-
AllOutputTypes,
|
|
66
|
-
TaskInputType
|
|
67
|
-
> = (source: SourceType, outputs: AllOutputTypes) => TaskInputType;
|
|
68
|
-
/**
|
|
69
57
|
* @internal Internal assembly type that couples a task with its input builder.
|
|
70
58
|
*/
|
|
71
|
-
interface TaskBundle
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
TaskInputType,
|
|
75
|
-
TaskOutputType,
|
|
76
|
-
IdentType extends string
|
|
77
|
-
> {
|
|
78
|
-
task: Task<TaskInputType, TaskOutputType, IdentType>;
|
|
79
|
-
builder: InputBuilder<SourceType, AllOutputTypes, TaskInputType>;
|
|
59
|
+
interface TaskBundle {
|
|
60
|
+
task: Task<any, any, string>;
|
|
61
|
+
builder: (source: any, outputs: any) => any;
|
|
80
62
|
}
|
|
81
63
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
64
|
+
* Returned by `Playlist.addTask()` - you must call `.input()` to provide the task's input builder.
|
|
65
|
+
*
|
|
66
|
+
* If you see this type in an error message, it means you forgot to call `.input()` after `.addTask()`.
|
|
85
67
|
*/
|
|
86
|
-
|
|
68
|
+
interface TaskInputRequired<
|
|
69
|
+
TInput,
|
|
70
|
+
TOutput,
|
|
71
|
+
TIdent extends string,
|
|
72
|
+
AllOutputTypes extends Record<string, any>,
|
|
73
|
+
SourceType
|
|
74
|
+
> {
|
|
75
|
+
/**
|
|
76
|
+
* Provide the input builder for this task.
|
|
77
|
+
* The builder receives the source and outputs from previous tasks.
|
|
78
|
+
*
|
|
79
|
+
* Return `null` to skip this task - its output will be `null` in the outputs map.
|
|
80
|
+
*/
|
|
81
|
+
input(builder: (source: SourceType, outputs: AllOutputTypes) => TInput | null): Playlist<AllOutputTypes & { [K in TIdent] : Railroad<TOutput> | null }, SourceType>;
|
|
82
|
+
}
|
|
87
83
|
/**
|
|
88
84
|
* An ordered sequence of Tasks executed with strong type inference.
|
|
89
85
|
*
|
|
@@ -108,38 +104,44 @@ declare class Playlist<
|
|
|
108
104
|
/**
|
|
109
105
|
* Internal list of task + builder pairs in the order they will run.
|
|
110
106
|
*/
|
|
111
|
-
bundles: TaskBundle
|
|
107
|
+
bundles: TaskBundle[];
|
|
112
108
|
/**
|
|
113
109
|
* Optional finalizer invoked after all tasks complete (successfully or not).
|
|
114
110
|
*/
|
|
115
111
|
finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>;
|
|
116
|
-
constructor(bundles?: TaskBundle
|
|
112
|
+
constructor(bundles?: TaskBundle[], finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>);
|
|
117
113
|
/**
|
|
118
114
|
* Append a task to the end of the playlist.
|
|
115
|
+
*
|
|
116
|
+
* Returns an object with an `input` method that accepts a builder function.
|
|
117
|
+
* The builder receives the source and all previous task outputs, and must
|
|
118
|
+
* return the input shape required by the task.
|
|
119
119
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
120
|
+
* @example
|
|
121
|
+
* playlist
|
|
122
|
+
* .addTask(new MyTask("myTask"))
|
|
123
|
+
* .input((source, outputs) => ({ value: source.startValue }))
|
|
124
|
+
* .addTask(new AnotherTask("another"))
|
|
125
|
+
* .input((source, outputs) => ({
|
|
126
|
+
* prev: outputs.myTask.success ? outputs.myTask.data : null
|
|
127
|
+
* }))
|
|
123
128
|
*
|
|
124
|
-
* @template
|
|
125
|
-
* @template
|
|
126
|
-
* @template
|
|
127
|
-
* @param task - The task instance to
|
|
128
|
-
* @
|
|
129
|
-
* @returns A new Playlist with the output map extended to include this task's result.
|
|
129
|
+
* @template TInput - Input type required by the task.
|
|
130
|
+
* @template TOutput - Output type produced by the task.
|
|
131
|
+
* @template TIdent - The task's identifier (string literal).
|
|
132
|
+
* @param task - The task instance to add.
|
|
133
|
+
* @returns An object with an `input` method for providing the builder.
|
|
130
134
|
*/
|
|
131
135
|
addTask<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
>(task: Task<
|
|
136
|
-
ident: IdentType
|
|
137
|
-
}, builder: (source: SourceType, outputs: AllOutputTypes) => NoInfer<TaskInputType>): Playlist<AllOutputTypes & { [K in IdentType] : Railroad<TaskOutputType> }, SourceType>;
|
|
136
|
+
TInput,
|
|
137
|
+
TOutput,
|
|
138
|
+
const TIdent extends string
|
|
139
|
+
>(task: Task<TInput, TOutput, TIdent>): TaskInputRequired<TInput, TOutput, TIdent, AllOutputTypes, SourceType>;
|
|
138
140
|
/**
|
|
139
141
|
* Register a callback to run after the playlist finishes. Use this hook to
|
|
140
142
|
* react to the last task or to adjust machine state before a transition.
|
|
141
143
|
*
|
|
142
|
-
* Note: The callback receives the strongly-typed `outputs`
|
|
144
|
+
* Note: The callback receives the strongly-typed `outputs` and `source` objects.
|
|
143
145
|
*
|
|
144
146
|
* @param finalizer - Callback executed once after all tasks complete.
|
|
145
147
|
* @returns This playlist for chaining.
|
|
@@ -148,6 +150,7 @@ declare class Playlist<
|
|
|
148
150
|
/**
|
|
149
151
|
* Execute all tasks in order, building each task's input via its builder
|
|
150
152
|
* and storing each result under the task's ident in the outputs map.
|
|
153
|
+
* If a builder returns `null`, the task is skipped and its output is `null`.
|
|
151
154
|
* If a task's `validateInput` returns false, execution stops with an error.
|
|
152
155
|
*
|
|
153
156
|
* @param source - The source object for this run (e.g., trigger event or machine state).
|
|
@@ -225,17 +228,11 @@ declare abstract class Trigger<
|
|
|
225
228
|
* See README Code Examples for building a full workflow.
|
|
226
229
|
*
|
|
227
230
|
* @template AllTriggerEvents - Union of all trigger event shapes in this workflow.
|
|
228
|
-
* @template TAllOutputs - Aggregated outputs map shape produced by the playlist.
|
|
229
|
-
* @template TPlaylist - Concrete playlist type or `null` before configuration.
|
|
230
231
|
*/
|
|
231
|
-
declare class Workflow<
|
|
232
|
-
|
|
233
|
-
TAllOutputs extends Record<string, any>,
|
|
234
|
-
TPlaylist extends Playlist<TAllOutputs, AllTriggerEvents> | null
|
|
235
|
-
> {
|
|
236
|
-
playlist: TPlaylist;
|
|
232
|
+
declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
|
|
233
|
+
playlist: Playlist<any, AllTriggerEvents> | null;
|
|
237
234
|
triggers: Trigger<string, any>[];
|
|
238
|
-
constructor(triggers: Trigger<string, any>[], playlist:
|
|
235
|
+
constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null);
|
|
239
236
|
/**
|
|
240
237
|
* Register a new trigger to feed events into the workflow.
|
|
241
238
|
* The resulting workflow type widens its `AllTriggerEvents` union accordingly.
|
|
@@ -248,23 +245,15 @@ declare class Workflow<
|
|
|
248
245
|
addTrigger<
|
|
249
246
|
const TIdent extends string,
|
|
250
247
|
TData
|
|
251
|
-
>(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData
|
|
248
|
+
>(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData>>;
|
|
252
249
|
/**
|
|
253
250
|
* Configure the playlist by providing a builder that starts from an empty
|
|
254
251
|
* `Playlist<{}, AllTriggerEvents>` and returns your fully configured playlist.
|
|
255
252
|
*
|
|
256
|
-
* This method ensures type inference flows from your tasks and idents into
|
|
257
|
-
* the resulting `TBuilderOutputs` map used by the workflow's callback.
|
|
258
|
-
*
|
|
259
|
-
* @template TBuilderOutputs - Aggregated outputs map (deduced from your tasks).
|
|
260
|
-
* @template TFinalPlaylist - Concrete playlist type returned by the builder.
|
|
261
253
|
* @param builder - Receives an empty playlist and must return a configured one.
|
|
262
|
-
* @returns A new Workflow with the
|
|
254
|
+
* @returns A new Workflow with the configured playlist.
|
|
263
255
|
*/
|
|
264
|
-
setPlaylist<
|
|
265
|
-
TBuilderOutputs extends Record<string, any>,
|
|
266
|
-
TFinalPlaylist extends Playlist<TBuilderOutputs, AllTriggerEvents>
|
|
267
|
-
>(builder: (p: Playlist<{}, AllTriggerEvents>) => TFinalPlaylist): Workflow<AllTriggerEvents, TBuilderOutputs, TFinalPlaylist>;
|
|
256
|
+
setPlaylist(builder: (p: Playlist<{}, AllTriggerEvents>) => Playlist<any, AllTriggerEvents>): Workflow<AllTriggerEvents>;
|
|
268
257
|
/**
|
|
269
258
|
* Begin polling triggers and run the playlist whenever an event is available.
|
|
270
259
|
* The loop uses `setTimeout` with the given `interval` and returns immediately.
|
|
@@ -275,12 +264,12 @@ declare class Workflow<
|
|
|
275
264
|
*/
|
|
276
265
|
start({ interval, callback }?: {
|
|
277
266
|
interval?: number
|
|
278
|
-
callback?: (source: AllTriggerEvents, outputs:
|
|
267
|
+
callback?: (source: AllTriggerEvents, outputs: Record<string, any>) => any
|
|
279
268
|
}): Promise<void>;
|
|
280
269
|
/**
|
|
281
270
|
* Create a new, empty workflow. Add triggers and set a playlist before starting.
|
|
282
271
|
*/
|
|
283
|
-
static create(): Workflow<never
|
|
272
|
+
static create(): Workflow<never>;
|
|
284
273
|
}
|
|
285
274
|
type Logger = {
|
|
286
275
|
info: (...args: any[]) => void
|
|
@@ -311,11 +300,17 @@ type Transition<TStateData> = {
|
|
|
311
300
|
* contains weighted conditional transitions to other nodes.
|
|
312
301
|
*
|
|
313
302
|
* @template TStateData - The shape of the external mutable state carried through the machine.
|
|
303
|
+
* @template TIdent - This node's identifier (string literal).
|
|
304
|
+
* @template AllStateIdents - Union of all valid state idents for transition targets.
|
|
314
305
|
*/
|
|
315
|
-
declare class StateNode<
|
|
306
|
+
declare class StateNode<
|
|
307
|
+
TStateData,
|
|
308
|
+
TIdent extends string = string,
|
|
309
|
+
AllStateIdents extends string = string
|
|
310
|
+
> {
|
|
316
311
|
transitions?: Transition<TStateData>[];
|
|
317
312
|
playlist: Playlist<any, TStateData>;
|
|
318
|
-
ident:
|
|
313
|
+
ident: TIdent;
|
|
319
314
|
tempTransitions?: {
|
|
320
315
|
to: string
|
|
321
316
|
condition: (stateData: TStateData) => Promise<boolean>
|
|
@@ -327,32 +322,23 @@ declare class StateNode<TStateData> {
|
|
|
327
322
|
/**
|
|
328
323
|
* Create a `StateNode`.
|
|
329
324
|
*
|
|
330
|
-
* @param
|
|
331
|
-
* @param playlist - The playlist to run when the node is entered.
|
|
332
|
-
*/
|
|
333
|
-
constructor(transitions: Transition<TStateData>[], playlist: Playlist<any, TStateData>);
|
|
334
|
-
/**
|
|
335
|
-
* Convenience factory for a new `StateNode` with no transitions and an empty playlist.
|
|
336
|
-
*
|
|
337
|
-
* @template TStateData
|
|
338
|
-
* @returns A new, unconfigured `StateNode`.
|
|
325
|
+
* @param ident - The unique identifier for this node.
|
|
339
326
|
*/
|
|
340
|
-
|
|
327
|
+
constructor(ident: TIdent);
|
|
341
328
|
/**
|
|
342
329
|
* Queue a transition to be resolved later during machine finalization.
|
|
343
|
-
*
|
|
344
|
-
* resolved to a node instance by the machine.
|
|
330
|
+
* The `to` parameter is constrained to known state idents for autocomplete.
|
|
345
331
|
*
|
|
346
|
-
* @param to - Target state `ident
|
|
332
|
+
* @param to - Target state `ident` (autocompleted from known states).
|
|
347
333
|
* @param condition - Async predicate that decides if the transition should fire.
|
|
348
334
|
* @param weight - Higher weight wins when multiple conditions are true; ties keep insertion order.
|
|
349
335
|
* @returns This node for chaining.
|
|
350
336
|
*/
|
|
351
337
|
addTransition({ to, condition, weight }: {
|
|
352
|
-
to:
|
|
338
|
+
to: AllStateIdents
|
|
353
339
|
condition: (stateData: TStateData) => Promise<boolean>
|
|
354
340
|
weight: number
|
|
355
|
-
}): StateNode<TStateData>;
|
|
341
|
+
}): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
356
342
|
/**
|
|
357
343
|
* Set or build the playlist that runs when entering this node.
|
|
358
344
|
*
|
|
@@ -362,24 +348,21 @@ declare class StateNode<TStateData> {
|
|
|
362
348
|
* @param arg - Either a `Playlist` instance or a builder function that returns one.
|
|
363
349
|
* @returns This node for chaining.
|
|
364
350
|
*/
|
|
365
|
-
setPlaylist(playlist: Playlist<any, TStateData>): StateNode<TStateData>;
|
|
366
|
-
setPlaylist<
|
|
367
|
-
TBuilderOutputs extends Record<string, any>,
|
|
368
|
-
TFinalPlaylist extends Playlist<TBuilderOutputs, TStateData>
|
|
369
|
-
>(builder: (p: Playlist<{}, TStateData>) => TFinalPlaylist): StateNode<TStateData>;
|
|
351
|
+
setPlaylist(playlist: Playlist<any, TStateData>): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
352
|
+
setPlaylist(builder: (p: Playlist<{}, TStateData>) => Playlist<any, TStateData>): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
370
353
|
/**
|
|
371
354
|
* Disable retry behavior for this node during `Machine.run` when no transition is available.
|
|
372
355
|
*
|
|
373
356
|
* @returns This node for chaining.
|
|
374
357
|
*/
|
|
375
|
-
preventRetry(): StateNode<TStateData>;
|
|
358
|
+
preventRetry(): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
376
359
|
/**
|
|
377
360
|
* Set the delay between retry attempts for this node during `Machine.run`.
|
|
378
361
|
*
|
|
379
362
|
* @param delayMs - Delay in milliseconds between retries.
|
|
380
363
|
* @returns This node for chaining.
|
|
381
364
|
*/
|
|
382
|
-
retryDelayMs(delayMs: number): StateNode<TStateData>;
|
|
365
|
+
retryDelayMs(delayMs: number): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
383
366
|
/**
|
|
384
367
|
* Set the maximum number of retries for this node during `Machine.run`.
|
|
385
368
|
* Use `preventRetry()` to disable retries entirely.
|
|
@@ -387,14 +370,7 @@ declare class StateNode<TStateData> {
|
|
|
387
370
|
* @param maxRetries - Maximum number of retry attempts before giving up.
|
|
388
371
|
* @returns This node for chaining.
|
|
389
372
|
*/
|
|
390
|
-
retryLimit(maxRetries: number): StateNode<TStateData>;
|
|
391
|
-
/**
|
|
392
|
-
* Assign a unique identifier for this node. Required for transition resolution.
|
|
393
|
-
*
|
|
394
|
-
* @param ident - Unique node identifier.
|
|
395
|
-
* @returns This node for chaining.
|
|
396
|
-
*/
|
|
397
|
-
setIdent(ident: string): StateNode<TStateData>;
|
|
373
|
+
retryLimit(maxRetries: number): StateNode<TStateData, TIdent, AllStateIdents>;
|
|
398
374
|
/**
|
|
399
375
|
* Depth-first search for a node by `ident` within the reachable subgraph from this node.
|
|
400
376
|
*
|
|
@@ -413,12 +389,38 @@ declare class StateNode<TStateData> {
|
|
|
413
389
|
next(data: TStateData): Promise<StateNode<TStateData> | null>;
|
|
414
390
|
}
|
|
415
391
|
/**
|
|
392
|
+
* Returned by `Machine.create()` - you must call `.withStates<...>()` to declare state idents.
|
|
393
|
+
*
|
|
394
|
+
* This ensures all state identifiers are known upfront for full transition autocomplete.
|
|
395
|
+
*/
|
|
396
|
+
interface MachineNeedsStates<TStateData> {
|
|
397
|
+
/**
|
|
398
|
+
* Declare all state identifiers that will be used in this machine.
|
|
399
|
+
* This enables full autocomplete for transition targets.
|
|
400
|
+
*
|
|
401
|
+
* @template TIdents - Union of all state idents (e.g., `"idle" | "running" | "complete"`).
|
|
402
|
+
* @returns The machine, ready for adding states.
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* Machine.create<MyState>()
|
|
406
|
+
* .withStates<"idle" | "running" | "complete">()
|
|
407
|
+
* .addState("idle", node => node
|
|
408
|
+
* .addTransition({ to: "running", ... }) // Autocomplete works!
|
|
409
|
+
* )
|
|
410
|
+
*/
|
|
411
|
+
withStates<TIdents extends string>(): Machine<TStateData, TIdents>;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
416
414
|
* A finite state machine that coordinates execution of `StateNode` playlists
|
|
417
415
|
* and transitions between them based on async conditions.
|
|
418
416
|
*
|
|
419
417
|
* @template TStateData - The shape of the external mutable state carried through the machine.
|
|
418
|
+
* @template AllStateIdents - Union of all declared state identifiers.
|
|
420
419
|
*/
|
|
421
|
-
declare class Machine<
|
|
420
|
+
declare class Machine<
|
|
421
|
+
TStateData,
|
|
422
|
+
AllStateIdents extends string = never
|
|
423
|
+
> {
|
|
422
424
|
initialState: StateNode<TStateData> | null;
|
|
423
425
|
statesToCreate: StateNode<TStateData>[];
|
|
424
426
|
private currentState;
|
|
@@ -442,12 +444,25 @@ declare class Machine<TStateData> {
|
|
|
442
444
|
*/
|
|
443
445
|
private sleep;
|
|
444
446
|
/**
|
|
445
|
-
*
|
|
447
|
+
* Create a new Machine. You must call `.withStates<...>()` next to declare
|
|
448
|
+
* all state identifiers before adding states.
|
|
446
449
|
*
|
|
447
|
-
* @template TStateData
|
|
448
|
-
* @returns A
|
|
450
|
+
* @template TStateData - The shape of the mutable state carried through the machine.
|
|
451
|
+
* @returns A machine builder that requires `.withStates()` to be called.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* Machine.create<MyState>()
|
|
455
|
+
* .withStates<"idle" | "running">()
|
|
456
|
+
* .addState("idle", node => ...)
|
|
457
|
+
*/
|
|
458
|
+
static create<TStateData>(): MachineNeedsStates<TStateData>;
|
|
459
|
+
/**
|
|
460
|
+
* Declare all state identifiers for this machine.
|
|
461
|
+
* Called automatically via the `MachineNeedsStates` interface.
|
|
462
|
+
*
|
|
463
|
+
* @internal
|
|
449
464
|
*/
|
|
450
|
-
|
|
465
|
+
withStates<TIdents extends string>(): Machine<TStateData, TIdents>;
|
|
451
466
|
/**
|
|
452
467
|
* Finalize the machine by resolving state transitions and locking configuration.
|
|
453
468
|
* Must be called before `start` or `run`.
|
|
@@ -462,16 +477,30 @@ declare class Machine<TStateData> {
|
|
|
462
477
|
ident?: string
|
|
463
478
|
}): Machine<TStateData>;
|
|
464
479
|
/**
|
|
465
|
-
* Add a state to the machine.
|
|
480
|
+
* Add a state to the machine using a builder pattern.
|
|
481
|
+
*
|
|
482
|
+
* The builder receives a StateNode with the ident already set, and with
|
|
483
|
+
* transition targets constrained to all known state idents (for autocomplete).
|
|
466
484
|
*
|
|
467
|
-
* @param
|
|
485
|
+
* @param ident - Unique identifier for this state (use a string literal).
|
|
486
|
+
* @param builder - Function that configures the state node.
|
|
468
487
|
* @param options - Options controlling how the state is added.
|
|
469
488
|
* @param options.initial - If true, marks this state as the initial state.
|
|
470
|
-
* @returns This machine for chaining.
|
|
471
|
-
|
|
472
|
-
|
|
489
|
+
* @returns This machine for chaining (with the new ident added to known states).
|
|
490
|
+
*
|
|
491
|
+
* @example
|
|
492
|
+
* Machine.create<MyState>()
|
|
493
|
+
* .addState("idle", node => node
|
|
494
|
+
* .setPlaylist(p => p.addTask(...).input(...))
|
|
495
|
+
* .addTransition({ to: "running", condition: async (s) => s.ready, weight: 1 })
|
|
496
|
+
* , { initial: true })
|
|
497
|
+
* .addState("running", node => node
|
|
498
|
+
* .addTransition({ to: "idle", condition: async () => true, weight: 1 })
|
|
499
|
+
* )
|
|
500
|
+
*/
|
|
501
|
+
addState<const TIdent extends string>(ident: TIdent, builder: (node: StateNode<TStateData, TIdent, AllStateIdents | TIdent>) => StateNode<TStateData, TIdent, AllStateIdents | TIdent>, options?: {
|
|
473
502
|
initial?: boolean
|
|
474
|
-
}): Machine<TStateData>;
|
|
503
|
+
}): Machine<TStateData, AllStateIdents | TIdent>;
|
|
475
504
|
/**
|
|
476
505
|
* Attach a logger to this machine. If the machine has an initial state set,
|
|
477
506
|
* the logger will be propagated to all currently reachable states.
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,14 @@ class Playlist {
|
|
|
6
6
|
this.bundles = bundles;
|
|
7
7
|
this.finalizer = finalizer;
|
|
8
8
|
}
|
|
9
|
-
addTask(task
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
addTask(task) {
|
|
10
|
+
return {
|
|
11
|
+
input: (builder) => {
|
|
12
|
+
const bundle = { task, builder };
|
|
13
|
+
const newBundles = [...this.bundles, bundle];
|
|
14
|
+
return new Playlist(newBundles, this.finalizer);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
13
17
|
}
|
|
14
18
|
finally(finalizer) {
|
|
15
19
|
this.finalizer = finalizer;
|
|
@@ -19,6 +23,10 @@ class Playlist {
|
|
|
19
23
|
const outputs = {};
|
|
20
24
|
for (const bundle of this.bundles) {
|
|
21
25
|
const input = bundle.builder(source, outputs);
|
|
26
|
+
if (input === null) {
|
|
27
|
+
outputs[bundle.task.ident] = null;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
22
30
|
const isValid = await bundle.task.validateInput(input);
|
|
23
31
|
if (!isValid) {
|
|
24
32
|
throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
|
|
@@ -136,16 +144,13 @@ class StateNode {
|
|
|
136
144
|
retry;
|
|
137
145
|
maxRetries;
|
|
138
146
|
logger;
|
|
139
|
-
constructor(
|
|
140
|
-
this.transitions =
|
|
141
|
-
this.playlist =
|
|
142
|
-
this.ident =
|
|
147
|
+
constructor(ident) {
|
|
148
|
+
this.transitions = [];
|
|
149
|
+
this.playlist = new Playlist;
|
|
150
|
+
this.ident = ident;
|
|
143
151
|
this.retry = 1000;
|
|
144
152
|
this.maxRetries = false;
|
|
145
153
|
}
|
|
146
|
-
static create() {
|
|
147
|
-
return new StateNode([], new Playlist);
|
|
148
|
-
}
|
|
149
154
|
addTransition({ to, condition, weight }) {
|
|
150
155
|
if (!this.tempTransitions) {
|
|
151
156
|
this.tempTransitions = [];
|
|
@@ -175,10 +180,6 @@ class StateNode {
|
|
|
175
180
|
this.maxRetries = maxRetries;
|
|
176
181
|
return this;
|
|
177
182
|
}
|
|
178
|
-
setIdent(ident) {
|
|
179
|
-
this.ident = ident;
|
|
180
|
-
return this;
|
|
181
|
-
}
|
|
182
183
|
getByIdent(ident, visited = []) {
|
|
183
184
|
if (this.ident === ident) {
|
|
184
185
|
return this;
|
|
@@ -253,6 +254,9 @@ class Machine {
|
|
|
253
254
|
static create() {
|
|
254
255
|
return new Machine;
|
|
255
256
|
}
|
|
257
|
+
withStates() {
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
256
260
|
finalize({
|
|
257
261
|
ident
|
|
258
262
|
} = {}) {
|
|
@@ -301,14 +305,16 @@ class Machine {
|
|
|
301
305
|
logger?.info({ phase: "end" }, `Machine ${this.ident} finalized.`);
|
|
302
306
|
return this;
|
|
303
307
|
}
|
|
304
|
-
addState(
|
|
308
|
+
addState(ident, builder, options = {}) {
|
|
305
309
|
const logger = this.logger?.child?.({ path: "machine.addState", instance: this.ident }) ?? this.logger;
|
|
306
|
-
logger?.info({ phase: "start", state:
|
|
307
|
-
|
|
310
|
+
logger?.info({ phase: "start", state: ident, isInitial: !!options.initial }, "Adding state");
|
|
311
|
+
const node = new StateNode(ident);
|
|
312
|
+
const configuredNode = builder(node);
|
|
313
|
+
this.statesToCreate.push(configuredNode);
|
|
308
314
|
if (options.initial) {
|
|
309
|
-
this.initialState =
|
|
315
|
+
this.initialState = configuredNode;
|
|
310
316
|
}
|
|
311
|
-
logger?.info({ phase: "end", state:
|
|
317
|
+
logger?.info({ phase: "end", state: ident }, "State added");
|
|
312
318
|
return this;
|
|
313
319
|
}
|
|
314
320
|
addLogger(logger) {
|
|
@@ -391,7 +397,7 @@ class Machine {
|
|
|
391
397
|
}
|
|
392
398
|
const resolvedNext = next;
|
|
393
399
|
if (resolvedNext === this.initialState) {
|
|
394
|
-
if (options.mode
|
|
400
|
+
if (options.mode === "roundtrip" || options.mode === "any") {
|
|
395
401
|
logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
|
|
396
402
|
return stateData;
|
|
397
403
|
}
|
package/package.json
CHANGED