@fkws/klonk 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
  ![Code](./.github/assets/blurry.png)
@@ -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 way to handle success and error states without throwing exceptions. You can also use it for the rest of your application.
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
- A `Playlist` is a sequence of `Tasks` that are executed in order. The magic of a `Playlist` is that each task has access to the outputs of all the tasks that ran before it, in a fully type-safe way. You build a `Playlist` by chaining `.addTask()` calls.
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
- A `Machine` is a finite state machine. It's made up of `StateNode`s. Each `StateNode` represents a state and has two key components:
61
- 1. A `Playlist` that runs when the machine enters that state.
62
- 2. A set of conditional `Transitions` to other states.
63
- 3. Retry rules for when a transition fails to resolve.
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, // These type parameters are part of the secret sauce typing system Klonk uses.
110
- TABasicTextInferenceOutput, // Input Type, Output Type, Ident Type
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, // Railroad is a simple result type
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. The next Task's input builder will react to this.
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 for the workflow to poll.
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 how the `builder` function for each task (`(source, outputs) => { ... }`) has access to the initial `source` data (from the trigger) and the `outputs` of all previous tasks. Klonk automatically infers the types for `source` and `outputs`!
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 a lot of tasks, integrations and a trigger.
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().addTrigger(
221
- // A workflow is initiated by one or more triggers.
222
- new TRDropboxFileAdded("dropbox-trigger", {
223
- client: dropboxProvider,
224
- folderPath: process.env.DROPBOX_INVOICES_FOLDER_PATH ?? "",
225
- })
226
- ).setPlaylist(p => p // Builder function allows complex types to be assembled!
227
- .addTask( // .addTask() adds a task to the playlist.
228
- new TANotionGetTitlesAndIdsForDatabase("get-payees", notionProvider),
229
- // The second argument to addTask builds the input for that task.
230
- // `source` is the data from the trigger, `outputs` contains all previous task outputs.
231
- (source, outputs) => {
232
- return { database_id: process.env.NOTION_PAYEES_DATABASE_ID!}
233
- }
234
- ).addTask(
235
- new TANotionGetTitlesAndIdsForDatabase("get-expense-types", notionProvider),
236
- (source, outputs) => { // Type inference works for source and outputs!
237
- return { database_id: process.env.NOTION_EXPENSE_TYPES_DATABASE_ID!}
238
- }
239
- ).addTask(
240
- new TADropboxDownloadFile("download-invoice-pdf", dropboxProvider),
241
- (source, outputs) => {
242
- // The `source` object contains the trigger ident, so you can handle multiple triggers.
243
- if (source.triggerIdent == "dropbox-trigger") {
244
- return { file_metadata: source.data}
245
- } else {
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
- ).addTask(
250
- new TAParsePdfAi("parse-invoice", openrouterProvider),
251
- (source, outputs) => {
252
- // Access the outputs of previous tasks via the `outputs` object.
253
- // The keys are the idents you provided to the tasks.
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.success) {
256
- throw downloadResult.error ?? new Error('Failed to download invoice PDF');
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.success) {
261
- throw payeesResult.error ?? new Error('Failed to load payees');
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.success) {
266
- throw expenseTypesResult.error ?? new Error('Failed to load expense types');
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(payees.map(p => p.id) as [string, ...string[]])
277
- .describe("The payee id of the invoice according to this map: " + JSON.stringify(payees, null, 2)),
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 of the invoice."),
334
+ .describe("The total amount"),
280
335
  invoice_date: z.string()
281
336
  .regex(/^\d{4}-\d{2}-\d{2}$/)
282
- .describe("The date of the invoice as an ISO 8601 string (YYYY-MM-DD)."),
283
- expense_type: z.enum(expenseTypes.map(e => e.id) as [string, ...string[]])
284
- .describe("The expense type id of the invoice according to this map: " + JSON.stringify(expenseTypes, null, 2))
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
- ).addTask(
289
- new TACreateNotionDatabaseItem("create-notion-invoice", notionProvider),
290
- (source, outputs) => {
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.success) {
293
- throw invoiceResult.error ?? new Error('Failed to parse invoice');
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: 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 a simple AI agent that takes a user's query, refines it, performs a web search, and then generates a final response.
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 can use it to decide which state to move to next.
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, StateNode } from "@fkws/klonk"
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 | undefined;
401
+ raw_content?: string;
353
402
  score: string;
354
403
  }[];
355
404
  query: string;
356
- answer?: string | undefined;
357
- images?: string[] | undefined;
358
- follow_up_questions?: string[] | undefined;
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
- .addState(StateNode
369
- .create<StateData>()
370
- .setIdent("refine_and_extract")
371
- .setPlaylist(p => p // Builder function allows complex types to be assembled!
372
- .addTask(new TABasicTextInference("refine", client),
373
- (state, outputs) => { // This function constructs the INPUT of the task from the state and outputs of previous tasks
374
- const input = state.input;
375
- const model = state.model ? state.model : "openai/gpt-5"
376
- const instructions = `You are a prompt refiner. Any prompts you receive, you will refine to improve LLM performance. Break down the prompt by Intent, Mood, and Instructions. Do NOT reply or answer the user's message! ONLY refine the prompt.`;
377
- return {
378
- inputText: input,
379
- model: model,
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
- if (outputs.extract_search_terms.success) {
402
- state.searchTerm = outputs.extract_search_terms.data.text
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
- .retryLimit(3) // Simple retry rule setters. Also includes .preventRetry() to disable retries entirely and .retryDelayMs(delayMs) to set the delay between retries. Default is infinite retries at 1000ms delay.
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", // Transitions refer to states by their ident.
408
- condition: async (stateData: StateData) => stateData.searchTerm ? true : false,
409
- weight: 2 // Weight determines the order in which transitions are tried. Higher weight = higher priority.
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 (stateData: StateData) => true,
455
+ to: "generate_response", // Autocomplete works!
456
+ condition: async () => true, // Fallback
414
457
  weight: 1
415
- }),
416
- { initial: true } // The machine needs an initial state.
417
- )
418
- .addState(StateNode.create<StateData>()
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
- return {
424
- query: state.searchTerm! // We are sure that the searchTerm is not undefined because of the transition condition.
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.success) {
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 (stateData: StateData) => true,
475
+ condition: async () => true,
435
476
  weight: 1
436
477
  })
437
478
  )
438
- .addState(StateNode.create<StateData>()
439
- .setIdent("generate_response")
479
+
480
+ .addState("generate_response", node => node
440
481
  .setPlaylist(p => p
441
- .addTask(new TABasicTextInference("generate_response", client),
442
- (state, outputs) => {
443
- return {
444
- inputText: state.input,
445
- model: state.model ? state.model : "openai/gpt-5",
446
- instructions: "You will receive a user request and a refined prompt. There may also be search results. Based on the information, please write a professional response to the user's request."
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
- if(outputs.generate_response.success) {
451
- state.finalResponse = outputs.generate_response.data.text
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
- .addLogger(pino()) // If you add a logger to your machine,
459
- // it will call its info(), error(), debug(), fatal(), warn(), and trace() methods. Pino is recommended.
460
- .finalize({ // Finalize your machine to make it ready to run. If you don't provide an ident, a uuidv4 will be generated for it.
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 = { // The state object is mutable and is passed to the machine and playlists.
502
+ const state: StateData = {
467
503
  input: "How do I update AMD graphic driver?",
468
- model: "openai/gpt-4o-mini"
469
- }
504
+ model: "openai/gpt-5.2-mini"
505
+ };
470
506
 
471
- // The .run() method executes the machine until it reaches a terminal condition
472
- // based on the selected mode. For example, 'roundtrip' stops when it returns
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) // The final state is returned.
477
- // Or simply:
478
- console.log(state.finalResponse) // original state object is also mutated.
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, builder) {
51
- const bundle = { task, builder };
52
- const newBundles = [...this.bundles, bundle];
53
- return new Playlist(newBundles, this.finalizer);
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(transitions, playlist) {
181
- this.transitions = transitions;
182
- this.playlist = 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(state, options = {}) {
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: state.ident, isInitial: !!options.initial }, "Adding state");
348
- this.statesToCreate.push(state);
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 = state;
356
+ this.initialState = configuredNode;
351
357
  }
352
- logger?.info({ phase: "end", state: state.ident }, "State added");
358
+ logger?.info({ phase: "end", state: ident }, "State added");
353
359
  return this;
354
360
  }
355
361
  addLogger(logger) {
package/dist/index.d.ts CHANGED
@@ -54,40 +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
- SourceType,
73
- AllOutputTypes,
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
- * Important typing note:
83
- *
84
- * We intentionally do NOT wrap the builder return in `NoInfer<...>`.
85
- * Doing so breaks contextual typing for object literals, causing string literal unions
86
- * (e.g. `"low" | "critical"`) to widen to `string` inside input builder callbacks.
87
- *
88
- * By letting `TaskInputType` be inferred from the `task` argument (and then checking the
89
- * builder against it), literal unions are preserved and DX stays sane.
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()`.
90
67
  */
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
+ }
91
83
  /**
92
84
  * An ordered sequence of Tasks executed with strong type inference.
93
85
  *
@@ -112,38 +104,44 @@ declare class Playlist<
112
104
  /**
113
105
  * Internal list of task + builder pairs in the order they will run.
114
106
  */
115
- bundles: TaskBundle<any, any, any, any, string>[];
107
+ bundles: TaskBundle[];
116
108
  /**
117
109
  * Optional finalizer invoked after all tasks complete (successfully or not).
118
110
  */
119
111
  finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>;
120
- constructor(bundles?: TaskBundle<any, any, any, any, string>[], finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>);
112
+ constructor(bundles?: TaskBundle[], finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>);
121
113
  /**
122
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.
123
119
  *
124
- * The task's `ident` is used as a key in the aggregated `outputs` object made
125
- * available to subsequent builders. The value under that key is the task's
126
- * `Railroad<Output>` result, enabling type-safe success/error handling.
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
+ * }))
127
128
  *
128
- * @template TaskInputType - Input required by the task.
129
- * @template TaskOutputType - Output produced by the task.
130
- * @template IdentType - The task's ident (string literal recommended).
131
- * @param task - The task instance to run at this step.
132
- * @param builder - Function that builds the task input from `source` and prior `outputs`.
133
- * @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.
134
134
  */
135
135
  addTask<
136
- TaskInputType,
137
- TaskOutputType,
138
- const IdentType extends string
139
- >(task: Task<TaskInputType, TaskOutputType, IdentType> & {
140
- ident: IdentType
141
- }, builder: (source: SourceType, outputs: AllOutputTypes) => 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>;
142
140
  /**
143
141
  * Register a callback to run after the playlist finishes. Use this hook to
144
142
  * react to the last task or to adjust machine state before a transition.
145
143
  *
146
- * Note: The callback receives the strongly-typed `outputs` object.
144
+ * Note: The callback receives the strongly-typed `outputs` and `source` objects.
147
145
  *
148
146
  * @param finalizer - Callback executed once after all tasks complete.
149
147
  * @returns This playlist for chaining.
@@ -152,6 +150,7 @@ declare class Playlist<
152
150
  /**
153
151
  * Execute all tasks in order, building each task's input via its builder
154
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`.
155
154
  * If a task's `validateInput` returns false, execution stops with an error.
156
155
  *
157
156
  * @param source - The source object for this run (e.g., trigger event or machine state).
@@ -229,17 +228,11 @@ declare abstract class Trigger<
229
228
  * See README Code Examples for building a full workflow.
230
229
  *
231
230
  * @template AllTriggerEvents - Union of all trigger event shapes in this workflow.
232
- * @template TAllOutputs - Aggregated outputs map shape produced by the playlist.
233
- * @template TPlaylist - Concrete playlist type or `null` before configuration.
234
231
  */
235
- declare class Workflow<
236
- AllTriggerEvents extends TriggerEvent<string, any>,
237
- TAllOutputs extends Record<string, any>,
238
- TPlaylist extends Playlist<TAllOutputs, AllTriggerEvents> | null
239
- > {
240
- playlist: TPlaylist;
232
+ declare class Workflow<AllTriggerEvents extends TriggerEvent<string, any>> {
233
+ playlist: Playlist<any, AllTriggerEvents> | null;
241
234
  triggers: Trigger<string, any>[];
242
- constructor(triggers: Trigger<string, any>[], playlist: TPlaylist);
235
+ constructor(triggers: Trigger<string, any>[], playlist: Playlist<any, AllTriggerEvents> | null);
243
236
  /**
244
237
  * Register a new trigger to feed events into the workflow.
245
238
  * The resulting workflow type widens its `AllTriggerEvents` union accordingly.
@@ -252,23 +245,15 @@ declare class Workflow<
252
245
  addTrigger<
253
246
  const TIdent extends string,
254
247
  TData
255
- >(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData>, TAllOutputs, Playlist<TAllOutputs, AllTriggerEvents | TriggerEvent<TIdent, TData>> | null>;
248
+ >(trigger: Trigger<TIdent, TData>): Workflow<AllTriggerEvents | TriggerEvent<TIdent, TData>>;
256
249
  /**
257
250
  * Configure the playlist by providing a builder that starts from an empty
258
251
  * `Playlist<{}, AllTriggerEvents>` and returns your fully configured playlist.
259
252
  *
260
- * This method ensures type inference flows from your tasks and idents into
261
- * the resulting `TBuilderOutputs` map used by the workflow's callback.
262
- *
263
- * @template TBuilderOutputs - Aggregated outputs map (deduced from your tasks).
264
- * @template TFinalPlaylist - Concrete playlist type returned by the builder.
265
253
  * @param builder - Receives an empty playlist and must return a configured one.
266
- * @returns A new Workflow with the concrete playlist and output types.
254
+ * @returns A new Workflow with the configured playlist.
267
255
  */
268
- setPlaylist<
269
- TBuilderOutputs extends Record<string, any>,
270
- TFinalPlaylist extends Playlist<TBuilderOutputs, AllTriggerEvents>
271
- >(builder: (p: Playlist<{}, AllTriggerEvents>) => TFinalPlaylist): Workflow<AllTriggerEvents, TBuilderOutputs, TFinalPlaylist>;
256
+ setPlaylist(builder: (p: Playlist<{}, AllTriggerEvents>) => Playlist<any, AllTriggerEvents>): Workflow<AllTriggerEvents>;
272
257
  /**
273
258
  * Begin polling triggers and run the playlist whenever an event is available.
274
259
  * The loop uses `setTimeout` with the given `interval` and returns immediately.
@@ -279,12 +264,12 @@ declare class Workflow<
279
264
  */
280
265
  start({ interval, callback }?: {
281
266
  interval?: number
282
- callback?: (source: AllTriggerEvents, outputs: TAllOutputs) => any
267
+ callback?: (source: AllTriggerEvents, outputs: Record<string, any>) => any
283
268
  }): Promise<void>;
284
269
  /**
285
270
  * Create a new, empty workflow. Add triggers and set a playlist before starting.
286
271
  */
287
- static create(): Workflow<never, {}, null>;
272
+ static create(): Workflow<never>;
288
273
  }
289
274
  type Logger = {
290
275
  info: (...args: any[]) => void
@@ -315,11 +300,17 @@ type Transition<TStateData> = {
315
300
  * contains weighted conditional transitions to other nodes.
316
301
  *
317
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.
318
305
  */
319
- declare class StateNode<TStateData> {
306
+ declare class StateNode<
307
+ TStateData,
308
+ TIdent extends string = string,
309
+ AllStateIdents extends string = string
310
+ > {
320
311
  transitions?: Transition<TStateData>[];
321
312
  playlist: Playlist<any, TStateData>;
322
- ident: string;
313
+ ident: TIdent;
323
314
  tempTransitions?: {
324
315
  to: string
325
316
  condition: (stateData: TStateData) => Promise<boolean>
@@ -331,32 +322,23 @@ declare class StateNode<TStateData> {
331
322
  /**
332
323
  * Create a `StateNode`.
333
324
  *
334
- * @param transitions - The resolved transitions from this node.
335
- * @param playlist - The playlist to run when the node is entered.
336
- */
337
- constructor(transitions: Transition<TStateData>[], playlist: Playlist<any, TStateData>);
338
- /**
339
- * Convenience factory for a new `StateNode` with no transitions and an empty playlist.
340
- *
341
- * @template TStateData
342
- * @returns A new, unconfigured `StateNode`.
325
+ * @param ident - The unique identifier for this node.
343
326
  */
344
- static create<TStateData>(): StateNode<TStateData>;
327
+ constructor(ident: TIdent);
345
328
  /**
346
329
  * Queue a transition to be resolved later during machine finalization.
347
- * Use the target node `ident` instead of a direct reference; it will be
348
- * resolved to a node instance by the machine.
330
+ * The `to` parameter is constrained to known state idents for autocomplete.
349
331
  *
350
- * @param to - Target state `ident`.
332
+ * @param to - Target state `ident` (autocompleted from known states).
351
333
  * @param condition - Async predicate that decides if the transition should fire.
352
334
  * @param weight - Higher weight wins when multiple conditions are true; ties keep insertion order.
353
335
  * @returns This node for chaining.
354
336
  */
355
337
  addTransition({ to, condition, weight }: {
356
- to: string
338
+ to: AllStateIdents
357
339
  condition: (stateData: TStateData) => Promise<boolean>
358
340
  weight: number
359
- }): StateNode<TStateData>;
341
+ }): StateNode<TStateData, TIdent, AllStateIdents>;
360
342
  /**
361
343
  * Set or build the playlist that runs when entering this node.
362
344
  *
@@ -366,24 +348,21 @@ declare class StateNode<TStateData> {
366
348
  * @param arg - Either a `Playlist` instance or a builder function that returns one.
367
349
  * @returns This node for chaining.
368
350
  */
369
- setPlaylist(playlist: Playlist<any, TStateData>): StateNode<TStateData>;
370
- setPlaylist<
371
- TBuilderOutputs extends Record<string, any>,
372
- TFinalPlaylist extends Playlist<TBuilderOutputs, TStateData>
373
- >(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>;
374
353
  /**
375
354
  * Disable retry behavior for this node during `Machine.run` when no transition is available.
376
355
  *
377
356
  * @returns This node for chaining.
378
357
  */
379
- preventRetry(): StateNode<TStateData>;
358
+ preventRetry(): StateNode<TStateData, TIdent, AllStateIdents>;
380
359
  /**
381
360
  * Set the delay between retry attempts for this node during `Machine.run`.
382
361
  *
383
362
  * @param delayMs - Delay in milliseconds between retries.
384
363
  * @returns This node for chaining.
385
364
  */
386
- retryDelayMs(delayMs: number): StateNode<TStateData>;
365
+ retryDelayMs(delayMs: number): StateNode<TStateData, TIdent, AllStateIdents>;
387
366
  /**
388
367
  * Set the maximum number of retries for this node during `Machine.run`.
389
368
  * Use `preventRetry()` to disable retries entirely.
@@ -391,14 +370,7 @@ declare class StateNode<TStateData> {
391
370
  * @param maxRetries - Maximum number of retry attempts before giving up.
392
371
  * @returns This node for chaining.
393
372
  */
394
- retryLimit(maxRetries: number): StateNode<TStateData>;
395
- /**
396
- * Assign a unique identifier for this node. Required for transition resolution.
397
- *
398
- * @param ident - Unique node identifier.
399
- * @returns This node for chaining.
400
- */
401
- setIdent(ident: string): StateNode<TStateData>;
373
+ retryLimit(maxRetries: number): StateNode<TStateData, TIdent, AllStateIdents>;
402
374
  /**
403
375
  * Depth-first search for a node by `ident` within the reachable subgraph from this node.
404
376
  *
@@ -417,12 +389,38 @@ declare class StateNode<TStateData> {
417
389
  next(data: TStateData): Promise<StateNode<TStateData> | null>;
418
390
  }
419
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
+ /**
420
414
  * A finite state machine that coordinates execution of `StateNode` playlists
421
415
  * and transitions between them based on async conditions.
422
416
  *
423
417
  * @template TStateData - The shape of the external mutable state carried through the machine.
418
+ * @template AllStateIdents - Union of all declared state identifiers.
424
419
  */
425
- declare class Machine<TStateData> {
420
+ declare class Machine<
421
+ TStateData,
422
+ AllStateIdents extends string = never
423
+ > {
426
424
  initialState: StateNode<TStateData> | null;
427
425
  statesToCreate: StateNode<TStateData>[];
428
426
  private currentState;
@@ -446,12 +444,25 @@ declare class Machine<TStateData> {
446
444
  */
447
445
  private sleep;
448
446
  /**
449
- * Convenience factory for a new `Machine`.
447
+ * Create a new Machine. You must call `.withStates<...>()` next to declare
448
+ * all state identifiers before adding states.
450
449
  *
451
- * @template TStateData
452
- * @returns A new unfinalized machine instance.
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
453
464
  */
454
- static create<TStateData>(): Machine<TStateData>;
465
+ withStates<TIdents extends string>(): Machine<TStateData, TIdents>;
455
466
  /**
456
467
  * Finalize the machine by resolving state transitions and locking configuration.
457
468
  * Must be called before `start` or `run`.
@@ -466,16 +477,30 @@ declare class Machine<TStateData> {
466
477
  ident?: string
467
478
  }): Machine<TStateData>;
468
479
  /**
469
- * 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).
470
484
  *
471
- * @param state - The state node to add.
485
+ * @param ident - Unique identifier for this state (use a string literal).
486
+ * @param builder - Function that configures the state node.
472
487
  * @param options - Options controlling how the state is added.
473
488
  * @param options.initial - If true, marks this state as the initial state.
474
- * @returns This machine for chaining.
475
- */
476
- addState(state: StateNode<TStateData>, options?: {
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?: {
477
502
  initial?: boolean
478
- }): Machine<TStateData>;
503
+ }): Machine<TStateData, AllStateIdents | TIdent>;
479
504
  /**
480
505
  * Attach a logger to this machine. If the machine has an initial state set,
481
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, builder) {
10
- const bundle = { task, builder };
11
- const newBundles = [...this.bundles, bundle];
12
- return new Playlist(newBundles, this.finalizer);
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(transitions, playlist) {
140
- this.transitions = transitions;
141
- this.playlist = 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(state, options = {}) {
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: state.ident, isInitial: !!options.initial }, "Adding state");
307
- this.statesToCreate.push(state);
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 = state;
315
+ this.initialState = configuredNode;
310
316
  }
311
- logger?.info({ phase: "end", state: state.ident }, "State added");
317
+ logger?.info({ phase: "end", state: ident }, "State added");
312
318
  return this;
313
319
  }
314
320
  addLogger(logger) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fkws/klonk",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "A lightweight, extensible workflow automation engine for Node.js and Bun",
5
5
  "repository": "https://github.com/klar-web-services/klonk",
6
6
  "homepage": "https://klonk.dev",