@portel/photon 1.6.1 → 1.8.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 +111 -160
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +218 -106
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
- package/dist/auto-ui/design-system/tokens.js +2 -2
- package/dist/auto-ui/design-system/tokens.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +1 -1
- package/dist/auto-ui/platform-compat.d.ts.map +1 -1
- package/dist/auto-ui/platform-compat.js +12 -2
- package/dist/auto-ui/platform-compat.js.map +1 -1
- package/dist/auto-ui/playground-html.js +5 -5
- package/dist/auto-ui/rendering/components.d.ts.map +1 -1
- package/dist/auto-ui/rendering/components.js +568 -0
- package/dist/auto-ui/rendering/components.js.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
- package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.js +177 -0
- package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
- package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.js +125 -1
- package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +370 -26
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +7 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +21932 -3307
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +37 -0
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +16 -0
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +640 -17
- package/dist/cli.js.map +1 -1
- package/dist/context-store.d.ts +79 -0
- package/dist/context-store.d.ts.map +1 -0
- package/dist/context-store.js +210 -0
- package/dist/context-store.js.map +1 -0
- package/dist/daemon/client.d.ts +13 -4
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +138 -77
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +0 -25
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +10 -38
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/protocol.d.ts +7 -2
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +317 -83
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +24 -4
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +62 -12
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +3 -20
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +87 -77
- package/dist/loader.js.map +1 -1
- package/dist/markdown-utils.d.ts.map +1 -1
- package/dist/markdown-utils.js +2 -1
- package/dist/markdown-utils.js.map +1 -1
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +20 -3
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +258 -218
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +2 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +45 -7
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/maker.photon.d.ts.map +1 -1
- package/dist/photons/maker.photon.js +22 -4
- package/dist/photons/maker.photon.js.map +1 -1
- package/dist/photons/maker.photon.ts +47 -11
- package/dist/security-scanner.d.ts.map +1 -1
- package/dist/security-scanner.js +8 -2
- package/dist/security-scanner.js.map +1 -1
- package/dist/serv/index.d.ts +1 -1
- package/dist/serv/index.d.ts.map +1 -1
- package/dist/serv/index.js +6 -4
- package/dist/serv/index.js.map +1 -1
- package/dist/server.d.ts +32 -15
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +525 -483
- package/dist/server.js.map +1 -1
- package/dist/shared/security.d.ts +79 -0
- package/dist/shared/security.d.ts.map +1 -0
- package/dist/shared/security.js +251 -0
- package/dist/shared/security.js.map +1 -0
- package/dist/shell-completions.d.ts +21 -0
- package/dist/shell-completions.d.ts.map +1 -0
- package/dist/shell-completions.js +102 -0
- package/dist/shell-completions.js.map +1 -0
- package/dist/template-manager.d.ts.map +1 -1
- package/dist/template-manager.js +10 -3
- package/dist/template-manager.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +12 -7
|
@@ -16,6 +16,8 @@ export interface DaemonRequest {
|
|
|
16
16
|
photonPath?: string;
|
|
17
17
|
sessionId?: string;
|
|
18
18
|
clientType?: 'cli' | 'mcp' | 'code-mode' | 'beam';
|
|
19
|
+
/** Instance name hint for auto-recovery from session drift */
|
|
20
|
+
instanceName?: string;
|
|
19
21
|
method?: string;
|
|
20
22
|
args?: Record<string, unknown>;
|
|
21
23
|
/** Response to a prompt request */
|
|
@@ -32,7 +34,7 @@ export interface DaemonRequest {
|
|
|
32
34
|
jobId?: string;
|
|
33
35
|
/** Cron expression for scheduled jobs */
|
|
34
36
|
cron?: string;
|
|
35
|
-
/** Last event
|
|
37
|
+
/** Last event timestamp received by client (for delta sync on reconnect) */
|
|
36
38
|
lastEventId?: string;
|
|
37
39
|
}
|
|
38
40
|
/**
|
|
@@ -44,6 +46,8 @@ export interface DaemonResponse {
|
|
|
44
46
|
success?: boolean;
|
|
45
47
|
data?: unknown;
|
|
46
48
|
error?: string;
|
|
49
|
+
/** Actionable hint for the caller when type === 'error' */
|
|
50
|
+
suggestion?: string;
|
|
47
51
|
/** Prompt request details (when type === 'prompt') */
|
|
48
52
|
prompt?: {
|
|
49
53
|
type: 'text' | 'password' | 'confirm' | 'select';
|
|
@@ -58,7 +62,7 @@ export interface DaemonResponse {
|
|
|
58
62
|
channel?: string;
|
|
59
63
|
/** Message payload for channel_message type */
|
|
60
64
|
message?: unknown;
|
|
61
|
-
/** Event
|
|
65
|
+
/** Event timestamp for tracking (for delta sync support) */
|
|
62
66
|
eventId?: string;
|
|
63
67
|
}
|
|
64
68
|
/**
|
|
@@ -78,6 +82,7 @@ export interface DaemonStatus {
|
|
|
78
82
|
export interface PhotonSession {
|
|
79
83
|
id: string;
|
|
80
84
|
instance: PhotonMCPClass;
|
|
85
|
+
instanceName: string;
|
|
81
86
|
createdAt: number;
|
|
82
87
|
lastActivity: number;
|
|
83
88
|
clientType?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../src/daemon/protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EACA,SAAS,GACT,MAAM,GACN,UAAU,GACV,QAAQ,GACR,iBAAiB,GACjB,WAAW,GACX,aAAa,GACb,SAAS,GACT,MAAM,GACN,QAAQ,GACR,UAAU,GACV,YAAY,GACZ,WAAW,GACX,YAAY,GACZ,kBAAkB,CAAC;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,2FAA2F;IAC3F,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,WAAW,GAAG,MAAM,CAAC;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,mCAAmC;IACnC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../src/daemon/protocol.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/daemon/protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/daemon/protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAgIH;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAY;IAC/C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,GAAG,GAAG,GAA6B,CAAC;IAE1C,IAAI,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAE7C,MAAM,UAAU,GAAG;QACjB,SAAS;QACT,MAAM;QACN,UAAU;QACV,QAAQ;QACR,iBAAiB;QACjB,WAAW;QACX,aAAa;QACb,SAAS;QACT,MAAM;QACN,QAAQ;QACR,UAAU;QACV,YAAY;QACZ,WAAW;QACX,YAAY;QACZ,kBAAkB;KACnB,CAAC;IACF,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAc,CAAC;QAAE,OAAO,KAAK,CAAC;IAE3D,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACnD,CAAC;IAED,4CAA4C;IAC5C,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAc,CAAC,EAAE,CAAC;QACzE,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACpD,CAAC;IAED,sCAAsC;IACtC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAc,CAAC,EAAE,CAAC;QACpD,IAAI,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACrD,CAAC;IAED,sDAAsD;IACtD,IAAI,GAAG,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;QAC5B,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QAChD,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QACjD,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACjD,CAAC;IAED,4BAA4B;IAC5B,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;QAC9B,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IAClD,CAAC;IAED,6BAA6B;IAC7B,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC1B,IAAI,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACvD,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAAY;IAChD,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,GAAG,GAAG,GAA8B,CAAC;IAE3C,IAAI,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC7C,IACE,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,QAAQ,CAClF,GAAG,CAAC,IAAc,CACnB;QAED,OAAO,KAAK,CAAC;IAEf,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/dist/daemon/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { isValidDaemonRequest, } from './protocol.js';
|
|
|
16
16
|
import { setPromptHandler } from '@portel/photon-core';
|
|
17
17
|
import { createLogger } from '../shared/logger.js';
|
|
18
18
|
import { getErrorMessage } from '../shared/error-handler.js';
|
|
19
|
+
import { timingSafeEqual, readBody, SimpleRateLimiter } from '../shared/security.js';
|
|
19
20
|
// Command line args: socketPath (global daemon only needs socket path)
|
|
20
21
|
const socketPath = process.argv[2];
|
|
21
22
|
const logger = createLogger({
|
|
@@ -30,46 +31,52 @@ if (!socketPath) {
|
|
|
30
31
|
// Map of photonName -> SessionManager (lazy initialized)
|
|
31
32
|
const sessionManagers = new Map();
|
|
32
33
|
const photonPaths = new Map(); // photonName -> photonPath
|
|
33
|
-
|
|
34
|
+
const fileWatchers = new Map();
|
|
35
|
+
const watchDebounce = new Map();
|
|
36
|
+
let idleTimeout = 0; // Daemon stays alive — it manages persistent stateful data
|
|
34
37
|
let idleTimer = null;
|
|
35
38
|
// Track pending prompts waiting for user input
|
|
36
39
|
const pendingPrompts = new Map();
|
|
37
40
|
// Channel subscriptions for pub/sub
|
|
38
41
|
const channelSubscriptions = new Map();
|
|
39
|
-
|
|
42
|
+
/** Buffer retention window — events older than this are purged */
|
|
43
|
+
const EVENT_BUFFER_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
40
44
|
const channelEventBuffers = new Map();
|
|
41
45
|
function bufferEvent(channel, message) {
|
|
42
46
|
let buffer = channelEventBuffers.get(channel);
|
|
43
47
|
if (!buffer) {
|
|
44
|
-
buffer = { events: []
|
|
48
|
+
buffer = { events: [] };
|
|
45
49
|
channelEventBuffers.set(channel, buffer);
|
|
46
50
|
}
|
|
47
|
-
const
|
|
51
|
+
const now = Date.now();
|
|
48
52
|
const event = {
|
|
49
|
-
id:
|
|
53
|
+
id: now,
|
|
50
54
|
channel,
|
|
51
55
|
message,
|
|
52
|
-
timestamp:
|
|
56
|
+
timestamp: now,
|
|
53
57
|
};
|
|
54
58
|
buffer.events.push(event);
|
|
55
|
-
//
|
|
56
|
-
|
|
59
|
+
// Purge events older than retention window
|
|
60
|
+
const cutoff = now - EVENT_BUFFER_DURATION_MS;
|
|
61
|
+
while (buffer.events.length > 0 && buffer.events[0].timestamp < cutoff) {
|
|
57
62
|
buffer.events.shift();
|
|
58
63
|
}
|
|
59
|
-
return
|
|
64
|
+
return now;
|
|
60
65
|
}
|
|
61
|
-
function getEventsSince(channel,
|
|
66
|
+
function getEventsSince(channel, lastTimestamp) {
|
|
62
67
|
const buffer = channelEventBuffers.get(channel);
|
|
63
68
|
if (!buffer || buffer.events.length === 0) {
|
|
64
|
-
|
|
69
|
+
// If client has a lastEventId but buffer is empty (e.g. daemon restarted),
|
|
70
|
+
// signal that a full refresh is needed — events were lost.
|
|
71
|
+
return { events: [], refreshNeeded: lastTimestamp > 0 };
|
|
65
72
|
}
|
|
66
73
|
const oldestEvent = buffer.events[0];
|
|
67
|
-
//
|
|
68
|
-
if (
|
|
74
|
+
// Client's last timestamp is older than our oldest buffered event → stale, full sync needed
|
|
75
|
+
if (lastTimestamp < oldestEvent.timestamp) {
|
|
69
76
|
return { events: [], refreshNeeded: true };
|
|
70
77
|
}
|
|
71
|
-
//
|
|
72
|
-
const events = buffer.events.filter((e) => e.
|
|
78
|
+
// Delta sync: return events after the client's last timestamp
|
|
79
|
+
const events = buffer.events.filter((e) => e.timestamp > lastTimestamp);
|
|
73
80
|
return { events, refreshNeeded: false };
|
|
74
81
|
}
|
|
75
82
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -233,6 +240,8 @@ function unscheduleJob(jobId) {
|
|
|
233
240
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
234
241
|
let webhookServer = null;
|
|
235
242
|
const WEBHOOK_PORT = parseInt(process.env.PHOTON_WEBHOOK_PORT || '0');
|
|
243
|
+
// Security: rate limiter for webhook endpoint
|
|
244
|
+
const webhookRateLimiter = new SimpleRateLimiter(30, 60_000);
|
|
236
245
|
function startWebhookServer(port) {
|
|
237
246
|
if (port <= 0)
|
|
238
247
|
return;
|
|
@@ -245,6 +254,13 @@ function startWebhookServer(port) {
|
|
|
245
254
|
res.end();
|
|
246
255
|
return;
|
|
247
256
|
}
|
|
257
|
+
// Security: rate limiting
|
|
258
|
+
const clientKey = req.socket?.remoteAddress || 'unknown';
|
|
259
|
+
if (!webhookRateLimiter.isAllowed(clientKey)) {
|
|
260
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
261
|
+
res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
248
264
|
// Parse URL: /webhook/{photonName}/{method}
|
|
249
265
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
250
266
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
@@ -258,62 +274,68 @@ function startWebhookServer(port) {
|
|
|
258
274
|
const expectedSecret = process.env.PHOTON_WEBHOOK_SECRET;
|
|
259
275
|
if (expectedSecret) {
|
|
260
276
|
const providedSecret = req.headers['x-webhook-secret'];
|
|
261
|
-
if (providedSecret
|
|
277
|
+
if (!providedSecret ||
|
|
278
|
+
typeof providedSecret !== 'string' ||
|
|
279
|
+
!timingSafeEqual(providedSecret, expectedSecret)) {
|
|
262
280
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
263
281
|
res.end(JSON.stringify({ error: 'Invalid webhook secret' }));
|
|
264
282
|
return;
|
|
265
283
|
}
|
|
266
284
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
query: Object.fromEntries(url.searchParams),
|
|
281
|
-
timestamp: Date.now(),
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
286
|
-
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
const sessionManager = sessionManagers.get(photonName);
|
|
290
|
-
if (!sessionManager) {
|
|
291
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
292
|
-
res.end(JSON.stringify({ error: `Photon '${photonName}' not initialized` }));
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
try {
|
|
296
|
-
const session = await sessionManager.getOrCreateSession('webhook', 'webhook');
|
|
297
|
-
const result = await sessionManager.loader.executeTool(session.instance, method, args);
|
|
298
|
-
logger.info('Webhook executed', { photon: photonName, method });
|
|
299
|
-
publishToChannel(`webhooks:${photonName}`, {
|
|
300
|
-
event: 'webhook-received',
|
|
301
|
-
method,
|
|
302
|
-
timestamp: Date.now(),
|
|
303
|
-
});
|
|
304
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
305
|
-
res.end(JSON.stringify({ success: true, data: result }));
|
|
306
|
-
}
|
|
307
|
-
catch (error) {
|
|
308
|
-
logger.error('Webhook execution failed', {
|
|
309
|
-
photon: photonName,
|
|
310
|
-
method,
|
|
311
|
-
error: getErrorMessage(error),
|
|
312
|
-
});
|
|
313
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
314
|
-
res.end(JSON.stringify({ error: getErrorMessage(error) }));
|
|
285
|
+
else if (!process.env.PHOTON_WEBHOOK_ALLOW_UNAUTHENTICATED) {
|
|
286
|
+
// Security: require explicit opt-in for unauthenticated webhooks
|
|
287
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
288
|
+
res.end(JSON.stringify({
|
|
289
|
+
error: 'Webhook secret not configured. Set PHOTON_WEBHOOK_SECRET or PHOTON_WEBHOOK_ALLOW_UNAUTHENTICATED=true',
|
|
290
|
+
}));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
let args = {};
|
|
294
|
+
try {
|
|
295
|
+
const body = await readBody(req);
|
|
296
|
+
if (body) {
|
|
297
|
+
args = JSON.parse(body);
|
|
315
298
|
}
|
|
316
|
-
|
|
299
|
+
args._webhook = {
|
|
300
|
+
method: req.method,
|
|
301
|
+
headers: req.headers,
|
|
302
|
+
query: Object.fromEntries(url.searchParams),
|
|
303
|
+
timestamp: Date.now(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
const status = err.message?.includes('too large') ? 413 : 400;
|
|
308
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
309
|
+
res.end(JSON.stringify({ error: status === 413 ? 'Request body too large' : 'Invalid JSON body' }));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const sessionManager = sessionManagers.get(photonName);
|
|
313
|
+
if (!sessionManager) {
|
|
314
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
315
|
+
res.end(JSON.stringify({ error: `Photon '${photonName}' not initialized` }));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const session = await sessionManager.getOrCreateSession('webhook', 'webhook');
|
|
320
|
+
const result = await sessionManager.loader.executeTool(session.instance, method, args);
|
|
321
|
+
logger.info('Webhook executed', { photon: photonName, method });
|
|
322
|
+
publishToChannel(`webhooks:${photonName}`, {
|
|
323
|
+
event: 'webhook-received',
|
|
324
|
+
method,
|
|
325
|
+
timestamp: Date.now(),
|
|
326
|
+
});
|
|
327
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(JSON.stringify({ success: true, data: result }));
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
logger.error('Webhook execution failed', {
|
|
332
|
+
photon: photonName,
|
|
333
|
+
method,
|
|
334
|
+
error: getErrorMessage(error),
|
|
335
|
+
});
|
|
336
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
337
|
+
res.end(JSON.stringify({ error: getErrorMessage(error) }));
|
|
338
|
+
}
|
|
317
339
|
});
|
|
318
340
|
webhookServer.listen(port, () => {
|
|
319
341
|
logger.info('Webhook server started', { port });
|
|
@@ -354,7 +376,8 @@ function publishToChannel(channel, message, excludeSocket) {
|
|
|
354
376
|
sentSockets.add(socket);
|
|
355
377
|
}
|
|
356
378
|
catch {
|
|
357
|
-
//
|
|
379
|
+
// Dead socket — remove from subscribers
|
|
380
|
+
exactSubscribers.delete(socket);
|
|
358
381
|
}
|
|
359
382
|
}
|
|
360
383
|
}
|
|
@@ -372,7 +395,8 @@ function publishToChannel(channel, message, excludeSocket) {
|
|
|
372
395
|
sentSockets.add(socket);
|
|
373
396
|
}
|
|
374
397
|
catch {
|
|
375
|
-
//
|
|
398
|
+
// Dead socket — remove from subscribers
|
|
399
|
+
wildcardSubscribers.delete(socket);
|
|
376
400
|
}
|
|
377
401
|
}
|
|
378
402
|
}
|
|
@@ -406,6 +430,7 @@ async function getOrCreateSessionManager(photonName, photonPath) {
|
|
|
406
430
|
manager = new SessionManager(pathToUse, photonName, idleTimeout, logger.child({ scope: photonName }));
|
|
407
431
|
sessionManagers.set(photonName, manager);
|
|
408
432
|
photonPaths.set(photonName, pathToUse);
|
|
433
|
+
watchPhotonFile(photonName, pathToUse);
|
|
409
434
|
logger.info('Session manager initialized', { photonName });
|
|
410
435
|
return manager;
|
|
411
436
|
}
|
|
@@ -493,32 +518,32 @@ async function handleRequest(request, socket) {
|
|
|
493
518
|
}
|
|
494
519
|
subs.add(socket);
|
|
495
520
|
logger.info('Client subscribed to channel', { channel, subscribers: subs.size });
|
|
496
|
-
// Replay missed events if lastEventId provided
|
|
521
|
+
// Replay missed events if lastEventId (timestamp) provided
|
|
497
522
|
if (lastEventId !== undefined) {
|
|
498
|
-
const
|
|
499
|
-
const { events, refreshNeeded } = getEventsSince(channel,
|
|
523
|
+
const lastTimestamp = parseInt(String(lastEventId), 10) || 0;
|
|
524
|
+
const { events, refreshNeeded } = getEventsSince(channel, lastTimestamp);
|
|
500
525
|
if (refreshNeeded) {
|
|
501
|
-
//
|
|
526
|
+
// Stale: client's timestamp is older than buffer window → full sync needed
|
|
502
527
|
socket.write(JSON.stringify({
|
|
503
528
|
type: 'refresh_needed',
|
|
504
529
|
id: request.id,
|
|
505
530
|
channel,
|
|
506
531
|
}) + '\n');
|
|
507
|
-
logger.info('
|
|
532
|
+
logger.info('Stale client, full sync needed', { channel, lastTimestamp });
|
|
508
533
|
}
|
|
509
534
|
else if (events.length > 0) {
|
|
510
|
-
//
|
|
535
|
+
// Delta sync: replay missed events
|
|
511
536
|
for (const event of events) {
|
|
512
537
|
socket.write(JSON.stringify({
|
|
513
538
|
type: 'channel_message',
|
|
514
|
-
id: `replay_${event.
|
|
515
|
-
eventId: event.
|
|
539
|
+
id: `replay_${event.timestamp}`,
|
|
540
|
+
eventId: event.timestamp,
|
|
516
541
|
channel: event.channel,
|
|
517
542
|
message: event.message,
|
|
518
543
|
replay: true,
|
|
519
544
|
}) + '\n');
|
|
520
545
|
}
|
|
521
|
-
logger.info('
|
|
546
|
+
logger.info('Delta sync: replayed events', { channel, count: events.length });
|
|
522
547
|
}
|
|
523
548
|
}
|
|
524
549
|
return {
|
|
@@ -553,11 +578,11 @@ async function handleRequest(request, socket) {
|
|
|
553
578
|
data: { published: true, channel, eventId },
|
|
554
579
|
};
|
|
555
580
|
}
|
|
556
|
-
// Handle get_events_since (for
|
|
581
|
+
// Handle get_events_since (for delta sync / full sync detection)
|
|
557
582
|
if (request.type === 'get_events_since') {
|
|
558
583
|
const channel = request.channel;
|
|
559
|
-
const
|
|
560
|
-
const { events, refreshNeeded } = getEventsSince(channel,
|
|
584
|
+
const lastTimestamp = parseInt(String(request.lastEventId || '0'), 10) || 0;
|
|
585
|
+
const { events, refreshNeeded } = getEventsSince(channel, lastTimestamp);
|
|
561
586
|
return {
|
|
562
587
|
type: 'result',
|
|
563
588
|
id: request.id,
|
|
@@ -608,7 +633,12 @@ async function handleRequest(request, socket) {
|
|
|
608
633
|
if (request.type === 'schedule') {
|
|
609
634
|
const photonName = request.photonName;
|
|
610
635
|
if (!photonName) {
|
|
611
|
-
return {
|
|
636
|
+
return {
|
|
637
|
+
type: 'error',
|
|
638
|
+
id: request.id,
|
|
639
|
+
error: 'photonName required for scheduling',
|
|
640
|
+
suggestion: 'Include photonName in the request payload',
|
|
641
|
+
};
|
|
612
642
|
}
|
|
613
643
|
const job = {
|
|
614
644
|
id: request.jobId,
|
|
@@ -644,11 +674,21 @@ async function handleRequest(request, socket) {
|
|
|
644
674
|
// Handle command execution
|
|
645
675
|
if (request.type === 'command') {
|
|
646
676
|
if (!request.method) {
|
|
647
|
-
return {
|
|
677
|
+
return {
|
|
678
|
+
type: 'error',
|
|
679
|
+
id: request.id,
|
|
680
|
+
error: 'Method name required',
|
|
681
|
+
suggestion: 'Specify the method to call: { method: "methodName" }',
|
|
682
|
+
};
|
|
648
683
|
}
|
|
649
684
|
const photonName = request.photonName;
|
|
650
685
|
if (!photonName) {
|
|
651
|
-
return {
|
|
686
|
+
return {
|
|
687
|
+
type: 'error',
|
|
688
|
+
id: request.id,
|
|
689
|
+
error: 'photonName required for commands',
|
|
690
|
+
suggestion: 'Include photonName in the request payload',
|
|
691
|
+
};
|
|
652
692
|
}
|
|
653
693
|
const sessionManager = await getOrCreateSessionManager(photonName, request.photonPath);
|
|
654
694
|
if (!sessionManager) {
|
|
@@ -660,10 +700,53 @@ async function handleRequest(request, socket) {
|
|
|
660
700
|
}
|
|
661
701
|
try {
|
|
662
702
|
const session = await sessionManager.getOrCreateSession(request.sessionId, request.clientType);
|
|
703
|
+
// ── Auto-recover from instance drift ─────────────────────────
|
|
704
|
+
// If the client tells us which instance it expects but the daemon
|
|
705
|
+
// session has drifted (e.g. session expired and was recreated as
|
|
706
|
+
// "default"), silently switch back to the correct instance.
|
|
707
|
+
if (request.instanceName && request.instanceName !== session.instanceName) {
|
|
708
|
+
logger.info('Instance drift detected, auto-switching', {
|
|
709
|
+
from: session.instanceName || 'default',
|
|
710
|
+
to: request.instanceName,
|
|
711
|
+
sessionId: session.id,
|
|
712
|
+
});
|
|
713
|
+
await sessionManager.switchInstance(session.id, request.instanceName);
|
|
714
|
+
}
|
|
715
|
+
// ── Runtime-injected instance tools ──────────────────────────
|
|
716
|
+
if (request.method === '_use') {
|
|
717
|
+
const instanceName = String(request.args?.name ?? '');
|
|
718
|
+
await sessionManager.switchInstance(session.id, instanceName);
|
|
719
|
+
const label = instanceName || 'default';
|
|
720
|
+
return {
|
|
721
|
+
type: 'result',
|
|
722
|
+
id: request.id,
|
|
723
|
+
success: true,
|
|
724
|
+
data: { instance: label, message: `Switched to instance: ${label}` },
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
if (request.method === '_instances') {
|
|
728
|
+
const { InstanceStore } = await import('../context-store.js');
|
|
729
|
+
const store = new InstanceStore();
|
|
730
|
+
const instances = store.listInstances(photonName);
|
|
731
|
+
const current = session.instanceName || 'default';
|
|
732
|
+
// Ensure current instance is always in the list (may not have a state file yet)
|
|
733
|
+
if (!instances.includes(current)) {
|
|
734
|
+
instances.push(current);
|
|
735
|
+
instances.sort();
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
type: 'result',
|
|
739
|
+
id: request.id,
|
|
740
|
+
success: true,
|
|
741
|
+
data: { instances, current },
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
// ─────────────────────────────────────────────────────────────
|
|
663
745
|
logger.info('Executing request', {
|
|
664
746
|
method: request.method,
|
|
665
747
|
photon: photonName,
|
|
666
748
|
sessionId: session.id,
|
|
749
|
+
instance: session.instanceName || 'default',
|
|
667
750
|
});
|
|
668
751
|
setPromptHandler(createSocketPromptHandler(socket, request.id));
|
|
669
752
|
const outputHandler = (emit) => {
|
|
@@ -674,6 +757,14 @@ async function handleRequest(request, socket) {
|
|
|
674
757
|
};
|
|
675
758
|
const result = await sessionManager.loader.executeTool(session.instance, request.method, request.args || {}, { outputHandler });
|
|
676
759
|
setPromptHandler(null);
|
|
760
|
+
// Persist reactive state after each tool call
|
|
761
|
+
await persistInstanceState(session.instance, photonName, session.instanceName);
|
|
762
|
+
// Notify subscribers that state may have changed
|
|
763
|
+
publishToChannel(`${photonName}:state-changed`, {
|
|
764
|
+
event: 'state-changed',
|
|
765
|
+
method: request.method,
|
|
766
|
+
data: result,
|
|
767
|
+
}, socket);
|
|
677
768
|
return { type: 'result', id: request.id, success: true, data: result };
|
|
678
769
|
}
|
|
679
770
|
catch (error) {
|
|
@@ -688,6 +779,144 @@ async function handleRequest(request, socket) {
|
|
|
688
779
|
return { type: 'error', id: request.id, error: `Unknown request type: ${request.type}` };
|
|
689
780
|
}
|
|
690
781
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
782
|
+
// STATE PERSISTENCE
|
|
783
|
+
// ════════════════════════════════════════════════════════════════════════════════
|
|
784
|
+
/**
|
|
785
|
+
* Persist reactive collection state to disk after each tool call.
|
|
786
|
+
* Scans the instance for properties with _propertyName (ReactiveArray/Map/Set markers)
|
|
787
|
+
* and serializes them to the instance-specific state file.
|
|
788
|
+
*/
|
|
789
|
+
/** Cache of state keys per photon (extracted once from source) */
|
|
790
|
+
const stateKeysCache = new Map();
|
|
791
|
+
/**
|
|
792
|
+
* Get the state property keys for a photon by extracting constructor params.
|
|
793
|
+
* State params: non-primitive with default on @stateful photon.
|
|
794
|
+
*/
|
|
795
|
+
async function getStateKeys(photonName, photonPath) {
|
|
796
|
+
if (stateKeysCache.has(photonName)) {
|
|
797
|
+
return stateKeysCache.get(photonName);
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const { SchemaExtractor } = await import('@portel/photon-core');
|
|
801
|
+
const fsPromises = await import('fs/promises');
|
|
802
|
+
const source = await fsPromises.readFile(photonPath, 'utf-8');
|
|
803
|
+
const extractor = new SchemaExtractor();
|
|
804
|
+
const injections = extractor.resolveInjections(source, photonName);
|
|
805
|
+
const keys = injections
|
|
806
|
+
.filter((inj) => inj.injectionType === 'state')
|
|
807
|
+
.map((inj) => inj.stateKey);
|
|
808
|
+
stateKeysCache.set(photonName, keys);
|
|
809
|
+
logger.debug('State keys extracted', { photon: photonName, keys });
|
|
810
|
+
return keys;
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
logger.error('Failed to extract state keys', {
|
|
814
|
+
photon: photonName,
|
|
815
|
+
error: getErrorMessage(error),
|
|
816
|
+
});
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Persist reactive collection state to disk after each tool call.
|
|
822
|
+
* Only persists properties identified as 'state' injection type
|
|
823
|
+
* (non-primitive constructor params with defaults on @stateful photons).
|
|
824
|
+
*/
|
|
825
|
+
async function persistInstanceState(instance, photonName, instanceName) {
|
|
826
|
+
try {
|
|
827
|
+
const photonPath = photonPaths.get(photonName);
|
|
828
|
+
if (!photonPath)
|
|
829
|
+
return;
|
|
830
|
+
const keys = await getStateKeys(photonName, photonPath);
|
|
831
|
+
if (keys.length === 0)
|
|
832
|
+
return;
|
|
833
|
+
// instance is PhotonMCPClass wrapper — actual user class is instance.instance
|
|
834
|
+
const target = instance?.instance ?? instance;
|
|
835
|
+
const snapshot = {};
|
|
836
|
+
for (const key of keys) {
|
|
837
|
+
const value = target[key];
|
|
838
|
+
if (value === undefined)
|
|
839
|
+
continue;
|
|
840
|
+
if (value && typeof value === 'object' && value._propertyName) {
|
|
841
|
+
// ReactiveArray/Map/Set — serialize underlying data
|
|
842
|
+
snapshot[key] = value.toJSON ? value.toJSON() : globalThis.Array.from(value);
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// Plain value (array, object, etc.)
|
|
846
|
+
snapshot[key] = value;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (Object.keys(snapshot).length > 0) {
|
|
850
|
+
const { getInstanceStatePath } = await import('../context-store.js');
|
|
851
|
+
const statePath = getInstanceStatePath(photonName, instanceName);
|
|
852
|
+
const fsPromises = await import('fs/promises');
|
|
853
|
+
const path = await import('path');
|
|
854
|
+
await fsPromises.mkdir(path.dirname(statePath), { recursive: true });
|
|
855
|
+
await fsPromises.writeFile(statePath, JSON.stringify(snapshot, null, 2));
|
|
856
|
+
logger.debug('Persisted state', { photon: photonName, instance: instanceName || 'default' });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
logger.error('Failed to persist state', {
|
|
861
|
+
photon: photonName,
|
|
862
|
+
error: getErrorMessage(error),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// ════════════════════════════════════════════════════════════════════════════════
|
|
867
|
+
// FILE WATCHING (Auto Hot-Reload)
|
|
868
|
+
// ════════════════════════════════════════════════════════════════════════════════
|
|
869
|
+
function watchPhotonFile(photonName, photonPath) {
|
|
870
|
+
if (fileWatchers.has(photonPath))
|
|
871
|
+
return;
|
|
872
|
+
try {
|
|
873
|
+
const watcher = fs.watch(photonPath, (eventType) => {
|
|
874
|
+
// Debounce: 100ms (same as Beam)
|
|
875
|
+
const existing = watchDebounce.get(photonPath);
|
|
876
|
+
if (existing)
|
|
877
|
+
clearTimeout(existing);
|
|
878
|
+
watchDebounce.set(photonPath, setTimeout(async () => {
|
|
879
|
+
watchDebounce.delete(photonPath);
|
|
880
|
+
// On macOS, editors like sed -i and some IDEs replace the file (new inode),
|
|
881
|
+
// which kills the watcher. Re-watch if file still exists.
|
|
882
|
+
if (eventType === 'rename') {
|
|
883
|
+
unwatchPhotonFile(photonPath);
|
|
884
|
+
if (fs.existsSync(photonPath)) {
|
|
885
|
+
watchPhotonFile(photonName, photonPath);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (!fs.existsSync(photonPath))
|
|
889
|
+
return;
|
|
890
|
+
logger.info('File changed, auto-reloading', { photonName, path: photonPath });
|
|
891
|
+
// Invalidate cached state keys so they're re-extracted from fresh source
|
|
892
|
+
stateKeysCache.delete(photonName);
|
|
893
|
+
await reloadPhoton(photonName, photonPath);
|
|
894
|
+
}, 100));
|
|
895
|
+
});
|
|
896
|
+
watcher.on('error', (err) => {
|
|
897
|
+
logger.warn('File watcher error', { photonName, error: getErrorMessage(err) });
|
|
898
|
+
unwatchPhotonFile(photonPath);
|
|
899
|
+
});
|
|
900
|
+
fileWatchers.set(photonPath, watcher);
|
|
901
|
+
logger.info('Watching photon file', { photonName, path: photonPath });
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
logger.warn('Failed to watch photon file', { photonName, error: getErrorMessage(err) });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function unwatchPhotonFile(photonPath) {
|
|
908
|
+
const watcher = fileWatchers.get(photonPath);
|
|
909
|
+
if (watcher) {
|
|
910
|
+
watcher.close();
|
|
911
|
+
fileWatchers.delete(photonPath);
|
|
912
|
+
}
|
|
913
|
+
const timer = watchDebounce.get(photonPath);
|
|
914
|
+
if (timer) {
|
|
915
|
+
clearTimeout(timer);
|
|
916
|
+
watchDebounce.delete(photonPath);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// ════════════════════════════════════════════════════════════════════════════════
|
|
691
920
|
// HOT RELOAD
|
|
692
921
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
693
922
|
async function reloadPhoton(photonName, newPhotonPath) {
|
|
@@ -695,8 +924,9 @@ async function reloadPhoton(photonName, newPhotonPath) {
|
|
|
695
924
|
logger.info('Hot-reloading photon', { photonName, path: newPhotonPath });
|
|
696
925
|
const sessionManager = sessionManagers.get(photonName);
|
|
697
926
|
if (!sessionManager) {
|
|
698
|
-
// First time - just register the path
|
|
927
|
+
// First time - just register the path and start watching
|
|
699
928
|
photonPaths.set(photonName, newPhotonPath);
|
|
929
|
+
watchPhotonFile(photonName, newPhotonPath);
|
|
700
930
|
return { success: true, sessionsUpdated: 0 };
|
|
701
931
|
}
|
|
702
932
|
await sessionManager.loader.reloadFile(newPhotonPath);
|
|
@@ -850,6 +1080,10 @@ function shutdown() {
|
|
|
850
1080
|
jobTimers.clear();
|
|
851
1081
|
scheduledJobs.clear();
|
|
852
1082
|
activeLocks.clear();
|
|
1083
|
+
// Close file watchers and debounce timers
|
|
1084
|
+
for (const photonPath of fileWatchers.keys()) {
|
|
1085
|
+
unwatchPhotonFile(photonPath);
|
|
1086
|
+
}
|
|
853
1087
|
for (const manager of sessionManagers.values()) {
|
|
854
1088
|
manager.destroy();
|
|
855
1089
|
}
|