@gesslar/actioneer 0.1.3 → 0.2.1

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.
@@ -1,18 +1,15 @@
1
- import {FileObject, Sass, Valid} from "@gesslar/toolkit"
1
+ import {Data, Sass, Valid} from "@gesslar/toolkit"
2
2
 
3
3
  import ActionBuilder from "./ActionBuilder.js"
4
4
  import {ACTIVITY} from "./Activity.js"
5
5
  import Piper from "./Piper.js"
6
6
 
7
- /** @typedef {import("./ActionHooks.js").default} ActionHooks */
8
-
9
7
  /**
10
8
  * @typedef {(message: string, level?: number, ...args: Array<unknown>) => void} DebugFn
11
9
  */
12
10
 
13
11
  /**
14
12
  * @typedef {object} ActionRunnerOptions
15
- * @property {ActionHooks} [hooks] Pre-configured hooks.
16
13
  * @property {DebugFn} [debug] Logger function.
17
14
  */
18
15
  /**
@@ -23,154 +20,192 @@ import Piper from "./Piper.js"
23
20
  * context object under `result.value` that can be replaced or enriched.
24
21
  */
25
22
  export default class ActionRunner extends Piper {
26
- /**
27
- * Pipeline produced by the builder.
28
- *
29
- * @type {import("./ActionWrapper.js").default|null}
30
- */
23
+ /** @type {import("./ActionBuilder.js").default|null} */
24
+ #actionBuilder = null
25
+ /** @type {import("./ActionWrapper.js").default|null} */
31
26
  #actionWrapper = null
27
+
32
28
  /**
33
29
  * Logger invoked for diagnostics.
34
30
  *
35
31
  * @type {DebugFn}
36
32
  */
37
33
  #debug = () => {}
38
- /**
39
- * Filesystem path to a hooks module.
40
- *
41
- * @type {string|null}
42
- */
43
- #hooksPath = null
44
- /**
45
- * Constructor name exported by the hooks module.
46
- *
47
- * @type {string|null}
48
- */
49
- #hooksClassName = null
50
- /**
51
- * Lazily instantiated hooks implementation.
52
- *
53
- * @type {ActionHooks|null}
54
- */
55
- #hooks = null
56
- /**
57
- * Unique tag for log correlation.
58
- *
59
- * @type {symbol|null}
60
- */
61
- #tag = null
62
34
 
63
35
  /**
64
36
  * Instantiate a runner over an optional action wrapper.
65
37
  *
66
- * @param {import("./ActionWrapper.js").default|null} wrappedAction Output of {@link ActionBuilder#build}.
67
- * @param {ActionRunnerOptions} [options] Optional hooks/debug overrides.
38
+ * @param {import("./ActionBuilder.js").default|null} actionBuilder ActionBuilder to build.
39
+ * @param {ActionRunnerOptions} [options] Optional debug overrides.
68
40
  */
69
- constructor(wrappedAction, {hooks,debug=(() => {})} = {}) {
41
+ constructor(actionBuilder, {debug=(() => {})} = {}) {
70
42
  super({debug})
71
43
 
72
- this.#tag = Symbol(performance.now())
73
-
74
44
  this.#debug = debug
75
45
 
76
- if(!wrappedAction)
46
+ if(!actionBuilder)
77
47
  return this
78
48
 
79
- if(wrappedAction?.constructor?.name !== "ActionWrapper")
80
- throw Sass.new("ActionRunner takes an instance of an ActionWrapper")
81
-
82
- this.#actionWrapper = wrappedAction
49
+ if(actionBuilder?.constructor?.name !== "ActionBuilder")
50
+ throw Sass.new("ActionRunner takes an instance of an ActionBuilder")
83
51
 
84
- if(hooks)
85
- this.#hooks = hooks
86
- else
87
- this.addSetup(this.#loadHooks)
52
+ this.#actionBuilder = actionBuilder
88
53
 
89
- this.addStep(this.run)
54
+ this.addStep(this.run, {
55
+ name: `ActionRunner for ${actionBuilder.tag.description}`
56
+ })
90
57
  }
91
58
 
92
59
  /**
93
60
  * Executes the configured action pipeline.
61
+ * Builds the ActionWrapper on first run and caches it for subsequent calls.
62
+ * Supports WHILE, UNTIL, and SPLIT activity kinds.
94
63
  *
95
64
  * @param {unknown} context - Seed value passed to the first activity.
96
- * @returns {Promise<unknown>} Final value produced by the pipeline, or null when a parallel stage reports failures.
97
- * @throws {Sass} When no activities are registered or required parallel builders are missing.
65
+ * @returns {Promise<unknown>} Final value produced by the pipeline.
66
+ * @throws {Sass} When no activities are registered, conflicting activity kinds are used, or execution fails.
98
67
  */
99
68
  async run(context) {
100
- this.#debug(this.#tag.description)
69
+ if(!this.#actionWrapper)
70
+ this.#actionWrapper = await this.#actionBuilder.build()
71
+
101
72
  const actionWrapper = this.#actionWrapper
102
73
  const activities = actionWrapper.activities
103
74
 
104
75
  for(const activity of activities) {
105
- activity.setActionHooks(this.#hooks)
106
-
107
- const kind = activity.kind
108
-
109
- // If we have no kind, then it's just a once.
110
- // Get it over and done with!
111
- if(!kind) {
112
- context = await this.#executeActivity(activity, context)
113
- } else {
114
- const {WHILE,UNTIL} = ACTIVITY
115
-
116
- const pred = activity.pred
117
- const kindWhile = kind & WHILE
118
- const kindUntil = kind & UNTIL
76
+ try {
77
+ // await timeout(500)
119
78
 
120
- if(kindWhile && kindUntil)
121
- throw Sass.new(
122
- "For Kathy Griffin's sake! You can't do something while AND " +
123
- "until. Pick one!"
124
- )
79
+ const kind = activity.kind
125
80
 
126
- if(kindWhile || kindUntil) {
127
- for(;;) {
128
-
129
- if(kindWhile)
130
- if(!await this.#predicateCheck(activity,pred,context))
131
- break
132
-
133
- context = await this.#executeActivity(activity,context)
134
-
135
- if(kindUntil)
136
- if(!await this.#predicateCheck(activity,pred,context))
137
- break
138
- }
81
+ // If we have no kind, then it's just a once.
82
+ // Get it over and done with!
83
+ if(!kind) {
84
+ context = await this.#execute(activity, context)
139
85
  } else {
140
- context = await this.#executeActivity(activity, context)
86
+ // Validate that only one activity kind bit is set
87
+ // (kind & (kind - 1)) !== 0 means multiple bits are set
88
+ const multipleBitsSet = (kind & (kind - 1)) !== 0
89
+ if(multipleBitsSet)
90
+ throw Sass.new(
91
+ "For Kathy Griffin's sake! You can't combine activity kinds. " +
92
+ "Pick one: WHILE, UNTIL, or SPLIT!"
93
+ )
94
+
95
+ const {WHILE,UNTIL,SPLIT} = ACTIVITY
96
+ const kindWhile = kind & WHILE
97
+ const kindUntil = kind & UNTIL
98
+ const kindSplit = kind & SPLIT
99
+
100
+ if(kindWhile || kindUntil) {
101
+ const predicate = activity.pred
102
+
103
+ for(;;) {
104
+
105
+ if(kindWhile)
106
+ if(!await this.#hasPredicate(activity,predicate,context))
107
+ break
108
+
109
+ context = await this.#execute(activity,context)
110
+
111
+ if(kindUntil)
112
+ if(!await this.#hasPredicate(activity,predicate,context))
113
+ break
114
+ }
115
+ } else if(kindSplit && activity.opKind === "ActionBuilder") {
116
+ // SPLIT activity: parallel execution with splitter/rejoiner pattern
117
+ const splitter = activity.splitter
118
+ const rejoiner = activity.rejoiner
119
+
120
+ if(!splitter || !rejoiner)
121
+ throw Sass.new(
122
+ `SPLIT activity "${String(activity.name)}" requires both splitter and rejoiner functions.`
123
+ )
124
+
125
+ const original = context
126
+ const splitContext = splitter.call(activity.action,context)
127
+ const newContext = await this.#execute(activity,splitContext,true)
128
+ const rejoined = rejoiner.call(activity.action, original,newContext)
129
+
130
+ context = rejoined
131
+ } else {
132
+ context = await this.#execute(activity, context)
133
+ }
141
134
  }
135
+ } catch(error) {
136
+ throw Sass.new("ActionRunner running activity", error)
142
137
  }
143
-
144
138
  }
145
139
 
146
140
  return context
147
141
  }
148
142
 
149
143
  /**
150
- * Execute a single activity, recursing into nested action wrappers when needed.
144
+ * Execute a single activity, recursing into nested ActionBuilders when needed.
145
+ * Handles both function-based activities and ActionBuilder-based nested pipelines.
146
+ * Automatically propagates hooks to nested builders and handles dynamic ActionBuilder returns.
147
+ *
148
+ * When parallel=true, uses Piper.pipe() for concurrent execution with worker pool pattern.
149
+ * This is triggered by SPLIT activities where context is divided for parallel processing.
150
+ * Results from parallel execution are filtered to only include successful outcomes ({ok: true}).
151
151
  *
152
152
  * @param {import("./Activity.js").default} activity Pipeline activity descriptor.
153
153
  * @param {unknown} context Current pipeline context.
154
+ * @param {boolean} [parallel] Whether to use parallel execution (via pipe() instead of run()). Default: false.
154
155
  * @returns {Promise<unknown>} Resolved activity result.
156
+ * @throws {Sass} If the operation kind is invalid, or if SPLIT activity lacks splitter/rejoiner.
155
157
  * @private
156
158
  */
157
- async #executeActivity(activity, context) {
159
+ async #execute(activity, context, parallel=false) {
158
160
  // What kind of op are we looking at? Is it a function?
159
- // Or a class instance of type ActionWrapper?
161
+ // Or a class instance of type ActionBuilder?
160
162
  const opKind = activity.opKind
161
- if(opKind === "ActionWrapper") {
163
+
164
+ if(opKind === "ActionBuilder") {
165
+ if(activity.hooks && !activity.op.hasActionHooks)
166
+ activity.op.withActionHooks(activity.hooks)
167
+
162
168
  const runner = new this.constructor(activity.op, {
163
- debug: this.#debug,
164
- hooks: this.#hooks,
169
+ debug: this.#debug, name: activity.name
165
170
  })
166
- .setHooks(this.#hooksPath, this.#hooksClassName)
167
171
 
168
- return await runner.run(context, true)
172
+ if(parallel) {
173
+ const piped = await runner.pipe(context)
174
+
175
+ return piped.filter(p => p.ok).map(p => p.value)
176
+ } else {
177
+ return await runner.run(context)
178
+ }
169
179
  } else if(opKind === "Function") {
170
- return await activity.run(context)
180
+ try {
181
+ const result = await activity.run(context)
182
+
183
+ if(Data.isType(result, "ActionBuilder")) {
184
+ if(activity.hooks)
185
+ result.withActionHooks(activity.hooks)
186
+
187
+ const runner = new this.constructor(result, {
188
+ debug: this.#debug, name: result.name
189
+ })
190
+
191
+ if(parallel) {
192
+ const piped = await runner.pipe(context)
193
+
194
+ return piped.filter(p => p.ok).map(p => p.value)
195
+ } else {
196
+ return await runner.run(context)
197
+ }
198
+ } else {
199
+ return result
200
+ }
201
+ } catch(error) {
202
+ throw Sass.new("Executing activity", error)
203
+ }
171
204
  }
172
205
 
173
- throw Sass.new("We buy Functions and ActionWrappers. Only. Not whatever that was.")
206
+ console.log(activity.opKind + " " + JSON.stringify(activity))
207
+
208
+ throw Sass.new("We buy Functions and ActionBuilders. Only. Not whatever that was.")
174
209
  }
175
210
 
176
211
  /**
@@ -182,7 +217,7 @@ export default class ActionRunner extends Piper {
182
217
  * @returns {Promise<boolean>} True when the predicate allows another iteration.
183
218
  * @private
184
219
  */
185
- async #predicateCheck(activity,predicate,context) {
220
+ async #hasPredicate(activity,predicate,context) {
186
221
  Valid.type(predicate, "Function")
187
222
 
188
223
  return !!(await predicate.call(activity.action, context))
@@ -191,46 +226,4 @@ export default class ActionRunner extends Piper {
191
226
  toString() {
192
227
  return `[object ${this.constructor.name}]`
193
228
  }
194
-
195
- /**
196
- * Configure hooks to be lazily loaded when the pipeline runs.
197
- *
198
- * @param {string} hooksPath Absolute path to the module exporting the hooks class.
199
- * @param {string} className Constructor to instantiate from the hooks module.
200
- * @returns {this} Runner instance for chaining.
201
- */
202
- setHooks(hooksPath, className) {
203
- this.#hooksPath = hooksPath
204
- this.#hooksClassName = className
205
-
206
- this.addSetup(() => this.#loadHooks())
207
-
208
- return this
209
- }
210
-
211
- /**
212
- * Import and instantiate the configured hooks module.
213
- *
214
- * @returns {Promise<null|void>} Null when hooks are disabled, otherwise void.
215
- * @private
216
- */
217
- async #loadHooks() {
218
- if(!this.#hooksPath)
219
- return null
220
-
221
- const file = new FileObject(this.#hooksPath)
222
- if(!await file.exists)
223
- throw Sass.new(`File '${file.uri} does not exist.`)
224
-
225
- const module = await file.import()
226
- const hooksClassName = this.#hooksClassName
227
-
228
- Valid.type(module[hooksClassName], "Function")
229
-
230
- const loaded = new module[hooksClassName]({
231
- debug: this.#debug
232
- })
233
-
234
- this.#hooks = loaded
235
- }
236
229
  }
@@ -3,9 +3,11 @@ import Activity from "./Activity.js"
3
3
  /**
4
4
  * @typedef {object} WrappedActivityConfig
5
5
  * @property {string|symbol} name Activity identifier used by hooks/logs.
6
- * @property {(context: unknown) => unknown|Promise<unknown>|ActionWrapper} op Operation or nested wrapper to execute.
6
+ * @property {(context: unknown) => unknown|Promise<unknown>|import("./ActionBuilder.js").default} op Operation or nested ActionBuilder to execute.
7
7
  * @property {number} [kind] Optional loop semantic flags.
8
8
  * @property {(context: unknown) => boolean|Promise<boolean>} [pred] Predicate tied to WHILE/UNTIL semantics.
9
+ * @property {(context: unknown) => unknown} [splitter] Splitter function for SPLIT activities.
10
+ * @property {(originalContext: unknown, splitResults: unknown) => unknown} [rejoiner] Rejoiner function for SPLIT activities.
9
11
  * @property {unknown} [action] Parent action instance supplied when invoking the op.
10
12
  * @property {(message: string, level?: number, ...args: Array<unknown>) => void} [debug] Optional logger reference.
11
13
  */
@@ -31,24 +33,35 @@ export default class ActionWrapper {
31
33
  */
32
34
  #debug = () => {}
33
35
 
36
+ /**
37
+ * ActionHooks instance shared across all activities.
38
+ *
39
+ * @type {import("./ActionHooks.js").default|null}
40
+ */
41
+ #hooks = null
42
+
34
43
  /**
35
44
  * Create a wrapper from the builder payload.
36
45
  *
37
- * @param {{activities: Map<string|symbol, WrappedActivityConfig>, debug: (message: string, level?: number, ...args: Array<unknown>) => void}} init Builder payload containing activities + logger.
46
+ * @param {object} config Builder payload containing activities + logger
47
+ * @param {Map<string|symbol, WrappedActivityConfig>} config.activities Activities map
48
+ * @param {(message: string, level?: number, ...args: Array<unknown>) => void} config.debug Debug function
49
+ * @param {object} config.hooks Hooks object
38
50
  */
39
- constructor({activities,debug}) {
40
- this.#debug = debug
41
- this.#activities = activities
51
+ constructor(config) {
52
+ this.#debug = config.debug
53
+ this.#hooks = config.hooks
54
+ this.#activities = config.activities
42
55
  this.#debug(
43
56
  "Instantiating ActionWrapper with %o activities.",
44
57
  2,
45
- activities.size,
58
+ this.#activities.size,
46
59
  )
47
60
  }
48
61
 
49
62
  *#_activities() {
50
63
  for(const [,activity] of this.#activities)
51
- yield new Activity(activity)
64
+ yield new Activity({...activity, hooks: this.#hooks})
52
65
  }
53
66
 
54
67
  /**
@@ -1,36 +1,65 @@
1
1
  import {Data} from "@gesslar/toolkit"
2
2
 
3
+ /** @typedef {import("./ActionHooks.js").default} ActionHooks */
4
+
3
5
  /**
4
6
  * Activity bit flags recognised by the builder. The flag decides
5
7
  * loop semantics for an activity.
6
8
  *
7
9
  * @readonly
8
10
  * @enum {number}
11
+ * @property {number} WHILE - Execute activity while predicate returns true (2)
12
+ * @property {number} UNTIL - Execute activity until predicate returns false (4)
13
+ * @property {number} SPLIT - Execute activity with split/rejoin pattern for parallel execution (8)
9
14
  */
10
15
  export const ACTIVITY = Object.freeze({
11
16
  WHILE: 1<<1,
12
17
  UNTIL: 1<<2,
18
+ SPLIT: 1<<3,
13
19
  })
14
20
 
15
21
  export default class Activity {
22
+ /** @type {unknown} */
16
23
  #action = null
24
+ /** @type {unknown} */
25
+ #context = null
26
+ /** @type {ActionHooks|null} */
27
+ #hooks = null
28
+ /** @type {number|null} */
29
+ #kind = null
30
+ /** @type {string|symbol} */
17
31
  #name = null
32
+ /** @type {((context: unknown) => unknown|Promise<unknown>)|import("./ActionBuilder.js").default} */
18
33
  #op = null
19
- #kind = null
34
+ /** @type {((context: unknown) => boolean|Promise<boolean>)|null} */
20
35
  #pred = null
21
- #hooks = null
36
+ /** @type {((originalContext: unknown, splitResults: unknown) => unknown)|null} */
37
+ #rejoiner = null
38
+ /** @type {((context: unknown) => unknown)|null} */
39
+ #splitter = null
22
40
 
23
41
  /**
24
42
  * Construct an Activity definition wrapper.
25
43
  *
26
- * @param {{action: unknown, name: string, op: (context: unknown) => unknown|Promise<unknown>|unknown, kind?: number, pred?: (context: unknown) => boolean|Promise<boolean>}} init - Initial properties describing the activity operation, loop semantics, and predicate
44
+ * @param {object} init - Initial properties describing the activity operation, loop semantics, and predicate
45
+ * @param {unknown} init.action - Parent action instance
46
+ * @param {string|symbol} init.name - Activity identifier
47
+ * @param {(context: unknown) => unknown|Promise<unknown>|import("./ActionBuilder.js").default} init.op - Operation to execute
48
+ * @param {number} [init.kind] - Optional loop semantics flags
49
+ * @param {(context: unknown) => boolean|Promise<boolean>} [init.pred] - Optional predicate for WHILE/UNTIL
50
+ * @param {ActionHooks} [init.hooks] - Optional hooks instance
51
+ * @param {(context: unknown) => unknown} [init.splitter] - Optional splitter function for SPLIT activities
52
+ * @param {(originalContext: unknown, splitResults: unknown) => unknown} [init.rejoiner] - Optional rejoiner function for SPLIT activities
27
53
  */
28
- constructor({action,name,op,kind,pred}) {
54
+ constructor({action,name,op,kind,pred,hooks,splitter,rejoiner}) {
55
+ this.#action = action
56
+ this.#hooks = hooks
57
+ this.#kind = kind
29
58
  this.#name = name
30
59
  this.#op = op
31
- this.#kind = kind
32
- this.#action = action
33
60
  this.#pred = pred
61
+ this.#rejoiner = rejoiner
62
+ this.#splitter = splitter
34
63
  }
35
64
 
36
65
  /**
@@ -61,7 +90,16 @@ export default class Activity {
61
90
  }
62
91
 
63
92
  /**
64
- * The operator kind name (Function or ActionWrapper).
93
+ * The current context (if set).
94
+ *
95
+ * @returns {unknown} Current context value
96
+ */
97
+ get context() {
98
+ return this.#context
99
+ }
100
+
101
+ /**
102
+ * The operator kind name (Function or ActionBuilder).
65
103
  *
66
104
  * @returns {string} - Kind name extracted via Data.typeOf
67
105
  */
@@ -70,14 +108,32 @@ export default class Activity {
70
108
  }
71
109
 
72
110
  /**
73
- * The operator to execute (function or nested wrapper).
111
+ * The operator to execute (function or nested ActionBuilder).
74
112
  *
75
- * @returns {unknown} - Activity operation
113
+ * @returns {(context: unknown) => unknown|Promise<unknown>|import("./ActionBuilder.js").default} - Activity operation
76
114
  */
77
115
  get op() {
78
116
  return this.#op
79
117
  }
80
118
 
119
+ /**
120
+ * The splitter function for SPLIT activities.
121
+ *
122
+ * @returns {((context: unknown) => unknown)|null} Splitter function or null
123
+ */
124
+ get splitter() {
125
+ return this.#splitter
126
+ }
127
+
128
+ /**
129
+ * The rejoiner function for SPLIT activities.
130
+ *
131
+ * @returns {((originalContext: unknown, splitResults: unknown) => unknown)|null} Rejoiner function or null
132
+ */
133
+ get rejoiner() {
134
+ return this.#rejoiner
135
+ }
136
+
81
137
  /**
82
138
  * The action instance this activity belongs to.
83
139
  *
@@ -91,22 +147,17 @@ export default class Activity {
91
147
  * Execute the activity with before/after hooks.
92
148
  *
93
149
  * @param {unknown} context - Mutable context flowing through the pipeline
94
- * @returns {Promise<{activityResult: unknown}>} - Activity result wrapper with new context
150
+ * @returns {Promise<unknown>} - Activity result
95
151
  */
96
152
  async run(context) {
97
- const hooks = this.#hooks
98
-
99
153
  // before hook
100
- const before = hooks?.[`before$${this.#name}`]
101
- if(Data.typeOf(before) === "Function")
102
- await before.call(hooks,context)
154
+ await this.#hooks?.callHook("before", this.#name, context)
103
155
 
156
+ // not a hook
104
157
  const result = await this.#op.call(this.#action,context)
105
158
 
106
159
  // after hook
107
- const after = hooks?.[`after$${this.#name}`]
108
- if(Data.typeOf(after) === "Function")
109
- await after.call(hooks,context)
160
+ await this.#hooks?.callHook("after", this.#name, context)
110
161
 
111
162
  return result
112
163
  }
@@ -114,7 +165,7 @@ export default class Activity {
114
165
  /**
115
166
  * Attach hooks to this activity instance.
116
167
  *
117
- * @param {unknown} hooks - Hooks instance with optional before$/after$ methods
168
+ * @param {ActionHooks} hooks - Hooks instance with optional before$/after$ methods
118
169
  * @returns {this} - This activity for chaining
119
170
  */
120
171
  setActionHooks(hooks) {
@@ -123,4 +174,13 @@ export default class Activity {
123
174
 
124
175
  return this
125
176
  }
177
+
178
+ /**
179
+ * Get the hooks instance attached to this activity.
180
+ *
181
+ * @returns {ActionHooks|null} The hooks instance or null
182
+ */
183
+ get hooks() {
184
+ return this.#hooks
185
+ }
126
186
  }