@db-lyon/flowkit 0.11.1 → 0.11.2

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,343 @@
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
+ ### `agent_prompt` — LLM step
193
+
194
+ When a `LLMProvider` is attached to the context under `ctx.llm`, the built-in `agent_prompt` task invokes it:
195
+
196
+ ```yaml
197
+ steps:
198
+ 1:
199
+ task: agent_prompt
200
+ options:
201
+ system: "You are a deployment triage agent."
202
+ prompt: "Last error: ${error.message}. Suggest a fix."
203
+ model: claude-opus-4-6
204
+ maxTokens: 512
205
+ schema: { type: object, properties: { fix: { type: string } } } # optional
206
+ ```
207
+
208
+ Returns `{ text, parsed?, usage? }`. Provider failures become step failures; missing provider is a clear error.
209
+
210
+ ## Config layering
211
+
212
+ `loadConfig()` merges up to four layers, left to right:
213
+
214
+ ```
215
+ defaults (code) → base file → env overlay → local overlay
216
+ ```
217
+
218
+ | Layer | Source | Purpose |
219
+ |-------|--------|---------|
220
+ | 1. Defaults | `options.defaults` in code | Hardcoded fallbacks |
221
+ | 2. Base file | `pipeline.yml` | Project-level config (committed) |
222
+ | 3. Env overlay | `pipeline.staging.yml` | Environment-specific overrides |
223
+ | 4. Local overlay | `pipeline.local.yml` | Developer-specific overrides (gitignored) |
224
+
225
+ ### Example
226
+
227
+ ```typescript
228
+ import { loadConfig, EngineConfigSchema } from '@db-lyon/flowkit';
229
+
230
+ const { config, configDir } = loadConfig({
231
+ filename: 'pipeline.yml',
232
+ schema: EngineConfigSchema,
233
+
234
+ // Hardcoded defaults merged under everything
235
+ defaults: {
236
+ tasks: {},
237
+ flows: {},
238
+ },
239
+
240
+ // Environment name — loads pipeline.{env}.yml
241
+ env: process.env.NODE_ENV,
242
+ // Or read from a specific env var:
243
+ // envVar: 'APP_ENV',
244
+
245
+ // Directory to search (default: cwd)
246
+ configDir: './config',
247
+ });
248
+ ```
249
+
250
+ The `configDir` return value tells you where the config was loaded from.
251
+
252
+ ### Environment selection
253
+
254
+ You can specify the environment explicitly or via an env var:
255
+
256
+ ```typescript
257
+ // Explicit
258
+ loadConfig({ filename: 'app.yml', schema, env: 'production' });
259
+
260
+ // From env var — reads process.env.APP_ENV
261
+ loadConfig({ filename: 'app.yml', schema, envVar: 'APP_ENV' });
262
+ ```
263
+
264
+ If both `env` and `envVar` are provided, `env` takes precedence.
265
+
266
+ ## Deep merge behavior
267
+
268
+ Config layers are merged using `deepMerge()`, which follows these rules:
269
+
270
+ | Scenario | Behavior |
271
+ |----------|----------|
272
+ | Objects | Recursive key-by-key merge (override wins per-key) |
273
+ | Arrays | Override replaces the base array |
274
+ | Scalars | Override wins |
275
+ | `null` override | Explicitly nullifies the base value |
276
+ | `undefined` override | No-op (base preserved) |
277
+
278
+ ### Array append mode
279
+
280
+ By default, arrays in an overlay replace the base array entirely. To append instead, add `__merge: append` to the override array:
281
+
282
+ ```yaml
283
+ # base.yml
284
+ plugins:
285
+ - eslint
286
+ - prettier
287
+
288
+ # base.local.yml
289
+ plugins:
290
+ - __merge: append
291
+ - my-custom-plugin
292
+ ```
293
+
294
+ Result: `['eslint', 'prettier', 'my-custom-plugin']`
295
+
296
+ The `__merge` annotation is stripped from the final array.
297
+
298
+ ## Finding config files
299
+
300
+ `findConfigFile()` walks up parent directories to locate a file:
301
+
302
+ ```typescript
303
+ import { findConfigFile } from '@db-lyon/flowkit';
304
+
305
+ const path = findConfigFile('pipeline.yml');
306
+ // Searches cwd, then parent, then grandparent, etc.
307
+ ```
308
+
309
+ Throws if the file isn't found in any ancestor directory.
310
+
311
+ ## Loading raw YAML
312
+
313
+ For cases where you need the raw parsed YAML without schema validation:
314
+
315
+ ```typescript
316
+ import { loadRawYaml } from '@db-lyon/flowkit';
317
+
318
+ const data = loadRawYaml('/path/to/file.yml');
319
+ ```
320
+
321
+ ## Custom schemas
322
+
323
+ `EngineConfigSchema` is the minimal schema flowkit needs. You can extend it for your own config sections:
324
+
325
+ ```typescript
326
+ import { z } from 'zod';
327
+ import { EngineConfigSchema } from '@db-lyon/flowkit';
328
+
329
+ const AppConfigSchema = EngineConfigSchema.extend({
330
+ database: z.object({
331
+ host: z.string(),
332
+ port: z.number().default(5432),
333
+ }),
334
+ features: z.record(z.boolean()).default({}),
335
+ });
336
+
337
+ const { config } = loadConfig({
338
+ filename: 'app.yml',
339
+ schema: AppConfigSchema,
340
+ });
341
+
342
+ // config.tasks, config.flows, config.database, config.features
343
+ ```
@@ -0,0 +1,203 @@
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 — use it to share state |
65
+ | `this.logger` | A child logger scoped to this task instance |
66
+
67
+ ## The task lifecycle
68
+
69
+ When `task.run()` is called (by the flow runner):
70
+
71
+ 1. `validate()` runs — throw here to reject bad options
72
+ 2. `execute()` runs — return a `TaskResult`
73
+ 3. The result gets a `duration` field added automatically
74
+ 4. If `validate()` or `execute()` throws, the error is caught and returned as `{ success: false, error }`
75
+
76
+ You never call `run()` yourself in normal usage — the flow runner handles it.
77
+
78
+ ## TaskResult
79
+
80
+ ```typescript
81
+ interface TaskResult {
82
+ success: boolean;
83
+ data?: Record<string, unknown>; // arbitrary output data
84
+ error?: Error; // populated on failure
85
+ duration?: number; // milliseconds, set by run()
86
+ }
87
+ ```
88
+
89
+ Return `{ success: true }` for success and `{ success: false, error }` for expected failures. Unexpected exceptions are caught automatically.
90
+
91
+ ## TaskContext
92
+
93
+ The context object is shared across all tasks in a flow run. Use it to pass shared state like database connections, API clients, or configuration:
94
+
95
+ ```typescript
96
+ const runner = new FlowRunner({
97
+ // ...
98
+ context: {
99
+ logger: myLogger,
100
+ db: databaseConnection,
101
+ apiKey: process.env.API_KEY,
102
+ },
103
+ });
104
+ ```
105
+
106
+ Inside a task:
107
+
108
+ ```typescript
109
+ async execute(): Promise<TaskResult> {
110
+ const db = this.ctx.db as Database;
111
+ // ...
112
+ }
113
+ ```
114
+
115
+ ## Registering tasks
116
+
117
+ ### By name
118
+
119
+ ```typescript
120
+ const registry = new TaskRegistry();
121
+ registry.register('fetch_data', FetchData as any);
122
+ ```
123
+
124
+ The YAML can then reference it directly:
125
+
126
+ ```yaml
127
+ tasks:
128
+ fetch_data:
129
+ class_path: fetch_data
130
+ ```
131
+
132
+ ### By class path
133
+
134
+ ```typescript
135
+ registry.registerClassPath('my.tasks.FetchData', FetchData as any);
136
+ ```
137
+
138
+ ### Bulk registration
139
+
140
+ ```typescript
141
+ registry.registerAll({
142
+ fetch_data: FetchData as any,
143
+ transform: TransformData as any,
144
+ upload: Upload as any,
145
+ });
146
+ ```
147
+
148
+ ### Dynamic resolution
149
+
150
+ If a `class_path` isn't found in the registry, flowkit converts dots to path separators and looks for a file on disk:
151
+
152
+ | class_path | Files checked |
153
+ |------------|---------------|
154
+ | `tasks.FetchData` | `tasks/FetchData.ts`, `tasks/FetchData.js`, `tasks/FetchData/index.ts`, `tasks/FetchData/index.js` |
155
+ | `lib.etl.Extract` | `lib/etl/Extract.ts`, `lib/etl/Extract.js`, ... |
156
+
157
+ 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`.
158
+
159
+ ## Built-in: ShellTask
160
+
161
+ `ShellTask` executes shell commands via `execSync`. Register it under any name you like:
162
+
163
+ ```typescript
164
+ import { ShellTask } from '@db-lyon/flowkit';
165
+
166
+ registry.register('shell', ShellTask as any);
167
+ ```
168
+
169
+ Then use it in YAML:
170
+
171
+ ```yaml
172
+ tasks:
173
+ lint:
174
+ class_path: shell
175
+ description: Run the linter
176
+ options:
177
+ command: npm run lint
178
+
179
+ build:
180
+ class_path: shell
181
+ description: Build the project
182
+ options:
183
+ command: npm run build
184
+ cwd: /path/to/project
185
+ timeout: 120000
186
+ ```
187
+
188
+ ### ShellTask options
189
+
190
+ | Option | Type | Default | Description |
191
+ |--------|------|---------|-------------|
192
+ | `command` | `string` | (required) | The shell command to execute |
193
+ | `cwd` | `string` | `undefined` | Working directory |
194
+ | `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
195
+
196
+ On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
197
+
198
+ ## Listing registered tasks
199
+
200
+ ```typescript
201
+ const names = registry.listRegistered();
202
+ // ['fetch_data', 'shell', 'my.tasks.Transform', ...]
203
+ ```
@@ -0,0 +1,172 @@
1
+ # Getting started
2
+
3
+ This guide walks through setting up flowkit from scratch.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js >= 20
8
+ - TypeScript project with `"module": "NodeNext"` (or compatible ESM setup)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @db-lyon/flowkit
14
+ ```
15
+
16
+ ## 1. Create a YAML config
17
+
18
+ Create `config/pipeline.yml`:
19
+
20
+ ```yaml
21
+ tasks:
22
+ greet:
23
+ class_path: tasks.Greet
24
+ description: Print a greeting
25
+ options:
26
+ name: world
27
+
28
+ flows:
29
+ hello:
30
+ description: Run the greeting
31
+ steps:
32
+ 1:
33
+ task: greet
34
+ ```
35
+
36
+ ### What's happening here
37
+
38
+ - **tasks** defines reusable units of work. Each task has a `class_path` that tells flowkit how to find the task implementation, and optional default `options`.
39
+ - **flows** defines sequences of steps. Each step references a task (or another flow) by name. Steps execute in numeric order.
40
+
41
+ ## 2. Create a task class
42
+
43
+ Create `tasks/Greet.ts`:
44
+
45
+ ```typescript
46
+ import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
47
+
48
+ interface GreetOptions {
49
+ name: string;
50
+ }
51
+
52
+ export default class Greet extends BaseTask<GreetOptions> {
53
+ get taskName() {
54
+ return 'greet';
55
+ }
56
+
57
+ async execute(): Promise<TaskResult> {
58
+ const message = `Hello, ${this.options.name}!`;
59
+ this.logger.info(message);
60
+ return { success: true, data: { message } };
61
+ }
62
+ }
63
+ ```
64
+
65
+ Every task must:
66
+ 1. Extend `BaseTask<TOptions>`
67
+ 2. Implement the `taskName` getter
68
+ 3. Implement the async `execute()` method returning a `TaskResult`
69
+
70
+ ## 3. Wire it up
71
+
72
+ Create `run.ts`:
73
+
74
+ ```typescript
75
+ import {
76
+ loadConfig,
77
+ EngineConfigSchema,
78
+ TaskRegistry,
79
+ FlowRunner,
80
+ } from '@db-lyon/flowkit';
81
+
82
+ // Load and validate the YAML config
83
+ const { config } = loadConfig({
84
+ filename: 'pipeline.yml',
85
+ schema: EngineConfigSchema,
86
+ configDir: './config',
87
+ });
88
+
89
+ // Create a registry — flowkit uses this to resolve class_path → constructor
90
+ const registry = new TaskRegistry();
91
+
92
+ // Create the flow runner
93
+ const runner = new FlowRunner({
94
+ tasks: config.tasks,
95
+ flows: config.flows,
96
+ registry,
97
+ context: {},
98
+ });
99
+
100
+ // Run the flow
101
+ const result = await runner.run({ flowName: 'hello' });
102
+
103
+ if (result.success) {
104
+ console.log('Flow completed successfully');
105
+ } else {
106
+ console.error('Flow failed:', result.error?.message);
107
+ }
108
+ ```
109
+
110
+ ## 4. Run it
111
+
112
+ ```bash
113
+ npx tsx run.ts
114
+ ```
115
+
116
+ The `greet` task's `class_path: tasks.Greet` tells flowkit to look for `tasks/Greet.ts` (or `.js`) relative to `process.cwd()`. It dynamically imports the file and instantiates the default export.
117
+
118
+ ## How task resolution works
119
+
120
+ When the flow runner encounters a task step, it:
121
+
122
+ 1. Looks up the task name in `config.tasks` to get the `class_path`
123
+ 2. Checks the registry for a constructor registered under that `class_path` or name
124
+ 3. If not found, converts the dotted path to a file path and dynamically imports it (e.g., `tasks.Greet` → `tasks/Greet.ts`)
125
+ 4. Instantiates the task with the merged options (task defaults + step overrides)
126
+ 5. Calls `task.run()` which runs `validate()` → `execute()` → returns the result
127
+
128
+ ## Explicit registration
129
+
130
+ Instead of relying on dynamic filesystem resolution, you can register tasks directly:
131
+
132
+ ```typescript
133
+ import Greet from './tasks/Greet.js';
134
+
135
+ const registry = new TaskRegistry();
136
+ registry.register('greet', Greet as any);
137
+
138
+ // Or by class_path
139
+ registry.registerClassPath('tasks.Greet', Greet as any);
140
+
141
+ // Or bulk register
142
+ registry.registerAll({
143
+ greet: Greet as any,
144
+ // ...more tasks
145
+ });
146
+ ```
147
+
148
+ ## Adding a logger
149
+
150
+ Flowkit accepts any logger with `debug`, `info`, `warn`, `error`, and `child` methods (pino, winston, etc.):
151
+
152
+ ```typescript
153
+ import pino from 'pino';
154
+
155
+ const logger = pino();
156
+
157
+ const runner = new FlowRunner({
158
+ tasks: config.tasks,
159
+ flows: config.flows,
160
+ registry,
161
+ context: { logger },
162
+ logger,
163
+ });
164
+ ```
165
+
166
+ The context logger is passed to each task instance. The runner logger is used for flow-level logging. Both fall back to a silent no-op logger if omitted.
167
+
168
+ ## Next steps
169
+
170
+ - [Custom tasks](custom-tasks.md) — validation, error handling, and the ShellTask
171
+ - [Configuration](configuration.md) — layered configs, environment overlays, deep merge
172
+ - [API reference](api-reference.md) — full type and function docs