@db-lyon/flowkit 0.12.0 → 0.14.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.
Files changed (41) hide show
  1. package/README.md +12 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/guard/index.d.ts +7 -0
  4. package/dist/guard/index.d.ts.map +1 -0
  5. package/dist/guard/index.js +5 -0
  6. package/dist/guard/index.js.map +1 -0
  7. package/dist/guard/pipeline.d.ts +22 -0
  8. package/dist/guard/pipeline.d.ts.map +1 -0
  9. package/dist/guard/pipeline.js +45 -0
  10. package/dist/guard/pipeline.js.map +1 -0
  11. package/dist/guard/registry.d.ts +20 -0
  12. package/dist/guard/registry.d.ts.map +1 -0
  13. package/dist/guard/registry.js +33 -0
  14. package/dist/guard/registry.js.map +1 -0
  15. package/dist/guard/task-guards.d.ts +89 -0
  16. package/dist/guard/task-guards.d.ts.map +1 -0
  17. package/dist/guard/task-guards.js +83 -0
  18. package/dist/guard/task-guards.js.map +1 -0
  19. package/dist/guard/types.d.ts +49 -0
  20. package/dist/guard/types.d.ts.map +1 -0
  21. package/dist/guard/types.js +38 -0
  22. package/dist/guard/types.js.map +1 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/references.d.ts.map +1 -1
  28. package/dist/references.js +6 -0
  29. package/dist/references.js.map +1 -1
  30. package/dist/task/shell-task.d.ts +2 -0
  31. package/dist/task/shell-task.d.ts.map +1 -1
  32. package/dist/task/shell-task.js +186 -40
  33. package/dist/task/shell-task.js.map +1 -1
  34. package/dist/task/shell-termination.d.ts +34 -0
  35. package/dist/task/shell-termination.d.ts.map +1 -0
  36. package/dist/task/shell-termination.js +179 -0
  37. package/dist/task/shell-termination.js.map +1 -0
  38. package/docs/api-reference.md +139 -1
  39. package/docs/custom-tasks.md +27 -1
  40. package/docs/guards.md +133 -0
  41. package/package.json +4 -2
@@ -171,7 +171,8 @@ interface TaskResult {
171
171
 
172
172
  ### `ShellTask`
173
173
 
174
- Built-in task that executes shell commands via `execSync`.
174
+ Built-in task that executes shell commands through the platform shell with
175
+ streamed stdout and stderr.
175
176
 
176
177
  ```typescript
177
178
  class ShellTask extends BaseTask<ShellTaskOptions> {
@@ -188,11 +189,38 @@ class ShellTask extends BaseTask<ShellTaskOptions> {
188
189
  | `command` | `string` | (required) | Shell command to execute |
189
190
  | `cwd` | `string` | `undefined` | Working directory |
190
191
  | `timeout` | `number` | `300000` | Timeout in milliseconds (5 min) |
192
+ | `signal` | `AbortSignal` | `undefined` | Cancels this individual programmatic invocation; not representable in YAML |
191
193
 
192
194
  **Success result:** `data.output` contains trimmed stdout.
193
195
 
194
196
  **Failure result:** `data.exitCode`, `data.stderr`, `data.stdout`.
195
197
 
198
+ When `signal` is already aborted, no shell process is launched and the task
199
+ returns the normal failed result with `Shell command cancelled`. An abort during
200
+ execution requests termination of the spawned shell process. On Windows,
201
+ Flowkit makes a best-effort `taskkill /T /F` request for that invocation's
202
+ shell PID and falls back to direct shell termination only if that request
203
+ fails or the three-second terminal deadline expires. On POSIX,
204
+ signal-bearing invocations run in a dedicated process group. Flowkit requests
205
+ `SIGTERM`, allows 250ms for cooperative cleanup, and then escalates the group
206
+ to `SIGKILL`. Neither approach guarantees termination of descendants that have
207
+ escaped the managed process tree or group; callers must not treat the returned
208
+ result as proof of complete descendant termination.
209
+
210
+ For signal-bearing invocations, Flowkit waits for the spawned shell to close
211
+ after requesting termination. If the operating system does not report closure
212
+ within one second on POSIX or three seconds on Windows, it returns the
213
+ cancellation or timeout result with the output captured so far; that bounded
214
+ fallback requests force termination and releases Flowkit's Node handles for
215
+ the root shell and any Windows `taskkill` helper, but is not confirmation that
216
+ either process, or any escaped descendant, has
217
+ exited. POSIX
218
+ signal-bearing invocations are in a separate process group, so terminal Ctrl+C
219
+ does not reach them automatically; cancel through the supplied `AbortSignal`.
220
+
221
+ Trailing stdout and stderr fragments are captured once. This corrects the
222
+ duplicate final-partial-line output in earlier releases.
223
+
196
224
  ---
197
225
 
198
226
  ### `TaskRegistry`
@@ -403,6 +431,116 @@ class MissingDependencyError extends Error {
403
431
 
404
432
  ---
405
433
 
434
+ ## Guard
435
+
436
+ *Import from `@db-lyon/flowkit` or `@db-lyon/flowkit/guard`*
437
+
438
+ A before/after pipeline around one host operation. See [guards.md](guards.md) for the guide.
439
+
440
+ ### `Guard<Ctx, TResult>`
441
+
442
+ ```typescript
443
+ interface Guard<Ctx extends GuardContext = GuardContext, TResult = unknown> {
444
+ readonly name: string;
445
+ readonly order?: number; // lower runs first, default 0
446
+ appliesTo?(ctx: Ctx): boolean | Promise<boolean>; // default: always
447
+ before?(ctx: Ctx): Promise<void>; // throw to DENY
448
+ after?(ctx: Ctx, result: TResult): Promise<TResult | void>; // return to replace
449
+ }
450
+ ```
451
+
452
+ ---
453
+
454
+ ### `GuardContext`
455
+
456
+ The minimum a host context must provide. Extend it with whatever the operation carries.
457
+
458
+ ```typescript
459
+ interface GuardContext {
460
+ meta: Map<string, unknown>; // scratch space shared across guards for one operation
461
+ }
462
+
463
+ function guardContextBase(): GuardContext
464
+ ```
465
+
466
+ ---
467
+
468
+ ### `lazy(ctx, key, compute)`
469
+
470
+ Wrap a computation so it runs at most once per operation, cached into `ctx.meta` under `key`.
471
+
472
+ ```typescript
473
+ function lazy<T>(ctx: GuardContext, key: string, compute: () => T): () => T
474
+ ```
475
+
476
+ ---
477
+
478
+ ### `GuardRegistry<Ctx, TResult>`
479
+
480
+ Ordered set of guards. Sorted on registration by `order`, then by name.
481
+
482
+ ```typescript
483
+ class GuardRegistry<Ctx extends GuardContext = GuardContext, TResult = unknown> {
484
+ register(guard: Guard<Ctx, TResult>): this;
485
+ registerAll(guards: Iterable<Guard<Ctx, TResult>>): this;
486
+ list(): readonly Guard<Ctx, TResult>[];
487
+ names(): string[];
488
+ get size(): number;
489
+ }
490
+ ```
491
+
492
+ ---
493
+
494
+ ### `runGuarded(ctx, registry, invoke)`
495
+
496
+ Run one operation through the pipeline: `before` in order, `invoke`, `after` in reverse.
497
+
498
+ ```typescript
499
+ function runGuarded<Ctx extends GuardContext, TResult>(
500
+ ctx: Ctx,
501
+ registry: GuardRegistry<Ctx, TResult>,
502
+ invoke: () => Promise<TResult>,
503
+ ): Promise<TResult>
504
+ ```
505
+
506
+ Applicability resolves once, up front. A `before` throw denies the operation and propagates unchanged. With an empty registry this is exactly `invoke()`.
507
+
508
+ ---
509
+
510
+ ### `discoverTaskGuards(registry, options)`
511
+
512
+ Build a `Guard` for every `guard.<name>.<before|after><Scope?>` task in a `TaskRegistry`.
513
+
514
+ ```typescript
515
+ function discoverTaskGuards<Ctx extends GuardContext, TResult = unknown>(
516
+ registry: TaskRegistry,
517
+ options: DiscoverTaskGuardsOptions<Ctx, TResult>,
518
+ ): Guard<Ctx, TResult>[]
519
+
520
+ interface DiscoverTaskGuardsOptions<Ctx extends GuardContext, TResult = unknown> {
521
+ scopes?: Record<string, (ctx: Ctx) => boolean | Promise<boolean>>;
522
+ contextFor(ctx: Ctx): TaskContext;
523
+ optionsFor(ctx: Ctx, result?: TResult): Record<string, unknown>;
524
+ onDeny?(info: GuardTaskFailure<Ctx>): Error;
525
+ onError?(info: GuardTaskFailure<Ctx>): Error;
526
+ onAfterFailure?(info: GuardTaskFailure<Ctx>): void;
527
+ logger?: Logger;
528
+ }
529
+
530
+ interface GuardTaskFailure<Ctx extends GuardContext> {
531
+ readonly guard: string; // 'p4' for guard.p4.beforeWrite
532
+ readonly phase: string; // 'beforeWrite'
533
+ readonly taskName: string; // 'guard.p4.beforeWrite'
534
+ readonly ctx: Ctx;
535
+ readonly reason: string;
536
+ readonly cause?: Error;
537
+ }
538
+ ```
539
+
540
+ Throws at discovery if a task names a scope the host did not register.
541
+
542
+ ---
543
+
406
544
  ## Logger
407
545
 
408
546
  *Import from `@db-lyon/flowkit`*
@@ -187,7 +187,8 @@ The module must have either a `default` export or a named export matching the la
187
187
 
188
188
  ## Built-in: ShellTask
189
189
 
190
- `ShellTask` executes shell commands via `execSync`. Register it under any name you like:
190
+ `ShellTask` executes shell commands through the platform shell and streams output.
191
+ Register it under any name you like:
191
192
 
192
193
  ```typescript
193
194
  import { ShellTask } from '@db-lyon/flowkit';
@@ -221,9 +222,34 @@ tasks:
221
222
  | `command` | `string` | (required) | The shell command to execute |
222
223
  | `cwd` | `string` | `undefined` | Working directory |
223
224
  | `timeout` | `number` | `300000` (5 min) | Timeout in milliseconds |
225
+ | `signal` | `AbortSignal` | `undefined` | Cancels one programmatic invocation; cannot be specified in YAML |
224
226
 
225
227
  On success, `result.data.output` contains the trimmed stdout. On failure, `result.data` includes `exitCode`, `stderr`, and `stdout`.
226
228
 
229
+ Existing YAML consumers need no change. Programmatic callers that need
230
+ cancellation pass an invocation-specific `AbortSignal` in the task options.
231
+ After cancellation, Flowkit waits for the shell to close, with a bounded
232
+ fallback if the operating system does not report closure (one second on POSIX,
233
+ three seconds on Windows). That fallback does not guarantee all descendants
234
+ have exited. At the deadline Flowkit requests force termination and releases
235
+ its Node handles for the root shell and any Windows `taskkill` helper before
236
+ returning. On POSIX,
237
+ signal-bearing invocations use a dedicated process group: Flowkit requests
238
+ `SIGTERM`, waits 250ms for cooperative cleanup, then escalates the group to
239
+ `SIGKILL`. Terminal Ctrl+C is not delivered to that separate group, so use the
240
+ supplied `AbortSignal` for cancellation.
241
+
242
+ On Windows, Flowkit asks `taskkill /T /F` to terminate the shell tree and does
243
+ not kill the shell while that traversal is in progress. If `taskkill` fails or
244
+ the three-second deadline expires, Flowkit requests force termination and
245
+ releases its Node handles for the root and helper. Windows and POSIX descendants that escape the managed process
246
+ tree or process group may still survive; Node does not provide a portable
247
+ guarantee of complete descendant termination.
248
+
249
+ Trailing stdout and stderr fragments are captured once, including when no
250
+ `signal` is supplied. This corrects the duplicate final-partial-line output in
251
+ earlier releases.
252
+
227
253
  ## Listing registered tasks
228
254
 
229
255
  ```typescript
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@db-lyon/flowkit",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "YAML-configured task and flow orchestration engine",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,8 @@
10
10
  "./config": "./dist/config/index.js",
11
11
  "./task": "./dist/task/index.js",
12
12
  "./flow": "./dist/flow/index.js",
13
- "./dag": "./dist/dag/index.js"
13
+ "./dag": "./dist/dag/index.js",
14
+ "./guard": "./dist/guard/index.js"
14
15
  },
15
16
  "files": [
16
17
  "/dist",
@@ -20,6 +21,7 @@
20
21
  "build": "tsc -b",
21
22
  "prepublishOnly": "tsc -b",
22
23
  "test": "vitest run",
24
+ "test:package-api": "npm run build && tsc -p test/tsconfig.public-api.json",
23
25
  "test:watch": "vitest"
24
26
  },
25
27
  "keywords": [