@db-lyon/flowkit 0.11.1 → 0.12.0

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.
@@ -0,0 +1,347 @@
1
+ # Configuration
2
+
3
+ Flowkit uses YAML files for declarative configuration, with support for layered merging, environment overlays, and schema validation via Zod.
4
+
5
+ ## YAML schema
6
+
7
+ A flowkit config file has two top-level keys:
8
+
9
+ ```yaml
10
+ tasks:
11
+ # ...
12
+ flows:
13
+ # ...
14
+ ```
15
+
16
+ Both default to `{}` if omitted.
17
+
18
+ ### Task definition
19
+
20
+ ```yaml
21
+ tasks:
22
+ my_task:
23
+ class_path: path.to.MyTask # required — how to resolve the task class
24
+ description: What this task does # optional
25
+ group: etl # optional — logical grouping label
26
+ options: # optional — default options passed to the task
27
+ key: value
28
+ ```
29
+
30
+ | Field | Type | Required | Description |
31
+ |-------|------|----------|-------------|
32
+ | `class_path` | `string` | yes | Dotted path to the task class, or a registered name |
33
+ | `description` | `string` | no | Human-readable description |
34
+ | `group` | `string` | no | Logical grouping label |
35
+ | `options` | `object` | no | Default options (merged with step-level overrides) |
36
+
37
+ ### Flow definition
38
+
39
+ ```yaml
40
+ flows:
41
+ my_flow:
42
+ description: What this flow does # required
43
+ steps:
44
+ 1:
45
+ task: my_task # reference a task by name
46
+ options: # optional — override/extend task defaults
47
+ key: override_value
48
+ 2:
49
+ flow: other_flow # reference another flow (nesting)
50
+ 3:
51
+ task: None # skip sentinel — step is always skipped
52
+ ```
53
+
54
+ | Field | Type | Required | Description |
55
+ |-------|------|----------|-------------|
56
+ | `description` | `string` | yes | Human-readable flow description |
57
+ | `steps` | `object` | yes | Steps keyed by number (execution order) |
58
+
59
+ ### Flow step
60
+
61
+ Each step must have exactly one of `task` or `flow` (mutually exclusive), unless `task: None` is used to mark a skipped step.
62
+
63
+ | Field | Type | Required | Description |
64
+ |-------|------|----------|-------------|
65
+ | `task` | `string` | one of task/flow | Task name to execute |
66
+ | `flow` | `string` | one of task/flow | Nested flow name to execute |
67
+ | `options` | `object` | no | Override options for this step |
68
+
69
+ Step numbers are sorted numerically at execution time, so `1, 2, 10` runs in that order (not lexicographic `1, 10, 2`).
70
+
71
+ ### Options merging
72
+
73
+ When a step executes, options are merged as: **task defaults** + **step overrides** (step wins):
74
+
75
+ ```yaml
76
+ tasks:
77
+ deploy:
78
+ class_path: tasks.Deploy
79
+ options:
80
+ environment: staging
81
+ notify: true
82
+
83
+ flows:
84
+ release:
85
+ description: Deploy to production
86
+ steps:
87
+ 1:
88
+ task: deploy
89
+ options:
90
+ environment: production # overrides "staging"
91
+ # notify: true is inherited from task defaults
92
+ ```
93
+
94
+ Runtime parameters passed to `FlowRunner.run({ params })` merge on top with the highest priority (**task defaults < step overrides < runtime params**).
95
+
96
+ ### Step references
97
+
98
+ Option values may reference the output of earlier steps in the same flow using `${steps.<id>.<path>}`:
99
+
100
+ ```yaml
101
+ flows:
102
+ chain:
103
+ description: Pass one step's output into the next
104
+ steps:
105
+ 1:
106
+ task: build
107
+ options:
108
+ target: plugin
109
+ 2:
110
+ task: deploy
111
+ options:
112
+ artifact: ${steps.1.path} # whole-value → raw type preserved
113
+ message: "deployed ${steps.build.version}" # embedded → stringified
114
+ ```
115
+
116
+ - **`<id>`** is a step number (`1`) or a task name (`build`, `level.place_actor`). Task names with dots are matched longest-prefix-first.
117
+ - **`<path>`** is a dot path into the step's `result.data`.
118
+ - When a task name appears in multiple steps, references resolve to the **most recently completed** one.
119
+ - A reference that fills the entire string (`"${steps.1.path}"`) is replaced with the raw value, so objects and arrays round-trip. References embedded inside a larger string are stringified.
120
+ - References that can't be resolved throw and fail the step.
121
+
122
+ References resolve just before the step runs, against the results of already-completed steps in the current flow. Nested flows have their own reference scope — they don't see their parent flow's steps.
123
+
124
+ ### Flow-level hooks
125
+
126
+ A flow can attach steps that run around the main step sequence, keyed by flow outcome:
127
+
128
+ ```yaml
129
+ flows:
130
+ deploy:
131
+ description: Deploy to prod
132
+ on_start: [ { task: notify, options: { msg: "starting" } } ]
133
+ on_success: [ { task: notify, options: { msg: "done ${steps.build.version}" } } ]
134
+ on_failure: [ { task: notify, options: { msg: "failed: ${error.message}" } } ]
135
+ finally: [ { task: cleanup } ]
136
+ steps:
137
+ 1: { task: build }
138
+ 2: { task: push }
139
+ ```
140
+
141
+ - **`on_start`** runs before any step. Its failure aborts the flow before steps execute.
142
+ - **`on_success`** runs when all steps succeed.
143
+ - **`on_failure`** runs when any step fails. It can reference the error via the `${error.*}` namespace.
144
+ - **`finally`** runs after either outcome, after `on_success`/`on_failure`.
145
+
146
+ Hook steps share the full step execution model — same task dispatch, same option merging, same runtime params, same `${steps.X.y}` resolution. Inside `on_failure` and `finally`, the `${error.message}`, `${error.name}`, `${error.stack}`, and `${error.step}` references resolve to the failure that triggered them.
147
+
148
+ Hook failures are captured in `FlowRunResult.hookErrors` but **do not** change the flow's primary success/failure outcome — a failed notifier doesn't rewrite history.
149
+
150
+ ### Per-step retry
151
+
152
+ A step can retry itself on failure:
153
+
154
+ ```yaml
155
+ steps:
156
+ 1:
157
+ task: flaky_network_call
158
+ retries: 3 # up to 4 total attempts
159
+ retryDelay: 500 # ms between attempts
160
+ retryOn: "timeout" # only retry when the error message contains this substring
161
+ ```
162
+
163
+ Omit `retryOn` to retry on any error. The number of attempts taken appears on `FlowStepResult.attempts`.
164
+
165
+ ### Rollback on failure
166
+
167
+ Mutating tasks may return a `rollback` record on their `TaskResult` pointing to an inverse task:
168
+
169
+ ```ts
170
+ return {
171
+ success: true,
172
+ data: { label: 'MyPillar' },
173
+ rollback: { taskName: 'delete_actor', payload: { label: 'MyPillar' } },
174
+ };
175
+ ```
176
+
177
+ When a flow sets `rollback_on_failure: true` (or the caller passes it on `FlowRunRunOptions`) and a later step fails, the runner invokes the collected rollback records in **reverse order**, best-effort: it continues past individual failures and reports all errors in `FlowRunResult.rollback`.
178
+
179
+ ```yaml
180
+ flows:
181
+ safe_deploy:
182
+ description: Deploy with rollback on failure
183
+ rollback_on_failure: true
184
+ steps:
185
+ 1: { task: create_thing, options: { label: A } }
186
+ 2: { task: create_thing, options: { label: B } }
187
+ 3: { task: finalize } # if this fails, thing:B then thing:A are rolled back
188
+ ```
189
+
190
+ Rollback runs after `on_failure` and before `finally`. Nested flow steps' rollback records bubble up to the parent flow so a single `rollback_on_failure` setting covers the whole tree.
191
+
192
+ The inverse task's configured `options` are resolved for `${ns.path}` references as usual, then the `payload` is merged over them. A payload is runtime data the task recorded, not configuration, so it is passed through **literally** — a `${...}` captured inside one reaches the inverse task unchanged.
193
+
194
+ Rollback runs outside any step's scope, so those configured `options` resolve against the host namespaces only. A `${steps.…}` or `${error.…}` in an inverse task's defaults has nothing to resolve against and fails that one rollback record (it is reported in `rollback.errors`; the remaining records still run). Keep inverse-task defaults to host namespaces and put step-derived values in the `payload`.
195
+
196
+ ### `agent_prompt` — LLM step
197
+
198
+ When a `LLMProvider` is attached to the context under `ctx.llm`, the built-in `agent_prompt` task invokes it:
199
+
200
+ ```yaml
201
+ steps:
202
+ 1:
203
+ task: agent_prompt
204
+ options:
205
+ system: "You are a deployment triage agent."
206
+ prompt: "Last error: ${error.message}. Suggest a fix."
207
+ model: claude-opus-4-6
208
+ maxTokens: 512
209
+ schema: { type: object, properties: { fix: { type: string } } } # optional
210
+ ```
211
+
212
+ Returns `{ text, parsed?, usage? }`. Provider failures become step failures; missing provider is a clear error.
213
+
214
+ ## Config layering
215
+
216
+ `loadConfig()` merges up to four layers, left to right:
217
+
218
+ ```
219
+ defaults (code) → base file → env overlay → local overlay
220
+ ```
221
+
222
+ | Layer | Source | Purpose |
223
+ |-------|--------|---------|
224
+ | 1. Defaults | `options.defaults` in code | Hardcoded fallbacks |
225
+ | 2. Base file | `pipeline.yml` | Project-level config (committed) |
226
+ | 3. Env overlay | `pipeline.staging.yml` | Environment-specific overrides |
227
+ | 4. Local overlay | `pipeline.local.yml` | Developer-specific overrides (gitignored) |
228
+
229
+ ### Example
230
+
231
+ ```typescript
232
+ import { loadConfig, EngineConfigSchema } from '@db-lyon/flowkit';
233
+
234
+ const { config, configDir } = loadConfig({
235
+ filename: 'pipeline.yml',
236
+ schema: EngineConfigSchema,
237
+
238
+ // Hardcoded defaults merged under everything
239
+ defaults: {
240
+ tasks: {},
241
+ flows: {},
242
+ },
243
+
244
+ // Environment name — loads pipeline.{env}.yml
245
+ env: process.env.NODE_ENV,
246
+ // Or read from a specific env var:
247
+ // envVar: 'APP_ENV',
248
+
249
+ // Directory to search (default: cwd)
250
+ configDir: './config',
251
+ });
252
+ ```
253
+
254
+ The `configDir` return value tells you where the config was loaded from.
255
+
256
+ ### Environment selection
257
+
258
+ You can specify the environment explicitly or via an env var:
259
+
260
+ ```typescript
261
+ // Explicit
262
+ loadConfig({ filename: 'app.yml', schema, env: 'production' });
263
+
264
+ // From env var — reads process.env.APP_ENV
265
+ loadConfig({ filename: 'app.yml', schema, envVar: 'APP_ENV' });
266
+ ```
267
+
268
+ If both `env` and `envVar` are provided, `env` takes precedence.
269
+
270
+ ## Deep merge behavior
271
+
272
+ Config layers are merged using `deepMerge()`, which follows these rules:
273
+
274
+ | Scenario | Behavior |
275
+ |----------|----------|
276
+ | Objects | Recursive key-by-key merge (override wins per-key) |
277
+ | Arrays | Override replaces the base array |
278
+ | Scalars | Override wins |
279
+ | `null` override | Explicitly nullifies the base value |
280
+ | `undefined` override | No-op (base preserved) |
281
+
282
+ ### Array append mode
283
+
284
+ By default, arrays in an overlay replace the base array entirely. To append instead, add `__merge: append` to the override array:
285
+
286
+ ```yaml
287
+ # base.yml
288
+ plugins:
289
+ - eslint
290
+ - prettier
291
+
292
+ # base.local.yml
293
+ plugins:
294
+ - __merge: append
295
+ - my-custom-plugin
296
+ ```
297
+
298
+ Result: `['eslint', 'prettier', 'my-custom-plugin']`
299
+
300
+ The `__merge` annotation is stripped from the final array.
301
+
302
+ ## Finding config files
303
+
304
+ `findConfigFile()` walks up parent directories to locate a file:
305
+
306
+ ```typescript
307
+ import { findConfigFile } from '@db-lyon/flowkit';
308
+
309
+ const path = findConfigFile('pipeline.yml');
310
+ // Searches cwd, then parent, then grandparent, etc.
311
+ ```
312
+
313
+ Throws if the file isn't found in any ancestor directory.
314
+
315
+ ## Loading raw YAML
316
+
317
+ For cases where you need the raw parsed YAML without schema validation:
318
+
319
+ ```typescript
320
+ import { loadRawYaml } from '@db-lyon/flowkit';
321
+
322
+ const data = loadRawYaml('/path/to/file.yml');
323
+ ```
324
+
325
+ ## Custom schemas
326
+
327
+ `EngineConfigSchema` is the minimal schema flowkit needs. You can extend it for your own config sections:
328
+
329
+ ```typescript
330
+ import { z } from 'zod';
331
+ import { EngineConfigSchema } from '@db-lyon/flowkit';
332
+
333
+ const AppConfigSchema = EngineConfigSchema.extend({
334
+ database: z.object({
335
+ host: z.string(),
336
+ port: z.number().default(5432),
337
+ }),
338
+ features: z.record(z.boolean()).default({}),
339
+ });
340
+
341
+ const { config } = loadConfig({
342
+ filename: 'app.yml',
343
+ schema: AppConfigSchema,
344
+ });
345
+
346
+ // config.tasks, config.flows, config.database, config.features
347
+ ```
@@ -0,0 +1,232 @@
1
+ # Custom tasks
2
+
3
+ Tasks are the building blocks of flowkit. Each task is a class that extends `BaseTask` and implements an `execute()` method.
4
+
5
+ ## Anatomy of a task
6
+
7
+ ```typescript
8
+ import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
9
+
10
+ interface MyOptions {
11
+ url: string;
12
+ retries?: number;
13
+ }
14
+
15
+ export default class FetchData extends BaseTask<MyOptions> {
16
+ get taskName() {
17
+ return 'fetch_data';
18
+ }
19
+
20
+ protected validate() {
21
+ if (!this.options.url) {
22
+ throw new Error('url option is required');
23
+ }
24
+ }
25
+
26
+ async execute(): Promise<TaskResult> {
27
+ const { url, retries = 3 } = this.options;
28
+
29
+ const response = await fetch(url);
30
+ if (!response.ok) {
31
+ return {
32
+ success: false,
33
+ error: new Error(`HTTP ${response.status}`),
34
+ };
35
+ }
36
+
37
+ const data = await response.json();
38
+ return {
39
+ success: true,
40
+ data: { body: data, status: response.status },
41
+ };
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Required members
47
+
48
+ | Member | Description |
49
+ |--------|-------------|
50
+ | `get taskName()` | A human-readable name used in logging |
51
+ | `execute()` | Async method that performs the work and returns a `TaskResult` |
52
+
53
+ ### Optional members
54
+
55
+ | Member | Description |
56
+ |--------|-------------|
57
+ | `validate()` | Called before `execute()`. Throw to abort with a validation error. |
58
+
59
+ ### Available on `this`
60
+
61
+ | Property | Description |
62
+ |----------|-------------|
63
+ | `this.options` | The merged options (task defaults + step overrides), typed as `TOptions` |
64
+ | `this.ctx` | The `TaskContext` passed to the flow runner — read-only, see below |
65
+ | `this.logger` | A child logger scoped to this task instance |
66
+ | `this.resolve(name, options?)` | Build another task by configured name or class path, unexecuted |
67
+ | `this.call(name, options?)` | `resolve()` plus `run()`, returning its `TaskResult` |
68
+
69
+ ## Calling other tasks
70
+
71
+ `this.call(name)` resolves `name` the same way a flow step does. A configured task name is looked up in the `tasks:` config and dispatched through its `class_path`, inheriting its configured `options` as defaults; anything you pass as `options` merges over them and wins. A name with no configured entry resolves as a class path directly, so a bare `vendor.tasks.Thing` still works.
72
+
73
+ ```yaml
74
+ tasks:
75
+ soql_query:
76
+ class_path: caseops.tasks.SoqlQuery
77
+ options:
78
+ org: ${org.username}
79
+ ```
80
+
81
+ ```typescript
82
+ // Runs caseops.tasks.SoqlQuery with { org: 'admin@example.com', query: 'SELECT ...' }
83
+ const result = await this.call('soql_query', { query: 'SELECT Id FROM Case' });
84
+ ```
85
+
86
+ The `${ns.path}` references in those configured defaults are interpolated for you, against the same scope the calling task itself runs under. The `options` you pass are your own runtime data and are **never** interpolated, so a `${...}` you computed reaches the task verbatim rather than being reinterpreted as configuration.
87
+
88
+ Calling a task requires a registry on the context, which `FlowRunner` supplies. A task constructed by hand without one throws.
89
+
90
+ ## The task lifecycle
91
+
92
+ When `task.run()` is called (by the flow runner):
93
+
94
+ 1. `validate()` runs — throw here to reject bad options
95
+ 2. `execute()` runs — return a `TaskResult`
96
+ 3. The result gets a `duration` field added automatically
97
+ 4. If `validate()` or `execute()` throws, the error is caught and returned as `{ success: false, error }`
98
+
99
+ You never call `run()` yourself in normal usage — the flow runner handles it.
100
+
101
+ ## TaskResult
102
+
103
+ ```typescript
104
+ interface TaskResult {
105
+ success: boolean;
106
+ data?: Record<string, unknown>; // arbitrary output data
107
+ error?: Error; // populated on failure
108
+ duration?: number; // milliseconds, set by run()
109
+ }
110
+ ```
111
+
112
+ Return `{ success: true }` for success and `{ success: false, error }` for expected failures. Unexpected exceptions are caught automatically.
113
+
114
+ ## TaskContext
115
+
116
+ The context carries host-supplied state to every task in a flow run — database connections, API clients, configuration:
117
+
118
+ ```typescript
119
+ const runner = new FlowRunner({
120
+ // ...
121
+ context: {
122
+ logger: myLogger,
123
+ db: databaseConnection,
124
+ apiKey: process.env.API_KEY,
125
+ },
126
+ });
127
+ ```
128
+
129
+ Inside a task:
130
+
131
+ ```typescript
132
+ async execute(): Promise<TaskResult> {
133
+ const db = this.ctx.db as Database;
134
+ // ...
135
+ }
136
+ ```
137
+
138
+ Treat the context as read-only. Each task is handed its own derived context, so assigning a key inside a task (`this.ctx.cached = x`) does not reach the next step, another task, or a sub-agent. To share mutable state, put a mutable object on the context up front and write into that:
139
+
140
+ ```typescript
141
+ context: { cache: new Map() } // this.ctx.cache.set(...) is visible everywhere
142
+ ```
143
+
144
+ ## Registering tasks
145
+
146
+ ### By name
147
+
148
+ ```typescript
149
+ const registry = new TaskRegistry();
150
+ registry.register('fetch_data', FetchData as any);
151
+ ```
152
+
153
+ The YAML can then reference it directly:
154
+
155
+ ```yaml
156
+ tasks:
157
+ fetch_data:
158
+ class_path: fetch_data
159
+ ```
160
+
161
+ ### By class path
162
+
163
+ ```typescript
164
+ registry.registerClassPath('my.tasks.FetchData', FetchData as any);
165
+ ```
166
+
167
+ ### Bulk registration
168
+
169
+ ```typescript
170
+ registry.registerAll({
171
+ fetch_data: FetchData as any,
172
+ transform: TransformData as any,
173
+ upload: Upload as any,
174
+ });
175
+ ```
176
+
177
+ ### Dynamic resolution
178
+
179
+ If a `class_path` isn't found in the registry, flowkit converts dots to path separators and looks for a file on disk:
180
+
181
+ | class_path | Files checked |
182
+ |------------|---------------|
183
+ | `tasks.FetchData` | `tasks/FetchData.ts`, `tasks/FetchData.js`, `tasks/FetchData/index.ts`, `tasks/FetchData/index.js` |
184
+ | `lib.etl.Extract` | `lib/etl/Extract.ts`, `lib/etl/Extract.js`, ... |
185
+
186
+ The module must have either a `default` export or a named export matching the last segment of the path (e.g., `FetchData`). The export must extend `BaseTask`.
187
+
188
+ ## Built-in: ShellTask
189
+
190
+ `ShellTask` executes shell commands via `execSync`. Register it under any name you like:
191
+
192
+ ```typescript
193
+ import { ShellTask } from '@db-lyon/flowkit';
194
+
195
+ registry.register('shell', ShellTask as any);
196
+ ```
197
+
198
+ Then use it in YAML:
199
+
200
+ ```yaml
201
+ tasks:
202
+ lint:
203
+ class_path: shell
204
+ description: Run the linter
205
+ options:
206
+ command: npm run lint
207
+
208
+ build:
209
+ class_path: shell
210
+ description: Build the project
211
+ options:
212
+ command: npm run build
213
+ cwd: /path/to/project
214
+ timeout: 120000
215
+ ```
216
+
217
+ ### ShellTask options
218
+
219
+ | Option | Type | Default | Description |
220
+ |--------|------|---------|-------------|
221
+ | `command` | `string` | (required) | The shell command to execute |
222
+ | `cwd` | `string` | `undefined` | Working directory |
223
+ | `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
224
+
225
+ On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
226
+
227
+ ## Listing registered tasks
228
+
229
+ ```typescript
230
+ const names = registry.listRegistered();
231
+ // ['fetch_data', 'shell', 'my.tasks.Transform', ...]
232
+ ```