@db-lyon/flowkit 0.13.0 → 0.15.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.
- package/README.md +387 -386
- package/dist/.tsbuildinfo +1 -1
- package/dist/flow/runner.d.ts +12 -4
- package/dist/flow/runner.d.ts.map +1 -1
- package/dist/flow/runner.js +27 -12
- package/dist/flow/runner.js.map +1 -1
- package/dist/guard/index.d.ts +7 -0
- package/dist/guard/index.d.ts.map +1 -0
- package/dist/guard/index.js +5 -0
- package/dist/guard/index.js.map +1 -0
- package/dist/guard/pipeline.d.ts +22 -0
- package/dist/guard/pipeline.d.ts.map +1 -0
- package/dist/guard/pipeline.js +45 -0
- package/dist/guard/pipeline.js.map +1 -0
- package/dist/guard/registry.d.ts +20 -0
- package/dist/guard/registry.d.ts.map +1 -0
- package/dist/guard/registry.js +33 -0
- package/dist/guard/registry.js.map +1 -0
- package/dist/guard/task-guards.d.ts +89 -0
- package/dist/guard/task-guards.d.ts.map +1 -0
- package/dist/guard/task-guards.js +97 -0
- package/dist/guard/task-guards.js.map +1 -0
- package/dist/guard/types.d.ts +49 -0
- package/dist/guard/types.d.ts.map +1 -0
- package/dist/guard/types.js +38 -0
- package/dist/guard/types.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/task/base-task.d.ts +65 -3
- package/dist/task/base-task.d.ts.map +1 -1
- package/dist/task/base-task.js +51 -5
- package/dist/task/base-task.js.map +1 -1
- package/dist/task/index.d.ts +2 -2
- package/dist/task/index.d.ts.map +1 -1
- package/dist/task/index.js +1 -1
- package/dist/task/index.js.map +1 -1
- package/dist/task/registry.d.ts +11 -3
- package/dist/task/registry.d.ts.map +1 -1
- package/dist/task/registry.js +11 -3
- package/dist/task/registry.js.map +1 -1
- package/docs/ai-agents.md +368 -368
- package/docs/api-reference.md +606 -458
- package/docs/configuration.md +347 -347
- package/docs/custom-tasks.md +271 -258
- package/docs/guards.md +133 -0
- package/docs/releases.md +59 -0
- package/package.json +59 -53
- package/dist/flow/references.d.ts +0 -39
- package/dist/flow/references.d.ts.map +0 -1
- package/dist/flow/references.js +0 -102
- package/dist/flow/references.js.map +0 -1
package/docs/custom-tasks.md
CHANGED
|
@@ -1,258 +1,271 @@
|
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
the
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
if (this.executionPhase === 'rollback') {
|
|
135
|
+
// This invocation is compensating for earlier successful work.
|
|
136
|
+
}
|
|
137
|
+
// ...
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`this.executionPhase` is a public, read-only lifecycle value:
|
|
142
|
+
`'task' | 'on_start' | 'on_success' | 'on_failure' | 'finally' | 'rollback'`.
|
|
143
|
+
Ordinary steps (including nested-flow steps), direct `runTask` calls,
|
|
144
|
+
task-to-task calls, and agent/tool work receive `'task'`. Hook tasks receive
|
|
145
|
+
their hook phase, and rollback-record invocations receive `'rollback'`.
|
|
146
|
+
Flowkit supplies the value; existing runner configurations do not need to add
|
|
147
|
+
it to `context`. `ctx.executionPhase` holds the same value but is typed
|
|
148
|
+
optional, because `TaskContext` is also the shape hosts build their own context
|
|
149
|
+
interfaces from; prefer the `this.executionPhase` accessor.
|
|
150
|
+
|
|
151
|
+
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:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
context: { cache: new Map() } // this.ctx.cache.set(...) is visible everywhere
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Registering tasks
|
|
158
|
+
|
|
159
|
+
### By name
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const registry = new TaskRegistry();
|
|
163
|
+
registry.register('fetch_data', FetchData as any);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The YAML can then reference it directly:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
tasks:
|
|
170
|
+
fetch_data:
|
|
171
|
+
class_path: fetch_data
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### By class path
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
registry.registerClassPath('my.tasks.FetchData', FetchData as any);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Bulk registration
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
registry.registerAll({
|
|
184
|
+
fetch_data: FetchData as any,
|
|
185
|
+
transform: TransformData as any,
|
|
186
|
+
upload: Upload as any,
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Dynamic resolution
|
|
191
|
+
|
|
192
|
+
If a `class_path` isn't found in the registry, flowkit converts dots to path separators and looks for a file on disk:
|
|
193
|
+
|
|
194
|
+
| class_path | Files checked |
|
|
195
|
+
|------------|---------------|
|
|
196
|
+
| `tasks.FetchData` | `tasks/FetchData.ts`, `tasks/FetchData.js`, `tasks/FetchData/index.ts`, `tasks/FetchData/index.js` |
|
|
197
|
+
| `lib.etl.Extract` | `lib/etl/Extract.ts`, `lib/etl/Extract.js`, ... |
|
|
198
|
+
|
|
199
|
+
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`.
|
|
200
|
+
|
|
201
|
+
## Built-in: ShellTask
|
|
202
|
+
|
|
203
|
+
`ShellTask` executes shell commands through the platform shell and streams output.
|
|
204
|
+
Register it under any name you like:
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { ShellTask } from '@db-lyon/flowkit';
|
|
208
|
+
|
|
209
|
+
registry.register('shell', ShellTask as any);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then use it in YAML:
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
tasks:
|
|
216
|
+
lint:
|
|
217
|
+
class_path: shell
|
|
218
|
+
description: Run the linter
|
|
219
|
+
options:
|
|
220
|
+
command: npm run lint
|
|
221
|
+
|
|
222
|
+
build:
|
|
223
|
+
class_path: shell
|
|
224
|
+
description: Build the project
|
|
225
|
+
options:
|
|
226
|
+
command: npm run build
|
|
227
|
+
cwd: /path/to/project
|
|
228
|
+
timeout: 120000
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### ShellTask options
|
|
232
|
+
|
|
233
|
+
| Option | Type | Default | Description |
|
|
234
|
+
|--------|------|---------|-------------|
|
|
235
|
+
| `command` | `string` | (required) | The shell command to execute |
|
|
236
|
+
| `cwd` | `string` | `undefined` | Working directory |
|
|
237
|
+
| `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
|
|
238
|
+
| `signal` | `AbortSignal` | `undefined` | Cancels one programmatic invocation; cannot be specified in YAML |
|
|
239
|
+
|
|
240
|
+
On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
|
|
241
|
+
|
|
242
|
+
Existing YAML consumers need no change. Programmatic callers that need
|
|
243
|
+
cancellation pass an invocation-specific `AbortSignal` in the task options.
|
|
244
|
+
After cancellation, Flowkit waits for the shell to close, with a bounded
|
|
245
|
+
fallback if the operating system does not report closure (one second on POSIX,
|
|
246
|
+
three seconds on Windows). That fallback does not guarantee all descendants
|
|
247
|
+
have exited. At the deadline Flowkit requests force termination and releases
|
|
248
|
+
its Node handles for the root shell and any Windows `taskkill` helper before
|
|
249
|
+
returning. On POSIX,
|
|
250
|
+
signal-bearing invocations use a dedicated process group: Flowkit requests
|
|
251
|
+
`SIGTERM`, waits 250ms for cooperative cleanup, then escalates the group to
|
|
252
|
+
`SIGKILL`. Terminal Ctrl+C is not delivered to that separate group, so use the
|
|
253
|
+
supplied `AbortSignal` for cancellation.
|
|
254
|
+
|
|
255
|
+
On Windows, Flowkit asks `taskkill /T /F` to terminate the shell tree and does
|
|
256
|
+
not kill the shell while that traversal is in progress. If `taskkill` fails or
|
|
257
|
+
the three-second deadline expires, Flowkit requests force termination and
|
|
258
|
+
releases its Node handles for the root and helper. Windows and POSIX descendants that escape the managed process
|
|
259
|
+
tree or process group may still survive; Node does not provide a portable
|
|
260
|
+
guarantee of complete descendant termination.
|
|
261
|
+
|
|
262
|
+
Trailing stdout and stderr fragments are captured once, including when no
|
|
263
|
+
`signal` is supplied. This corrects the duplicate final-partial-line output in
|
|
264
|
+
earlier releases.
|
|
265
|
+
|
|
266
|
+
## Listing registered tasks
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
const names = registry.listRegistered();
|
|
270
|
+
// ['fetch_data', 'shell', 'my.tasks.Transform', ...]
|
|
271
|
+
```
|
package/docs/guards.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Guards
|
|
2
|
+
|
|
3
|
+
A guard is a `before`/`after` pipeline around one host operation. It sits on a seam the host already has (an RPC call, a write, a command dispatch) and may veto that operation, act on it, or observe its result.
|
|
4
|
+
|
|
5
|
+
The pipeline knows nothing about what any guard does. Access policy, source control checkout, audit logging, rate limiting and approval gating are all just guards.
|
|
6
|
+
|
|
7
|
+
Guards are distinct from [`FlowRunnerHooks`](api-reference.md#flow), which fire around flow steps. A hook observes a step; a guard wraps one host operation and can deny it.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { GuardRegistry, runGuarded, guardContextBase, lazy } from '@db-lyon/flowkit/guard';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The context
|
|
14
|
+
|
|
15
|
+
Flowkit requires only a scratch map. Everything else belongs to the host.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
interface GuardContext {
|
|
19
|
+
readonly meta: Map<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Extend it with whatever your operation carries, and build it with `guardContextBase()`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
interface CallContext extends GuardContext {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
readonly params: Record<string, unknown>;
|
|
29
|
+
/** Files this call will modify. Computed on demand. */
|
|
30
|
+
files(): string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeCallContext(method: string, params: Record<string, unknown>): CallContext {
|
|
34
|
+
const ctx = { ...guardContextBase(), method, params } as CallContext;
|
|
35
|
+
ctx.files = lazy(ctx, 'files', () => classify(method, params));
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`lazy(ctx, key, compute)` caches into `meta`, so enrichment that is expensive to compute and that most guards never consult costs nothing when it is ignored and is computed once when several guards want it.
|
|
41
|
+
|
|
42
|
+
`meta` is also how guards talk to each other. A `before` hook can stash what it did and the matching `after` hook, or a later guard, can read it back.
|
|
43
|
+
|
|
44
|
+
## Writing a guard
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface Guard<Ctx extends GuardContext, TResult> {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly order?: number;
|
|
50
|
+
appliesTo?(ctx: Ctx): boolean | Promise<boolean>;
|
|
51
|
+
before?(ctx: Ctx): Promise<void>;
|
|
52
|
+
after?(ctx: Ctx, result: TResult): Promise<TResult | void>;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **`before`** runs before the operation. Throw to deny it: the operation never happens and your error propagates unchanged, so the host's own error type survives.
|
|
57
|
+
- **`after`** runs after a successful operation. Return a value to replace the result; return nothing to leave it alone.
|
|
58
|
+
- **`appliesTo`** decides whether the guard participates at all. Default is always.
|
|
59
|
+
- **`order`** sorts the chain, lower first. Ties break by name, so a registry built from an unordered source still runs deterministically.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const sourceControl: Guard<CallContext, unknown> = {
|
|
63
|
+
name: 'source-control',
|
|
64
|
+
order: 10,
|
|
65
|
+
appliesTo: (ctx) => ctx.files().length > 0,
|
|
66
|
+
before: async (ctx) => {
|
|
67
|
+
const denied = await checkout(ctx.files());
|
|
68
|
+
if (denied.length) throw new Error(`locked by another user: ${denied.join(', ')}`);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Running the pipeline
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const guards = new GuardRegistry<CallContext, Result>().registerAll([sourceControl, audit]);
|
|
77
|
+
|
|
78
|
+
async function call(method: string, params: Record<string, unknown>): Promise<Result> {
|
|
79
|
+
return runGuarded(makeCallContext(method, params), guards, () => transport.send(method, params));
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`before` hooks run in registration order, then `invoke`, then `after` hooks in reverse order, so a guard's two halves nest rather than interleave.
|
|
84
|
+
|
|
85
|
+
Applicability resolves once, up front. A guard whose `before` changes the answer to its own `appliesTo` (a source-control guard that checks a file out, making it writable) still gets its `after` half.
|
|
86
|
+
|
|
87
|
+
With an empty registry `runGuarded` is exactly `invoke()`, so it is safe to install on a seam before any guard exists.
|
|
88
|
+
|
|
89
|
+
## Guards from tasks
|
|
90
|
+
|
|
91
|
+
A host that already loads tasks from config or from plugins gets guards for free. Name a task `guard.<name>.<phase>` and `discoverTaskGuards` turns it into a guard. No separate activation concept is needed, because the task registry is already the list of everything the host was given.
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
tasks:
|
|
95
|
+
guard.p4.beforeWrite:
|
|
96
|
+
class_path: ./guards/perforce.js
|
|
97
|
+
guard.audit.after:
|
|
98
|
+
class_path: ./guards/audit.js
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`<phase>` is `before` or `after`, optionally suffixed with a scope the host declared:
|
|
102
|
+
|
|
103
|
+
| Task name | Runs |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `guard.audit.before` | before every operation |
|
|
106
|
+
| `guard.audit.after` | after every successful operation |
|
|
107
|
+
| `guard.p4.beforeWrite` | before an operation the `write` scope claims |
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const guards = discoverTaskGuards<CallContext, Result>(taskRegistry, {
|
|
111
|
+
scopes: { write: (ctx) => ctx.files().length > 0 },
|
|
112
|
+
contextFor: (ctx) => ({ logger, registry: taskRegistry, transport: ctx.transport }),
|
|
113
|
+
optionsFor: (ctx, result) => ({
|
|
114
|
+
method: ctx.method,
|
|
115
|
+
params: ctx.params,
|
|
116
|
+
paths: ctx.files(),
|
|
117
|
+
...(result !== undefined ? { result } : {}),
|
|
118
|
+
}),
|
|
119
|
+
onDeny: (info) => new PolicyError(`blocked (${info.ctx.method}): ${info.reason}`),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`contextFor` is called per operation, so a guard can be bound to whatever the operation belongs to rather than to a single ambient target. A host driving several connections should hand each guard the one serving the call it is guarding.
|
|
124
|
+
|
|
125
|
+
A scope named by a task but not registered by the host is an error at discovery time. A typo in a plugin's task name surfaces at startup rather than becoming a guard that silently runs on everything.
|
|
126
|
+
|
|
127
|
+
### Denial and failure
|
|
128
|
+
|
|
129
|
+
A `before` guard denies by returning `success: false` or by throwing. Both route to `onDeny`, because `BaseTask.run` turns an exception into a failed result: a guard that crashes denies the operation rather than waving it through, which is the safe direction for the thing standing between a caller and a mutation.
|
|
130
|
+
|
|
131
|
+
`onError` is separate and narrower. It fires only when the guard task cannot be constructed at all, an unresolvable class path or a module that fails to import, where nothing about the operation was evaluated.
|
|
132
|
+
|
|
133
|
+
An `after` guard observes the result and cannot replace it, since a task returns a `TaskResult` rather than the host's result type. A failure is reported through `onAfterFailure` instead of failing an operation that already happened. Reach for a hand-written `Guard` when you need an `after` hook that transforms the result.
|