apcore-js 0.15.1 → 0.17.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 (67) hide show
  1. package/README.md +2 -2
  2. package/dist/acl-handlers.d.ts +42 -0
  3. package/dist/acl-handlers.d.ts.map +1 -0
  4. package/dist/acl-handlers.js +128 -0
  5. package/dist/acl-handlers.js.map +1 -0
  6. package/dist/acl.d.ts +11 -0
  7. package/dist/acl.d.ts.map +1 -1
  8. package/dist/acl.js +148 -37
  9. package/dist/acl.js.map +1 -1
  10. package/dist/builtin-steps.d.ts +159 -0
  11. package/dist/builtin-steps.d.ts.map +1 -0
  12. package/dist/builtin-steps.js +532 -0
  13. package/dist/builtin-steps.js.map +1 -0
  14. package/dist/client.d.ts +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +1 -1
  17. package/dist/client.js.map +1 -1
  18. package/dist/config.d.ts +39 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +174 -24
  21. package/dist/config.js.map +1 -1
  22. package/dist/context-key.d.ts +25 -0
  23. package/dist/context-key.d.ts.map +1 -0
  24. package/dist/context-key.js +37 -0
  25. package/dist/context-key.js.map +1 -0
  26. package/dist/context-keys.d.ts +11 -0
  27. package/dist/context-keys.d.ts.map +1 -0
  28. package/dist/context-keys.js +13 -0
  29. package/dist/context-keys.js.map +1 -0
  30. package/dist/context.d.ts +28 -3
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +60 -21
  33. package/dist/context.js.map +1 -1
  34. package/dist/errors.d.ts +4 -0
  35. package/dist/errors.d.ts.map +1 -1
  36. package/dist/errors.js +7 -0
  37. package/dist/errors.js.map +1 -1
  38. package/dist/executor.d.ts +49 -28
  39. package/dist/executor.d.ts.map +1 -1
  40. package/dist/executor.js +319 -383
  41. package/dist/executor.js.map +1 -1
  42. package/dist/generated/version.d.ts +1 -1
  43. package/dist/generated/version.js +1 -1
  44. package/dist/index.d.ts +9 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +10 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/module.d.ts +22 -6
  49. package/dist/module.d.ts.map +1 -1
  50. package/dist/module.js +73 -0
  51. package/dist/module.js.map +1 -1
  52. package/dist/pipeline-config.d.ts +68 -0
  53. package/dist/pipeline-config.d.ts.map +1 -0
  54. package/dist/pipeline-config.js +153 -0
  55. package/dist/pipeline-config.js.map +1 -0
  56. package/dist/pipeline.d.ts +134 -0
  57. package/dist/pipeline.d.ts.map +1 -0
  58. package/dist/pipeline.js +308 -0
  59. package/dist/pipeline.js.map +1 -0
  60. package/dist/schema/annotations.d.ts.map +1 -1
  61. package/dist/schema/annotations.js +1 -0
  62. package/dist/schema/annotations.js.map +1 -1
  63. package/dist/sys-modules/toggle.d.ts +5 -0
  64. package/dist/sys-modules/toggle.d.ts.map +1 -1
  65. package/dist/sys-modules/toggle.js +5 -0
  66. package/dist/sys-modules/toggle.js.map +1 -1
  67. package/package.json +1 -1
package/dist/executor.js CHANGED
@@ -4,18 +4,16 @@
4
4
  * Async-only execution pipeline. Python's call() + call_async() merge into one async call().
5
5
  * Timeout uses Promise.race instead of threading.
6
6
  */
7
- import { Kind } from '@sinclair/typebox';
8
- import { Value } from '@sinclair/typebox/value';
9
- import { jsonSchemaToTypeBox } from './schema/loader.js';
10
- import { createApprovalRequest } from './approval.js';
11
7
  import { Context } from './context.js';
12
8
  import { ExecutionCancelledError } from './cancel.js';
13
- import { ACLDeniedError, ApprovalDeniedError, ApprovalPendingError, ApprovalTimeoutError, InvalidInputError, ModuleError, ModuleNotFoundError, ModuleTimeoutError, SchemaValidationError, } from './errors.js';
9
+ import { InvalidInputError, ModuleError, } from './errors.js';
14
10
  import { AfterMiddleware, BeforeMiddleware } from './middleware/index.js';
15
11
  import { MiddlewareChainError, MiddlewareManager } from './middleware/manager.js';
16
- import { guardCallChain } from './utils/call-chain.js';
17
- import { DEFAULT_ANNOTATIONS, createPreflightResult } from './module.js';
12
+ import { createPreflightResult } from './module.js';
18
13
  import { MODULE_ID_PATTERN } from './registry/registry.js';
14
+ import { ExecutionStrategy, PipelineEngine, PipelineAbortError, StrategyNotFoundError } from './pipeline.js';
15
+ import { BuiltinACLCheck, BuiltinApprovalGate, buildStandardStrategy, buildInternalStrategy, buildTestingStrategy, buildPerformanceStrategy, } from './builtin-steps.js';
16
+ import { propagateError } from './utils/error-propagation.js';
19
17
  export const REDACTED_VALUE = '***REDACTED***';
20
18
  /** Well-known context.data keys used internally by the runtime. */
21
19
  export const CTX_GLOBAL_DEADLINE = '_apcore.executor.global_deadline';
@@ -74,26 +72,6 @@ function redactSecretPrefix(data) {
74
72
  }
75
73
  }
76
74
  }
77
- /**
78
- * Normalize a dict-form annotations object into a ModuleAnnotations interface.
79
- * Handles both camelCase and snake_case keys (parallel to Python's
80
- * ``ModuleAnnotations(**{k: v for k, v in annotations.items() if k in valid_fields})``).
81
- */
82
- function dictToAnnotations(dict) {
83
- return {
84
- readonly: Boolean(dict['readonly'] ?? false),
85
- destructive: Boolean(dict['destructive'] ?? false),
86
- idempotent: Boolean(dict['idempotent'] ?? false),
87
- requiresApproval: Boolean(dict['requiresApproval'] ?? dict['requires_approval'] ?? false),
88
- openWorld: Boolean(dict['openWorld'] ?? dict['open_world'] ?? true),
89
- streaming: Boolean(dict['streaming'] ?? false),
90
- cacheable: Boolean(dict['cacheable'] ?? false),
91
- cacheTtl: Number(dict['cacheTtl'] ?? dict['cache_ttl'] ?? 0),
92
- cacheKeyFields: (dict['cacheKeyFields'] ?? dict['cache_key_fields'] ?? null),
93
- paginated: Boolean(dict['paginated'] ?? false),
94
- paginationStyle: (dict['paginationStyle'] ?? dict['pagination_style'] ?? 'cursor'),
95
- };
96
- }
97
75
  export class Executor {
98
76
  _registry;
99
77
  _middlewareManager;
@@ -104,6 +82,10 @@ export class Executor {
104
82
  _globalTimeout;
105
83
  _maxCallDepth;
106
84
  _maxModuleRepeat;
85
+ _strategy;
86
+ _pipelineEngine;
87
+ /** Global strategy registry for name-based resolution. */
88
+ static _strategyRegistry = new Map();
107
89
  constructor(options) {
108
90
  this._registry = options.registry;
109
91
  this._middlewareManager = new MiddlewareManager();
@@ -127,6 +109,73 @@ export class Executor {
127
109
  this._maxCallDepth = 32;
128
110
  this._maxModuleRepeat = 3;
129
111
  }
112
+ // Resolve strategy option (default to standard)
113
+ const strategyOpt = options.strategy;
114
+ if (strategyOpt === undefined || strategyOpt === null) {
115
+ // Default to standard strategy (pipeline-first)
116
+ this._strategy = buildStandardStrategy(this._buildStrategyDeps());
117
+ }
118
+ else if (typeof strategyOpt === 'string') {
119
+ // Resolve by name from the global registry
120
+ const resolved = Executor._strategyRegistry.get(strategyOpt);
121
+ if (resolved === undefined) {
122
+ // Try built-in factory names
123
+ const deps = this._buildStrategyDeps();
124
+ const builtinFactories = {
125
+ standard: buildStandardStrategy,
126
+ internal: buildInternalStrategy,
127
+ testing: buildTestingStrategy,
128
+ performance: buildPerformanceStrategy,
129
+ };
130
+ const factory = builtinFactories[strategyOpt];
131
+ if (factory !== undefined) {
132
+ this._strategy = factory(deps);
133
+ }
134
+ else {
135
+ throw new StrategyNotFoundError(`Strategy '${strategyOpt}' not found in registry`);
136
+ }
137
+ }
138
+ else {
139
+ this._strategy = resolved;
140
+ }
141
+ }
142
+ else {
143
+ // ExecutionStrategy instance
144
+ this._strategy = strategyOpt;
145
+ }
146
+ this._pipelineEngine = new PipelineEngine();
147
+ }
148
+ /** Build the dependency bag for strategy factories. */
149
+ _buildStrategyDeps() {
150
+ return {
151
+ config: this._config,
152
+ registry: this._registry,
153
+ acl: this._acl,
154
+ approvalHandler: this._approvalHandler,
155
+ middlewareManager: this._middlewareManager,
156
+ };
157
+ }
158
+ // -----------------------------------------------------------------------
159
+ // Static strategy registry (introspection - Task 4)
160
+ // -----------------------------------------------------------------------
161
+ /** Register a named strategy in the global registry. */
162
+ static registerStrategy(name, strategy) {
163
+ Executor._strategyRegistry.set(name, strategy);
164
+ }
165
+ /** List all registered strategy names. */
166
+ static listStrategies() {
167
+ return [...Executor._strategyRegistry.keys()];
168
+ }
169
+ /** Describe the pipeline of the given strategy (or the executor's current strategy). */
170
+ describePipeline(strategy) {
171
+ const target = strategy ?? this._strategy;
172
+ if (target === null)
173
+ return null;
174
+ return target.info();
175
+ }
176
+ /** Get the current execution strategy. */
177
+ get currentStrategy() {
178
+ return this._strategy;
130
179
  }
131
180
  static fromRegistry(registry, middlewares, acl, config, approvalHandler) {
132
181
  return new Executor({ registry, middlewares, acl, config, approvalHandler });
@@ -137,13 +186,25 @@ export class Executor {
137
186
  get middlewares() {
138
187
  return this._middlewareManager.snapshot();
139
188
  }
140
- /** Set the access control provider. */
189
+ /** Set the access control provider. Updates both the executor field and the strategy's ACL step. */
141
190
  setAcl(acl) {
142
191
  this._acl = acl;
192
+ for (const step of this._strategy.steps) {
193
+ if (step.name === 'acl_check' && step instanceof BuiltinACLCheck) {
194
+ step.setAcl(acl);
195
+ break;
196
+ }
197
+ }
143
198
  }
144
- /** Set the approval handler for Step 5 gate. */
199
+ /** Set the approval handler for Step 5 gate. Updates both the executor field and the strategy's approval step. */
145
200
  setApprovalHandler(handler) {
146
201
  this._approvalHandler = handler;
202
+ for (const step of this._strategy.steps) {
203
+ if (step.name === 'approval_gate' && step instanceof BuiltinApprovalGate) {
204
+ step.setApprovalHandler(handler);
205
+ break;
206
+ }
207
+ }
147
208
  }
148
209
  use(middleware) {
149
210
  this._middlewareManager.add(middleware);
@@ -160,9 +221,45 @@ export class Executor {
160
221
  remove(middleware) {
161
222
  return this._middlewareManager.remove(middleware);
162
223
  }
163
- async call(moduleId, inputs, context, _versionHint) {
164
- const { mod, effectiveInputs, ctx } = await this._prepareExecution(moduleId, inputs, context);
165
- return this._executeWithMiddleware(mod, moduleId, effectiveInputs, ctx);
224
+ async call(moduleId, inputs, context, versionHint) {
225
+ this._validateModuleId(moduleId);
226
+ const ctx = context != null ? context.child(moduleId) : Context.create(this).child(moduleId);
227
+ const pipeCtx = {
228
+ moduleId,
229
+ inputs: inputs ?? {},
230
+ context: ctx,
231
+ module: null,
232
+ validatedInputs: null,
233
+ output: null,
234
+ validatedOutput: null,
235
+ stream: false,
236
+ outputStream: null,
237
+ strategy: this._strategy,
238
+ trace: null,
239
+ versionHint: versionHint ?? null,
240
+ };
241
+ try {
242
+ const [output, _trace] = await this._pipelineEngine.run(this._strategy, pipeCtx);
243
+ return (output ?? {});
244
+ }
245
+ catch (exc) {
246
+ if (exc instanceof ExecutionCancelledError)
247
+ throw exc;
248
+ // Pipeline errors propagate with their original types (ModuleNotFoundError, etc.)
249
+ // because builtin steps now throw directly.
250
+ const ctxObj = pipeCtx.context;
251
+ const wrapped = propagateError(exc, moduleId, ctxObj);
252
+ const executedMw = pipeCtx.executedMiddlewares;
253
+ if (executedMw && executedMw.length > 0) {
254
+ const recovery = this._middlewareManager.executeOnError(moduleId, pipeCtx.inputs, wrapped, ctxObj, executedMw);
255
+ if (recovery !== null)
256
+ return recovery;
257
+ }
258
+ if (exc instanceof MiddlewareChainError) {
259
+ throw new ModuleError('MODULE_EXECUTE_ERROR', String(exc), { moduleId });
260
+ }
261
+ throw wrapped;
262
+ }
166
263
  }
167
264
  /**
168
265
  * Alias for call(). Provided for compatibility with MCP bridge packages
@@ -171,6 +268,30 @@ export class Executor {
171
268
  async callAsync(moduleId, inputs, context, versionHint) {
172
269
  return this.call(moduleId, inputs, context, versionHint);
173
270
  }
271
+ /**
272
+ * Execute a module through the pipeline strategy and return both the output
273
+ * and a full execution trace. Requires a strategy to be set (either on the
274
+ * executor or passed via options).
275
+ */
276
+ async callWithTrace(moduleId, inputs, context, options) {
277
+ const strategy = options?.strategy ?? this._strategy;
278
+ const ctx = context ?? Context.create(this).child(moduleId);
279
+ const pipelineCtx = {
280
+ moduleId,
281
+ inputs: inputs ?? {},
282
+ context: ctx,
283
+ module: null,
284
+ validatedInputs: null,
285
+ output: null,
286
+ validatedOutput: null,
287
+ stream: false,
288
+ outputStream: null,
289
+ strategy,
290
+ trace: null,
291
+ };
292
+ const [output, trace] = await this._pipelineEngine.run(strategy, pipelineCtx);
293
+ return [(output ?? {}), trace];
294
+ }
174
295
  /**
175
296
  * Streaming execution pipeline. If the module exposes a stream() async generator,
176
297
  * yields each chunk. Otherwise falls back to call() and yields a single chunk.
@@ -182,191 +303,103 @@ export class Executor {
182
303
  * validation/side-effects but its return value is not yielded since chunks were already
183
304
  * emitted. In the non-streaming fallback, after-middleware can transform the output.
184
305
  */
185
- async *stream(moduleId, inputs, context, _versionHint) {
186
- const { mod, effectiveInputs, ctx } = await this._prepareExecution(moduleId, inputs, context);
187
- yield* this._streamWithMiddleware(mod, moduleId, effectiveInputs, ctx);
188
- }
189
- async *_streamWithMiddleware(mod, moduleId, inputs, ctx) {
190
- let effectiveInputs = inputs;
191
- let executedMiddlewares = [];
306
+ /**
307
+ * Streaming execution pipeline using split-pipeline design.
308
+ *
309
+ * Phase 1: Pipeline runs all steps with ctx.stream=true. BuiltinExecute detects
310
+ * stream mode and sets ctx.outputStream if module has stream().
311
+ * Phase 2: Iterate stream, accumulate chunks and yield each.
312
+ * Phase 3: Run output_validation + middleware_after on accumulated output.
313
+ *
314
+ * If the module has no stream(), the pipeline executes normally and yields ctx.output.
315
+ */
316
+ async *stream(moduleId, inputs, context, versionHint) {
317
+ this._validateModuleId(moduleId);
318
+ const ctx = context != null ? context.child(moduleId) : Context.create(this).child(moduleId);
319
+ const pipeCtx = {
320
+ moduleId,
321
+ inputs: inputs ?? {},
322
+ context: ctx,
323
+ module: null,
324
+ validatedInputs: null,
325
+ output: null,
326
+ validatedOutput: null,
327
+ stream: true,
328
+ outputStream: null,
329
+ strategy: this._strategy,
330
+ trace: null,
331
+ versionHint: versionHint ?? null,
332
+ };
333
+ // Phase 1: Run the full pipeline. BuiltinExecute detects ctx.stream=true.
192
334
  try {
193
- try {
194
- [effectiveInputs, executedMiddlewares] = this._middlewareManager.executeBefore(moduleId, effectiveInputs, ctx);
195
- }
196
- catch (e) {
197
- if (e instanceof MiddlewareChainError) {
198
- executedMiddlewares = e.executedMiddlewares;
199
- const recovery = this._middlewareManager.executeOnError(moduleId, effectiveInputs, e.original, ctx, executedMiddlewares);
200
- if (recovery !== null) {
201
- yield recovery;
202
- return;
203
- }
204
- executedMiddlewares = [];
205
- throw e.original;
206
- }
207
- throw e;
208
- }
209
- // Cancel check before execution
210
- if (ctx.cancelToken !== null) {
211
- ctx.cancelToken.check();
212
- }
213
- const streamFn = mod['stream'];
214
- if (typeof streamFn === 'function') {
215
- // Module has a stream() method: iterate and yield each chunk
216
- let accumulated = {};
217
- for await (const chunk of streamFn.call(mod, effectiveInputs, ctx)) {
218
- accumulated = { ...accumulated, ...chunk };
219
- yield chunk;
220
- }
221
- // Validate accumulated output against output schema
222
- this._validateOutput(mod, accumulated);
223
- // Run after-middleware on the accumulated result
224
- this._middlewareManager.executeAfter(moduleId, effectiveInputs, accumulated, ctx);
225
- }
226
- else {
227
- // Fallback: execute normally and yield single chunk
228
- let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
229
- this._validateOutput(mod, output);
230
- output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
231
- yield output;
232
- }
335
+ await this._pipelineEngine.run(this._strategy, pipeCtx);
233
336
  }
234
337
  catch (exc) {
235
338
  if (exc instanceof ExecutionCancelledError)
236
339
  throw exc;
237
- if (executedMiddlewares.length > 0) {
238
- const recovery = this._middlewareManager.executeOnError(moduleId, effectiveInputs, exc, ctx, executedMiddlewares);
340
+ const ctxObj = pipeCtx.context;
341
+ const wrapped = propagateError(exc, moduleId, ctxObj);
342
+ if (pipeCtx.executedMiddlewares && pipeCtx.executedMiddlewares.length > 0) {
343
+ const recovery = this._middlewareManager.executeOnError(moduleId, pipeCtx.inputs, wrapped, ctxObj, pipeCtx.executedMiddlewares);
239
344
  if (recovery !== null) {
240
345
  yield recovery;
241
346
  return;
242
347
  }
243
348
  }
244
- throw exc;
349
+ throw wrapped;
245
350
  }
246
- }
247
- /**
248
- * Shared pipeline: context -> global deadline -> safety -> lookup -> ACL -> approval -> validate.
249
- */
250
- async _prepareExecution(moduleId, inputs, context) {
251
- let effectiveInputs = inputs ?? {};
252
- const ctx = this._createContext(moduleId, context);
253
- // Set global deadline on root call only
254
- if (!(CTX_GLOBAL_DEADLINE in ctx.data) && this._globalTimeout > 0) {
255
- ctx.data[CTX_GLOBAL_DEADLINE] = Date.now() + this._globalTimeout;
256
- }
257
- this._checkSafety(moduleId, ctx);
258
- const mod = this._lookupModule(moduleId);
259
- this._checkAcl(moduleId, ctx);
260
- // Step 5 -- Approval Gate (strips internal keys like _approval_token)
261
- effectiveInputs = await this._checkApproval(mod, moduleId, effectiveInputs, ctx);
262
- effectiveInputs = this._validateInputs(mod, effectiveInputs, ctx);
263
- return { mod, effectiveInputs, ctx };
264
- }
265
- _createContext(moduleId, context) {
266
- if (context == null) {
267
- return Context.create(this).child(moduleId);
268
- }
269
- return context.child(moduleId);
270
- }
271
- _lookupModule(moduleId) {
272
- const module = this._registry.get(moduleId);
273
- if (module === null) {
274
- throw new ModuleNotFoundError(moduleId);
275
- }
276
- return module;
277
- }
278
- _checkAcl(moduleId, ctx) {
279
- if (this._acl !== null) {
280
- const allowed = this._acl.check(ctx.callerId, moduleId, ctx);
281
- if (!allowed) {
282
- throw new ACLDeniedError(ctx.callerId, moduleId);
283
- }
284
- }
285
- }
286
- _validateInputs(mod, inputs, ctx) {
287
- const inputSchema = this._resolveSchema(mod, 'inputSchema');
288
- if (inputSchema == null)
289
- return inputs;
290
- this._validateSchema(inputSchema, inputs, 'Input');
291
- ctx.redactedInputs = redactSensitive(inputs, inputSchema);
292
- return inputs;
293
- }
294
- _resolveSchema(mod, key) {
295
- const schema = mod[key];
296
- if (schema == null)
297
- return null;
298
- if (Kind in schema)
299
- return schema;
300
- const converted = jsonSchemaToTypeBox(schema);
301
- mod[key] = converted;
302
- return converted;
303
- }
304
- _validateSchema(schema, data, direction) {
305
- if (Value.Check(schema, data))
351
+ // If no outputStream, pipeline already executed normally — yield single result
352
+ if (pipeCtx.outputStream == null) {
353
+ yield (pipeCtx.output ?? {});
306
354
  return;
307
- const errors = [];
308
- for (const error of Value.Errors(schema, data)) {
309
- errors.push({
310
- field: error.path || '/',
311
- code: String(error.type),
312
- message: error.message,
313
- });
314
355
  }
315
- throw new SchemaValidationError(`${direction} validation failed`, errors);
316
- }
317
- async _executeWithMiddleware(mod, moduleId, inputs, ctx) {
318
- let effectiveInputs = inputs;
319
- let executedMiddlewares = [];
356
+ // Phase 2: Iterate stream, accumulate chunks
357
+ const outputStream = pipeCtx.outputStream;
358
+ let accumulated = {};
320
359
  try {
321
- try {
322
- [effectiveInputs, executedMiddlewares] = this._middlewareManager.executeBefore(moduleId, effectiveInputs, ctx);
360
+ for await (const chunk of outputStream) {
361
+ accumulated = { ...accumulated, ...chunk };
362
+ yield chunk;
323
363
  }
324
- catch (e) {
325
- if (e instanceof MiddlewareChainError) {
326
- executedMiddlewares = e.executedMiddlewares;
327
- const recovery = this._middlewareManager.executeOnError(moduleId, effectiveInputs, e.original, ctx, executedMiddlewares);
328
- if (recovery !== null)
329
- return recovery;
330
- executedMiddlewares = [];
331
- throw e.original;
332
- }
333
- throw e;
334
- }
335
- // Cancel check before execution
336
- if (ctx.cancelToken !== null) {
337
- ctx.cancelToken.check();
338
- }
339
- let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
340
- this._validateOutput(mod, output);
341
- output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
342
- return output;
343
364
  }
344
365
  catch (exc) {
345
366
  if (exc instanceof ExecutionCancelledError)
346
367
  throw exc;
347
- if (executedMiddlewares.length > 0) {
348
- const recovery = this._middlewareManager.executeOnError(moduleId, effectiveInputs, exc, ctx, executedMiddlewares);
349
- if (recovery !== null)
350
- return recovery;
368
+ const ctxObj = pipeCtx.context;
369
+ const wrapped = propagateError(exc, moduleId, ctxObj);
370
+ if (pipeCtx.executedMiddlewares && pipeCtx.executedMiddlewares.length > 0) {
371
+ const recovery = this._middlewareManager.executeOnError(moduleId, pipeCtx.inputs, wrapped, ctxObj, pipeCtx.executedMiddlewares);
372
+ if (recovery !== null) {
373
+ yield recovery;
374
+ return;
375
+ }
351
376
  }
352
- throw exc;
377
+ throw wrapped;
353
378
  }
354
- }
355
- _validateOutput(mod, output) {
356
- const outputSchema = this._resolveSchema(mod, 'outputSchema');
357
- if (outputSchema != null) {
358
- this._validateSchema(outputSchema, output, 'Output');
379
+ // Phase 3: Output validation + middleware_after on accumulated result
380
+ pipeCtx.output = accumulated;
381
+ const postSteps = this._strategy.steps.filter((s) => s.name === 'output_validation' || s.name === 'middleware_after' || s.name === 'return_result');
382
+ if (postSteps.length > 0) {
383
+ const postStrategy = new ExecutionStrategy('post_stream', postSteps);
384
+ try {
385
+ await this._pipelineEngine.run(postStrategy, pipeCtx);
386
+ }
387
+ catch {
388
+ // Post-stream validation errors are non-fatal for already-yielded chunks
389
+ }
359
390
  }
360
391
  }
361
392
  /**
362
- * Non-destructive preflight check through Steps 1-6 of the pipeline.
363
- * Returns a PreflightResult that is duck-type compatible with ValidationResult.
393
+ * Non-destructive preflight check using pipeline dry_run mode.
394
+ *
395
+ * Runs all pure steps (context creation, call chain guard, module lookup,
396
+ * ACL, input validation). Steps with pure=false (approval, middleware,
397
+ * execute) are automatically skipped. Returns a PreflightResult.
364
398
  */
365
- validate(moduleId, inputs, context) {
399
+ async validate(moduleId, inputs, context) {
366
400
  const effectiveInputs = inputs ?? {};
367
401
  const checks = [];
368
- let requiresApproval = false;
369
- // Check 1: module_id format
402
+ // Check 0: module_id format (before pipeline)
370
403
  if (!MODULE_ID_PATTERN.test(moduleId)) {
371
404
  checks.push({
372
405
  check: 'module_id', passed: false,
@@ -375,100 +408,117 @@ export class Executor {
375
408
  return createPreflightResult(checks);
376
409
  }
377
410
  checks.push({ check: 'module_id', passed: true });
378
- // Check 2: module lookup
379
- const module = this._registry.get(moduleId);
380
- if (module === null) {
381
- checks.push({
382
- check: 'module_lookup', passed: false,
383
- error: { code: 'MODULE_NOT_FOUND', message: `Module not found: ${moduleId}` },
384
- });
385
- return createPreflightResult(checks);
386
- }
387
- checks.push({ check: 'module_lookup', passed: true });
388
- const mod = module;
389
- // Check 3: call chain safety
390
- const ctx = this._createContext(moduleId, context);
411
+ // Run pipeline in dry_run mode — pure=false steps are skipped
412
+ const pipeCtx = {
413
+ moduleId,
414
+ inputs: effectiveInputs,
415
+ context: context ?? Context.create(this).child(moduleId),
416
+ module: null,
417
+ validatedInputs: null,
418
+ output: null,
419
+ validatedOutput: null,
420
+ stream: false,
421
+ outputStream: null,
422
+ strategy: this._strategy,
423
+ trace: null,
424
+ dryRun: true,
425
+ };
426
+ let trace = null;
391
427
  try {
392
- this._checkSafety(moduleId, ctx);
393
- checks.push({ check: 'call_chain', passed: true });
428
+ const [, t] = await this._pipelineEngine.run(this._strategy, pipeCtx);
429
+ trace = t;
394
430
  }
395
431
  catch (e) {
396
- const err = e instanceof ModuleError
397
- ? { code: e.code, message: e.message }
398
- : { code: 'CALL_CHAIN_ERROR', message: String(e) };
399
- checks.push({ check: 'call_chain', passed: false, error: err });
400
- }
401
- // Check 4: ACL
402
- if (this._acl !== null) {
403
- const allowed = this._acl.check(ctx.callerId, moduleId, ctx);
404
- if (!allowed) {
405
- checks.push({
406
- check: 'acl', passed: false,
407
- error: { code: 'ACL_DENIED', message: `Access denied: ${ctx.callerId} -> ${moduleId}` },
408
- });
432
+ // Step raised a domain error (ModuleNotFoundError, ACLDeniedError, etc.)
433
+ if (e instanceof PipelineAbortError) {
434
+ trace = e.pipelineTrace;
409
435
  }
410
436
  else {
411
- checks.push({ check: 'acl', passed: true });
437
+ const errorDict = (e instanceof ModuleError)
438
+ ? { code: e.code, message: e.message }
439
+ : { code: e.constructor?.name ?? 'Error', message: String(e) };
440
+ const code = (e instanceof ModuleError) ? e.code : e.constructor?.name ?? 'Error';
441
+ let checkName;
442
+ if (code === 'MODULE_NOT_FOUND')
443
+ checkName = 'module_lookup';
444
+ else if (code === 'ACL_DENIED')
445
+ checkName = 'acl';
446
+ else if (code === 'SCHEMA_VALIDATION_ERROR' || code === 'INVALID_INPUT')
447
+ checkName = 'schema';
448
+ else if (code === 'CALL_DEPTH_EXCEEDED' || code === 'CIRCULAR_CALL' || code === 'CALL_FREQUENCY_EXCEEDED')
449
+ checkName = 'call_chain';
450
+ else
451
+ checkName = 'unknown';
452
+ checks.push({ check: checkName, passed: false, error: errorDict });
412
453
  }
413
454
  }
414
- else {
415
- checks.push({ check: 'acl', passed: true });
416
- }
417
- // Check 5: approval detection (report only, no handler invocation)
418
- if (this._needsApproval(mod)) {
419
- requiresApproval = true;
455
+ // Convert pipeline trace to PreflightResult checks
456
+ if (trace !== null) {
457
+ checks.push(...this._traceToChecks(trace));
420
458
  }
421
- checks.push({ check: 'approval', passed: true });
422
- // Check 6: input schema validation
423
- const inputSchema = mod['inputSchema'];
424
- if (inputSchema != null) {
425
- if (Value.Check(inputSchema, effectiveInputs)) {
426
- checks.push({ check: 'schema', passed: true });
427
- }
428
- else {
429
- const errors = [];
430
- for (const error of Value.Errors(inputSchema, effectiveInputs)) {
431
- errors.push({
432
- field: error.path || '/',
433
- code: String(error.type),
434
- message: error.message,
459
+ // Detect requires_approval
460
+ let requiresApproval = false;
461
+ if (pipeCtx.module != null) {
462
+ requiresApproval = this._needsApproval(pipeCtx.module);
463
+ }
464
+ // Module-level preflight (optional)
465
+ if (pipeCtx.module != null) {
466
+ const mod = pipeCtx.module;
467
+ const modWithPreflight = mod;
468
+ if (typeof modWithPreflight.preflight === 'function') {
469
+ try {
470
+ const preflightWarnings = modWithPreflight.preflight(effectiveInputs, pipeCtx.context);
471
+ if (Array.isArray(preflightWarnings) && preflightWarnings.length > 0) {
472
+ checks.push({ check: 'module_preflight', passed: true, warnings: preflightWarnings });
473
+ }
474
+ else {
475
+ checks.push({ check: 'module_preflight', passed: true });
476
+ }
477
+ }
478
+ catch (exc) {
479
+ const excName = exc instanceof Error ? exc.constructor.name : 'Error';
480
+ const excMsg = exc instanceof Error ? exc.message : String(exc);
481
+ checks.push({
482
+ check: 'module_preflight',
483
+ passed: true,
484
+ warnings: [`preflight() raised ${excName}: ${excMsg}`],
435
485
  });
436
486
  }
437
- checks.push({
438
- check: 'schema', passed: false,
439
- error: { code: 'SCHEMA_VALIDATION_ERROR', errors },
440
- });
441
487
  }
442
488
  }
443
- else {
444
- checks.push({ check: 'schema', passed: true });
445
- }
446
- // Check 7: module-level preflight (optional)
447
- const modWithPreflight = mod;
448
- if (typeof modWithPreflight.preflight === 'function') {
449
- try {
450
- const preflightWarnings = modWithPreflight.preflight(effectiveInputs, ctx);
451
- if (Array.isArray(preflightWarnings) && preflightWarnings.length > 0) {
452
- checks.push({ check: 'module_preflight', passed: true, warnings: preflightWarnings });
453
- }
454
- else {
455
- checks.push({ check: 'module_preflight', passed: true });
456
- }
457
- }
458
- catch (exc) {
459
- const excName = exc instanceof Error ? exc.constructor.name : 'Error';
460
- const excMsg = exc instanceof Error ? exc.message : String(exc);
461
- checks.push({
462
- check: 'module_preflight',
463
- passed: true,
464
- warnings: [`preflight() raised ${excName}: ${excMsg}`],
465
- });
489
+ return createPreflightResult(checks, requiresApproval);
490
+ }
491
+ /** Map pipeline step names to PreflightResult check names. */
492
+ static _STEP_TO_CHECK = {
493
+ context_creation: 'context',
494
+ call_chain_guard: 'call_chain',
495
+ module_lookup: 'module_lookup',
496
+ acl_check: 'acl',
497
+ approval_gate: 'approval',
498
+ middleware_before: 'middleware',
499
+ input_validation: 'schema',
500
+ };
501
+ /** Convert PipelineTrace steps to PreflightCheckResult list. */
502
+ _traceToChecks(trace) {
503
+ const checks = [];
504
+ for (const st of trace.steps) {
505
+ if (st.skipped)
506
+ continue;
507
+ const checkName = Executor._STEP_TO_CHECK[st.name] ?? st.name;
508
+ const passed = st.result.action !== 'abort';
509
+ let error;
510
+ if (!passed && st.result.explanation) {
511
+ error = { code: `STEP_${st.name.toUpperCase()}_FAILED`, message: st.result.explanation };
466
512
  }
513
+ checks.push({ check: checkName, passed, error });
467
514
  }
468
- return createPreflightResult(checks, requiresApproval);
515
+ return checks;
469
516
  }
470
- _checkSafety(moduleId, ctx) {
471
- guardCallChain(moduleId, ctx.callChain, this._maxCallDepth, this._maxModuleRepeat);
517
+ /** Validate module_id format at public entry points. */
518
+ _validateModuleId(moduleId) {
519
+ if (!moduleId || !MODULE_ID_PATTERN.test(moduleId)) {
520
+ throw new InvalidInputError(`Invalid module ID: '${moduleId}'. Must match pattern: ${MODULE_ID_PATTERN.source}`);
521
+ }
472
522
  }
473
523
  /** Check if a module requires approval, handling both interface and dict annotations. */
474
524
  _needsApproval(mod) {
@@ -477,127 +527,13 @@ export class Executor {
477
527
  return false;
478
528
  if (typeof annotations !== 'object')
479
529
  return false;
480
- // ModuleAnnotations interface (camelCase)
481
530
  if ('requiresApproval' in annotations) {
482
531
  return Boolean(annotations.requiresApproval);
483
532
  }
484
- // Dict-form annotations (snake_case)
485
533
  if ('requires_approval' in annotations) {
486
534
  return Boolean(annotations['requires_approval']);
487
535
  }
488
536
  return false;
489
537
  }
490
- /** Build an ApprovalRequest from module metadata. */
491
- _buildApprovalRequest(mod, moduleId, inputs, ctx) {
492
- const annotations = mod['annotations'];
493
- let ann;
494
- if (annotations != null && typeof annotations === 'object' && 'requiresApproval' in annotations) {
495
- ann = annotations;
496
- }
497
- else if (annotations != null && typeof annotations === 'object') {
498
- ann = dictToAnnotations(annotations);
499
- }
500
- else {
501
- ann = DEFAULT_ANNOTATIONS;
502
- }
503
- return createApprovalRequest({
504
- moduleId,
505
- arguments: inputs,
506
- context: ctx,
507
- annotations: ann,
508
- description: mod['description'] ?? null,
509
- tags: mod['tags'] ?? [],
510
- });
511
- }
512
- /** Map an ApprovalResult status to the appropriate action or error. */
513
- _handleApprovalResult(result, moduleId) {
514
- if (result.status === 'approved')
515
- return;
516
- if (result.status === 'rejected') {
517
- throw new ApprovalDeniedError(result, moduleId);
518
- }
519
- if (result.status === 'timeout') {
520
- throw new ApprovalTimeoutError(result, moduleId);
521
- }
522
- if (result.status === 'pending') {
523
- throw new ApprovalPendingError(result, moduleId);
524
- }
525
- // Unknown status treated as denied
526
- console.warn(`[apcore:executor] Unknown approval status '${result.status}' for module ${moduleId}, treating as denied`);
527
- throw new ApprovalDeniedError(result, moduleId);
528
- }
529
- /** Emit an audit event for the approval decision (logging + span event). */
530
- _emitApprovalEvent(result, moduleId, ctx) {
531
- console.warn(`[apcore:executor] Approval decision: module=${moduleId} status=${result.status} approved_by=${result.approvedBy} reason=${result.reason}`);
532
- const spansStack = ctx.data[CTX_TRACING_SPANS];
533
- if (spansStack && spansStack.length > 0) {
534
- spansStack[spansStack.length - 1].events.push({
535
- name: 'approval_decision',
536
- module_id: moduleId,
537
- status: result.status,
538
- approved_by: result.approvedBy ?? '',
539
- reason: result.reason ?? '',
540
- approval_id: result.approvalId ?? '',
541
- });
542
- }
543
- }
544
- /** Step 5: Approval gate. Returns inputs with internal keys stripped. */
545
- async _checkApproval(mod, moduleId, inputs, ctx) {
546
- if (this._approvalHandler === null)
547
- return inputs;
548
- if (!this._needsApproval(mod))
549
- return inputs;
550
- let result;
551
- let cleanInputs = inputs;
552
- if ('_approval_token' in inputs) {
553
- const token = inputs['_approval_token'];
554
- const { _approval_token: _, ...rest } = inputs;
555
- cleanInputs = rest;
556
- result = await this._approvalHandler.checkApproval(token);
557
- }
558
- else {
559
- const request = this._buildApprovalRequest(mod, moduleId, inputs, ctx);
560
- result = await this._approvalHandler.requestApproval(request);
561
- }
562
- this._emitApprovalEvent(result, moduleId, ctx);
563
- this._handleApprovalResult(result, moduleId);
564
- return cleanInputs;
565
- }
566
- async _executeWithTimeout(mod, moduleId, inputs, ctx) {
567
- let timeoutMs = this._defaultTimeout;
568
- // Respect global deadline: use whichever is shorter
569
- const globalDeadline = ctx.data[CTX_GLOBAL_DEADLINE];
570
- if (globalDeadline !== undefined) {
571
- const remaining = globalDeadline - Date.now();
572
- if (remaining <= 0) {
573
- throw new ModuleTimeoutError(moduleId, 0);
574
- }
575
- if (timeoutMs === 0 || remaining < timeoutMs) {
576
- timeoutMs = remaining;
577
- }
578
- }
579
- if (timeoutMs < 0) {
580
- throw new InvalidInputError(`Negative timeout: ${timeoutMs}ms`);
581
- }
582
- const executeFn = mod['execute'];
583
- if (typeof executeFn !== 'function') {
584
- throw new InvalidInputError(`Module '${moduleId}' has no execute method`);
585
- }
586
- const executionPromise = Promise.resolve(executeFn
587
- .call(mod, inputs, ctx));
588
- if (timeoutMs === 0) {
589
- console.warn('[apcore:executor] Timeout is 0, timeout limit disabled');
590
- return executionPromise;
591
- }
592
- let timer;
593
- const timeoutPromise = new Promise((_, reject) => {
594
- timer = setTimeout(() => {
595
- reject(new ModuleTimeoutError(moduleId, timeoutMs));
596
- }, timeoutMs);
597
- });
598
- return Promise.race([executionPromise, timeoutPromise]).finally(() => {
599
- clearTimeout(timer);
600
- });
601
- }
602
538
  }
603
539
  //# sourceMappingURL=executor.js.map