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 +32 -1
- package/package.json +1 -1
- package/src/async-task.ts +267 -0
- package/src/cancel.ts +32 -0
- package/src/context.ts +83 -1
- package/src/errors.ts +4 -0
- package/src/executor.ts +18 -0
- package/src/extensions.ts +265 -0
- package/src/index.ts +17 -3
- package/src/middleware/manager.ts +1 -1
- package/src/observability/tracing.ts +69 -5
- package/src/registry/index.ts +1 -0
- package/src/registry/registry.ts +224 -4
- package/src/trace-context.ts +102 -0
- package/tests/async-task.test.ts +335 -0
- package/tests/observability/test-tracing.test.ts +173 -1
- package/tests/registry/test-registry.test.ts +389 -0
- package/tests/test-cancel.test.ts +71 -0
- package/tests/test-context.test.ts +115 -0
- package/tests/test-extensions.test.ts +310 -0
- package/tests/test-trace-context.test.ts +251 -0
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.
|
|
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
|
@@ -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
|
-
|
|
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,
|