@portel/photon 1.20.1 → 1.22.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/README.md +5 -5
- package/dist/ag-ui/adapter.d.ts +4 -1
- package/dist/ag-ui/adapter.d.ts.map +1 -1
- package/dist/ag-ui/adapter.js +58 -3
- package/dist/ag-ui/adapter.js.map +1 -1
- package/dist/ag-ui/types.d.ts +12 -0
- package/dist/ag-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +8 -49
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +79 -1
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +23 -31
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts.map +1 -1
- package/dist/auto-ui/bridge/index.js +107 -11
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/bridge/renderers.d.ts +14 -0
- package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
- package/dist/auto-ui/bridge/renderers.js +680 -57
- package/dist/auto-ui/bridge/renderers.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +3 -3
- package/dist/auto-ui/frontend/pure-view.html +19 -19
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +53 -2
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/ui-resolver.d.ts +25 -0
- package/dist/auto-ui/ui-resolver.d.ts.map +1 -0
- package/dist/auto-ui/ui-resolver.js +95 -0
- package/dist/auto-ui/ui-resolver.js.map +1 -0
- package/dist/beam-form.bundle.js +7 -7
- package/dist/beam-form.bundle.js.map +1 -1
- package/dist/beam.bundle.js +905 -185
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +9 -5
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +93 -53
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/publish.d.ts +14 -0
- package/dist/cli/commands/publish.d.ts.map +1 -0
- package/dist/cli/commands/publish.js +126 -0
- package/dist/cli/commands/publish.js.map +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -1
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +17 -5
- package/dist/context.js.map +1 -1
- package/dist/daemon/client.d.ts +9 -1
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +54 -1
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +3 -0
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +88 -38
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/ownership.d.ts +12 -0
- package/dist/daemon/ownership.d.ts.map +1 -0
- package/dist/daemon/ownership.js +55 -0
- package/dist/daemon/ownership.js.map +1 -0
- package/dist/daemon/protocol.d.ts +4 -2
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +15 -2
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +557 -83
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +9 -1
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +54 -1
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/daemon/worker-manager.d.ts +12 -0
- package/dist/daemon/worker-manager.d.ts.map +1 -1
- package/dist/daemon/worker-manager.js +89 -6
- package/dist/daemon/worker-manager.js.map +1 -1
- package/dist/loader.d.ts +17 -9
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +415 -141
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +26 -2
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photons/canvas/ui/canvas.photon.html +1493 -0
- package/dist/photons/canvas.photon.d.ts +400 -0
- package/dist/photons/canvas.photon.d.ts.map +1 -0
- package/dist/photons/canvas.photon.js +662 -0
- package/dist/photons/canvas.photon.js.map +1 -0
- package/dist/photons/canvas.photon.ts +814 -0
- package/dist/photons/publish.photon.d.ts +97 -0
- package/dist/photons/publish.photon.d.ts.map +1 -0
- package/dist/photons/publish.photon.js +569 -0
- package/dist/photons/publish.photon.js.map +1 -0
- package/dist/photons/publish.photon.ts +683 -0
- package/dist/photons/ui/canvas.photon.html +624 -0
- package/dist/resource-server.d.ts.map +1 -1
- package/dist/resource-server.js +7 -1
- package/dist/resource-server.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +67 -37
- package/dist/server.js.map +1 -1
- package/dist/shared/error-handler.d.ts +1 -0
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +68 -10
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/logger.js +34 -0
- package/dist/shared/logger.js.map +1 -1
- package/dist/shared-utils.d.ts.map +1 -1
- package/dist/shared-utils.js +2 -2
- package/dist/shared-utils.js.map +1 -1
- package/dist/telemetry/context.d.ts +24 -0
- package/dist/telemetry/context.d.ts.map +1 -0
- package/dist/telemetry/context.js +17 -0
- package/dist/telemetry/context.js.map +1 -0
- package/dist/telemetry/logs.d.ts +38 -0
- package/dist/telemetry/logs.d.ts.map +1 -0
- package/dist/telemetry/logs.js +108 -0
- package/dist/telemetry/logs.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +71 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +184 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/otel.d.ts +20 -1
- package/dist/telemetry/otel.d.ts.map +1 -1
- package/dist/telemetry/otel.js +79 -2
- package/dist/telemetry/otel.js.map +1 -1
- package/dist/telemetry/sdk.d.ts +49 -0
- package/dist/telemetry/sdk.d.ts.map +1 -0
- package/dist/telemetry/sdk.js +110 -0
- package/dist/telemetry/sdk.js.map +1 -0
- package/dist/tsx-compiler.d.ts +23 -0
- package/dist/tsx-compiler.d.ts.map +1 -0
- package/dist/tsx-compiler.js +221 -0
- package/dist/tsx-compiler.js.map +1 -0
- package/package.json +7 -7
package/dist/loader.js
CHANGED
|
@@ -8,9 +8,11 @@ import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync } from '
|
|
|
8
8
|
import { readText, readJSON, writeText, writeJSON } from './shared/io.js';
|
|
9
9
|
import { createRequire } from 'module';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { pathToFileURL } from 'url';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
12
12
|
import * as crypto from 'crypto';
|
|
13
13
|
import { startToolSpan } from './telemetry/otel.js';
|
|
14
|
+
import { recordToolCall, recordCircuitStateChange, recordRateLimitRejection, recordBulkheadRejection, } from './telemetry/metrics.js';
|
|
15
|
+
import { runWithRequestContext } from './telemetry/context.js';
|
|
14
16
|
import { spawn } from 'child_process';
|
|
15
17
|
import { SchemaExtractor, DependencyManager,
|
|
16
18
|
// Generator utilities (ask/emit pattern from 1.2.0)
|
|
@@ -130,6 +132,38 @@ const cliRenderZone = new CLIRenderZone();
|
|
|
130
132
|
export function clearRenderZone() {
|
|
131
133
|
cliRenderZone.clear();
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Inject emit-based convenience helpers on a plain-class instance.
|
|
137
|
+
* Every helper is a thin wrapper around this.emit so generator-yield and
|
|
138
|
+
* imperative-call styles produce identical wire events.
|
|
139
|
+
*/
|
|
140
|
+
function injectEmitHelpers(instance) {
|
|
141
|
+
const emit = (data) => instance.emit(data);
|
|
142
|
+
// Mirrors photon-core base-class render(): UI-feedback formats route to their
|
|
143
|
+
// dedicated emit events; all other formats go through the render channel.
|
|
144
|
+
instance.render = (format, value) => {
|
|
145
|
+
if (format === undefined)
|
|
146
|
+
return emit({ emit: 'render:clear' });
|
|
147
|
+
if (format === 'status')
|
|
148
|
+
return emit(typeof value === 'string'
|
|
149
|
+
? { emit: 'status', message: value }
|
|
150
|
+
: { emit: 'status', ...value });
|
|
151
|
+
if (format === 'progress')
|
|
152
|
+
return emit(typeof value === 'number' ? { emit: 'progress', value } : { emit: 'progress', ...value });
|
|
153
|
+
if (format === 'toast')
|
|
154
|
+
return emit(typeof value === 'string' ? { emit: 'toast', message: value } : { emit: 'toast', ...value });
|
|
155
|
+
emit({ emit: 'render', format, value });
|
|
156
|
+
};
|
|
157
|
+
instance.toast = (message, opts = {}) => emit({ emit: 'toast', message, ...opts });
|
|
158
|
+
instance.log = (message, opts = {}) => emit({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
|
|
159
|
+
instance.status = (message) => emit({ emit: 'status', message });
|
|
160
|
+
instance.progress = (value, message) => emit({ emit: 'progress', value, message });
|
|
161
|
+
instance.thinking = (active = true) => emit({ emit: 'thinking', active });
|
|
162
|
+
}
|
|
163
|
+
/** Extra regex checks that force 'emit' capability when helper methods are used. */
|
|
164
|
+
function detectEmitHelperUsage(source) {
|
|
165
|
+
return /this\.(toast|log|status|progress|thinking)\s*\(/.test(source);
|
|
166
|
+
}
|
|
133
167
|
/**
|
|
134
168
|
* Render a formatted value in the CLI using @portel/cli's formatOutput.
|
|
135
169
|
* Uses clear-and-replace semantics — each call overwrites the previous render.
|
|
@@ -203,6 +237,19 @@ export class PhotonLoader {
|
|
|
203
237
|
middlewareStates = new Map();
|
|
204
238
|
/** Per-photon custom middleware definitions discovered from module exports */
|
|
205
239
|
photonMiddleware = new Map();
|
|
240
|
+
/** Shadow registry of circuit breaker states, keyed by `${photon}:${instance}:${tool}` */
|
|
241
|
+
circuitHealthTracker = new Map();
|
|
242
|
+
/**
|
|
243
|
+
* Returns all tracked circuit breaker states across all photons.
|
|
244
|
+
* Each entry uses the key format `${photon}:${instance}:${tool}`.
|
|
245
|
+
*/
|
|
246
|
+
getCircuitHealth() {
|
|
247
|
+
const result = {};
|
|
248
|
+
for (const [key, entry] of this.circuitHealthTracker) {
|
|
249
|
+
result[key] = { ...entry };
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
206
253
|
/** Base directory for state/config/cache (defaults to ~/.photon) */
|
|
207
254
|
baseDir;
|
|
208
255
|
/**
|
|
@@ -650,15 +697,12 @@ export class PhotonLoader {
|
|
|
650
697
|
// Auto-wire ReactiveArray/Map/Set properties for zero-boilerplate reactivity
|
|
651
698
|
// Developers just `import { Array } from '@portel/photon-core'` and use normally
|
|
652
699
|
this.wireReactiveCollections(instance);
|
|
700
|
+
// Inject format catalog — this.formats
|
|
701
|
+
this.injectFormatCatalog(instance);
|
|
653
702
|
// Inject @mcp dependencies from source (this.github, this.fs, etc.)
|
|
654
703
|
if (tsContent) {
|
|
655
704
|
await this.injectMCPDependencies(instance, tsContent, name);
|
|
656
705
|
}
|
|
657
|
-
// Auto-wrap public methods in @stateful classes to emit events
|
|
658
|
-
// All method calls automatically produce events with params, result, timestamp
|
|
659
|
-
if (tsContent) {
|
|
660
|
-
this.wrapStatefulMethods(instance, tsContent);
|
|
661
|
-
}
|
|
662
706
|
// Inject MCP client factory if available (enables this.mcp() calls)
|
|
663
707
|
const setMCPFactory = instance.setMCPFactory;
|
|
664
708
|
if (this.mcpClientFactory && typeof setMCPFactory === 'function') {
|
|
@@ -669,9 +713,31 @@ export class PhotonLoader {
|
|
|
669
713
|
if (typeof instance._callHandler === 'undefined' || instance._callHandler === undefined) {
|
|
670
714
|
const callBaseDir = this.baseDir;
|
|
671
715
|
instance._callHandler = async (photonName, method, params, targetInstance) => {
|
|
716
|
+
// Propagate trace context to nested photon-to-photon calls so the
|
|
717
|
+
// downstream span chains as a child of the current span. Uses
|
|
718
|
+
// in-band `_meta.traceparent` which executeTool peeks at before
|
|
719
|
+
// schema validation — works through worker threads transparently.
|
|
720
|
+
const ctxMod = await import('./telemetry/context.js');
|
|
721
|
+
const ctx = ctxMod.getRequestContext();
|
|
722
|
+
let forwardedParams = params;
|
|
723
|
+
if (ctx) {
|
|
724
|
+
const traceparent = ctx.parentTraceparent ||
|
|
725
|
+
(ctx.traceId
|
|
726
|
+
? `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`
|
|
727
|
+
: undefined);
|
|
728
|
+
if (traceparent) {
|
|
729
|
+
const existingMeta = params?._meta;
|
|
730
|
+
forwardedParams = {
|
|
731
|
+
...(params || {}),
|
|
732
|
+
_meta: existingMeta && typeof existingMeta === 'object'
|
|
733
|
+
? { traceparent, ...existingMeta }
|
|
734
|
+
: { traceparent },
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
672
738
|
// Dynamic import to avoid circular dependency
|
|
673
739
|
const { sendCommand } = await import('./daemon/client.js');
|
|
674
|
-
return sendCommand(photonName, method,
|
|
740
|
+
return sendCommand(photonName, method, forwardedParams, {
|
|
675
741
|
workingDir: callBaseDir,
|
|
676
742
|
targetInstance,
|
|
677
743
|
});
|
|
@@ -682,6 +748,8 @@ export class PhotonLoader {
|
|
|
682
748
|
// Photon base class already has these built-in, so only inject for plain classes
|
|
683
749
|
if (tsContent && typeof instance.executeTool !== 'function') {
|
|
684
750
|
const caps = detectCapabilities(tsContent);
|
|
751
|
+
if (detectEmitHelperUsage(tsContent))
|
|
752
|
+
caps.add('emit');
|
|
685
753
|
if (caps.size > 0) {
|
|
686
754
|
this.log(`🔍 Detected capabilities for ${name}: ${[...caps].join(', ')}`);
|
|
687
755
|
}
|
|
@@ -727,15 +795,8 @@ export class PhotonLoader {
|
|
|
727
795
|
});
|
|
728
796
|
}
|
|
729
797
|
};
|
|
730
|
-
//
|
|
731
|
-
instance
|
|
732
|
-
if (format === undefined) {
|
|
733
|
-
instance.emit({ emit: 'render:clear' });
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
instance.emit({ emit: 'render', format, value });
|
|
737
|
-
}
|
|
738
|
-
};
|
|
798
|
+
// Inject convenience helpers (render, toast, log, status, progress, thinking)
|
|
799
|
+
injectEmitHelpers(instance);
|
|
739
800
|
}
|
|
740
801
|
if (caps.has('memory')) {
|
|
741
802
|
// Inject lazy memory provider — capture baseDir from loader context
|
|
@@ -1001,8 +1062,13 @@ export class PhotonLoader {
|
|
|
1001
1062
|
throw initError;
|
|
1002
1063
|
}
|
|
1003
1064
|
}
|
|
1065
|
+
// Auto-wrap public methods in @stateful classes to emit events
|
|
1066
|
+
// Must happen AFTER capability injection so that emit() is available
|
|
1067
|
+
if (tsContent) {
|
|
1068
|
+
this.wrapStatefulMethods(instance, tsContent);
|
|
1069
|
+
}
|
|
1004
1070
|
// Extract tools, templates, and statics (with schema override support)
|
|
1005
|
-
const { tools, templates, statics, settingsSchema } = await this.extractTools(MCPClass, absolutePath);
|
|
1071
|
+
const { tools, templates, statics, settingsSchema, auth: extractedAuth, } = await this.extractTools(MCPClass, absolutePath);
|
|
1006
1072
|
// ═══ SETTINGS INJECTION ═══
|
|
1007
1073
|
// If the photon declared `protected settings = { ... }`, inject persistence + proxy
|
|
1008
1074
|
if (settingsSchema?.hasSettings &&
|
|
@@ -1052,6 +1118,8 @@ export class PhotonLoader {
|
|
|
1052
1118
|
result.icon = classIcon;
|
|
1053
1119
|
if (isStateful)
|
|
1054
1120
|
result.stateful = true;
|
|
1121
|
+
if (extractedAuth)
|
|
1122
|
+
result.auth = extractedAuth;
|
|
1055
1123
|
// Store class constructor for static method access
|
|
1056
1124
|
result.classConstructor = MCPClass;
|
|
1057
1125
|
// Store settings schema for Beam UI
|
|
@@ -1123,6 +1191,8 @@ export class PhotonLoader {
|
|
|
1123
1191
|
instance._photonResolver = (photonName, instanceName) => {
|
|
1124
1192
|
return this.resolveAndLoadPhoton(photonName, absolutePath, instanceName);
|
|
1125
1193
|
};
|
|
1194
|
+
// Inject format catalog — this.formats
|
|
1195
|
+
this.injectFormatCatalog(instance);
|
|
1126
1196
|
// Wire reactive collections
|
|
1127
1197
|
this.wireReactiveCollections(instance);
|
|
1128
1198
|
// Inject @mcp dependencies
|
|
@@ -1152,6 +1222,8 @@ export class PhotonLoader {
|
|
|
1152
1222
|
// Detect and inject capabilities for plain classes
|
|
1153
1223
|
if (tsContent && typeof instance.executeTool !== 'function') {
|
|
1154
1224
|
const caps = detectCapabilities(tsContent);
|
|
1225
|
+
if (detectEmitHelperUsage(tsContent))
|
|
1226
|
+
caps.add('emit');
|
|
1155
1227
|
this.injectPathHelpers(instance, tsContent);
|
|
1156
1228
|
if (caps.has('emit')) {
|
|
1157
1229
|
instance.emit = (data) => {
|
|
@@ -1186,15 +1258,8 @@ export class PhotonLoader {
|
|
|
1186
1258
|
});
|
|
1187
1259
|
}
|
|
1188
1260
|
};
|
|
1189
|
-
//
|
|
1190
|
-
instance
|
|
1191
|
-
if (format === undefined) {
|
|
1192
|
-
instance.emit({ emit: 'render:clear' });
|
|
1193
|
-
}
|
|
1194
|
-
else {
|
|
1195
|
-
instance.emit({ emit: 'render', format, value });
|
|
1196
|
-
}
|
|
1197
|
-
};
|
|
1261
|
+
// Inject convenience helpers (render, toast, log, status, progress, thinking)
|
|
1262
|
+
injectEmitHelpers(instance);
|
|
1198
1263
|
}
|
|
1199
1264
|
if (caps.has('memory')) {
|
|
1200
1265
|
const memoryBaseDir = this.baseDir;
|
|
@@ -1296,7 +1361,7 @@ export class PhotonLoader {
|
|
|
1296
1361
|
}
|
|
1297
1362
|
}
|
|
1298
1363
|
// Extract tools and metadata from embedded source (no disk I/O)
|
|
1299
|
-
const { tools, templates, statics, settingsSchema } = await this.extractTools(MCPClass, absolutePath, tsContent);
|
|
1364
|
+
const { tools, templates, statics, settingsSchema, auth: extractedAuth, } = await this.extractTools(MCPClass, absolutePath, tsContent);
|
|
1300
1365
|
// Settings injection
|
|
1301
1366
|
if (settingsSchema?.hasSettings && instance.settings && typeof instance.settings === 'object') {
|
|
1302
1367
|
const instanceName = options?.instanceName || 'default';
|
|
@@ -1326,6 +1391,8 @@ export class PhotonLoader {
|
|
|
1326
1391
|
result.icon = classIcon;
|
|
1327
1392
|
if (isStateful)
|
|
1328
1393
|
result.stateful = true;
|
|
1394
|
+
if (extractedAuth)
|
|
1395
|
+
result.auth = extractedAuth;
|
|
1329
1396
|
result.classConstructor = MCPClass;
|
|
1330
1397
|
if (settingsSchema?.hasSettings) {
|
|
1331
1398
|
result.settingsSchema = settingsSchema;
|
|
@@ -1341,6 +1408,14 @@ export class PhotonLoader {
|
|
|
1341
1408
|
const match = source.match(/\/\*\*([\s\S]*?)\*\/\s*export\s+default\s+class\b/);
|
|
1342
1409
|
return match ? match[1] : '';
|
|
1343
1410
|
}
|
|
1411
|
+
extractAuthTag(source) {
|
|
1412
|
+
const docblock = this.extractClassDocblock(source);
|
|
1413
|
+
// Use \b to avoid matching @author, @authorize, etc.
|
|
1414
|
+
const match = docblock.match(/@auth\b(?:\s+(\S+))?/i);
|
|
1415
|
+
if (!match)
|
|
1416
|
+
return undefined;
|
|
1417
|
+
return match[1]?.trim() || 'required';
|
|
1418
|
+
}
|
|
1344
1419
|
/**
|
|
1345
1420
|
* Reload a Photon MCP file (for hot reload)
|
|
1346
1421
|
*/
|
|
@@ -1456,7 +1531,7 @@ export class PhotonLoader {
|
|
|
1456
1531
|
if (!description)
|
|
1457
1532
|
return '';
|
|
1458
1533
|
if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
|
|
1459
|
-
|
|
1534
|
+
this.logger.debug(`[stripJSDocTags] Input: "${description}"`);
|
|
1460
1535
|
}
|
|
1461
1536
|
// Remove lines that start with @ (full line removal)
|
|
1462
1537
|
let cleaned = description
|
|
@@ -1467,7 +1542,7 @@ export class PhotonLoader {
|
|
|
1467
1542
|
// Also remove inline @ tags (e.g., "text @emits ... " at end of line)
|
|
1468
1543
|
cleaned = cleaned.replace(/\s*@\w+.*$/gm, '').trim();
|
|
1469
1544
|
if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
|
|
1470
|
-
|
|
1545
|
+
this.logger.debug(`[stripJSDocTags] Output: "${cleaned}"`);
|
|
1471
1546
|
}
|
|
1472
1547
|
return cleaned;
|
|
1473
1548
|
}
|
|
@@ -1550,7 +1625,7 @@ export class PhotonLoader {
|
|
|
1550
1625
|
if (process.env.PHOTON_DEBUG_EXTRACT) {
|
|
1551
1626
|
tools.forEach((t) => {
|
|
1552
1627
|
if (t.description?.includes('@')) {
|
|
1553
|
-
|
|
1628
|
+
this.logger.debug(`[EXTRACTOR] Before clean: ${t.name}: "${t.description}"`);
|
|
1554
1629
|
}
|
|
1555
1630
|
});
|
|
1556
1631
|
}
|
|
@@ -1563,11 +1638,17 @@ export class PhotonLoader {
|
|
|
1563
1638
|
if (process.env.PHOTON_DEBUG_EXTRACT) {
|
|
1564
1639
|
tools.forEach((t) => {
|
|
1565
1640
|
if (t.name === 'clear') {
|
|
1566
|
-
|
|
1641
|
+
this.logger.debug(`[EXTRACTOR] After clean: ${t.name}: "${t.description}"`);
|
|
1567
1642
|
}
|
|
1568
1643
|
});
|
|
1569
1644
|
}
|
|
1570
|
-
return {
|
|
1645
|
+
return {
|
|
1646
|
+
tools,
|
|
1647
|
+
templates,
|
|
1648
|
+
statics,
|
|
1649
|
+
settingsSchema: metadata.settingsSchema,
|
|
1650
|
+
auth: this.extractAuthTag(source),
|
|
1651
|
+
};
|
|
1571
1652
|
}
|
|
1572
1653
|
throw jsonError;
|
|
1573
1654
|
}
|
|
@@ -2241,11 +2322,14 @@ export class PhotonLoader {
|
|
|
2241
2322
|
return;
|
|
2242
2323
|
if (path.dirname(realCurrentPhotonPath) !== path.dirname(resolvedPath))
|
|
2243
2324
|
return;
|
|
2244
|
-
|
|
2325
|
+
// Symlink into the directory where the current photon's symlink lives
|
|
2326
|
+
// (e.g. ~/.photon/), NOT this.baseDir which may be a different workspace.
|
|
2327
|
+
const symlinkDir = path.dirname(currentPhotonPath);
|
|
2328
|
+
const linkPath = path.join(symlinkDir, path.basename(resolvedPath));
|
|
2245
2329
|
await this.createSymlinkIfMissing(resolvedPath, linkPath);
|
|
2246
2330
|
const depName = path.basename(resolvedPath).replace(/\.photon\.(ts|js)$/, '');
|
|
2247
2331
|
const sourceAssetDir = path.join(path.dirname(resolvedPath), depName);
|
|
2248
|
-
const targetAssetDir = path.join(
|
|
2332
|
+
const targetAssetDir = path.join(symlinkDir, depName);
|
|
2249
2333
|
if (existsSync(sourceAssetDir)) {
|
|
2250
2334
|
await this.createSymlinkIfMissing(sourceAssetDir, targetAssetDir, 'dir');
|
|
2251
2335
|
}
|
|
@@ -2546,14 +2630,17 @@ Run: photon mcp ${mcpName} --config
|
|
|
2546
2630
|
* Tags become composable wrappers applied in the correct order via phase-sorted middleware.
|
|
2547
2631
|
* All middleware (built-in and custom) follows the same code path — no if-chain.
|
|
2548
2632
|
*/
|
|
2549
|
-
applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName) {
|
|
2633
|
+
applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName, outputHandler) {
|
|
2550
2634
|
// Build middleware context
|
|
2635
|
+
const store = executionContext.getStore();
|
|
2551
2636
|
const ctx = {
|
|
2552
2637
|
photon: photonName,
|
|
2553
2638
|
tool: toolName,
|
|
2554
2639
|
instance: instanceName || 'default',
|
|
2555
2640
|
params: parameters,
|
|
2556
|
-
|
|
2641
|
+
caller: store?.caller,
|
|
2642
|
+
outputHandler,
|
|
2643
|
+
}; // MiddlewareContext type is in photon-core (read-only); caller/outputHandler added via runtime extension
|
|
2557
2644
|
// Get declarations from the new middleware[] field
|
|
2558
2645
|
const declarations = toolMeta.middleware || [];
|
|
2559
2646
|
if (declarations.length === 0) {
|
|
@@ -2605,6 +2692,94 @@ Run: photon mcp ${mcpName} --config
|
|
|
2605
2692
|
return withLockHelper(lockName, next);
|
|
2606
2693
|
};
|
|
2607
2694
|
});
|
|
2695
|
+
// Handler override for circuitBreaker — mirror state into circuitHealthTracker for introspection
|
|
2696
|
+
handlerOverrides.set('circuitBreaker', (config, _state) => {
|
|
2697
|
+
const tracker = this.circuitHealthTracker;
|
|
2698
|
+
return async (ctx, next) => {
|
|
2699
|
+
const key = `${ctx.photon}:${ctx.instance}:${ctx.tool}`;
|
|
2700
|
+
let circuit = tracker.get(key);
|
|
2701
|
+
if (!circuit) {
|
|
2702
|
+
circuit = { failures: 0, state: 'closed', openedAt: 0 };
|
|
2703
|
+
tracker.set(key, circuit);
|
|
2704
|
+
}
|
|
2705
|
+
const openError = () => {
|
|
2706
|
+
const error = new Error(`Circuit open: ${ctx.photon}.${ctx.tool} has failed ${config.threshold} consecutive times. Resets in ${Math.ceil((config.resetAfterMs - (Date.now() - circuit.openedAt)) / 1000)}s`);
|
|
2707
|
+
error.name = 'PhotonCircuitOpenError';
|
|
2708
|
+
return error;
|
|
2709
|
+
};
|
|
2710
|
+
const emitTransition = (from, to) => {
|
|
2711
|
+
if (from === to)
|
|
2712
|
+
return;
|
|
2713
|
+
recordCircuitStateChange({
|
|
2714
|
+
photon: ctx.photon,
|
|
2715
|
+
tool: ctx.tool,
|
|
2716
|
+
instance: ctx.instance,
|
|
2717
|
+
from,
|
|
2718
|
+
to,
|
|
2719
|
+
});
|
|
2720
|
+
// Broadcast state-change as a domain event so AG-UI clients
|
|
2721
|
+
// (via createAGUIOutputHandler) receive a STATE_DELTA in real
|
|
2722
|
+
// time instead of polling /api/health/circuits.
|
|
2723
|
+
const handler = ctx.outputHandler;
|
|
2724
|
+
if (handler) {
|
|
2725
|
+
try {
|
|
2726
|
+
handler({
|
|
2727
|
+
channel: `${ctx.photon}:circuits`,
|
|
2728
|
+
event: 'state-change',
|
|
2729
|
+
data: {
|
|
2730
|
+
key: `${ctx.photon}:${ctx.instance}:${ctx.tool}`,
|
|
2731
|
+
from,
|
|
2732
|
+
to,
|
|
2733
|
+
timestamp: Date.now(),
|
|
2734
|
+
},
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
catch {
|
|
2738
|
+
/* best-effort — don't fail the middleware chain */
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
if (circuit.state === 'open') {
|
|
2743
|
+
if (Date.now() - circuit.openedAt >= config.resetAfterMs) {
|
|
2744
|
+
// Transition to half-open and allow exactly one probe through.
|
|
2745
|
+
emitTransition('open', 'half-open');
|
|
2746
|
+
circuit.state = 'half-open';
|
|
2747
|
+
circuit.probeInFlight = true;
|
|
2748
|
+
}
|
|
2749
|
+
else {
|
|
2750
|
+
throw openError();
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
else if (circuit.state === 'half-open') {
|
|
2754
|
+
// Probe already in flight — reject concurrent requests to avoid stampede.
|
|
2755
|
+
if (circuit.probeInFlight) {
|
|
2756
|
+
throw openError();
|
|
2757
|
+
}
|
|
2758
|
+
circuit.probeInFlight = true;
|
|
2759
|
+
}
|
|
2760
|
+
const stateBefore = circuit.state;
|
|
2761
|
+
try {
|
|
2762
|
+
const result = await next();
|
|
2763
|
+
circuit.failures = 0;
|
|
2764
|
+
if (stateBefore !== 'closed')
|
|
2765
|
+
emitTransition(stateBefore, 'closed');
|
|
2766
|
+
circuit.state = 'closed';
|
|
2767
|
+
circuit.probeInFlight = false;
|
|
2768
|
+
return result;
|
|
2769
|
+
}
|
|
2770
|
+
catch (error) {
|
|
2771
|
+
circuit.failures++;
|
|
2772
|
+
circuit.probeInFlight = false;
|
|
2773
|
+
// A failed probe in half-open immediately reopens the circuit.
|
|
2774
|
+
if (stateBefore === 'half-open' || circuit.failures >= config.threshold) {
|
|
2775
|
+
emitTransition(circuit.state, 'open');
|
|
2776
|
+
circuit.state = 'open';
|
|
2777
|
+
circuit.openedAt = Date.now();
|
|
2778
|
+
}
|
|
2779
|
+
throw error;
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
});
|
|
2608
2783
|
return buildMiddlewareChain(execute, declarations, combinedRegistry, this.middlewareStates, ctx, handlerOverrides);
|
|
2609
2784
|
}
|
|
2610
2785
|
/**
|
|
@@ -2621,11 +2796,54 @@ Run: photon mcp ${mcpName} --config
|
|
|
2621
2796
|
* @returns Tool result, or wrapped result with runId for stateful workflows
|
|
2622
2797
|
*/
|
|
2623
2798
|
async executeTool(mcp, toolName, parameters, options) {
|
|
2799
|
+
// Resolve parentTraceparent from options or in-band `_meta.traceparent` so
|
|
2800
|
+
// the ALS context reflects the true parent for any nested `this.call()`.
|
|
2801
|
+
let resolvedParentTraceparent = options?.parentTraceparent;
|
|
2802
|
+
if (!resolvedParentTraceparent &&
|
|
2803
|
+
parameters &&
|
|
2804
|
+
typeof parameters === 'object' &&
|
|
2805
|
+
'_meta' in parameters) {
|
|
2806
|
+
const metaPeek = parameters._meta;
|
|
2807
|
+
if (metaPeek && typeof metaPeek.traceparent === 'string') {
|
|
2808
|
+
resolvedParentTraceparent = metaPeek.traceparent;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
return runWithRequestContext({
|
|
2812
|
+
photon: mcp.name,
|
|
2813
|
+
tool: toolName,
|
|
2814
|
+
traceId: options?.traceId,
|
|
2815
|
+
parentTraceparent: resolvedParentTraceparent,
|
|
2816
|
+
caller: options?.caller,
|
|
2817
|
+
startedAt: Date.now(),
|
|
2818
|
+
}, () => this._executeToolInner(mcp, toolName, parameters, {
|
|
2819
|
+
...options,
|
|
2820
|
+
parentTraceparent: resolvedParentTraceparent,
|
|
2821
|
+
}));
|
|
2822
|
+
}
|
|
2823
|
+
async _executeToolInner(mcp, toolName, parameters, options) {
|
|
2624
2824
|
// Start audit trail recording
|
|
2625
2825
|
const audit = getAuditTrail();
|
|
2626
2826
|
const { finish: auditFinish } = audit.start(mcp.name, toolName, parameters || {});
|
|
2827
|
+
// Start wall-clock timer for tool-call duration histogram.
|
|
2828
|
+
const toolStartedAt = Date.now();
|
|
2829
|
+
let metricsStatus = 'ok';
|
|
2830
|
+
let metricsErrorType;
|
|
2831
|
+
// Peek at _meta.traceparent before span creation so distributed traces chain.
|
|
2832
|
+
// Explicit option wins over in-band _meta (server may have extracted from headers).
|
|
2833
|
+
let parentTraceparent = options?.parentTraceparent;
|
|
2834
|
+
if (!parentTraceparent &&
|
|
2835
|
+
parameters &&
|
|
2836
|
+
typeof parameters === 'object' &&
|
|
2837
|
+
'_meta' in parameters) {
|
|
2838
|
+
const metaPeek = parameters._meta;
|
|
2839
|
+
if (metaPeek && typeof metaPeek === 'object' && typeof metaPeek.traceparent === 'string') {
|
|
2840
|
+
parentTraceparent = metaPeek.traceparent;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2627
2843
|
// Start OTel span for tool execution (no-op if SDK not installed)
|
|
2628
|
-
const
|
|
2844
|
+
const toolMetaForSpan = mcp?.meta?.tools?.[toolName];
|
|
2845
|
+
const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcp?.meta?.stateful);
|
|
2846
|
+
const span = startToolSpan(mcp.name, toolName, parameters, options?.traceId, isStateful, parentTraceparent);
|
|
2629
2847
|
if (mcp.instance?.instanceName) {
|
|
2630
2848
|
span.setAttribute('photon.instance', mcp.instance.instanceName);
|
|
2631
2849
|
}
|
|
@@ -2658,6 +2876,39 @@ Run: photon mcp ${mcpName} --config
|
|
|
2658
2876
|
if (mcp.instance._photonConfigError) {
|
|
2659
2877
|
throw new Error(mcp.instance._photonConfigError);
|
|
2660
2878
|
}
|
|
2879
|
+
// Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon)
|
|
2880
|
+
const photonAuth = mcp.auth;
|
|
2881
|
+
if (photonAuth === 'required') {
|
|
2882
|
+
const caller = options?.caller;
|
|
2883
|
+
if (!caller || caller.anonymous) {
|
|
2884
|
+
const inputProvider = options?.inputProvider || this.createInputProvider();
|
|
2885
|
+
try {
|
|
2886
|
+
const token = await inputProvider({
|
|
2887
|
+
ask: 'password',
|
|
2888
|
+
message: `${mcp.name} requires authentication. Enter your access token:`,
|
|
2889
|
+
});
|
|
2890
|
+
if (token && typeof token === 'string') {
|
|
2891
|
+
options = {
|
|
2892
|
+
...options,
|
|
2893
|
+
caller: {
|
|
2894
|
+
id: token.slice(0, 8),
|
|
2895
|
+
name: 'authenticated-user',
|
|
2896
|
+
anonymous: false,
|
|
2897
|
+
claims: { token },
|
|
2898
|
+
},
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
else {
|
|
2902
|
+
throw new Error(`Authentication required: ${mcp.name} has @auth required but no credentials were provided`);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
catch (e) {
|
|
2906
|
+
if (e.message?.includes('Authentication required'))
|
|
2907
|
+
throw e;
|
|
2908
|
+
throw new Error(`Authentication required: ${mcp.name} has @auth required. Provide credentials via Authorization header, MCP elicitation, or CLI prompt.`);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2661
2912
|
// Intercept auto-generated settings tool
|
|
2662
2913
|
if (toolName === 'settings' && mcp.instance._settingsBacking) {
|
|
2663
2914
|
const result = await this.executeSettingsTool(mcp.instance, parameters, {
|
|
@@ -2707,7 +2958,7 @@ Run: photon mcp ${mcpName} --config
|
|
|
2707
2958
|
};
|
|
2708
2959
|
// Apply functional tag middleware if any tags present
|
|
2709
2960
|
if (hasFunctionalTags) {
|
|
2710
|
-
executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
|
|
2961
|
+
executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
|
|
2711
2962
|
}
|
|
2712
2963
|
const result = await executeBase();
|
|
2713
2964
|
this.progressRenderer.done();
|
|
@@ -2735,30 +2986,36 @@ Run: photon mcp ${mcpName} --config
|
|
|
2735
2986
|
configurable: true,
|
|
2736
2987
|
});
|
|
2737
2988
|
}
|
|
2738
|
-
// Construct
|
|
2739
|
-
//
|
|
2740
|
-
|
|
2989
|
+
// Construct CloudEvents 1.0 envelope for @stateful event emissions.
|
|
2990
|
+
// The `data` field carries the photon-specific payload; `channel` and `event`
|
|
2991
|
+
// are transport metadata used by the daemon pub/sub router.
|
|
2992
|
+
const cloudEventData = {
|
|
2741
2993
|
method: toolName,
|
|
2742
2994
|
params: parameters,
|
|
2743
2995
|
result,
|
|
2744
|
-
|
|
2996
|
+
instance: mcp.instance.instanceName ?? null,
|
|
2745
2997
|
};
|
|
2746
|
-
// Add instance name if available
|
|
2747
|
-
if (mcp.instance.instanceName) {
|
|
2748
|
-
eventPayload.instance = mcp.instance.instanceName;
|
|
2749
|
-
}
|
|
2750
2998
|
// Add index/pagination info if result is from items array
|
|
2751
2999
|
if (result && typeof result === 'object' && Array.isArray(mcp.instance.items)) {
|
|
2752
3000
|
const index = mcp.instance.items.findIndex((item) => item === result);
|
|
2753
3001
|
if (index !== -1) {
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
3002
|
+
cloudEventData.index = index;
|
|
3003
|
+
cloudEventData.totalCount = mcp.instance.items.length;
|
|
3004
|
+
cloudEventData.affectedRange = {
|
|
2757
3005
|
start: index,
|
|
2758
3006
|
end: index + 1,
|
|
2759
3007
|
};
|
|
2760
3008
|
}
|
|
2761
3009
|
}
|
|
3010
|
+
// CloudEvents 1.0 envelope (https://cloudevents.io)
|
|
3011
|
+
const eventPayload = {
|
|
3012
|
+
specversion: '1.0',
|
|
3013
|
+
id: crypto.randomUUID(),
|
|
3014
|
+
source: `photon/${photonName}`,
|
|
3015
|
+
type: `photon.${photonName}.${toolName}.executed`,
|
|
3016
|
+
time: new Date().toISOString(),
|
|
3017
|
+
data: cloudEventData,
|
|
3018
|
+
};
|
|
2762
3019
|
// Wrap in daemon pub/sub format: { channel, event, data }
|
|
2763
3020
|
const eventData = {
|
|
2764
3021
|
channel: `${photonName}:${toolName}`, // For daemon pub/sub routing
|
|
@@ -2770,18 +3027,24 @@ Run: photon mcp ${mcpName} --config
|
|
|
2770
3027
|
if (options?.outputHandler) {
|
|
2771
3028
|
try {
|
|
2772
3029
|
if (process.env.PHOTON_DEBUG_EMIT === '1') {
|
|
2773
|
-
|
|
3030
|
+
this.logger.debug('[emit] sending event', {
|
|
3031
|
+
method: eventPayload.data.method,
|
|
3032
|
+
channel: eventData.channel,
|
|
3033
|
+
hasMeta: !!result?.__meta,
|
|
3034
|
+
});
|
|
2774
3035
|
}
|
|
2775
3036
|
// Cast to DaemonEventEnvelope - outputHandler is flexible and routes any object with channel property
|
|
2776
3037
|
void Promise.resolve(options.outputHandler(eventData)).catch((e) => {
|
|
2777
3038
|
this.logger.debug('Output handler failed for event', { error: e?.message || e });
|
|
2778
3039
|
});
|
|
2779
3040
|
if (process.env.PHOTON_DEBUG_EMIT === '1') {
|
|
2780
|
-
|
|
3041
|
+
this.logger.debug('[emit] event transmitted to outputHandler');
|
|
2781
3042
|
}
|
|
2782
3043
|
}
|
|
2783
3044
|
catch (e) {
|
|
2784
|
-
|
|
3045
|
+
this.logger.error('Failed to send event through outputHandler', {
|
|
3046
|
+
error: e instanceof Error ? e.message : String(e),
|
|
3047
|
+
});
|
|
2785
3048
|
}
|
|
2786
3049
|
}
|
|
2787
3050
|
else if (mcp.instance && typeof mcp.instance.emit === 'function') {
|
|
@@ -2789,19 +3052,22 @@ Run: photon mcp ${mcpName} --config
|
|
|
2789
3052
|
// (this path won't route to daemon pub/sub, but at least calls emit)
|
|
2790
3053
|
try {
|
|
2791
3054
|
if (process.env.PHOTON_DEBUG_EMIT === '1') {
|
|
2792
|
-
|
|
3055
|
+
this.logger.debug('[emit] no outputHandler, falling back to instance.emit');
|
|
2793
3056
|
}
|
|
2794
3057
|
mcp.instance.emit(eventData);
|
|
2795
3058
|
}
|
|
2796
3059
|
catch (e) {
|
|
2797
|
-
|
|
3060
|
+
this.logger.error('Failed to emit event via instance.emit', {
|
|
3061
|
+
error: e instanceof Error ? e.message : String(e),
|
|
3062
|
+
});
|
|
2798
3063
|
}
|
|
2799
3064
|
}
|
|
2800
3065
|
}
|
|
2801
3066
|
catch (e) {
|
|
2802
3067
|
// Log emit errors but don't break tool execution
|
|
2803
|
-
|
|
2804
|
-
|
|
3068
|
+
this.logger.error('Failed to emit @stateful event', {
|
|
3069
|
+
error: e instanceof Error ? e.message : String(e),
|
|
3070
|
+
});
|
|
2805
3071
|
}
|
|
2806
3072
|
}
|
|
2807
3073
|
// Apply _meta post-processing (format transformation, field selection)
|
|
@@ -2858,7 +3124,7 @@ Run: photon mcp ${mcpName} --config
|
|
|
2858
3124
|
});
|
|
2859
3125
|
// Apply functional tag middleware if any tags present
|
|
2860
3126
|
if (hasFunctionalTags) {
|
|
2861
|
-
generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
|
|
3127
|
+
generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
|
|
2862
3128
|
}
|
|
2863
3129
|
// Use maybeStatefulExecute for all executions
|
|
2864
3130
|
// It handles both regular async and generators, detecting checkpoint yields
|
|
@@ -2881,6 +3147,7 @@ Run: photon mcp ${mcpName} --config
|
|
|
2881
3147
|
if (execResult.runId) {
|
|
2882
3148
|
error.runId = execResult.runId;
|
|
2883
3149
|
}
|
|
3150
|
+
span.recordException(error);
|
|
2884
3151
|
span.setStatus('ERROR', error.message);
|
|
2885
3152
|
auditFinish(null, error);
|
|
2886
3153
|
throw error;
|
|
@@ -2913,13 +3180,41 @@ Run: photon mcp ${mcpName} --config
|
|
|
2913
3180
|
catch (error) {
|
|
2914
3181
|
// Clear progress on error too
|
|
2915
3182
|
this.progressRenderer.done();
|
|
3183
|
+
span.recordException(error);
|
|
2916
3184
|
span.setStatus('ERROR', error instanceof Error ? error.message : String(error));
|
|
2917
3185
|
auditFinish(null, error);
|
|
3186
|
+
metricsStatus = 'error';
|
|
3187
|
+
metricsErrorType = error instanceof Error ? error.name || 'Error' : 'unknown';
|
|
3188
|
+
// Emit a dedicated counter for rate-limit rejections so dashboards can
|
|
3189
|
+
// alert on sustained throttling without scanning the generic error
|
|
3190
|
+
// stream.
|
|
3191
|
+
if (metricsErrorType === 'PhotonRateLimitError') {
|
|
3192
|
+
recordRateLimitRejection({
|
|
3193
|
+
photon: mcp.name,
|
|
3194
|
+
tool: toolName,
|
|
3195
|
+
instance: mcp.instance?.instanceName,
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
else if (metricsErrorType === 'PhotonBulkheadFullError') {
|
|
3199
|
+
recordBulkheadRejection({
|
|
3200
|
+
photon: mcp.name,
|
|
3201
|
+
tool: toolName,
|
|
3202
|
+
instance: mcp.instance?.instanceName,
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
2918
3205
|
this.logger.error(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
|
|
2919
3206
|
throw error;
|
|
2920
3207
|
}
|
|
2921
3208
|
finally {
|
|
2922
3209
|
span.end();
|
|
3210
|
+
recordToolCall({
|
|
3211
|
+
photon: mcp.name,
|
|
3212
|
+
tool: toolName,
|
|
3213
|
+
durationMs: Date.now() - toolStartedAt,
|
|
3214
|
+
status: metricsStatus,
|
|
3215
|
+
errorType: metricsErrorType,
|
|
3216
|
+
stateful: isStateful,
|
|
3217
|
+
});
|
|
2923
3218
|
}
|
|
2924
3219
|
}
|
|
2925
3220
|
normalizeNestedParamsTool(toolMeta, parameters) {
|
|
@@ -3048,7 +3343,7 @@ Run: photon mcp ${mcpName} --config
|
|
|
3048
3343
|
: emit.type === 'success'
|
|
3049
3344
|
? '✅'
|
|
3050
3345
|
: 'ℹ';
|
|
3051
|
-
|
|
3346
|
+
process.stdout.write(`${icon} ${emit.message}\n`);
|
|
3052
3347
|
break;
|
|
3053
3348
|
case 'thinking':
|
|
3054
3349
|
if (emit.active) {
|
|
@@ -3115,6 +3410,30 @@ Run: photon mcp ${mcpName} --config
|
|
|
3115
3410
|
* }
|
|
3116
3411
|
* ```
|
|
3117
3412
|
*/
|
|
3413
|
+
_formatCatalogCache = null;
|
|
3414
|
+
injectFormatCatalog(instance) {
|
|
3415
|
+
if (instance.formats)
|
|
3416
|
+
return;
|
|
3417
|
+
// Eagerly load once per loader, cached across all photon instances
|
|
3418
|
+
if (!this._formatCatalogCache) {
|
|
3419
|
+
try {
|
|
3420
|
+
const loaderDir = path.dirname(fileURLToPath(import.meta.url));
|
|
3421
|
+
const renderersPath = path.join(loaderDir, 'auto-ui', 'bridge', 'renderers.js');
|
|
3422
|
+
const esmRequire = createRequire(import.meta.url);
|
|
3423
|
+
this._formatCatalogCache = esmRequire(renderersPath).FORMAT_CATALOG;
|
|
3424
|
+
}
|
|
3425
|
+
catch (e) {
|
|
3426
|
+
this.logger.debug('Failed to load format catalog', { error: e?.message });
|
|
3427
|
+
this._formatCatalogCache = {};
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
Object.defineProperty(instance, 'formats', {
|
|
3431
|
+
value: this._formatCatalogCache,
|
|
3432
|
+
configurable: true,
|
|
3433
|
+
enumerable: false,
|
|
3434
|
+
writable: false,
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3118
3437
|
wireReactiveCollections(instance) {
|
|
3119
3438
|
// Get the emit function if available
|
|
3120
3439
|
const emit = typeof instance.emit === 'function'
|
|
@@ -3163,7 +3482,18 @@ Run: photon mcp ${mcpName} --config
|
|
|
3163
3482
|
// Get all public method names from the instance
|
|
3164
3483
|
// Skip runtime-injected methods (emit, render, push, ask) — these are
|
|
3165
3484
|
// capability methods injected by the loader, not user-defined tools
|
|
3166
|
-
const RUNTIME_METHODS = new Set([
|
|
3485
|
+
const RUNTIME_METHODS = new Set([
|
|
3486
|
+
'emit',
|
|
3487
|
+
'render',
|
|
3488
|
+
'channel',
|
|
3489
|
+
'ask',
|
|
3490
|
+
'call',
|
|
3491
|
+
'toast',
|
|
3492
|
+
'log',
|
|
3493
|
+
'status',
|
|
3494
|
+
'progress',
|
|
3495
|
+
'thinking',
|
|
3496
|
+
]);
|
|
3167
3497
|
const proto = Object.getPrototypeOf(instance);
|
|
3168
3498
|
const methodNames = Object.getOwnPropertyNames(proto).filter((name) => {
|
|
3169
3499
|
// Skip constructor and private/protected methods
|
|
@@ -3188,95 +3518,39 @@ Run: photon mcp ${mcpName} --config
|
|
|
3188
3518
|
if (typeof original !== 'function')
|
|
3189
3519
|
continue;
|
|
3190
3520
|
instance[methodName] = function (...args) {
|
|
3191
|
-
// Extract parameter names and map arguments to them
|
|
3192
|
-
const paramNames = PhotonLoader.extractParamNames(original);
|
|
3193
|
-
const params = Object.fromEntries(paramNames.map((name, i) => [name, args[i]]));
|
|
3194
|
-
// Call the original method
|
|
3195
3521
|
const result = original.apply(this, args);
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
},
|
|
3211
|
-
enumerable: false,
|
|
3212
|
-
writable: true,
|
|
3213
|
-
configurable: true,
|
|
3214
|
-
});
|
|
3215
|
-
}
|
|
3216
|
-
// Emit event with complete context
|
|
3217
|
-
const eventData = {
|
|
3218
|
-
method: methodName,
|
|
3219
|
-
params,
|
|
3220
|
-
result,
|
|
3221
|
-
timestamp: new Date().toISOString(),
|
|
3222
|
-
};
|
|
3223
|
-
if (this.instanceName) {
|
|
3224
|
-
eventData.instance = this.instanceName;
|
|
3225
|
-
}
|
|
3226
|
-
// Detect array mutations for range-based pagination support (Phase 5)
|
|
3227
|
-
// If result is an object from this.items, add index and array metadata
|
|
3228
|
-
if (result && typeof result === 'object' && Array.isArray(this.items)) {
|
|
3229
|
-
const index = this.items.findIndex((item) => item === result);
|
|
3230
|
-
if (index !== -1) {
|
|
3231
|
-
eventData.index = index;
|
|
3232
|
-
eventData.totalCount = this.items.length;
|
|
3233
|
-
// Affected range: just this item
|
|
3234
|
-
eventData.affectedRange = {
|
|
3235
|
-
start: index,
|
|
3236
|
-
end: index + 1,
|
|
3237
|
-
};
|
|
3522
|
+
const attachMeta = (obj) => {
|
|
3523
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj) && !obj.__meta) {
|
|
3524
|
+
Object.defineProperty(obj, '__meta', {
|
|
3525
|
+
value: {
|
|
3526
|
+
createdAt: new Date().toISOString(),
|
|
3527
|
+
createdBy: methodName,
|
|
3528
|
+
modifiedAt: null,
|
|
3529
|
+
modifiedBy: null,
|
|
3530
|
+
modifications: [],
|
|
3531
|
+
},
|
|
3532
|
+
enumerable: false,
|
|
3533
|
+
writable: true,
|
|
3534
|
+
configurable: true,
|
|
3535
|
+
});
|
|
3238
3536
|
}
|
|
3537
|
+
};
|
|
3538
|
+
// Generators must pass through unwrapped - .then() would kill the iterator protocol
|
|
3539
|
+
if (result && typeof result[Symbol.asyncIterator] === 'function') {
|
|
3540
|
+
return result;
|
|
3541
|
+
}
|
|
3542
|
+
// For async methods, attach __meta to the resolved value, not the Promise
|
|
3543
|
+
if (result && typeof result.then === 'function') {
|
|
3544
|
+
return result.then((resolved) => {
|
|
3545
|
+
attachMeta(resolved);
|
|
3546
|
+
return resolved;
|
|
3547
|
+
});
|
|
3239
3548
|
}
|
|
3240
|
-
|
|
3241
|
-
// (line 2362 via outputHandler). This method wrapper is only called during
|
|
3242
|
-
// direct instantiation testing, not in actual MCP execution paths where executeTool
|
|
3243
|
-
// is the proper routing point.
|
|
3244
|
-
//
|
|
3245
|
-
// If we emit here too, we get duplicate messages:
|
|
3246
|
-
// 1. This wrapper emits directly: emit(eventData)
|
|
3247
|
-
// 2. executeTool emits via outputHandler: outputHandler(eventData)
|
|
3248
|
-
// Both route to daemon, causing double notifications.
|
|
3549
|
+
attachMeta(result);
|
|
3249
3550
|
return result;
|
|
3250
3551
|
};
|
|
3251
3552
|
}
|
|
3252
3553
|
}
|
|
3253
|
-
/**
|
|
3254
|
-
* Extract parameter names from a function by parsing its signature
|
|
3255
|
-
*
|
|
3256
|
-
* Examples:
|
|
3257
|
-
* - (text, priority = 'medium') => ['text', 'priority']
|
|
3258
|
-
* - (id) => ['id']
|
|
3259
|
-
* - () => []
|
|
3260
|
-
*/
|
|
3261
|
-
static extractParamNames(fn) {
|
|
3262
|
-
const fnStr = fn.toString();
|
|
3263
|
-
// Match parameters inside parentheses: ( ... )
|
|
3264
|
-
const match = fnStr.match(/\(([^)]*)\)/);
|
|
3265
|
-
if (!match?.[1]) {
|
|
3266
|
-
return [];
|
|
3267
|
-
}
|
|
3268
|
-
return match[1]
|
|
3269
|
-
.split(',')
|
|
3270
|
-
.map((param) => {
|
|
3271
|
-
const cleaned = param
|
|
3272
|
-
.trim()
|
|
3273
|
-
.split('=')[0] // Remove default value
|
|
3274
|
-
.split(':')[0] // Remove type annotations
|
|
3275
|
-
.trim();
|
|
3276
|
-
return cleaned;
|
|
3277
|
-
})
|
|
3278
|
-
.filter((name) => name && name !== 'this');
|
|
3279
|
-
}
|
|
3280
3554
|
/**
|
|
3281
3555
|
* Extract @mcp dependencies from source and inject them as instance properties
|
|
3282
3556
|
*
|