@fkws/klonk 0.0.13 → 0.0.16

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
@@ -64,11 +64,21 @@ A `Machine` is a finite state machine. It's made up of `StateNode`s. Each `State
64
64
 
65
65
  The `Machine` carries a mutable `stateData` object that can be read from and written to by playlists and transition conditions throughout its execution.
66
66
 
67
+ #### Machine run modes
68
+ - **any**: Runs until the first terminal condition occurs (leaf state, roundtrip to the initial state, or all reachable states visited).
69
+ - **leaf**: Runs until a leaf state (no transitions) is reached.
70
+ - **roundtrip**: Runs until it transitions back to the initial state.
71
+ - **infinitely**: Continues running indefinitely, sleeping between iterations (`interval` ms, default 1000). Use `stopAfter` to cap total states entered.
72
+
73
+ Notes:
74
+ - `stopAfter` counts states entered, including the initial state. For example, `stopAfter: 1` will run the initial state's playlist once and then stop; `stopAfter: 0` stops before entering the initial state.
75
+ - 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
+
67
77
  ## Features
68
78
  - **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.
69
79
  - **Code-First**: Define your automations directly in TypeScript. No YAML, no drag-and-drop UIs. Just the full power of a real programming language.
70
80
  - **Composable & Extensible**: The core primitives (`Task`, `Trigger`) are simple abstract classes, making it easy to create your own reusable components and integrations.
71
- - **Flexible Execution**: `Machines` can be run synchronously to completion (`run`) for request/response style work, or started as a long-running background process (`start`).
81
+ - **Flexible Execution**: `Machines` run with configurable modes via `run(state, options)`: `any`, `leaf`, `roundtrip`, or `infinitely` (with optional `interval`).
72
82
 
73
83
  ## Klonkworks: Pre-built Components
74
84
  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.
@@ -445,8 +455,9 @@ const webSearchAgent = Machine
445
455
  }
446
456
  })
447
457
  ))
448
- .finalize({ // Finalize your machine to make it ready to run. Verbose machines emit JSON logs. If you don't provide an ident, a uuidv4 will be generated for it.
449
- verbose: true,
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.
450
461
  ident: "web-search-agent"
451
462
  })
452
463
 
@@ -457,10 +468,10 @@ const state: StateData = { // The state object is mutable and is passed to the m
457
468
  model: "openai/gpt-4o-mini"
458
469
  }
459
470
 
460
- // The .run() method executes the machine until it reaches a terminal state
461
- // (leaf, failed, out of retries, looped back to initial state)
462
- // and returns the final state. The original state object is also mutated.
463
- const finalState = await webSearchAgent.run(state)
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' })
464
475
 
465
476
  console.log(finalState.finalResponse) // The final state is returned.
466
477
  // Or simply:
package/dist/index.cjs CHANGED
@@ -1,21 +1,8 @@
1
1
  var import_node_module = require("node:module");
2
- var __create = Object.create;
3
- var __getProtoOf = Object.getPrototypeOf;
4
2
  var __defProp = Object.defineProperty;
5
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __toESM = (mod, isNodeMode, target) => {
9
- target = mod != null ? __create(__getProtoOf(mod)) : {};
10
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
- for (let key of __getOwnPropNames(mod))
12
- if (!__hasOwnProp.call(to, key))
13
- __defProp(to, key, {
14
- get: () => mod[key],
15
- enumerable: true
16
- });
17
- return to;
18
- };
19
6
  var __moduleCache = /* @__PURE__ */ new WeakMap;
20
7
  var __toCommonJS = (from) => {
21
8
  var entry = __moduleCache.get(from), desc;
@@ -54,16 +41,16 @@ module.exports = __toCommonJS(exports_src);
54
41
 
55
42
  // src/prototypes/Playlist.ts
56
43
  class Playlist {
57
- machines;
44
+ bundles;
58
45
  finalizer;
59
- constructor(machines = [], finalizer) {
60
- this.machines = machines;
46
+ constructor(bundles = [], finalizer) {
47
+ this.bundles = bundles;
61
48
  this.finalizer = finalizer;
62
49
  }
63
50
  addTask(task, builder) {
64
- const machine = { task, builder };
65
- const newMachines = [...this.machines, machine];
66
- return new Playlist(newMachines, this.finalizer);
51
+ const bundle = { task, builder };
52
+ const newBundles = [...this.bundles, bundle];
53
+ return new Playlist(newBundles, this.finalizer);
67
54
  }
68
55
  finally(finalizer) {
69
56
  this.finalizer = finalizer;
@@ -71,14 +58,14 @@ class Playlist {
71
58
  }
72
59
  async run(source) {
73
60
  const outputs = {};
74
- for (const machine of this.machines) {
75
- const input = machine.builder(source, outputs);
76
- const isValid = await machine.task.validateInput(input);
61
+ for (const bundle of this.bundles) {
62
+ const input = bundle.builder(source, outputs);
63
+ const isValid = await bundle.task.validateInput(input);
77
64
  if (!isValid) {
78
- throw new Error(`Input validation failed for task '${machine.task.ident}'`);
65
+ throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
79
66
  }
80
- const result = await machine.task.run(input);
81
- outputs[machine.task.ident] = result;
67
+ const result = await bundle.task.run(input);
68
+ outputs[bundle.task.ident] = result;
82
69
  }
83
70
  if (this.finalizer) {
84
71
  await this.finalizer(source, outputs);
@@ -181,22 +168,18 @@ class Trigger {
181
168
  }
182
169
  }
183
170
  // src/prototypes/Machine.ts
184
- var import_pino = __toESM(require("pino"));
185
171
  var import_crypto = require("crypto");
186
- var glogger = null;
187
-
188
172
  class StateNode {
189
173
  transitions;
190
174
  playlist;
191
- timeToNextTick;
192
175
  ident;
193
176
  tempTransitions;
194
177
  retry;
195
178
  maxRetries;
179
+ logger;
196
180
  constructor(transitions, playlist) {
197
181
  this.transitions = transitions;
198
182
  this.playlist = playlist;
199
- this.timeToNextTick = 1000;
200
183
  this.ident = "";
201
184
  this.retry = 1000;
202
185
  this.maxRetries = false;
@@ -255,7 +238,7 @@ class StateNode {
255
238
  return null;
256
239
  }
257
240
  async next(data) {
258
- const logger = glogger?.child({ path: "StateNode.next", state: this.ident });
241
+ const logger = this.logger?.child?.({ path: "StateNode.next", state: this.ident }) ?? this.logger;
259
242
  logger?.info({ phase: "start" }, "Evaluating next state");
260
243
  const sorted = [...this.transitions || []].map((t, i) => ({ t, i })).sort((a, b) => (b.t.weight ?? 0) - (a.t.weight ?? 0) || a.i - b.i);
261
244
  for (const { t } of sorted) {
@@ -280,8 +263,10 @@ class Machine {
280
263
  finalized = false;
281
264
  logger;
282
265
  ident;
266
+ started = false;
267
+ tickTimer = null;
283
268
  getAllStates() {
284
- const logger = glogger?.child({ path: "machine.getAllStates" });
269
+ const logger = this.logger?.child?.({ path: "machine.getAllStates" }) ?? this.logger;
285
270
  logger?.info({ phase: "start" }, "Gathering all states...");
286
271
  if (!this.initialState)
287
272
  return [];
@@ -310,10 +295,9 @@ class Machine {
310
295
  return new Machine;
311
296
  }
312
297
  finalize({
313
- verbose,
314
298
  ident
315
299
  } = {}) {
316
- const logger = glogger?.child({ path: "machine.finalize", instance: this.ident });
300
+ const logger = this.logger?.child?.({ path: "machine.finalize", instance: this.ident }) ?? this.logger;
317
301
  if (!this.initialState || this.statesToCreate.length === 0) {
318
302
  logger?.error({ phase: "error" }, "Finalization failed: no initial state or states to create");
319
303
  throw new Error("Cannot finalize a machine without an initial state or states to create.");
@@ -323,13 +307,11 @@ class Machine {
323
307
  } else {
324
308
  this.ident = import_crypto.randomUUID();
325
309
  }
326
- if (verbose)
327
- glogger = import_pino.default();
328
- logger?.info("Logging enabled.");
329
310
  logger?.info({ phase: "start" }, `Finalizing machine ${this.ident}...`);
330
311
  const registry = new Map;
331
312
  logger?.info({ phase: "progress" }, `Building state registry...`);
332
313
  for (const s of this.statesToCreate) {
314
+ s.logger = this.logger;
333
315
  if (!s.ident) {
334
316
  logger?.error({ phase: "error" }, "Finalization failed: state missing ident");
335
317
  throw new Error("State missing ident.");
@@ -361,7 +343,7 @@ class Machine {
361
343
  return this;
362
344
  }
363
345
  addState(state, options = {}) {
364
- const logger = glogger?.child({ path: "machine.addState", instance: this.ident });
346
+ const logger = this.logger?.child?.({ path: "machine.addState", instance: this.ident }) ?? this.logger;
365
347
  logger?.info({ phase: "start", state: state.ident, isInitial: !!options.initial }, "Adding state");
366
348
  this.statesToCreate.push(state);
367
349
  if (options.initial) {
@@ -370,45 +352,29 @@ class Machine {
370
352
  logger?.info({ phase: "end", state: state.ident }, "State added");
371
353
  return this;
372
354
  }
373
- async start(stateData, options) {
374
- const logger = glogger?.child({ path: "machine.start", instance: this.ident });
375
- logger?.info({ phase: "start" }, "Starting machine...");
376
- if (!this.finalized) {
377
- logger?.error({ phase: "error" }, "Machine not finalized");
378
- throw new Error("Cannot start a machine that is not finalized.");
379
- }
380
- if (!this.initialState) {
381
- logger?.error({ phase: "error" }, "No initial state");
382
- throw new Error("Cannot start a machine without an initial state.");
383
- }
384
- const interval = options?.interval ?? 1000;
385
- logger?.info({ phase: "progress", interval }, "Machine interval set");
386
- if (!this.currentState) {
387
- this.currentState = this.initialState;
388
- logger?.info({ phase: "progress", state: this.currentState.ident }, "Set initial state. Running playlist.");
389
- await this.currentState.playlist.run(stateData);
390
- logger?.info({ phase: "progress", state: this.currentState.ident }, "Initial playlist run complete.");
391
- }
392
- const tick = async () => {
393
- const tickLogger = logger?.child({ path: "machine.tick" });
394
- tickLogger?.info({ phase: "start", state: this.currentState.ident }, "Tick.");
395
- const next = await this.currentState.next(stateData);
396
- if (next) {
397
- tickLogger?.info({ phase: "progress", from: this.currentState.ident, to: next.ident }, "Transitioning state.");
398
- this.currentState = next;
399
- await this.currentState.playlist.run(stateData);
400
- tickLogger?.info({ phase: "progress", state: this.currentState.ident }, "Playlist run complete.");
401
- } else {
402
- tickLogger?.info({ phase: "progress", state: this.currentState.ident }, "No next state.");
355
+ addLogger(logger) {
356
+ this.logger = logger;
357
+ if (this.initialState) {
358
+ const visited = new Set;
359
+ const stack = [this.initialState];
360
+ while (stack.length > 0) {
361
+ const node = stack.pop();
362
+ if (!node.ident || visited.has(node.ident))
363
+ continue;
364
+ visited.add(node.ident);
365
+ node.logger = logger;
366
+ for (const tr of node.transitions || []) {
367
+ if (tr.to && tr.to.ident && !visited.has(tr.to.ident)) {
368
+ stack.push(tr.to);
369
+ }
370
+ }
403
371
  }
404
- setTimeout(tick, interval);
405
- };
406
- tick();
407
- logger?.info({ phase: "end" }, "Machine started, tick loop initiated.");
372
+ }
373
+ return this;
408
374
  }
409
- async run(stateData) {
410
- const logger = glogger?.child({ path: "machine.run", instance: this.ident });
411
- logger?.info({ phase: "start" }, "Running machine...");
375
+ async run(stateData, options) {
376
+ const logger = this.logger?.child?.({ path: "machine.run", instance: this.ident }) ?? this.logger;
377
+ logger?.info({ phase: "start", options }, "Running machine...");
412
378
  if (!this.finalized) {
413
379
  logger?.error({ phase: "error" }, "Machine not finalized");
414
380
  throw new Error("Cannot run a machine that is not finalized.");
@@ -419,27 +385,39 @@ class Machine {
419
385
  }
420
386
  const allStates = this.getAllStates();
421
387
  const visitedIdents = new Set;
388
+ let transitionsCount = 0;
389
+ if (options.stopAfter !== undefined && options.stopAfter === 0) {
390
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
391
+ return stateData;
392
+ }
422
393
  let current = this.initialState;
423
394
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
424
395
  await current.playlist.run(stateData);
396
+ transitionsCount = 1;
425
397
  visitedIdents.add(current.ident);
398
+ if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
399
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
400
+ return stateData;
401
+ }
426
402
  while (true) {
427
403
  if (!current.transitions || current.transitions.length === 0) {
428
- logger?.info({ phase: "end", state: current.ident }, "Reached leaf node, run complete.");
429
- return stateData;
404
+ if (options.mode !== "infinitely") {
405
+ logger?.info({ phase: "end", state: current.ident, reason: "leaf" }, "Stop condition met.");
406
+ return stateData;
407
+ }
430
408
  }
431
409
  let next = await current.next(stateData);
432
410
  if (!next) {
433
411
  const retryDelay = current.retry;
434
- if (!retryDelay) {
435
- logger?.info({ phase: "end", state: current.ident }, "No next state and retries disabled, run complete.");
412
+ if (retryDelay === false) {
413
+ logger?.info({ phase: "end", state: current.ident, reason: "no-transition-no-retry" }, "Stop condition met.");
436
414
  return stateData;
437
415
  }
438
416
  let retries = 0;
439
417
  logger?.info({ phase: "progress", state: current.ident, retryDelay }, "No next state, beginning retry logic.");
440
418
  while (!next) {
441
- if (current.maxRetries && retries >= current.maxRetries) {
442
- logger?.warn({ phase: "end", state: current.ident, retries }, "Retry limit exhausted, run complete.");
419
+ if (current.maxRetries !== false && retries >= current.maxRetries) {
420
+ logger?.warn({ phase: "end", state: current.ident, retries, reason: "retries-exhausted" }, "Stop condition met.");
443
421
  return stateData;
444
422
  }
445
423
  await this.sleep(retryDelay);
@@ -454,17 +432,29 @@ class Machine {
454
432
  }
455
433
  const resolvedNext = next;
456
434
  if (resolvedNext === this.initialState) {
457
- logger?.info({ phase: "end" }, "Next state is initial state, run complete.");
458
- return stateData;
435
+ if (options.mode !== "infinitely") {
436
+ logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
437
+ return stateData;
438
+ }
459
439
  }
460
440
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
461
441
  current = resolvedNext;
462
442
  await current.playlist.run(stateData);
463
443
  visitedIdents.add(current.ident);
464
- if (visitedIdents.size >= allStates.length) {
465
- logger?.info({ phase: "end" }, "All reachable states visited, run complete.");
444
+ transitionsCount++;
445
+ if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
446
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
466
447
  return stateData;
467
448
  }
449
+ if (visitedIdents.size >= allStates.length) {
450
+ if (options.mode === "any") {
451
+ logger?.info({ phase: "end", reason: "all-visited" }, "Stop condition met.");
452
+ return stateData;
453
+ }
454
+ }
455
+ if (options.mode === "infinitely") {
456
+ await this.sleep(options.interval ?? 1000);
457
+ }
468
458
  }
469
459
  }
470
460
  }
package/dist/index.d.ts CHANGED
@@ -68,7 +68,7 @@ type InputBuilder<
68
68
  /**
69
69
  * @internal Internal assembly type that couples a task with its input builder.
70
70
  */
71
- interface Machine<
71
+ interface TaskBundle<
72
72
  SourceType,
73
73
  AllOutputTypes,
74
74
  TaskInputType,
@@ -79,11 +79,15 @@ interface Machine<
79
79
  builder: InputBuilder<SourceType, AllOutputTypes, TaskInputType>;
80
80
  }
81
81
  /**
82
- * Prevent TypeScript from inferring `T` from a builder argument so that the
83
- * task definition remains the source of truth. Useful for preserving safety
84
- * when chaining `.addTask` calls.
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.
85
90
  */
86
- type NoInfer<T> = [T][T extends any ? 0 : never];
87
91
  /**
88
92
  * An ordered sequence of Tasks executed with strong type inference.
89
93
  *
@@ -108,12 +112,12 @@ declare class Playlist<
108
112
  /**
109
113
  * Internal list of task + builder pairs in the order they will run.
110
114
  */
111
- machines: Machine<any, any, any, any, string>[];
115
+ bundles: TaskBundle<any, any, any, any, string>[];
112
116
  /**
113
117
  * Optional finalizer invoked after all tasks complete (successfully or not).
114
118
  */
115
119
  finalizer?: (source: SourceType, outputs: Record<string, any>) => void | Promise<void>;
116
- constructor(machines?: Machine<any, any, any, any, string>[], 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>);
117
121
  /**
118
122
  * Append a task to the end of the playlist.
119
123
  *
@@ -134,7 +138,7 @@ declare class Playlist<
134
138
  const IdentType extends string
135
139
  >(task: Task<TaskInputType, TaskOutputType, IdentType> & {
136
140
  ident: IdentType
137
- }, builder: (source: SourceType, outputs: AllOutputTypes) => NoInfer<TaskInputType>): Playlist<AllOutputTypes & { [K in IdentType] : Railroad<TaskOutputType> }, SourceType>;
141
+ }, builder: (source: SourceType, outputs: AllOutputTypes) => TaskInputType): Playlist<AllOutputTypes & { [K in IdentType] : Railroad<TaskOutputType> }, SourceType>;
138
142
  /**
139
143
  * Register a callback to run after the playlist finishes. Use this hook to
140
144
  * react to the last task or to adjust machine state before a transition.
@@ -282,7 +286,15 @@ declare class Workflow<
282
286
  */
283
287
  static create(): Workflow<never, {}, null>;
284
288
  }
285
- import { Logger } from "pino";
289
+ type Logger = {
290
+ info: (...args: any[]) => void
291
+ error: (...args: any[]) => void
292
+ debug: (...args: any[]) => void
293
+ fatal: (...args: any[]) => void
294
+ warn: (...args: any[]) => void
295
+ trace: (...args: any[]) => void
296
+ child?: (bindings: Record<string, unknown>) => Logger
297
+ };
286
298
  /**
287
299
  * A weighted, conditional edge to a target state.
288
300
  * Transitions are evaluated in descending `weight` order, then by insertion order.
@@ -307,7 +319,6 @@ type Transition<TStateData> = {
307
319
  declare class StateNode<TStateData> {
308
320
  transitions?: Transition<TStateData>[];
309
321
  playlist: Playlist<any, TStateData>;
310
- timeToNextTick: number;
311
322
  ident: string;
312
323
  tempTransitions?: {
313
324
  to: string
@@ -316,6 +327,7 @@ declare class StateNode<TStateData> {
316
327
  }[];
317
328
  retry: false | number;
318
329
  maxRetries: false | number;
330
+ logger?: Logger;
319
331
  /**
320
332
  * Create a `StateNode`.
321
333
  *
@@ -410,13 +422,15 @@ declare class StateNode<TStateData> {
410
422
  *
411
423
  * @template TStateData - The shape of the external mutable state carried through the machine.
412
424
  */
413
- declare class Machine2<TStateData> {
425
+ declare class Machine<TStateData> {
414
426
  initialState: StateNode<TStateData> | null;
415
427
  statesToCreate: StateNode<TStateData>[];
416
428
  private currentState;
417
429
  finalized: boolean;
418
430
  logger?: Logger;
419
431
  ident?: string;
432
+ private started;
433
+ private tickTimer;
420
434
  /**
421
435
  * Compute the set of reachable states starting from the initial state.
422
436
  * Used by `run` to determine completion once all states have been visited.
@@ -437,7 +451,7 @@ declare class Machine2<TStateData> {
437
451
  * @template TStateData
438
452
  * @returns A new unfinalized machine instance.
439
453
  */
440
- static create<TStateData>(): Machine2<TStateData>;
454
+ static create<TStateData>(): Machine<TStateData>;
441
455
  /**
442
456
  * Finalize the machine by resolving state transitions and locking configuration.
443
457
  * Must be called before `start` or `run`.
@@ -448,10 +462,9 @@ declare class Machine2<TStateData> {
448
462
  * @returns This machine for chaining.
449
463
  * @throws If there is no initial state, no states have been added, a state is missing an ident, or idents are duplicated.
450
464
  */
451
- finalize({ verbose, ident }?: {
452
- verbose?: boolean
465
+ finalize({ ident }?: {
453
466
  ident?: string
454
- }): Machine2<TStateData>;
467
+ }): Machine<TStateData>;
455
468
  /**
456
469
  * Add a state to the machine.
457
470
  *
@@ -462,21 +475,12 @@ declare class Machine2<TStateData> {
462
475
  */
463
476
  addState(state: StateNode<TStateData>, options?: {
464
477
  initial?: boolean
465
- }): Machine2<TStateData>;
478
+ }): Machine<TStateData>;
466
479
  /**
467
- * Start the machine in a repeated tick loop. Executes the current state's playlist,
468
- * evaluates transitions, and schedules the next tick with `setTimeout`.
469
- * The method returns immediately after initializing the loop.
470
- *
471
- * @param stateData - Mutable external state provided to playlists and transition conditions.
472
- * @param options - Start options.
473
- * @param options.interval - Tick interval in milliseconds (default 1000ms).
474
- * @returns A promise that resolves once the loop has been initiated.
475
- * @throws If the machine has not been finalized or no initial state is set.
480
+ * Attach a logger to this machine. If the machine has an initial state set,
481
+ * the logger will be propagated to all currently reachable states.
476
482
  */
477
- start(stateData: TStateData, options?: {
478
- interval?: number
479
- }): Promise<void>;
483
+ addLogger(logger: Logger): Machine<TStateData>;
480
484
  /**
481
485
  * Run the machine synchronously until it reaches a terminal condition:
482
486
  * - A leaf state (no transitions)
@@ -489,6 +493,18 @@ declare class Machine2<TStateData> {
489
493
  * @returns A promise that resolves once the run completes.
490
494
  * @throws If the machine has not been finalized or no initial state is set.
491
495
  */
492
- run(stateData: TStateData): Promise<TStateData>;
496
+ run(stateData: TStateData, options: RunOptions): Promise<TStateData>;
493
497
  }
494
- export { Workflow, TriggerEvent, Trigger, Task, StateNode, Railroad, Playlist, Machine2 as Machine };
498
+ type RunOptions = {
499
+ stopAfter?: number
500
+ } & ({
501
+ mode: "any"
502
+ } | {
503
+ mode: "leaf"
504
+ } | {
505
+ mode: "roundtrip"
506
+ } | {
507
+ mode: "infinitely"
508
+ interval?: number
509
+ });
510
+ export { Workflow, TriggerEvent, Trigger, Task, StateNode, RunOptions, Railroad, Playlist, Machine };
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  // src/prototypes/Playlist.ts
2
2
  class Playlist {
3
- machines;
3
+ bundles;
4
4
  finalizer;
5
- constructor(machines = [], finalizer) {
6
- this.machines = machines;
5
+ constructor(bundles = [], finalizer) {
6
+ this.bundles = bundles;
7
7
  this.finalizer = finalizer;
8
8
  }
9
9
  addTask(task, builder) {
10
- const machine = { task, builder };
11
- const newMachines = [...this.machines, machine];
12
- return new Playlist(newMachines, this.finalizer);
10
+ const bundle = { task, builder };
11
+ const newBundles = [...this.bundles, bundle];
12
+ return new Playlist(newBundles, this.finalizer);
13
13
  }
14
14
  finally(finalizer) {
15
15
  this.finalizer = finalizer;
@@ -17,14 +17,14 @@ class Playlist {
17
17
  }
18
18
  async run(source) {
19
19
  const outputs = {};
20
- for (const machine of this.machines) {
21
- const input = machine.builder(source, outputs);
22
- const isValid = await machine.task.validateInput(input);
20
+ for (const bundle of this.bundles) {
21
+ const input = bundle.builder(source, outputs);
22
+ const isValid = await bundle.task.validateInput(input);
23
23
  if (!isValid) {
24
- throw new Error(`Input validation failed for task '${machine.task.ident}'`);
24
+ throw new Error(`Input validation failed for task '${bundle.task.ident}'`);
25
25
  }
26
- const result = await machine.task.run(input);
27
- outputs[machine.task.ident] = result;
26
+ const result = await bundle.task.run(input);
27
+ outputs[bundle.task.ident] = result;
28
28
  }
29
29
  if (this.finalizer) {
30
30
  await this.finalizer(source, outputs);
@@ -127,22 +127,18 @@ class Trigger {
127
127
  }
128
128
  }
129
129
  // src/prototypes/Machine.ts
130
- import pino from "pino";
131
130
  import { randomUUID } from "crypto";
132
- var glogger = null;
133
-
134
131
  class StateNode {
135
132
  transitions;
136
133
  playlist;
137
- timeToNextTick;
138
134
  ident;
139
135
  tempTransitions;
140
136
  retry;
141
137
  maxRetries;
138
+ logger;
142
139
  constructor(transitions, playlist) {
143
140
  this.transitions = transitions;
144
141
  this.playlist = playlist;
145
- this.timeToNextTick = 1000;
146
142
  this.ident = "";
147
143
  this.retry = 1000;
148
144
  this.maxRetries = false;
@@ -201,7 +197,7 @@ class StateNode {
201
197
  return null;
202
198
  }
203
199
  async next(data) {
204
- const logger = glogger?.child({ path: "StateNode.next", state: this.ident });
200
+ const logger = this.logger?.child?.({ path: "StateNode.next", state: this.ident }) ?? this.logger;
205
201
  logger?.info({ phase: "start" }, "Evaluating next state");
206
202
  const sorted = [...this.transitions || []].map((t, i) => ({ t, i })).sort((a, b) => (b.t.weight ?? 0) - (a.t.weight ?? 0) || a.i - b.i);
207
203
  for (const { t } of sorted) {
@@ -226,8 +222,10 @@ class Machine {
226
222
  finalized = false;
227
223
  logger;
228
224
  ident;
225
+ started = false;
226
+ tickTimer = null;
229
227
  getAllStates() {
230
- const logger = glogger?.child({ path: "machine.getAllStates" });
228
+ const logger = this.logger?.child?.({ path: "machine.getAllStates" }) ?? this.logger;
231
229
  logger?.info({ phase: "start" }, "Gathering all states...");
232
230
  if (!this.initialState)
233
231
  return [];
@@ -256,10 +254,9 @@ class Machine {
256
254
  return new Machine;
257
255
  }
258
256
  finalize({
259
- verbose,
260
257
  ident
261
258
  } = {}) {
262
- const logger = glogger?.child({ path: "machine.finalize", instance: this.ident });
259
+ const logger = this.logger?.child?.({ path: "machine.finalize", instance: this.ident }) ?? this.logger;
263
260
  if (!this.initialState || this.statesToCreate.length === 0) {
264
261
  logger?.error({ phase: "error" }, "Finalization failed: no initial state or states to create");
265
262
  throw new Error("Cannot finalize a machine without an initial state or states to create.");
@@ -269,13 +266,11 @@ class Machine {
269
266
  } else {
270
267
  this.ident = randomUUID();
271
268
  }
272
- if (verbose)
273
- glogger = pino();
274
- logger?.info("Logging enabled.");
275
269
  logger?.info({ phase: "start" }, `Finalizing machine ${this.ident}...`);
276
270
  const registry = new Map;
277
271
  logger?.info({ phase: "progress" }, `Building state registry...`);
278
272
  for (const s of this.statesToCreate) {
273
+ s.logger = this.logger;
279
274
  if (!s.ident) {
280
275
  logger?.error({ phase: "error" }, "Finalization failed: state missing ident");
281
276
  throw new Error("State missing ident.");
@@ -307,7 +302,7 @@ class Machine {
307
302
  return this;
308
303
  }
309
304
  addState(state, options = {}) {
310
- const logger = glogger?.child({ path: "machine.addState", instance: this.ident });
305
+ const logger = this.logger?.child?.({ path: "machine.addState", instance: this.ident }) ?? this.logger;
311
306
  logger?.info({ phase: "start", state: state.ident, isInitial: !!options.initial }, "Adding state");
312
307
  this.statesToCreate.push(state);
313
308
  if (options.initial) {
@@ -316,45 +311,29 @@ class Machine {
316
311
  logger?.info({ phase: "end", state: state.ident }, "State added");
317
312
  return this;
318
313
  }
319
- async start(stateData, options) {
320
- const logger = glogger?.child({ path: "machine.start", instance: this.ident });
321
- logger?.info({ phase: "start" }, "Starting machine...");
322
- if (!this.finalized) {
323
- logger?.error({ phase: "error" }, "Machine not finalized");
324
- throw new Error("Cannot start a machine that is not finalized.");
325
- }
326
- if (!this.initialState) {
327
- logger?.error({ phase: "error" }, "No initial state");
328
- throw new Error("Cannot start a machine without an initial state.");
329
- }
330
- const interval = options?.interval ?? 1000;
331
- logger?.info({ phase: "progress", interval }, "Machine interval set");
332
- if (!this.currentState) {
333
- this.currentState = this.initialState;
334
- logger?.info({ phase: "progress", state: this.currentState.ident }, "Set initial state. Running playlist.");
335
- await this.currentState.playlist.run(stateData);
336
- logger?.info({ phase: "progress", state: this.currentState.ident }, "Initial playlist run complete.");
337
- }
338
- const tick = async () => {
339
- const tickLogger = logger?.child({ path: "machine.tick" });
340
- tickLogger?.info({ phase: "start", state: this.currentState.ident }, "Tick.");
341
- const next = await this.currentState.next(stateData);
342
- if (next) {
343
- tickLogger?.info({ phase: "progress", from: this.currentState.ident, to: next.ident }, "Transitioning state.");
344
- this.currentState = next;
345
- await this.currentState.playlist.run(stateData);
346
- tickLogger?.info({ phase: "progress", state: this.currentState.ident }, "Playlist run complete.");
347
- } else {
348
- tickLogger?.info({ phase: "progress", state: this.currentState.ident }, "No next state.");
314
+ addLogger(logger) {
315
+ this.logger = logger;
316
+ if (this.initialState) {
317
+ const visited = new Set;
318
+ const stack = [this.initialState];
319
+ while (stack.length > 0) {
320
+ const node = stack.pop();
321
+ if (!node.ident || visited.has(node.ident))
322
+ continue;
323
+ visited.add(node.ident);
324
+ node.logger = logger;
325
+ for (const tr of node.transitions || []) {
326
+ if (tr.to && tr.to.ident && !visited.has(tr.to.ident)) {
327
+ stack.push(tr.to);
328
+ }
329
+ }
349
330
  }
350
- setTimeout(tick, interval);
351
- };
352
- tick();
353
- logger?.info({ phase: "end" }, "Machine started, tick loop initiated.");
331
+ }
332
+ return this;
354
333
  }
355
- async run(stateData) {
356
- const logger = glogger?.child({ path: "machine.run", instance: this.ident });
357
- logger?.info({ phase: "start" }, "Running machine...");
334
+ async run(stateData, options) {
335
+ const logger = this.logger?.child?.({ path: "machine.run", instance: this.ident }) ?? this.logger;
336
+ logger?.info({ phase: "start", options }, "Running machine...");
358
337
  if (!this.finalized) {
359
338
  logger?.error({ phase: "error" }, "Machine not finalized");
360
339
  throw new Error("Cannot run a machine that is not finalized.");
@@ -365,27 +344,39 @@ class Machine {
365
344
  }
366
345
  const allStates = this.getAllStates();
367
346
  const visitedIdents = new Set;
347
+ let transitionsCount = 0;
348
+ if (options.stopAfter !== undefined && options.stopAfter === 0) {
349
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
350
+ return stateData;
351
+ }
368
352
  let current = this.initialState;
369
353
  logger?.info({ phase: "progress", state: current.ident }, "Set initial state. Running playlist.");
370
354
  await current.playlist.run(stateData);
355
+ transitionsCount = 1;
371
356
  visitedIdents.add(current.ident);
357
+ if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
358
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
359
+ return stateData;
360
+ }
372
361
  while (true) {
373
362
  if (!current.transitions || current.transitions.length === 0) {
374
- logger?.info({ phase: "end", state: current.ident }, "Reached leaf node, run complete.");
375
- return stateData;
363
+ if (options.mode !== "infinitely") {
364
+ logger?.info({ phase: "end", state: current.ident, reason: "leaf" }, "Stop condition met.");
365
+ return stateData;
366
+ }
376
367
  }
377
368
  let next = await current.next(stateData);
378
369
  if (!next) {
379
370
  const retryDelay = current.retry;
380
- if (!retryDelay) {
381
- logger?.info({ phase: "end", state: current.ident }, "No next state and retries disabled, run complete.");
371
+ if (retryDelay === false) {
372
+ logger?.info({ phase: "end", state: current.ident, reason: "no-transition-no-retry" }, "Stop condition met.");
382
373
  return stateData;
383
374
  }
384
375
  let retries = 0;
385
376
  logger?.info({ phase: "progress", state: current.ident, retryDelay }, "No next state, beginning retry logic.");
386
377
  while (!next) {
387
- if (current.maxRetries && retries >= current.maxRetries) {
388
- logger?.warn({ phase: "end", state: current.ident, retries }, "Retry limit exhausted, run complete.");
378
+ if (current.maxRetries !== false && retries >= current.maxRetries) {
379
+ logger?.warn({ phase: "end", state: current.ident, retries, reason: "retries-exhausted" }, "Stop condition met.");
389
380
  return stateData;
390
381
  }
391
382
  await this.sleep(retryDelay);
@@ -400,17 +391,29 @@ class Machine {
400
391
  }
401
392
  const resolvedNext = next;
402
393
  if (resolvedNext === this.initialState) {
403
- logger?.info({ phase: "end" }, "Next state is initial state, run complete.");
404
- return stateData;
394
+ if (options.mode !== "infinitely") {
395
+ logger?.info({ phase: "end", reason: "roundtrip" }, "Stop condition met.");
396
+ return stateData;
397
+ }
405
398
  }
406
399
  logger?.info({ phase: "progress", from: current.ident, to: resolvedNext.ident }, "Transitioning state.");
407
400
  current = resolvedNext;
408
401
  await current.playlist.run(stateData);
409
402
  visitedIdents.add(current.ident);
410
- if (visitedIdents.size >= allStates.length) {
411
- logger?.info({ phase: "end" }, "All reachable states visited, run complete.");
403
+ transitionsCount++;
404
+ if (options.stopAfter !== undefined && transitionsCount >= options.stopAfter) {
405
+ logger?.info({ phase: "end", reason: "stopAfter", count: transitionsCount }, "Stop condition met.");
412
406
  return stateData;
413
407
  }
408
+ if (visitedIdents.size >= allStates.length) {
409
+ if (options.mode === "any") {
410
+ logger?.info({ phase: "end", reason: "all-visited" }, "Stop condition met.");
411
+ return stateData;
412
+ }
413
+ }
414
+ if (options.mode === "infinitely") {
415
+ await this.sleep(options.interval ?? 1000);
416
+ }
414
417
  }
415
418
  }
416
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fkws/klonk",
3
- "version": "0.0.13",
3
+ "version": "0.0.16",
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",
@@ -48,12 +48,11 @@
48
48
  "@types/node": "latest",
49
49
  "@vitest/coverage-v8": "^4.0.3",
50
50
  "bunup": "^0.6.2",
51
- "vitest": "^4.0.3"
51
+ "vitest": "^4.0.3",
52
+ "pino": "^10.1.0"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "typescript": "^5"
55
56
  },
56
- "dependencies": {
57
- "pino": "^10.1.0"
58
- }
57
+ "dependencies": {}
59
58
  }