apcore-js 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,7 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.0] - 2026-02-22
8
+ ## [0.5.0] - 2026-02-23
9
+
10
+ ### Added
11
+ - **Cancellation support** with `CancelToken` and `ExecutionCancelledError`, including executor pre-execution cancellation checks.
12
+ - **Async task system** with `AsyncTaskManager`, `TaskStatus`, and `TaskInfo` for background module execution, status tracking, cancellation, and cleanup.
13
+ - **Extension framework** via `ExtensionManager` and `ExtensionPoint`, with built-in extension points for `discoverer`, `middleware`, `acl`, `span_exporter`, and `module_validator`.
14
+ - **W3C Trace Context support** through `TraceContext` and `TraceParent` (`inject`, `extract`, `fromTraceparent`) for distributed trace propagation.
15
+ - **OTLP tracing exporter** (`OTLPExporter`) for OpenTelemetry-compatible HTTP span export.
16
+ - **Registry extensibility hooks**: custom `Discoverer` and `ModuleValidator` interfaces and runtime registration methods.
17
+ - **Registry constraints and constants**: `MAX_MODULE_ID_LENGTH`, `RESERVED_WORDS`, and stricter module ID validation rules.
18
+ - **Context interoperability APIs**: `Context.toJSON()`, `Context.fromJSON()`, and `ContextFactory` interface.
19
+
20
+ ### Changed
21
+ - `Context.create()` now accepts optional `traceParent` and can derive `traceId` from inbound distributed trace headers.
22
+ - `Registry.discover()` now supports async custom discovery/validation flow in addition to default filesystem discovery.
23
+ - `TracingMiddleware` now supports runtime exporter replacement via `setExporter()` and uses Unix epoch seconds with OTLP-compatible nanosecond conversion.
24
+ - Public exports were expanded in `index.ts` to expose new cancellation, extension, tracing, registry, and async-task APIs.
25
+ - `MiddlewareChainError` now preserves the original cause when wrapping middleware exceptions.
26
+
27
+ ### Fixed
28
+ - Improved cancellation correctness by bypassing middleware error recovery for `ExecutionCancelledError`.
29
+ - Improved async task concurrency behavior around queued-task cancellation to avoid counter corruption.
30
+ - Improved context serialization safety by excluding internal `data` keys prefixed with `_` from `toJSON()` output.
31
+
32
+ ### Tests
33
+ - Added comprehensive tests for cancellation, async task management, extension wiring, trace context parsing/injection, registry hot-reload/custom hooks, and OTLP export behavior.
34
+
35
+ ## [0.4.0] - 2026-02-23
9
36
 
10
37
  ### Changed
11
38
  - Improved performance of `Executor.stream()` with optimized buffering.
@@ -13,6 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
40
  ### Added
14
41
  - Introduced `ModuleAnnotations.batchProcessing` for enhanced batch processing capabilities.
15
42
  - Added new logging features for better observability in the execution pipeline.
43
+ - **ExtensionManager** and **ExtensionPoint** exports for unified extension point management (discoverer, middleware, acl, span_exporter, module_validator)
44
+ - **AsyncTaskManager**, **TaskStatus**, **TaskInfo** exports for async task execution with status tracking (PENDING, RUNNING, COMPLETED, FAILED, CANCELLED) and cancellation
45
+ - **TraceContext** and **TraceParent** exports for W3C Trace Context support with `inject()`, `extract()`, and `fromTraceparent()` methods
46
+ - `Context.create()` accepts optional `traceParent` parameter for distributed trace propagation
16
47
 
17
48
  ### Fixed
18
49
  - Resolved issues with error handling in `context.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apcore-js",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI-Perceivable Core — schema-driven module development framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Async task manager for background module execution.
3
+ */
4
+
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import type { Context } from './context.js';
7
+ import type { Executor } from './executor.js';
8
+
9
+ export enum TaskStatus {
10
+ PENDING = 'pending',
11
+ RUNNING = 'running',
12
+ COMPLETED = 'completed',
13
+ FAILED = 'failed',
14
+ CANCELLED = 'cancelled',
15
+ }
16
+
17
+ export interface TaskInfo {
18
+ readonly taskId: string;
19
+ readonly moduleId: string;
20
+ readonly status: TaskStatus;
21
+ readonly submittedAt: number;
22
+ readonly startedAt: number | null;
23
+ readonly completedAt: number | null;
24
+ readonly result: Record<string, unknown> | null;
25
+ readonly error: string | null;
26
+ }
27
+
28
+ type InternalTaskInfo = { -readonly [K in keyof TaskInfo]: TaskInfo[K] };
29
+
30
+ interface InternalTask {
31
+ info: InternalTaskInfo;
32
+ promise: Promise<void>;
33
+ cancelled: boolean;
34
+ resolve: () => void;
35
+ }
36
+
37
+ /**
38
+ * Manages background execution of modules via Promises.
39
+ *
40
+ * Uses a simple counter-based concurrency limiter instead of a semaphore.
41
+ */
42
+ export class AsyncTaskManager {
43
+ private readonly _executor: Executor;
44
+ private readonly _maxConcurrent: number;
45
+ private readonly _maxTasks: number;
46
+ private readonly _tasks: Map<string, InternalTask> = new Map();
47
+ private _runningCount: number = 0;
48
+ private readonly _waitQueue: Array<() => void> = [];
49
+
50
+ constructor(executor: Executor, maxConcurrent: number = 10, maxTasks: number = 1000) {
51
+ this._executor = executor;
52
+ this._maxConcurrent = maxConcurrent;
53
+ this._maxTasks = maxTasks;
54
+ }
55
+
56
+ /**
57
+ * Submit a module for background execution.
58
+ *
59
+ * Returns the generated task_id immediately.
60
+ */
61
+ submit(
62
+ moduleId: string,
63
+ inputs: Record<string, unknown>,
64
+ context?: Context | null,
65
+ ): string {
66
+ if (this._tasks.size >= this._maxTasks) {
67
+ throw new Error(`Task limit reached (${this._maxTasks})`);
68
+ }
69
+ const taskId = uuidv4();
70
+ const info: InternalTaskInfo = {
71
+ taskId,
72
+ moduleId,
73
+ status: TaskStatus.PENDING,
74
+ submittedAt: Date.now() / 1000,
75
+ startedAt: null,
76
+ completedAt: null,
77
+ result: null,
78
+ error: null,
79
+ };
80
+
81
+ let resolvePromise!: () => void;
82
+ const promise = new Promise<void>((resolve) => {
83
+ resolvePromise = resolve;
84
+ });
85
+
86
+ const internal: InternalTask = {
87
+ info,
88
+ promise,
89
+ cancelled: false,
90
+ resolve: resolvePromise,
91
+ };
92
+
93
+ this._tasks.set(taskId, internal);
94
+ this._enqueue(taskId, moduleId, inputs, context ?? null);
95
+ return taskId;
96
+ }
97
+
98
+ /**
99
+ * Return the TaskInfo for a task, or null if not found.
100
+ */
101
+ getStatus(taskId: string): TaskInfo | null {
102
+ const internal = this._tasks.get(taskId);
103
+ return internal ? { ...internal.info } : null;
104
+ }
105
+
106
+ /**
107
+ * Return the result of a completed task.
108
+ *
109
+ * Throws if the task is not found or not in COMPLETED status.
110
+ */
111
+ getResult(taskId: string): Record<string, unknown> {
112
+ const internal = this._tasks.get(taskId);
113
+ if (!internal) {
114
+ throw new Error(`Task not found: ${taskId}`);
115
+ }
116
+ if (internal.info.status !== TaskStatus.COMPLETED) {
117
+ throw new Error(`Task ${taskId} is not completed (status=${internal.info.status})`);
118
+ }
119
+ return internal.info.result!;
120
+ }
121
+
122
+ /**
123
+ * Cancel a running or pending task.
124
+ *
125
+ * Sets the cancelled flag and updates status immediately.
126
+ * The underlying execution may still be in-flight but its result
127
+ * will be discarded when it completes.
128
+ *
129
+ * Returns true if the task was successfully marked as cancelled.
130
+ */
131
+ cancel(taskId: string): boolean {
132
+ const internal = this._tasks.get(taskId);
133
+ if (!internal) return false;
134
+
135
+ const { info } = internal;
136
+ if (info.status !== TaskStatus.PENDING && info.status !== TaskStatus.RUNNING) {
137
+ return false;
138
+ }
139
+
140
+ internal.cancelled = true;
141
+ info.status = TaskStatus.CANCELLED;
142
+ info.completedAt = Date.now() / 1000;
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * Return all tasks, optionally filtered by status.
148
+ */
149
+ listTasks(status?: TaskStatus): TaskInfo[] {
150
+ const tasks = [...this._tasks.values()];
151
+ const filtered = status ? tasks.filter(t => t.info.status === status) : tasks;
152
+ return filtered.map(t => ({ ...t.info }));
153
+ }
154
+
155
+ /**
156
+ * Remove terminal-state tasks older than maxAgeSeconds seconds.
157
+ *
158
+ * Terminal states: COMPLETED, FAILED, CANCELLED.
159
+ * Returns the number of tasks removed.
160
+ */
161
+ cleanup(maxAgeSeconds: number = 3600): number {
162
+ const terminal = new Set([TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]);
163
+ const now = Date.now() / 1000;
164
+ let removed = 0;
165
+
166
+ for (const [taskId, internal] of this._tasks.entries()) {
167
+ if (!terminal.has(internal.info.status)) continue;
168
+ const refTime = internal.info.completedAt ?? internal.info.submittedAt;
169
+ if ((now - refTime) >= maxAgeSeconds) {
170
+ this._tasks.delete(taskId);
171
+ removed++;
172
+ }
173
+ }
174
+
175
+ return removed;
176
+ }
177
+
178
+ /**
179
+ * Cancel all pending and running tasks.
180
+ */
181
+ shutdown(): void {
182
+ for (const [taskId, task] of this._tasks) {
183
+ if (task.info.status === TaskStatus.PENDING || task.info.status === TaskStatus.RUNNING) {
184
+ this.cancel(taskId);
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Acquire a concurrency slot. Resolves when a slot is available.
191
+ */
192
+ private _acquireSlot(): Promise<void> {
193
+ if (this._runningCount < this._maxConcurrent) {
194
+ this._runningCount++;
195
+ return Promise.resolve();
196
+ }
197
+ return new Promise<void>((resolve) => {
198
+ this._waitQueue.push(() => {
199
+ this._runningCount++;
200
+ resolve();
201
+ });
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Release a concurrency slot and notify the next waiter.
207
+ */
208
+ private _releaseSlot(): void {
209
+ this._runningCount--;
210
+ if (this._waitQueue.length > 0) {
211
+ const next = this._waitQueue.shift()!;
212
+ next();
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Enqueue the task for execution under the concurrency limit.
218
+ */
219
+ private _enqueue(
220
+ taskId: string,
221
+ moduleId: string,
222
+ inputs: Record<string, unknown>,
223
+ context: Context | null,
224
+ ): void {
225
+ const run = async (): Promise<void> => {
226
+ const internal = this._tasks.get(taskId);
227
+ if (!internal) return;
228
+
229
+ try {
230
+ await this._acquireSlot();
231
+
232
+ // Check if cancelled while waiting for a slot
233
+ if (internal.cancelled) {
234
+ return; // finally block handles releaseSlot + resolve
235
+ }
236
+
237
+ internal.info.status = TaskStatus.RUNNING;
238
+ internal.info.startedAt = Date.now() / 1000;
239
+
240
+ const result = await this._executor.call(moduleId, inputs, context);
241
+
242
+ // Check if cancelled during execution
243
+ if (internal.cancelled) {
244
+ return; // finally block handles releaseSlot + resolve
245
+ }
246
+
247
+ internal.info.status = TaskStatus.COMPLETED;
248
+ internal.info.completedAt = Date.now() / 1000;
249
+ internal.info.result = result;
250
+ } catch (err) {
251
+ if (!internal.cancelled) {
252
+ internal.info.status = TaskStatus.FAILED;
253
+ internal.info.completedAt = Date.now() / 1000;
254
+ internal.info.error = err instanceof Error ? err.message : String(err);
255
+ }
256
+ } finally {
257
+ this._releaseSlot();
258
+ internal.resolve();
259
+ }
260
+ };
261
+
262
+ // Fire and forget -- errors are captured inside run()
263
+ run().catch((err) => {
264
+ console.warn('[apcore:async-task] Unexpected error in task runner:', err);
265
+ });
266
+ }
267
+ }
package/src/cancel.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cooperative cancellation support for apcore module execution.
3
+ */
4
+
5
+ export class ExecutionCancelledError extends Error {
6
+ constructor(message: string = "Execution was cancelled") {
7
+ super(message);
8
+ this.name = "ExecutionCancelledError";
9
+ }
10
+ }
11
+
12
+ export class CancelToken {
13
+ private _cancelled: boolean = false;
14
+
15
+ get isCancelled(): boolean {
16
+ return this._cancelled;
17
+ }
18
+
19
+ cancel(): void {
20
+ this._cancelled = true;
21
+ }
22
+
23
+ check(): void {
24
+ if (this._cancelled) {
25
+ throw new ExecutionCancelledError();
26
+ }
27
+ }
28
+
29
+ reset(): void {
30
+ this._cancelled = false;
31
+ }
32
+ }
package/src/context.ts CHANGED
@@ -1,5 +1,7 @@
1
1
 
2
2
  import { v4 as uuidv4 } from 'uuid';
3
+ import type { CancelToken } from './cancel.js';
4
+ import type { TraceParent } from './trace-context.js';
3
5
 
4
6
  /**
5
7
  * Execution context, identity, and context creation.
@@ -29,6 +31,7 @@ export class Context {
29
31
  readonly identity: Identity | null;
30
32
  redactedInputs: Record<string, unknown> | null;
31
33
  readonly data: Record<string, unknown>;
34
+ readonly cancelToken: CancelToken | null;
32
35
 
33
36
  constructor(
34
37
  traceId: string,
@@ -38,6 +41,7 @@ export class Context {
38
41
  identity: Identity | null = null,
39
42
  redactedInputs: Record<string, unknown> | null = null,
40
43
  data: Record<string, unknown> = {},
44
+ cancelToken: CancelToken | null = null,
41
45
  ) {
42
46
  this.traceId = traceId;
43
47
  this.callerId = callerId;
@@ -46,15 +50,31 @@ export class Context {
46
50
  this.identity = identity;
47
51
  this.redactedInputs = redactedInputs;
48
52
  this.data = data;
53
+ this.cancelToken = cancelToken;
49
54
  }
50
55
 
56
+ /**
57
+ * Create a new top-level Context with a generated UUID v4 traceId.
58
+ *
59
+ * When `traceParent` is provided, its `traceId` (32 hex chars) is
60
+ * converted to UUID format (8-4-4-4-12) and used instead of generating
61
+ * a new one.
62
+ */
51
63
  static create(
52
64
  executor: unknown = null,
53
65
  identity: Identity | null = null,
54
66
  data?: Record<string, unknown>,
67
+ traceParent?: TraceParent | null,
55
68
  ): Context {
69
+ let traceId: string;
70
+ if (traceParent) {
71
+ const h = traceParent.traceId;
72
+ traceId = `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
73
+ } else {
74
+ traceId = uuidv4();
75
+ }
56
76
  return new Context(
57
- uuidv4(),
77
+ traceId,
58
78
  null,
59
79
  [],
60
80
  executor,
@@ -64,6 +84,57 @@ export class Context {
64
84
  );
65
85
  }
66
86
 
87
+ toJSON(): Record<string, unknown> {
88
+ const publicData: Record<string, unknown> = {};
89
+ for (const [key, value] of Object.entries(this.data)) {
90
+ if (!key.startsWith('_')) {
91
+ publicData[key] = value;
92
+ }
93
+ }
94
+ return {
95
+ traceId: this.traceId,
96
+ callerId: this.callerId,
97
+ callChain: [...this.callChain],
98
+ identity: this.identity ? {
99
+ id: this.identity.id,
100
+ type: this.identity.type,
101
+ roles: [...this.identity.roles],
102
+ attrs: { ...this.identity.attrs },
103
+ } : null,
104
+ redactedInputs: this.redactedInputs ? { ...this.redactedInputs } : null,
105
+ data: publicData,
106
+ };
107
+ }
108
+
109
+ static fromJSON(data: Record<string, unknown>, executor?: unknown): Context {
110
+ const identityData = data.identity as Record<string, unknown> | null;
111
+ const identity = identityData ? {
112
+ id: identityData.id as string,
113
+ type: (identityData.type as string) ?? 'user',
114
+ roles: Object.freeze([...(Array.isArray(identityData.roles) ? identityData.roles : [])]),
115
+ attrs: Object.freeze((identityData.attrs && typeof identityData.attrs === 'object' ? identityData.attrs : {}) as Record<string, unknown>),
116
+ } : null;
117
+ return new Context(
118
+ data.traceId as string,
119
+ (data.callerId as string) ?? null,
120
+ (data.callChain as string[]) ?? [],
121
+ executor ?? null,
122
+ identity,
123
+ (data.redactedInputs as Record<string, unknown>) ?? null,
124
+ data.data ? { ...(data.data as Record<string, unknown>) } : {},
125
+ );
126
+ }
127
+
128
+ get logger(): { debug: (...args: unknown[]) => void; info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void } {
129
+ const prefix = `[apcore:${this.callerId ?? 'unknown'}]`;
130
+ return {
131
+ debug: (...args: unknown[]) => console.debug(prefix, ...args),
132
+ info: (...args: unknown[]) => console.info(prefix, ...args),
133
+ warn: (...args: unknown[]) => console.warn(prefix, ...args),
134
+ error: (...args: unknown[]) => console.error(prefix, ...args),
135
+ };
136
+ }
137
+
67
138
  child(targetModuleId: string): Context {
68
139
  return new Context(
69
140
  this.traceId,
@@ -73,6 +144,17 @@ export class Context {
73
144
  this.identity,
74
145
  null,
75
146
  this.data, // shared reference
147
+ this.cancelToken,
76
148
  );
77
149
  }
78
150
  }
151
+
152
+ /**
153
+ * Interface for creating Context from framework-specific requests.
154
+ *
155
+ * Web framework integrations should implement this to extract Identity
156
+ * from HTTP requests (e.g., Express request, JWT tokens, API keys).
157
+ */
158
+ export interface ContextFactory {
159
+ createContext(request: unknown): Context;
160
+ }
package/src/errors.ts CHANGED
@@ -420,6 +420,10 @@ export const ErrorCodes = Object.freeze({
420
420
  BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
421
421
  CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
422
422
  MIDDLEWARE_CHAIN_ERROR: "MIDDLEWARE_CHAIN_ERROR",
423
+ // Forward declarations for Level 2 Phase 2 features.
424
+ // Exception classes will be added when the corresponding features are implemented.
425
+ GENERAL_NOT_IMPLEMENTED: "GENERAL_NOT_IMPLEMENTED",
426
+ DEPENDENCY_NOT_FOUND: "DEPENDENCY_NOT_FOUND",
423
427
  } as const);
424
428
 
425
429
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
package/src/executor.ts CHANGED
@@ -10,6 +10,7 @@ import { Value } from '@sinclair/typebox/value';
10
10
  import type { ACL } from './acl.js';
11
11
  import type { Config } from './config.js';
12
12
  import { Context } from './context.js';
13
+ import { ExecutionCancelledError } from './cancel.js';
13
14
  import {
14
15
  ACLDeniedError,
15
16
  CallDepthExceededError,
@@ -132,6 +133,11 @@ export class Executor {
132
133
  return this._middlewareManager.snapshot();
133
134
  }
134
135
 
136
+ /** Set the access control provider. */
137
+ setAcl(acl: ACL): void {
138
+ this._acl = acl;
139
+ }
140
+
135
141
  use(middleware: Middleware): Executor {
136
142
  this._middlewareManager.add(middleware);
137
143
  return this;
@@ -236,6 +242,11 @@ export class Executor {
236
242
  throw e;
237
243
  }
238
244
 
245
+ // Cancel check before execution
246
+ if (ctx.cancelToken !== null) {
247
+ ctx.cancelToken.check();
248
+ }
249
+
239
250
  const streamFn = mod['stream'] as
240
251
  | ((inputs: Record<string, unknown>, context: Context) => AsyncGenerator<Record<string, unknown>>)
241
252
  | undefined;
@@ -261,6 +272,7 @@ export class Executor {
261
272
  yield output;
262
273
  }
263
274
  } catch (exc) {
275
+ if (exc instanceof ExecutionCancelledError) throw exc;
264
276
  if (executedMiddlewares.length > 0) {
265
277
  const recovery = this._middlewareManager.executeOnError(
266
278
  moduleId, effectiveInputs, exc as Error, ctx, executedMiddlewares,
@@ -357,6 +369,11 @@ export class Executor {
357
369
  throw e;
358
370
  }
359
371
 
372
+ // Cancel check before execution
373
+ if (ctx.cancelToken !== null) {
374
+ ctx.cancelToken.check();
375
+ }
376
+
360
377
  let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
361
378
 
362
379
  this._validateOutput(mod, output);
@@ -364,6 +381,7 @@ export class Executor {
364
381
  output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
365
382
  return output;
366
383
  } catch (exc) {
384
+ if (exc instanceof ExecutionCancelledError) throw exc;
367
385
  if (executedMiddlewares.length > 0) {
368
386
  const recovery = this._middlewareManager.executeOnError(
369
387
  moduleId, effectiveInputs, exc as Error, ctx, executedMiddlewares,