apcore-js 0.2.0 → 0.4.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/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.md +73 -0
- package/package.json +4 -2
- package/src/acl.ts +21 -8
- package/src/bindings.ts +6 -0
- package/src/context.ts +5 -2
- package/src/errors.ts +3 -2
- package/src/executor.ts +115 -13
- package/src/index.ts +1 -1
- package/src/module.ts +2 -0
- package/src/observability/context-logger.ts +4 -2
- package/src/observability/metrics.ts +4 -2
- package/src/observability/tracing.ts +4 -3
- package/src/registry/registry.ts +5 -1
- package/src/registry/scanner.ts +28 -10
- package/src/registry/schema-export.ts +10 -3
- package/src/schema/annotations.ts +1 -0
- package/src/schema/loader.ts +29 -15
- package/src/schema/ref-resolver.ts +14 -2
- package/src/schema/strict.ts +11 -1
- package/tests/integration/test-acl-safety.test.ts +2 -1
- package/tests/observability/test-metrics.test.ts +98 -1
- package/tests/registry/test-registry.test.ts +869 -1
- package/tests/registry/test-schema-export.test.ts +132 -1
- package/tests/schema/test-annotations.test.ts +2 -0
- package/tests/schema/test-exporter.test.ts +1 -0
- package/tests/schema/test-loader.test.ts +366 -2
- package/tests/schema/test-ref-resolver.test.ts +427 -2
- package/tests/schema/test-strict.test.ts +209 -0
- package/tests/test-acl.test.ts +218 -1
- package/tests/test-errors.test.ts +448 -5
- package/tests/test-executor-stream.test.ts +208 -0
- 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,79 @@ 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
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Improved performance of `Executor.stream()` with optimized buffering.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Introduced `ModuleAnnotations.batchProcessing` for enhanced batch processing capabilities.
|
|
15
|
+
- Added new logging features for better observability in the execution pipeline.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Resolved issues with error handling in `context.ts`.
|
|
19
|
+
|
|
20
|
+
### Co-Authors
|
|
21
|
+
- Claude Opus 4.6 <noreply@anthropic.com>
|
|
22
|
+
- New Contributor <newcontributor@example.com>
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Error classes and constants**
|
|
27
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
28
|
+
- `InternalError` — New error class for general internal errors
|
|
29
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
30
|
+
- `ErrorCode` — Type definition for all error codes
|
|
31
|
+
- **Registry constants**
|
|
32
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
33
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
34
|
+
- **Executor methods**
|
|
35
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
40
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
41
|
+
- **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`)
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
46
|
+
|
|
47
|
+
## [0.3.0] - 2026-02-20
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Use shallow merge for `stream()` accumulation instead of last-chunk.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
- Add `Executor.stream()` async generator and `ModuleAnnotations.streaming` for streaming support in the core execution pipeline.
|
|
54
|
+
|
|
55
|
+
### Co-Authors
|
|
56
|
+
- Claude Opus 4.6 <noreply@anthropic.com>
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Error classes and constants**
|
|
61
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
62
|
+
- `InternalError` — New error class for general internal errors
|
|
63
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
64
|
+
- `ErrorCode` — Type definition for all error codes
|
|
65
|
+
- **Registry constants**
|
|
66
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
67
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
68
|
+
- **Executor methods**
|
|
69
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
74
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
75
|
+
- **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`)
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
80
|
+
|
|
8
81
|
## [0.2.0] - 2026-02-20
|
|
9
82
|
|
|
10
83
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apcore-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
|
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
|
|
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']
|
|
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']
|
|
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']
|
|
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
|
|
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/context.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Execution context, identity, and context creation.
|
|
3
6
|
*/
|
|
@@ -38,7 +41,7 @@ export class Context {
|
|
|
38
41
|
) {
|
|
39
42
|
this.traceId = traceId;
|
|
40
43
|
this.callerId = callerId;
|
|
41
|
-
this.callChain = callChain;
|
|
44
|
+
this.callChain = Object.freeze([...callChain]);
|
|
42
45
|
this.executor = executor;
|
|
43
46
|
this.identity = identity;
|
|
44
47
|
this.redactedInputs = redactedInputs;
|
|
@@ -51,7 +54,7 @@ export class Context {
|
|
|
51
54
|
data?: Record<string, unknown>,
|
|
52
55
|
): Context {
|
|
53
56
|
return new Context(
|
|
54
|
-
|
|
57
|
+
uuidv4(),
|
|
55
58
|
null,
|
|
56
59
|
[],
|
|
57
60
|
executor,
|
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,7 @@ 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",
|
|
422
423
|
} as const);
|
|
423
424
|
|
|
424
425
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
package/src/executor.ts
CHANGED
|
@@ -78,8 +78,11 @@ function redactFields(data: Record<string, unknown>, schemaDict: Record<string,
|
|
|
78
78
|
|
|
79
79
|
function redactSecretPrefix(data: Record<string, unknown>): void {
|
|
80
80
|
for (const key of Object.keys(data)) {
|
|
81
|
-
|
|
81
|
+
const value = data[key];
|
|
82
|
+
if (key.startsWith('_secret_') && value !== null && value !== undefined) {
|
|
82
83
|
data[key] = REDACTED_VALUE;
|
|
84
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
85
|
+
redactSecretPrefix(value as Record<string, unknown>);
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -90,7 +93,6 @@ export class Executor {
|
|
|
90
93
|
private _acl: ACL | null;
|
|
91
94
|
private _config: Config | null;
|
|
92
95
|
private _defaultTimeout: number;
|
|
93
|
-
private _globalTimeout: number;
|
|
94
96
|
private _maxCallDepth: number;
|
|
95
97
|
private _maxModuleRepeat: number;
|
|
96
98
|
|
|
@@ -113,12 +115,10 @@ export class Executor {
|
|
|
113
115
|
|
|
114
116
|
if (this._config !== null) {
|
|
115
117
|
this._defaultTimeout = (this._config.get('executor.default_timeout') as number) ?? 30000;
|
|
116
|
-
this._globalTimeout = (this._config.get('executor.global_timeout') as number) ?? 60000;
|
|
117
118
|
this._maxCallDepth = (this._config.get('executor.max_call_depth') as number) ?? 32;
|
|
118
119
|
this._maxModuleRepeat = (this._config.get('executor.max_module_repeat') as number) ?? 3;
|
|
119
120
|
} else {
|
|
120
121
|
this._defaultTimeout = 30000;
|
|
121
|
-
this._globalTimeout = 60000;
|
|
122
122
|
this._maxCallDepth = 32;
|
|
123
123
|
this._maxModuleRepeat = 3;
|
|
124
124
|
}
|
|
@@ -180,6 +180,100 @@ export class Executor {
|
|
|
180
180
|
return this.call(moduleId, inputs, context);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Streaming execution pipeline. If the module exposes a stream() async generator,
|
|
185
|
+
* yields each chunk. Otherwise falls back to call() and yields a single chunk.
|
|
186
|
+
*
|
|
187
|
+
* Pipeline: context -> safety -> lookup -> ACL -> validate inputs -> before-middleware
|
|
188
|
+
* -> stream (or fallback to execute) -> validate accumulated output -> after-middleware
|
|
189
|
+
*
|
|
190
|
+
* Note: In the streaming path, after-middleware runs on the accumulated output for
|
|
191
|
+
* validation/side-effects but its return value is not yielded since chunks were already
|
|
192
|
+
* emitted. In the non-streaming fallback, after-middleware can transform the output.
|
|
193
|
+
*/
|
|
194
|
+
async *stream(
|
|
195
|
+
moduleId: string,
|
|
196
|
+
inputs?: Record<string, unknown> | null,
|
|
197
|
+
context?: Context | null,
|
|
198
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
199
|
+
let effectiveInputs = inputs ?? {};
|
|
200
|
+
const ctx = this._createContext(moduleId, context);
|
|
201
|
+
this._checkSafety(moduleId, ctx);
|
|
202
|
+
|
|
203
|
+
const mod = this._lookupModule(moduleId);
|
|
204
|
+
this._checkAcl(moduleId, ctx);
|
|
205
|
+
|
|
206
|
+
effectiveInputs = this._validateInputs(mod, effectiveInputs, ctx);
|
|
207
|
+
|
|
208
|
+
yield* this._streamWithMiddleware(mod, moduleId, effectiveInputs, ctx);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async *_streamWithMiddleware(
|
|
212
|
+
mod: Record<string, unknown>,
|
|
213
|
+
moduleId: string,
|
|
214
|
+
inputs: Record<string, unknown>,
|
|
215
|
+
ctx: Context,
|
|
216
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
217
|
+
let effectiveInputs = inputs;
|
|
218
|
+
let executedMiddlewares: Middleware[] = [];
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
try {
|
|
222
|
+
[effectiveInputs, executedMiddlewares] = this._middlewareManager.executeBefore(moduleId, effectiveInputs, ctx);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e instanceof MiddlewareChainError) {
|
|
225
|
+
executedMiddlewares = e.executedMiddlewares;
|
|
226
|
+
const recovery = this._middlewareManager.executeOnError(
|
|
227
|
+
moduleId, effectiveInputs, e.original, ctx, executedMiddlewares,
|
|
228
|
+
);
|
|
229
|
+
if (recovery !== null) {
|
|
230
|
+
yield recovery;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
executedMiddlewares = [];
|
|
234
|
+
throw e.original;
|
|
235
|
+
}
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const streamFn = mod['stream'] as
|
|
240
|
+
| ((inputs: Record<string, unknown>, context: Context) => AsyncGenerator<Record<string, unknown>>)
|
|
241
|
+
| undefined;
|
|
242
|
+
|
|
243
|
+
if (typeof streamFn === 'function') {
|
|
244
|
+
// Module has a stream() method: iterate and yield each chunk
|
|
245
|
+
let accumulated: Record<string, unknown> = {};
|
|
246
|
+
for await (const chunk of streamFn.call(mod, effectiveInputs, ctx)) {
|
|
247
|
+
accumulated = { ...accumulated, ...chunk };
|
|
248
|
+
yield chunk;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Validate accumulated output against output schema
|
|
252
|
+
this._validateOutput(mod, accumulated);
|
|
253
|
+
|
|
254
|
+
// Run after-middleware on the accumulated result
|
|
255
|
+
this._middlewareManager.executeAfter(moduleId, effectiveInputs, accumulated, ctx);
|
|
256
|
+
} else {
|
|
257
|
+
// Fallback: execute normally and yield single chunk
|
|
258
|
+
let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
|
|
259
|
+
this._validateOutput(mod, output);
|
|
260
|
+
output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
|
|
261
|
+
yield output;
|
|
262
|
+
}
|
|
263
|
+
} catch (exc) {
|
|
264
|
+
if (executedMiddlewares.length > 0) {
|
|
265
|
+
const recovery = this._middlewareManager.executeOnError(
|
|
266
|
+
moduleId, effectiveInputs, exc as Error, ctx, executedMiddlewares,
|
|
267
|
+
);
|
|
268
|
+
if (recovery !== null) {
|
|
269
|
+
yield recovery;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
throw exc;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
183
277
|
private _createContext(moduleId: string, context?: Context | null): Context {
|
|
184
278
|
if (context == null) {
|
|
185
279
|
return Context.create(this).child(moduleId);
|
|
@@ -265,10 +359,7 @@ export class Executor {
|
|
|
265
359
|
|
|
266
360
|
let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
|
|
267
361
|
|
|
268
|
-
|
|
269
|
-
if (outputSchema != null) {
|
|
270
|
-
this._validateSchema(outputSchema, output, 'Output');
|
|
271
|
-
}
|
|
362
|
+
this._validateOutput(mod, output);
|
|
272
363
|
|
|
273
364
|
output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
|
|
274
365
|
return output;
|
|
@@ -283,6 +374,13 @@ export class Executor {
|
|
|
283
374
|
}
|
|
284
375
|
}
|
|
285
376
|
|
|
377
|
+
private _validateOutput(mod: Record<string, unknown>, output: Record<string, unknown>): void {
|
|
378
|
+
const outputSchema = mod['outputSchema'] as TSchema | undefined;
|
|
379
|
+
if (outputSchema != null) {
|
|
380
|
+
this._validateSchema(outputSchema, output, 'Output');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
286
384
|
validate(moduleId: string, inputs: Record<string, unknown>): ValidationResult {
|
|
287
385
|
const module = this._registry.get(moduleId);
|
|
288
386
|
if (module === null) {
|
|
@@ -348,14 +446,18 @@ export class Executor {
|
|
|
348
446
|
throw new InvalidInputError(`Negative timeout: ${timeoutMs}ms`);
|
|
349
447
|
}
|
|
350
448
|
|
|
351
|
-
const executeFn = mod['execute']
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
449
|
+
const executeFn = mod['execute'];
|
|
450
|
+
if (typeof executeFn !== 'function') {
|
|
451
|
+
throw new InvalidInputError(`Module '${moduleId}' has no execute method`);
|
|
452
|
+
}
|
|
355
453
|
|
|
356
|
-
const executionPromise = Promise.resolve(
|
|
454
|
+
const executionPromise = Promise.resolve(
|
|
455
|
+
(executeFn as (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>)
|
|
456
|
+
.call(mod, inputs, ctx),
|
|
457
|
+
);
|
|
357
458
|
|
|
358
459
|
if (timeoutMs === 0) {
|
|
460
|
+
console.warn('[apcore:executor] Timeout is 0, timeout limit disabled');
|
|
359
461
|
return executionPromise;
|
|
360
462
|
}
|
|
361
463
|
|
package/src/index.ts
CHANGED
|
@@ -82,4 +82,4 @@ export type { Span, SpanExporter } from './observability/tracing.js';
|
|
|
82
82
|
export { MetricsCollector, MetricsMiddleware } from './observability/metrics.js';
|
|
83
83
|
export { ContextLogger, ObsLoggingMiddleware } from './observability/context-logger.js';
|
|
84
84
|
|
|
85
|
-
export const VERSION = '0.
|
|
85
|
+
export const VERSION = '0.3.0';
|
package/src/module.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ModuleAnnotations {
|
|
|
11
11
|
readonly idempotent: boolean;
|
|
12
12
|
readonly requiresApproval: boolean;
|
|
13
13
|
readonly openWorld: boolean;
|
|
14
|
+
readonly streaming: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_ANNOTATIONS: ModuleAnnotations = Object.freeze({
|
|
@@ -19,6 +20,7 @@ export const DEFAULT_ANNOTATIONS: ModuleAnnotations = Object.freeze({
|
|
|
19
20
|
idempotent: false,
|
|
20
21
|
requiresApproval: false,
|
|
21
22
|
openWorld: true,
|
|
23
|
+
streaming: false,
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
export interface ModuleExample {
|
|
@@ -165,7 +165,8 @@ export class ObsLoggingMiddleware extends Middleware {
|
|
|
165
165
|
output: Record<string, unknown>,
|
|
166
166
|
context: Context,
|
|
167
167
|
): null {
|
|
168
|
-
const starts = context.data['_obs_logging_starts'] as number[];
|
|
168
|
+
const starts = context.data['_obs_logging_starts'] as number[] | undefined;
|
|
169
|
+
if (!starts || starts.length === 0) return null;
|
|
169
170
|
const startTime = starts.pop()!;
|
|
170
171
|
const durationMs = performance.now() - startTime;
|
|
171
172
|
|
|
@@ -186,7 +187,8 @@ export class ObsLoggingMiddleware extends Middleware {
|
|
|
186
187
|
error: Error,
|
|
187
188
|
context: Context,
|
|
188
189
|
): null {
|
|
189
|
-
const starts = context.data['_obs_logging_starts'] as number[];
|
|
190
|
+
const starts = context.data['_obs_logging_starts'] as number[] | undefined;
|
|
191
|
+
if (!starts || starts.length === 0) return null;
|
|
190
192
|
const startTime = starts.pop()!;
|
|
191
193
|
const durationMs = performance.now() - startTime;
|
|
192
194
|
|
|
@@ -186,7 +186,8 @@ export class MetricsMiddleware extends Middleware {
|
|
|
186
186
|
_output: Record<string, unknown>,
|
|
187
187
|
context: Context,
|
|
188
188
|
): null {
|
|
189
|
-
const starts = context.data['_metrics_starts'] as number[];
|
|
189
|
+
const starts = context.data['_metrics_starts'] as number[] | undefined;
|
|
190
|
+
if (!starts || starts.length === 0) return null;
|
|
190
191
|
const startTime = starts.pop()!;
|
|
191
192
|
const durationS = (performance.now() - startTime) / 1000;
|
|
192
193
|
this._collector.incrementCalls(moduleId, 'success');
|
|
@@ -200,7 +201,8 @@ export class MetricsMiddleware extends Middleware {
|
|
|
200
201
|
error: Error,
|
|
201
202
|
context: Context,
|
|
202
203
|
): null {
|
|
203
|
-
const starts = context.data['_metrics_starts'] as number[];
|
|
204
|
+
const starts = context.data['_metrics_starts'] as number[] | undefined;
|
|
205
|
+
if (!starts || starts.length === 0) return null;
|
|
204
206
|
const startTime = starts.pop()!;
|
|
205
207
|
const durationS = (performance.now() - startTime) / 1000;
|
|
206
208
|
const errorCode = error instanceof ModuleError ? error.code : error.constructor.name;
|
|
@@ -58,10 +58,11 @@ export class InMemoryExporter implements SpanExporter {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export(span: Span): void {
|
|
61
|
-
this._spans.
|
|
62
|
-
|
|
63
|
-
this._spans.
|
|
61
|
+
if (this._spans.length >= this._maxSpans) {
|
|
62
|
+
// Drop oldest half to amortize the cost instead of O(n) shift per insert
|
|
63
|
+
this._spans = this._spans.slice(Math.floor(this._maxSpans / 2));
|
|
64
64
|
}
|
|
65
|
+
this._spans.push(span);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
getSpans(): Span[] {
|
package/src/registry/registry.ts
CHANGED
|
@@ -223,13 +223,17 @@ export class Registry {
|
|
|
223
223
|
|
|
224
224
|
this._modules.set(moduleId, module);
|
|
225
225
|
|
|
226
|
-
//
|
|
226
|
+
// Populate metadata from the module object
|
|
227
227
|
const modObj = module as Record<string, unknown>;
|
|
228
|
+
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
|
|
229
|
+
|
|
230
|
+
// Call onLoad if available
|
|
228
231
|
if (typeof modObj['onLoad'] === 'function') {
|
|
229
232
|
try {
|
|
230
233
|
(modObj['onLoad'] as () => void)();
|
|
231
234
|
} catch (e) {
|
|
232
235
|
this._modules.delete(moduleId);
|
|
236
|
+
this._moduleMeta.delete(moduleId);
|
|
233
237
|
throw e;
|
|
234
238
|
}
|
|
235
239
|
}
|
package/src/registry/scanner.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Directory scanner for discovering TypeScript/JavaScript extension modules.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readdirSync, statSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { readdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { resolve, relative, join, extname, basename, sep } from 'node:path';
|
|
7
7
|
import { ConfigError, ConfigNotFoundError } from '../errors.js';
|
|
8
8
|
import type { DiscoveredModule } from './types.js';
|
|
@@ -53,23 +53,41 @@ export function scanExtensions(
|
|
|
53
53
|
if (SKIP_DIR_NAMES.has(name)) continue;
|
|
54
54
|
|
|
55
55
|
const entryPath = join(dirPath, name);
|
|
56
|
-
let
|
|
56
|
+
let lstat;
|
|
57
57
|
try {
|
|
58
|
-
|
|
58
|
+
lstat = lstatSync(entryPath);
|
|
59
59
|
} catch {
|
|
60
60
|
console.warn(`[apcore:scanner] Cannot stat entry: ${entryPath}`);
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
const isSymlink = lstat.isSymbolicLink();
|
|
65
|
+
let isDir: boolean;
|
|
66
|
+
let isFile: boolean;
|
|
67
|
+
|
|
68
|
+
if (isSymlink) {
|
|
69
|
+
if (!followSymlinks) continue;
|
|
70
|
+
const real = realpathSync(entryPath);
|
|
71
|
+
if (visitedRealPaths.has(real)) continue;
|
|
72
|
+
visitedRealPaths.add(real);
|
|
73
|
+
// Resolve the symlink target to check if it's a dir or file
|
|
74
|
+
let targetStat;
|
|
75
|
+
try {
|
|
76
|
+
targetStat = statSync(entryPath);
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn(`[apcore:scanner] Cannot resolve symlink target: ${entryPath}`);
|
|
79
|
+
continue;
|
|
70
80
|
}
|
|
81
|
+
isDir = targetStat.isDirectory();
|
|
82
|
+
isFile = targetStat.isFile();
|
|
83
|
+
} else {
|
|
84
|
+
isDir = lstat.isDirectory();
|
|
85
|
+
isFile = lstat.isFile();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isDir) {
|
|
71
89
|
scanDir(entryPath, depth + 1);
|
|
72
|
-
} else if (
|
|
90
|
+
} else if (isFile) {
|
|
73
91
|
const ext = extname(name);
|
|
74
92
|
if (!VALID_EXTENSIONS.has(ext)) continue;
|
|
75
93
|
if (SKIP_SUFFIXES.some((s) => name.endsWith(s))) continue;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { TSchema } from '@sinclair/typebox';
|
|
6
|
-
import
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
7
|
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
8
|
-
import { ModuleNotFoundError } from '../errors.js';
|
|
8
|
+
import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
|
|
9
9
|
import { deepCopy } from '../utils/index.js';
|
|
10
10
|
import { SchemaExporter } from '../schema/exporter.js';
|
|
11
11
|
import { stripExtensions, toStrictSchema } from '../schema/strict.js';
|
|
@@ -129,6 +129,13 @@ function exportWithProfile(
|
|
|
129
129
|
const examples = module ? ((module as Record<string, unknown>)['examples'] as ModuleExample[]) ?? [] : [];
|
|
130
130
|
const name = module ? (module as Record<string, unknown>)['name'] as string | undefined : undefined;
|
|
131
131
|
|
|
132
|
+
const validProfiles = new Set(Object.values(ExportProfile));
|
|
133
|
+
if (!validProfiles.has(profile as ExportProfile)) {
|
|
134
|
+
throw new InvalidInputError(
|
|
135
|
+
`Invalid export profile: '${profile}'. Must be one of: ${[...validProfiles].join(', ')}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
const exported = new SchemaExporter().export(
|
|
133
140
|
schemaDef,
|
|
134
141
|
profile as ExportProfile,
|
|
@@ -168,7 +175,7 @@ function truncateDescription(description: string): string {
|
|
|
168
175
|
|
|
169
176
|
function serialize(data: unknown, format: string): string {
|
|
170
177
|
if (format === 'yaml') {
|
|
171
|
-
return
|
|
178
|
+
return yaml.dump(data, { flowLevel: -1 });
|
|
172
179
|
}
|
|
173
180
|
return JSON.stringify(data, null, 2);
|
|
174
181
|
}
|