apcore-js 0.3.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.
Files changed (40) hide show
  1. package/.github/workflows/ci.yml +39 -0
  2. package/CHANGELOG.md +70 -0
  3. package/package.json +4 -2
  4. package/src/acl.ts +21 -8
  5. package/src/async-task.ts +267 -0
  6. package/src/bindings.ts +6 -0
  7. package/src/cancel.ts +32 -0
  8. package/src/context.ts +87 -2
  9. package/src/errors.ts +7 -2
  10. package/src/executor.ts +35 -9
  11. package/src/extensions.ts +265 -0
  12. package/src/index.ts +18 -4
  13. package/src/middleware/manager.ts +1 -1
  14. package/src/observability/context-logger.ts +4 -2
  15. package/src/observability/metrics.ts +4 -2
  16. package/src/observability/tracing.ts +73 -8
  17. package/src/registry/index.ts +1 -0
  18. package/src/registry/registry.ts +229 -5
  19. package/src/registry/scanner.ts +28 -10
  20. package/src/registry/schema-export.ts +10 -3
  21. package/src/schema/loader.ts +29 -15
  22. package/src/schema/ref-resolver.ts +14 -2
  23. package/src/schema/strict.ts +11 -1
  24. package/src/trace-context.ts +102 -0
  25. package/tests/async-task.test.ts +335 -0
  26. package/tests/integration/test-acl-safety.test.ts +2 -1
  27. package/tests/observability/test-metrics.test.ts +98 -1
  28. package/tests/observability/test-tracing.test.ts +173 -1
  29. package/tests/registry/test-registry.test.ts +1258 -1
  30. package/tests/registry/test-schema-export.test.ts +131 -1
  31. package/tests/schema/test-loader.test.ts +366 -2
  32. package/tests/schema/test-ref-resolver.test.ts +427 -2
  33. package/tests/schema/test-strict.test.ts +209 -0
  34. package/tests/test-acl.test.ts +218 -1
  35. package/tests/test-cancel.test.ts +71 -0
  36. package/tests/test-context.test.ts +115 -0
  37. package/tests/test-errors.test.ts +448 -5
  38. package/tests/test-extensions.test.ts +310 -0
  39. package/tests/test-trace-context.test.ts +251 -0
  40. package/tests/utils/test-pattern.test.ts +109 -0
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build-and-test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '18'
20
+
21
+ - name: Install pnpm
22
+ run: npm install -g pnpm
23
+
24
+ - name: Install dependencies
25
+ run: pnpm install
26
+
27
+ - name: Set up Python
28
+ uses: actions/setup-python@v4
29
+ with:
30
+ python-version: '3.11'
31
+
32
+ - name: Install pre-commit
33
+ run: pip install pre-commit
34
+
35
+ - name: Run pre-commit checks
36
+ run: pre-commit run --all-files
37
+
38
+ - name: Run tests
39
+ run: pnpm test
package/CHANGELOG.md CHANGED
@@ -5,6 +5,76 @@ 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.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
36
+
37
+ ### Changed
38
+ - Improved performance of `Executor.stream()` with optimized buffering.
39
+
40
+ ### Added
41
+ - Introduced `ModuleAnnotations.batchProcessing` for enhanced batch processing capabilities.
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
47
+
48
+ ### Fixed
49
+ - Resolved issues with error handling in `context.ts`.
50
+
51
+ ### Co-Authors
52
+ - Claude Opus 4.6 <noreply@anthropic.com>
53
+ - New Contributor <newcontributor@example.com>
54
+
55
+ ### Added
56
+
57
+ - **Error classes and constants**
58
+ - `ModuleExecuteError` — New error class for module execution failures
59
+ - `InternalError` — New error class for general internal errors
60
+ - `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
61
+ - `ErrorCode` — Type definition for all error codes
62
+ - **Registry constants**
63
+ - `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
64
+ - `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
65
+ - **Executor methods**
66
+ - `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
67
+
68
+ ### Changed
69
+
70
+ - **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
71
+ - **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
72
+ - **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
73
+
74
+ ### Fixed
75
+
76
+ - **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
77
+
8
78
  ## [0.3.0] - 2026-02-20
9
79
 
10
80
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apcore-js",
3
- "version": "0.3.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",
@@ -32,12 +32,14 @@
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
34
  "@sinclair/typebox": "^0.34.0",
35
- "js-yaml": "^4.1.0"
35
+ "js-yaml": "^4.1.0",
36
+ "uuid": "^9.0.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "typescript": "^5.5.0",
39
40
  "@types/node": "^20.0.0",
40
41
  "@types/js-yaml": "^4.0.9",
42
+ "@types/uuid": "^9.0.0",
41
43
  "apdev-js": "^0.1.1",
42
44
  "vitest": "^2.0.0",
43
45
  "@vitest/coverage-v8": "^2.0.0"
package/src/acl.ts CHANGED
@@ -59,6 +59,9 @@ export class ACL {
59
59
  debug: boolean = false;
60
60
 
61
61
  constructor(rules: ACLRule[], defaultEffect: string = 'deny') {
62
+ if (defaultEffect !== 'allow' && defaultEffect !== 'deny') {
63
+ throw new ACLRuleError(`Invalid default_effect '${defaultEffect}', must be 'allow' or 'deny'`);
64
+ }
62
65
  this._rules = [...rules];
63
66
  this._defaultEffect = defaultEffect;
64
67
  }
@@ -101,16 +104,14 @@ export class ACL {
101
104
 
102
105
  check(callerId: string | null, targetId: string, context?: Context | null): boolean {
103
106
  const effectiveCaller = callerId === null ? '@external' : callerId;
104
- const rules = [...this._rules];
105
- const defaultEffect = this._defaultEffect;
106
107
 
107
- for (const rule of rules) {
108
+ for (const rule of this._rules) {
108
109
  if (this._matchesRule(rule, effectiveCaller, targetId, context ?? null)) {
109
110
  return rule.effect === 'allow';
110
111
  }
111
112
  }
112
113
 
113
- return defaultEffect === 'allow';
114
+ return this._defaultEffect === 'allow';
114
115
  }
115
116
 
116
117
  private _matchPattern(pattern: string, value: string, context: Context | null): boolean {
@@ -139,19 +140,31 @@ export class ACL {
139
140
  if (context === null) return false;
140
141
 
141
142
  if ('identity_types' in conditions) {
142
- const types = conditions['identity_types'] as string[];
143
+ const types = conditions['identity_types'];
144
+ if (!Array.isArray(types)) {
145
+ console.warn('[apcore:acl] identity_types condition must be an array');
146
+ return false;
147
+ }
143
148
  if (context.identity === null || !types.includes(context.identity.type)) return false;
144
149
  }
145
150
 
146
151
  if ('roles' in conditions) {
147
- const roles = conditions['roles'] as string[];
152
+ const roles = conditions['roles'];
153
+ if (!Array.isArray(roles)) {
154
+ console.warn('[apcore:acl] roles condition must be an array');
155
+ return false;
156
+ }
148
157
  if (context.identity === null) return false;
149
158
  const identityRoles = new Set(context.identity.roles);
150
- if (!roles.some((r) => identityRoles.has(r))) return false;
159
+ if (!roles.some((r: string) => identityRoles.has(r))) return false;
151
160
  }
152
161
 
153
162
  if ('max_call_depth' in conditions) {
154
- const maxDepth = conditions['max_call_depth'] as number;
163
+ const maxDepth = conditions['max_call_depth'];
164
+ if (typeof maxDepth !== 'number') {
165
+ console.warn('[apcore:acl] max_call_depth condition must be a number');
166
+ return false;
167
+ }
155
168
  if (context.callChain.length > maxDepth) return false;
156
169
  }
157
170
 
@@ -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/bindings.ts CHANGED
@@ -106,6 +106,12 @@ export class BindingLoader {
106
106
  );
107
107
  }
108
108
 
109
+ if (modulePath.startsWith('file:')) {
110
+ throw new BindingInvalidTargetError(
111
+ `Module path '${modulePath}' must not use file: URLs`,
112
+ );
113
+ }
114
+
109
115
  let mod: Record<string, unknown>;
110
116
  try {
111
117
  mod = await import(modulePath);
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,3 +1,8 @@
1
+
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import type { CancelToken } from './cancel.js';
4
+ import type { TraceParent } from './trace-context.js';
5
+
1
6
  /**
2
7
  * Execution context, identity, and context creation.
3
8
  */
@@ -26,6 +31,7 @@ export class Context {
26
31
  readonly identity: Identity | null;
27
32
  redactedInputs: Record<string, unknown> | null;
28
33
  readonly data: Record<string, unknown>;
34
+ readonly cancelToken: CancelToken | null;
29
35
 
30
36
  constructor(
31
37
  traceId: string,
@@ -35,23 +41,40 @@ export class Context {
35
41
  identity: Identity | null = null,
36
42
  redactedInputs: Record<string, unknown> | null = null,
37
43
  data: Record<string, unknown> = {},
44
+ cancelToken: CancelToken | null = null,
38
45
  ) {
39
46
  this.traceId = traceId;
40
47
  this.callerId = callerId;
41
- this.callChain = callChain;
48
+ this.callChain = Object.freeze([...callChain]);
42
49
  this.executor = executor;
43
50
  this.identity = identity;
44
51
  this.redactedInputs = redactedInputs;
45
52
  this.data = data;
53
+ this.cancelToken = cancelToken;
46
54
  }
47
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
+ */
48
63
  static create(
49
64
  executor: unknown = null,
50
65
  identity: Identity | null = null,
51
66
  data?: Record<string, unknown>,
67
+ traceParent?: TraceParent | null,
52
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
+ }
53
76
  return new Context(
54
- crypto.randomUUID(),
77
+ traceId,
55
78
  null,
56
79
  [],
57
80
  executor,
@@ -61,6 +84,57 @@ export class Context {
61
84
  );
62
85
  }
63
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
+
64
138
  child(targetModuleId: string): Context {
65
139
  return new Context(
66
140
  this.traceId,
@@ -70,6 +144,17 @@ export class Context {
70
144
  this.identity,
71
145
  null,
72
146
  this.data, // shared reference
147
+ this.cancelToken,
73
148
  );
74
149
  }
75
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
@@ -5,7 +5,7 @@
5
5
  export class ModuleError extends Error {
6
6
  readonly code: string;
7
7
  readonly details: Record<string, unknown>;
8
- readonly cause?: Error;
8
+ override readonly cause?: Error;
9
9
  readonly traceId?: string;
10
10
  readonly timestamp: string;
11
11
 
@@ -16,7 +16,7 @@ export class ModuleError extends Error {
16
16
  cause?: Error,
17
17
  traceId?: string,
18
18
  ) {
19
- super(message);
19
+ super(message, cause ? { cause } : undefined);
20
20
  this.name = 'ModuleError';
21
21
  this.code = code;
22
22
  this.details = details ?? {};
@@ -419,6 +419,11 @@ export const ErrorCodes = Object.freeze({
419
419
  BINDING_SCHEMA_MISSING: "BINDING_SCHEMA_MISSING",
420
420
  BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
421
421
  CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
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",
422
427
  } as const);
423
428
 
424
429
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];