@portel/photon 1.22.0 → 1.23.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 +19 -8
- package/dist/a2ui/mapper.d.ts +40 -0
- package/dist/a2ui/mapper.d.ts.map +1 -0
- package/dist/a2ui/mapper.js +286 -0
- package/dist/a2ui/mapper.js.map +1 -0
- package/dist/a2ui/types.d.ts +129 -0
- package/dist/a2ui/types.d.ts.map +1 -0
- package/dist/a2ui/types.js +20 -0
- package/dist/a2ui/types.js.map +1 -0
- package/dist/ag-ui/adapter.d.ts +9 -1
- package/dist/ag-ui/adapter.d.ts.map +1 -1
- package/dist/ag-ui/adapter.js +33 -16
- package/dist/ag-ui/adapter.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-daemon.d.ts +18 -0
- package/dist/auto-ui/beam/routes/api-daemon.d.ts.map +1 -0
- package/dist/auto-ui/beam/routes/api-daemon.js +118 -0
- package/dist/auto-ui/beam/routes/api-daemon.js.map +1 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +34 -34
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
- package/dist/auto-ui/bridge/renderers.js +371 -0
- package/dist/auto-ui/bridge/renderers.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +38 -1
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +19 -0
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +757 -107
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/beam.d.ts.map +1 -1
- package/dist/cli/commands/beam.js +2 -0
- package/dist/cli/commands/beam.js.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +2 -0
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +92 -3
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/host.d.ts.map +1 -1
- package/dist/cli/commands/host.js +9 -1
- package/dist/cli/commands/host.js.map +1 -1
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +7 -3
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +4 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/maker.d.ts +8 -0
- package/dist/cli/commands/maker.d.ts.map +1 -1
- package/dist/cli/commands/maker.js +113 -46
- package/dist/cli/commands/maker.js.map +1 -1
- package/dist/cli/commands/marketplace.d.ts.map +1 -1
- package/dist/cli/commands/marketplace.js +7 -1
- package/dist/cli/commands/marketplace.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +215 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +33 -15
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli/commands/ps.d.ts +16 -0
- package/dist/cli/commands/ps.d.ts.map +1 -0
- package/dist/cli/commands/ps.js +267 -0
- package/dist/cli/commands/ps.js.map +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +7 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +14 -4
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +9 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/context-store.d.ts +4 -4
- package/dist/context-store.d.ts.map +1 -1
- package/dist/context-store.js +20 -17
- package/dist/context-store.js.map +1 -1
- package/dist/context.d.ts +5 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +68 -14
- package/dist/context.js.map +1 -1
- package/dist/daemon/client.d.ts +60 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +76 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/execution-history-sqlite.d.ts +50 -0
- package/dist/daemon/execution-history-sqlite.d.ts.map +1 -0
- package/dist/daemon/execution-history-sqlite.js +165 -0
- package/dist/daemon/execution-history-sqlite.js.map +1 -0
- package/dist/daemon/execution-history.d.ts +78 -0
- package/dist/daemon/execution-history.d.ts.map +1 -0
- package/dist/daemon/execution-history.js +246 -0
- package/dist/daemon/execution-history.js.map +1 -0
- package/dist/daemon/hot-reload-state.d.ts +27 -0
- package/dist/daemon/hot-reload-state.d.ts.map +1 -0
- package/dist/daemon/hot-reload-state.js +48 -0
- package/dist/daemon/hot-reload-state.js.map +1 -0
- package/dist/daemon/protocol.d.ts +5 -1
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +13 -0
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/registry-keys.d.ts +88 -0
- package/dist/daemon/registry-keys.d.ts.map +1 -0
- package/dist/daemon/registry-keys.js +91 -0
- package/dist/daemon/registry-keys.js.map +1 -0
- package/dist/daemon/server.js +1521 -186
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-resolver.d.ts +28 -0
- package/dist/daemon/session-resolver.d.ts.map +1 -0
- package/dist/daemon/session-resolver.js +41 -0
- package/dist/daemon/session-resolver.js.map +1 -0
- package/dist/data-migration.js +20 -9
- package/dist/data-migration.js.map +1 -1
- package/dist/loader.d.ts +22 -8
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +214 -94
- package/dist/loader.js.map +1 -1
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +9 -5
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/namespace-migration.d.ts.map +1 -1
- package/dist/namespace-migration.js +28 -23
- package/dist/namespace-migration.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +57 -8
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/serv/auth/auth-store.d.ts +155 -0
- package/dist/serv/auth/auth-store.d.ts.map +1 -0
- package/dist/serv/auth/auth-store.js +240 -0
- package/dist/serv/auth/auth-store.js.map +1 -0
- package/dist/serv/auth/endpoints.d.ts +113 -0
- package/dist/serv/auth/endpoints.d.ts.map +1 -0
- package/dist/serv/auth/endpoints.js +1005 -0
- package/dist/serv/auth/endpoints.js.map +1 -0
- package/dist/serv/auth/http-adapter.d.ts +60 -0
- package/dist/serv/auth/http-adapter.d.ts.map +1 -0
- package/dist/serv/auth/http-adapter.js +235 -0
- package/dist/serv/auth/http-adapter.js.map +1 -0
- package/dist/serv/auth/jwt.d.ts +92 -6
- package/dist/serv/auth/jwt.d.ts.map +1 -1
- package/dist/serv/auth/jwt.js +226 -24
- package/dist/serv/auth/jwt.js.map +1 -1
- package/dist/serv/auth/oauth-sqlite-stores.d.ts +48 -0
- package/dist/serv/auth/oauth-sqlite-stores.d.ts.map +1 -0
- package/dist/serv/auth/oauth-sqlite-stores.js +212 -0
- package/dist/serv/auth/oauth-sqlite-stores.js.map +1 -0
- package/dist/serv/auth/sqlite-stores.d.ts +85 -0
- package/dist/serv/auth/sqlite-stores.d.ts.map +1 -0
- package/dist/serv/auth/sqlite-stores.js +446 -0
- package/dist/serv/auth/sqlite-stores.js.map +1 -0
- package/dist/serv/auth/well-known.d.ts +54 -1
- package/dist/serv/auth/well-known.d.ts.map +1 -1
- package/dist/serv/auth/well-known.js +166 -17
- package/dist/serv/auth/well-known.js.map +1 -1
- package/dist/serv/index.d.ts +45 -2
- package/dist/serv/index.d.ts.map +1 -1
- package/dist/serv/index.js +65 -1
- package/dist/serv/index.js.map +1 -1
- package/dist/serv/types/index.d.ts +80 -0
- package/dist/serv/types/index.d.ts.map +1 -1
- package/dist/serv/types/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +61 -6
- package/dist/server.js.map +1 -1
- package/dist/shared/announce-context.d.ts +51 -0
- package/dist/shared/announce-context.d.ts.map +1 -0
- package/dist/shared/announce-context.js +73 -0
- package/dist/shared/announce-context.js.map +1 -0
- package/dist/shared/audit-sqlite.d.ts +63 -0
- package/dist/shared/audit-sqlite.d.ts.map +1 -0
- package/dist/shared/audit-sqlite.js +187 -0
- package/dist/shared/audit-sqlite.js.map +1 -0
- package/dist/shared/audit.d.ts +25 -3
- package/dist/shared/audit.d.ts.map +1 -1
- package/dist/shared/audit.js +97 -3
- package/dist/shared/audit.js.map +1 -1
- package/dist/shared/error-handler.d.ts +10 -1
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +17 -2
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/security.d.ts +12 -0
- package/dist/shared/security.d.ts.map +1 -1
- package/dist/shared/security.js +80 -0
- package/dist/shared/security.js.map +1 -1
- package/dist/shared/sqlite-runtime.d.ts +46 -0
- package/dist/shared/sqlite-runtime.d.ts.map +1 -0
- package/dist/shared/sqlite-runtime.js +110 -0
- package/dist/shared/sqlite-runtime.js.map +1 -0
- package/dist/tasks/store.d.ts +1 -1
- package/dist/tasks/store.d.ts.map +1 -1
- package/dist/tasks/store.js +29 -15
- package/dist/tasks/store.js.map +1 -1
- package/dist/telemetry/metrics.d.ts +26 -0
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +31 -0
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +3 -3
- package/dist/test-runner.js.map +1 -1
- package/dist/version-checker.d.ts.map +1 -1
- package/dist/version-checker.js +7 -14
- package/dist/version-checker.js.map +1 -1
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +103 -1
- package/dist/version.js.map +1 -1
- package/package.json +6 -2
- package/templates/photon.template.ts +7 -13
package/dist/daemon/server.js
CHANGED
|
@@ -15,13 +15,16 @@ import * as path from 'path';
|
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import * as crypto from 'crypto';
|
|
17
17
|
import { SessionManager } from './session-manager.js';
|
|
18
|
+
import { transferHotReloadState } from './hot-reload-state.js';
|
|
19
|
+
import { resolveWithGlobalFallback } from './session-resolver.js';
|
|
18
20
|
import { isValidDaemonRequest, } from './protocol.js';
|
|
19
|
-
import { setPromptHandler, setBroker } from '@portel/photon-core';
|
|
21
|
+
import { setPromptHandler, setBroker, touchBase, getPhotonSchedulesDir, getPhotonStateLogPath, listActiveBases, pruneBasesRegistry, resolvePhotonPath, } from '@portel/photon-core';
|
|
20
22
|
import { getDefaultContext } from '../context.js';
|
|
21
23
|
import { createLogger } from '../shared/logger.js';
|
|
22
24
|
import { getErrorMessage } from '../shared/error-handler.js';
|
|
23
|
-
import { timingSafeEqual, readBody, SimpleRateLimiter } from '../shared/security.js';
|
|
25
|
+
import { timingSafeEqual, readBody, SimpleRateLimiter, ipInAllowlist, parseAllowlistEnv, } from '../shared/security.js';
|
|
24
26
|
import { audit } from '../shared/audit.js';
|
|
27
|
+
import { recordExecution, previewResult, readExecutionHistory, sweepAllBases as sweepExecutionHistoryBases, } from './execution-history.js';
|
|
25
28
|
import { WorkerManager } from './worker-manager.js';
|
|
26
29
|
import fastJsonPatch from 'fast-json-patch';
|
|
27
30
|
import { getOwnerFilePath, isPidAlive, readOwnerRecord, removeOwnerRecord, waitForPidExit, writeOwnerRecord, } from './ownership.js';
|
|
@@ -134,7 +137,11 @@ const connectedSockets = new Set();
|
|
|
134
137
|
let daemonServer = null;
|
|
135
138
|
/** Whether the daemon is shutting down (reject new commands) */
|
|
136
139
|
let isShuttingDown = false;
|
|
137
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Tracks active executeTool calls per (photon, base) composite key.
|
|
142
|
+
* Typed on PhotonCompositeKey so bare-string `.get(photonName)` stops
|
|
143
|
+
* compiling — same guardrail as the schedule maps.
|
|
144
|
+
*/
|
|
138
145
|
const activeExecutions = new Map();
|
|
139
146
|
/** Per-key mutex to prevent concurrent reloads (format-on-save race) */
|
|
140
147
|
const reloadMutex = new Map();
|
|
@@ -164,24 +171,91 @@ function untrackExecution(key) {
|
|
|
164
171
|
const sessionManagers = new Map();
|
|
165
172
|
const photonPaths = new Map(); // compositeKey -> photonPath
|
|
166
173
|
const workingDirs = new Map(); // compositeKey -> workingDir
|
|
174
|
+
// Keyed by resolved file path (realpathSync), not composite key.
|
|
167
175
|
const fileWatchers = new Map();
|
|
168
|
-
const watchDebounce = new Map();
|
|
176
|
+
const watchDebounce = new Map(); // keyed by base:filename, not photon
|
|
177
|
+
const photonSourceStats = new Map();
|
|
178
|
+
/** Snapshot the stat fields we use as change signal. Null on ENOENT etc. */
|
|
179
|
+
function statOrNull(filePath) {
|
|
180
|
+
try {
|
|
181
|
+
const s = fs.statSync(filePath);
|
|
182
|
+
return { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Record the current stat for a photon after a successful load or reload. */
|
|
189
|
+
function recordPhotonSourceStat(key, photonPath) {
|
|
190
|
+
if (!photonPath)
|
|
191
|
+
return;
|
|
192
|
+
const s = statOrNull(photonPath);
|
|
193
|
+
if (s)
|
|
194
|
+
photonSourceStats.set(key, s);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Compare the current file stat against the last-recorded stat. If the
|
|
198
|
+
* file has changed since the cached photon was loaded, trigger a
|
|
199
|
+
* synchronous reload before returning. On any error (missing file,
|
|
200
|
+
* reload failure), the call returns without throwing so dispatch can
|
|
201
|
+
* surface a richer error downstream.
|
|
202
|
+
*/
|
|
203
|
+
async function statGate(key, photonName, photonPath, workingDir) {
|
|
204
|
+
if (!photonPath)
|
|
205
|
+
return;
|
|
206
|
+
const cached = photonSourceStats.get(key);
|
|
207
|
+
if (!cached) {
|
|
208
|
+
// No baseline yet — record what's there now and let this request run on
|
|
209
|
+
// the current (just-loaded) instance.
|
|
210
|
+
recordPhotonSourceStat(key, photonPath);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const current = statOrNull(photonPath);
|
|
214
|
+
if (!current)
|
|
215
|
+
return;
|
|
216
|
+
if (current.mtimeMs === cached.mtimeMs &&
|
|
217
|
+
current.size === cached.size &&
|
|
218
|
+
current.ino === cached.ino) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
logger.info('stat-gate: source changed since last load, syncing reload', {
|
|
222
|
+
photon: photonName,
|
|
223
|
+
path: photonPath,
|
|
224
|
+
prevMtime: cached.mtimeMs,
|
|
225
|
+
newMtime: current.mtimeMs,
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
const result = await reloadPhoton(photonName, photonPath, workingDir);
|
|
229
|
+
if (result.success) {
|
|
230
|
+
photonSourceStats.set(key, current);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
logger.warn('stat-gate: reload failed — continuing with stale instance', {
|
|
235
|
+
photon: photonName,
|
|
236
|
+
error: getErrorMessage(err),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
169
240
|
// parentDir -> SafeWatcher: one watcher per unique parent directory
|
|
170
241
|
const parentDirWatchers = new Map();
|
|
171
242
|
// workingDir -> inode: recorded at watch time for rename-vs-delete detection
|
|
172
243
|
const workingDirInodes = new Map();
|
|
173
244
|
// workingDir -> SafeWatcher: watches {workingDir}/state/ for photon subdir deletions
|
|
174
245
|
const stateDirWatchers = new Map();
|
|
246
|
+
// basePath -> SafeWatcher: watches a PHOTON_DIR root for .photon.ts edits so
|
|
247
|
+
// @scheduled cron changes and @webhook additions reach the declared-set
|
|
248
|
+
// without a daemon restart. See watchBaseForProactiveMetadata().
|
|
249
|
+
const baseDirWatchers = new Map();
|
|
250
|
+
import { compositeKey as _compositeKey, declaredKey as _declaredKey, webhookKey as _webhookKey, locationKey as _locationKey, findByPhoton, asScheduleKey, } from './registry-keys.js';
|
|
175
251
|
/**
|
|
176
252
|
* Create a composite key from photonName + workingDir for map lookups.
|
|
177
|
-
*
|
|
178
|
-
*
|
|
253
|
+
* Delegates to registry-keys.ts (the single source of truth for key shapes).
|
|
254
|
+
* Return type is branded so every caller that indexes a PhotonCompositeKey
|
|
255
|
+
* map has to route through this helper — bare-string lookups won't compile.
|
|
179
256
|
*/
|
|
180
257
|
function compositeKey(photonName, workingDir) {
|
|
181
|
-
|
|
182
|
-
return photonName;
|
|
183
|
-
const dirHash = crypto.createHash('sha256').update(workingDir).digest('hex').slice(0, 8);
|
|
184
|
-
return `${photonName}:${dirHash}`;
|
|
258
|
+
return _compositeKey(photonName, workingDir, getDefaultContext().baseDir);
|
|
185
259
|
}
|
|
186
260
|
/**
|
|
187
261
|
* Determine if a photon should run in a worker thread.
|
|
@@ -351,6 +425,14 @@ staleMapCleanupInterval.unref();
|
|
|
351
425
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
352
426
|
// SCHEDULED JOBS
|
|
353
427
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
428
|
+
/**
|
|
429
|
+
* Scheduled-jobs registry. Keyed by ScheduleKey (branded) so bare-string
|
|
430
|
+
* lookups fail at compile time — the same guardrail as declaredSchedules.
|
|
431
|
+
* scheduleJob()/unscheduleJob() are the ONLY correct way to mutate the
|
|
432
|
+
* (scheduledJobs, jobTimers) pair together. Direct map writes leave one
|
|
433
|
+
* of the two half-populated and were the cause of at least two rounds of
|
|
434
|
+
* codex regression findings.
|
|
435
|
+
*/
|
|
354
436
|
const scheduledJobs = new Map();
|
|
355
437
|
const jobTimers = new Map();
|
|
356
438
|
function parseCronField(field, min, max) {
|
|
@@ -509,17 +591,21 @@ function scheduleJob(job) {
|
|
|
509
591
|
logger.error('Invalid cron expression', { jobId: job.id, cron: job.cron });
|
|
510
592
|
return false;
|
|
511
593
|
}
|
|
594
|
+
// ScheduledJob.id is typed string at the protocol boundary, but every
|
|
595
|
+
// producer inside this daemon creates it via declaredKey(). Coerce once
|
|
596
|
+
// here so downstream map access gets the branded type.
|
|
597
|
+
const jobKey = asScheduleKey(job.id);
|
|
512
598
|
job.nextRun = nextRun;
|
|
513
|
-
scheduledJobs.set(
|
|
514
|
-
const existingTimer = jobTimers.get(
|
|
599
|
+
scheduledJobs.set(jobKey, job);
|
|
600
|
+
const existingTimer = jobTimers.get(jobKey);
|
|
515
601
|
if (existingTimer) {
|
|
516
602
|
clearTimeout(existingTimer);
|
|
517
603
|
}
|
|
518
604
|
const delay = nextRun - Date.now();
|
|
519
605
|
const timer = setTimeout(() => {
|
|
520
|
-
void runJob(
|
|
606
|
+
void runJob(jobKey);
|
|
521
607
|
}, delay);
|
|
522
|
-
jobTimers.set(
|
|
608
|
+
jobTimers.set(jobKey, timer);
|
|
523
609
|
logger.info('Job scheduled', {
|
|
524
610
|
jobId: job.id,
|
|
525
611
|
method: job.method,
|
|
@@ -533,7 +619,24 @@ async function runJob(jobId) {
|
|
|
533
619
|
if (!job)
|
|
534
620
|
return;
|
|
535
621
|
const key = compositeKey(job.photonName, job.workingDir);
|
|
536
|
-
|
|
622
|
+
let sessionManager = sessionManagers.get(key);
|
|
623
|
+
// Lazy-load: schedules registered proactively at boot carry the photon's
|
|
624
|
+
// source path on the job, so we can spin up the session when the timer
|
|
625
|
+
// fires rather than require the photon to be invoked manually first.
|
|
626
|
+
if (!sessionManager && job.photonPath) {
|
|
627
|
+
try {
|
|
628
|
+
sessionManager =
|
|
629
|
+
(await getOrCreateSessionManager(job.photonName, job.photonPath, job.workingDir)) ??
|
|
630
|
+
undefined;
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
logger.warn('Lazy-load for scheduled job failed', {
|
|
634
|
+
jobId,
|
|
635
|
+
photon: job.photonName,
|
|
636
|
+
error: getErrorMessage(err),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
537
640
|
if (!sessionManager) {
|
|
538
641
|
logger.warn('Cannot run job - photon not initialized', { jobId, photon: job.photonName });
|
|
539
642
|
scheduleJob(job); // Reschedule anyway
|
|
@@ -541,9 +644,13 @@ async function runJob(jobId) {
|
|
|
541
644
|
}
|
|
542
645
|
logger.info('Running scheduled job', { jobId, method: job.method, photon: job.photonName });
|
|
543
646
|
trackExecution(key);
|
|
647
|
+
const startTs = Date.now();
|
|
648
|
+
let status = 'success';
|
|
649
|
+
let errorMessage;
|
|
650
|
+
let result;
|
|
544
651
|
try {
|
|
545
652
|
const session = await sessionManager.getOrCreateSession('scheduler', 'scheduler');
|
|
546
|
-
await sessionManager.loader.executeTool(session.instance, job.method, job.args || {});
|
|
653
|
+
result = await sessionManager.loader.executeTool(session.instance, job.method, job.args || {});
|
|
547
654
|
job.lastRun = Date.now();
|
|
548
655
|
job.runCount++;
|
|
549
656
|
// Update persisted schedule file if this came from ScheduleProvider
|
|
@@ -560,16 +667,27 @@ async function runJob(jobId) {
|
|
|
560
667
|
logger.info('Job completed', { jobId, method: job.method, runCount: job.runCount });
|
|
561
668
|
}
|
|
562
669
|
catch (error) {
|
|
563
|
-
|
|
670
|
+
status = 'error';
|
|
671
|
+
errorMessage = getErrorMessage(error);
|
|
672
|
+
logger.error('Job failed', { jobId, method: job.method, error: errorMessage });
|
|
564
673
|
publishToChannel(`jobs:${job.photonName}`, {
|
|
565
674
|
event: 'job-failed',
|
|
566
675
|
jobId,
|
|
567
676
|
method: job.method,
|
|
568
|
-
error:
|
|
677
|
+
error: errorMessage,
|
|
569
678
|
});
|
|
570
679
|
}
|
|
571
680
|
finally {
|
|
572
681
|
untrackExecution(key);
|
|
682
|
+
recordExecution(job.photonName, {
|
|
683
|
+
ts: Date.now(),
|
|
684
|
+
jobId,
|
|
685
|
+
method: job.method,
|
|
686
|
+
durationMs: Date.now() - startTs,
|
|
687
|
+
status,
|
|
688
|
+
errorMessage,
|
|
689
|
+
outputPreview: status === 'success' ? previewResult(result) : undefined,
|
|
690
|
+
}, job.workingDir);
|
|
573
691
|
}
|
|
574
692
|
scheduleJob(job);
|
|
575
693
|
}
|
|
@@ -585,6 +703,60 @@ function unscheduleJob(jobId) {
|
|
|
585
703
|
}
|
|
586
704
|
return existed;
|
|
587
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Resolve the canonical schedules dir for a photon under the Option B
|
|
708
|
+
* contract: {workingDir || default baseDir}/.data/{photonName}/schedules/.
|
|
709
|
+
*
|
|
710
|
+
* KNOWN LIMITATION: this path uses only the bare `photonName` — no
|
|
711
|
+
* namespace/subdirectory is threaded through. Two photons with the same
|
|
712
|
+
* filename under different subdirectories in one PHOTON_DIR (e.g.
|
|
713
|
+
* `alice/foo.photon.ts` and `bob/foo.photon.ts`) resolve to the same
|
|
714
|
+
* `.data/foo/schedules/` tree and therefore share schedule state. This
|
|
715
|
+
* predates the post-v1.22.1 multi-base work and fixing it cleanly
|
|
716
|
+
* requires photon-core to accept a namespace in getPhotonSchedulesDir
|
|
717
|
+
* and every scheduled-job record to carry that namespace. Do NOT load
|
|
718
|
+
* namespaced duplicates of a photon into the same daemon until this
|
|
719
|
+
* is resolved. Flagged by codex review 2026-04-19.
|
|
720
|
+
*/
|
|
721
|
+
function resolveScheduleDir(photonName, workingDir) {
|
|
722
|
+
return getPhotonSchedulesDir('', photonName, workingDir || getDefaultContext().baseDir);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Root of the legacy schedules tree (pre-Option-B layout).
|
|
726
|
+
* Honors `PHOTON_SCHEDULES_DIR` only for the one-release deprecation
|
|
727
|
+
* window; production code should rely on the per-PHOTON_DIR layout.
|
|
728
|
+
*/
|
|
729
|
+
let _schedulesEnvWarned = false;
|
|
730
|
+
function resolveLegacySchedulesRoot() {
|
|
731
|
+
const override = process.env.PHOTON_SCHEDULES_DIR;
|
|
732
|
+
if (override) {
|
|
733
|
+
if (!_schedulesEnvWarned) {
|
|
734
|
+
_schedulesEnvWarned = true;
|
|
735
|
+
logger.warn('PHOTON_SCHEDULES_DIR is deprecated and will be removed in the next minor release. ' +
|
|
736
|
+
'Schedules now live under {PHOTON_DIR}/.data/{photon}/schedules/ per Option B.', { path: override });
|
|
737
|
+
}
|
|
738
|
+
return override;
|
|
739
|
+
}
|
|
740
|
+
return path.join(os.homedir(), '.photon', 'schedules');
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Legacy schedules dir (pre-Option-B), read-only compatibility path.
|
|
744
|
+
* Kept for the one-release transition window so schedules created before
|
|
745
|
+
* this change continue to be discoverable.
|
|
746
|
+
*/
|
|
747
|
+
function resolveLegacyScheduleDir(photonName) {
|
|
748
|
+
return path.join(resolveLegacySchedulesRoot(), photonName.replace(/[^a-zA-Z0-9_-]/g, '_'));
|
|
749
|
+
}
|
|
750
|
+
/** Locate a persisted schedule file, preferring the new location. */
|
|
751
|
+
function findPersistedScheduleFile(photonName, taskId, workingDir) {
|
|
752
|
+
const newPath = path.join(resolveScheduleDir(photonName, workingDir), `${taskId}.json`);
|
|
753
|
+
if (fs.existsSync(newPath))
|
|
754
|
+
return newPath;
|
|
755
|
+
const legacyPath = path.join(resolveLegacyScheduleDir(photonName), `${taskId}.json`);
|
|
756
|
+
if (fs.existsSync(legacyPath))
|
|
757
|
+
return legacyPath;
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
588
760
|
/** Update persisted schedule file after job execution */
|
|
589
761
|
function updatePersistedSchedule(jobId, photonName, updates) {
|
|
590
762
|
// Handle both ScheduleProvider jobs (photonName:sched:uuid) and IPC jobs (photonName:*:ipc:uuid)
|
|
@@ -593,8 +765,10 @@ function updatePersistedSchedule(jobId, photonName, updates) {
|
|
|
593
765
|
if (!schedMatch && !ipcMatch)
|
|
594
766
|
return;
|
|
595
767
|
const taskId = schedMatch ? schedMatch[1] : ipcMatch[1];
|
|
596
|
-
const
|
|
597
|
-
const filePath =
|
|
768
|
+
const workingDir = scheduledJobs.get(asScheduleKey(jobId))?.workingDir;
|
|
769
|
+
const filePath = findPersistedScheduleFile(photonName, taskId, workingDir);
|
|
770
|
+
if (!filePath)
|
|
771
|
+
return;
|
|
598
772
|
try {
|
|
599
773
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
600
774
|
const task = JSON.parse(content);
|
|
@@ -610,7 +784,7 @@ function updatePersistedSchedule(jobId, photonName, updates) {
|
|
|
610
784
|
}
|
|
611
785
|
/** Persist an IPC-created schedule job to disk for daemon restart recovery */
|
|
612
786
|
function persistIpcSchedule(job) {
|
|
613
|
-
const schedulesDir =
|
|
787
|
+
const schedulesDir = resolveScheduleDir(job.photonName, job.workingDir);
|
|
614
788
|
try {
|
|
615
789
|
fs.mkdirSync(schedulesDir, { recursive: true });
|
|
616
790
|
}
|
|
@@ -652,106 +826,699 @@ function deletePersistedIpcSchedule(jobId, photonName) {
|
|
|
652
826
|
if (!match)
|
|
653
827
|
return;
|
|
654
828
|
const taskId = match[1];
|
|
655
|
-
const
|
|
656
|
-
const filePath =
|
|
829
|
+
const workingDir = scheduledJobs.get(asScheduleKey(jobId))?.workingDir;
|
|
830
|
+
const filePath = findPersistedScheduleFile(photonName, taskId, workingDir);
|
|
831
|
+
if (!filePath)
|
|
832
|
+
return;
|
|
657
833
|
try {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
logger.debug('Deleted persisted IPC schedule', { jobId, path: filePath });
|
|
661
|
-
}
|
|
834
|
+
fs.unlinkSync(filePath);
|
|
835
|
+
logger.debug('Deleted persisted IPC schedule', { jobId, path: filePath });
|
|
662
836
|
}
|
|
663
837
|
catch {
|
|
664
838
|
// Ignore — file may already be gone
|
|
665
839
|
}
|
|
666
840
|
}
|
|
667
|
-
/**
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
841
|
+
/**
|
|
842
|
+
* Process IPC schedule files from one photon's schedules directory.
|
|
843
|
+
* Mutates loadedCount / skippedCount via the returned counters.
|
|
844
|
+
*/
|
|
845
|
+
function loadIpcSchedulesFromDir(schedulesPath, ttlMs) {
|
|
846
|
+
let loaded = 0;
|
|
847
|
+
let skipped = 0;
|
|
848
|
+
let files;
|
|
849
|
+
try {
|
|
850
|
+
files = fs.readdirSync(schedulesPath).filter((f) => f.endsWith('.json'));
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return { loaded, skipped };
|
|
854
|
+
}
|
|
855
|
+
for (const file of files) {
|
|
856
|
+
const filePath = path.join(schedulesPath, file);
|
|
857
|
+
try {
|
|
858
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
859
|
+
const task = JSON.parse(content);
|
|
860
|
+
// Skip non-IPC jobs (ScheduleProvider handles its own)
|
|
861
|
+
if (task.source !== 'ipc')
|
|
862
|
+
continue;
|
|
863
|
+
// Validate required fields
|
|
864
|
+
if (!task.id || !task.method || !task.cron || !task.photonName) {
|
|
865
|
+
logger.warn('Skipping invalid persisted schedule', { file: filePath });
|
|
866
|
+
skipped++;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
// TTL check: skip jobs not executed in 30+ days
|
|
870
|
+
const lastExec = task.lastExecutionAt ? new Date(task.lastExecutionAt).getTime() : 0;
|
|
871
|
+
const created = task.createdAt ? new Date(task.createdAt).getTime() : 0;
|
|
872
|
+
const lastActivity = Math.max(lastExec, created);
|
|
873
|
+
if (lastActivity > 0 && Date.now() - lastActivity > ttlMs) {
|
|
874
|
+
logger.info('Removing expired schedule (TTL)', {
|
|
875
|
+
jobId: task.id,
|
|
876
|
+
lastActivity: new Date(lastActivity).toISOString(),
|
|
877
|
+
});
|
|
878
|
+
try {
|
|
879
|
+
fs.unlinkSync(filePath);
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
/* ignore */
|
|
883
|
+
}
|
|
884
|
+
skipped++;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
// Skip if already registered (another source may have loaded it)
|
|
888
|
+
if (scheduledJobs.has(task.id))
|
|
889
|
+
continue;
|
|
890
|
+
const job = {
|
|
891
|
+
id: task.id,
|
|
892
|
+
method: task.method,
|
|
893
|
+
args: task.args || {},
|
|
894
|
+
cron: task.cron,
|
|
895
|
+
runCount: task.executionCount || 0,
|
|
896
|
+
createdAt: created || Date.now(),
|
|
897
|
+
createdBy: task.createdBy,
|
|
898
|
+
photonName: task.photonName,
|
|
899
|
+
workingDir: task.workingDir,
|
|
900
|
+
};
|
|
901
|
+
if (scheduleJob(job)) {
|
|
902
|
+
loaded++;
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
logger.warn('Failed to schedule persisted job (invalid cron?)', { jobId: task.id });
|
|
906
|
+
skipped++;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
logger.warn('Failed to load persisted schedule file', {
|
|
911
|
+
file: filePath,
|
|
912
|
+
error: getErrorMessage(err),
|
|
913
|
+
});
|
|
914
|
+
skipped++;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { loaded, skipped };
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* One-time sweep of legacy IPC schedules from ~/.photon/schedules/ to the
|
|
921
|
+
* new per-base location. Only moves files that carry a `workingDir` field
|
|
922
|
+
* (IPC-originated schedules have this); ScheduleProvider-originated files
|
|
923
|
+
* that lack `workingDir` are left in place and continue to be read via the
|
|
924
|
+
* legacy fallback in autoRegisterFromMetadata until the photon that owns
|
|
925
|
+
* them runs under the new layout and regenerates them.
|
|
926
|
+
*
|
|
927
|
+
* Idempotent: files already moved are no longer at the legacy path, so the
|
|
928
|
+
* next startup is a no-op for them. Called before loadAllPersistedSchedules.
|
|
929
|
+
*/
|
|
930
|
+
function migrateLegacyIpcSchedules() {
|
|
931
|
+
const legacyRoot = resolveLegacySchedulesRoot();
|
|
932
|
+
if (!fs.existsSync(legacyRoot))
|
|
671
933
|
return;
|
|
672
|
-
let
|
|
673
|
-
let
|
|
934
|
+
let moved = 0;
|
|
935
|
+
let kept = 0;
|
|
936
|
+
let photonDirs;
|
|
937
|
+
try {
|
|
938
|
+
photonDirs = fs.readdirSync(legacyRoot, { withFileTypes: true });
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
for (const dir of photonDirs) {
|
|
944
|
+
if (!dir.isDirectory())
|
|
945
|
+
continue;
|
|
946
|
+
const photonLegacyDir = path.join(legacyRoot, dir.name);
|
|
947
|
+
let files;
|
|
948
|
+
try {
|
|
949
|
+
files = fs.readdirSync(photonLegacyDir).filter((f) => f.endsWith('.json'));
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
for (const file of files) {
|
|
955
|
+
const legacyPath = path.join(photonLegacyDir, file);
|
|
956
|
+
try {
|
|
957
|
+
const content = fs.readFileSync(legacyPath, 'utf-8');
|
|
958
|
+
const task = JSON.parse(content);
|
|
959
|
+
// Only migrate IPC-sourced files with a known working dir.
|
|
960
|
+
if (task.source !== 'ipc' || !task.workingDir || typeof task.workingDir !== 'string') {
|
|
961
|
+
kept++;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const destDir = resolveScheduleDir(task.photonName || dir.name, task.workingDir);
|
|
965
|
+
const destPath = path.join(destDir, file);
|
|
966
|
+
// Skip if the destination already has content (e.g. a previous partial
|
|
967
|
+
// migration succeeded); don't overwrite.
|
|
968
|
+
if (fs.existsSync(destPath)) {
|
|
969
|
+
kept++;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
973
|
+
fs.renameSync(legacyPath, destPath);
|
|
974
|
+
moved++;
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
kept++;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
// If the legacy photon dir is now empty, remove it to keep the tree tidy.
|
|
981
|
+
try {
|
|
982
|
+
if (fs.readdirSync(photonLegacyDir).length === 0) {
|
|
983
|
+
fs.rmdirSync(photonLegacyDir);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
// Non-fatal
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (moved > 0) {
|
|
991
|
+
logger.info('Migrated legacy IPC schedules to per-base location', { moved, kept });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Load all persisted IPC schedules from disk on daemon startup.
|
|
996
|
+
*
|
|
997
|
+
* Iterates every PHOTON_DIR recorded in the bases registry (`listActiveBases`)
|
|
998
|
+
* and scans each base's `{base}/.data/<photon>/schedules/` for IPC schedule
|
|
999
|
+
* records. Also scans the legacy flat dir (`~/.photon/schedules/` or
|
|
1000
|
+
* `PHOTON_SCHEDULES_DIR`) for backwards compatibility with schedules that
|
|
1001
|
+
* haven't yet been migrated.
|
|
1002
|
+
*/
|
|
1003
|
+
function loadAllPersistedSchedules() {
|
|
674
1004
|
const TTL_DAYS = 30;
|
|
675
1005
|
const ttlMs = TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
676
|
-
|
|
677
|
-
|
|
1006
|
+
let loadedCount = 0;
|
|
1007
|
+
let skippedCount = 0;
|
|
1008
|
+
const scannedDirs = new Set();
|
|
1009
|
+
const scanFlatRoot = (root) => {
|
|
1010
|
+
if (!fs.existsSync(root))
|
|
1011
|
+
return;
|
|
1012
|
+
let photonDirs;
|
|
1013
|
+
try {
|
|
1014
|
+
photonDirs = fs.readdirSync(root, { withFileTypes: true });
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
logger.warn('Failed to scan schedules directory', {
|
|
1018
|
+
dir: root,
|
|
1019
|
+
error: getErrorMessage(err),
|
|
1020
|
+
});
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
678
1023
|
for (const dir of photonDirs) {
|
|
679
1024
|
if (!dir.isDirectory())
|
|
680
1025
|
continue;
|
|
681
|
-
const schedulesPath = path.join(
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1026
|
+
const schedulesPath = path.join(root, dir.name);
|
|
1027
|
+
if (scannedDirs.has(schedulesPath))
|
|
1028
|
+
continue;
|
|
1029
|
+
scannedDirs.add(schedulesPath);
|
|
1030
|
+
const result = loadIpcSchedulesFromDir(schedulesPath, ttlMs);
|
|
1031
|
+
loadedCount += result.loaded;
|
|
1032
|
+
skippedCount += result.skipped;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
const scanBaseDataRoot = (baseDir) => {
|
|
1036
|
+
const dataRoot = path.join(baseDir, '.data');
|
|
1037
|
+
if (!fs.existsSync(dataRoot))
|
|
1038
|
+
return;
|
|
1039
|
+
let entries;
|
|
1040
|
+
try {
|
|
1041
|
+
entries = fs.readdirSync(dataRoot, { withFileTypes: true });
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
logger.warn('Failed to scan base schedules', {
|
|
1045
|
+
base: baseDir,
|
|
1046
|
+
error: getErrorMessage(err),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
for (const entry of entries) {
|
|
1051
|
+
if (!entry.isDirectory())
|
|
1052
|
+
continue;
|
|
1053
|
+
// Skip reserved buckets (_global, _sessions, .cache, tasks, .migrated, etc.)
|
|
1054
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.') || entry.name === 'tasks') {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const schedulesPath = path.join(dataRoot, entry.name, 'schedules');
|
|
1058
|
+
if (!fs.existsSync(schedulesPath))
|
|
1059
|
+
continue;
|
|
1060
|
+
if (scannedDirs.has(schedulesPath))
|
|
1061
|
+
continue;
|
|
1062
|
+
scannedDirs.add(schedulesPath);
|
|
1063
|
+
const result = loadIpcSchedulesFromDir(schedulesPath, ttlMs);
|
|
1064
|
+
loadedCount += result.loaded;
|
|
1065
|
+
skippedCount += result.skipped;
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
// Scan every PHOTON_DIR we've ever served. Prune dead entries first so a
|
|
1069
|
+
// deleted project folder doesn't emit warnings each startup.
|
|
1070
|
+
const pruned = pruneBasesRegistry();
|
|
1071
|
+
if (pruned.length > 0) {
|
|
1072
|
+
logger.info('Pruned bases that no longer exist', {
|
|
1073
|
+
removed: pruned.map((b) => b.path),
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
const bases = listActiveBases();
|
|
1077
|
+
// Always include the default base even if the registry hasn't recorded it yet.
|
|
1078
|
+
const defaultBase = getDefaultContext().baseDir;
|
|
1079
|
+
if (!bases.some((b) => b.path === path.resolve(defaultBase))) {
|
|
1080
|
+
scanBaseDataRoot(defaultBase);
|
|
1081
|
+
}
|
|
1082
|
+
for (const base of bases) {
|
|
1083
|
+
scanBaseDataRoot(base.path);
|
|
1084
|
+
}
|
|
1085
|
+
// Legacy location for schedules that predate the per-base layout.
|
|
1086
|
+
scanFlatRoot(resolveLegacySchedulesRoot());
|
|
1087
|
+
if (loadedCount > 0 || skippedCount > 0) {
|
|
1088
|
+
logger.info('Loaded persisted schedules', { loaded: loadedCount, skipped: skippedCount });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Boot-time discovery of `@scheduled` and `@webhook` tags across every
|
|
1093
|
+
* known PHOTON_DIR. Before this ran, scheduled methods on a photon that
|
|
1094
|
+
* had never been invoked would silently not fire, and webhook routes
|
|
1095
|
+
* were only registered on first invocation. Now both work from the
|
|
1096
|
+
* moment a photon file exists under a registered base.
|
|
1097
|
+
*
|
|
1098
|
+
* We never INSTANTIATE the photon here — only parse its source with
|
|
1099
|
+
* the schema extractor to read tag metadata. When the schedule's cron
|
|
1100
|
+
* fires (or a webhook request arrives), the daemon lazy-loads the
|
|
1101
|
+
* photon via the normal session-manager path.
|
|
1102
|
+
*/
|
|
1103
|
+
/**
|
|
1104
|
+
* Re-extract @scheduled and @webhook metadata for a single source file
|
|
1105
|
+
* and reconcile the declaredSchedules / webhookRoutes maps in place.
|
|
1106
|
+
* Returns true if anything changed (caller can re-sync active schedules
|
|
1107
|
+
* and fan out a schedules-changed event).
|
|
1108
|
+
*
|
|
1109
|
+
* Shared between boot discovery and the live watcher — any behavior
|
|
1110
|
+
* change here benefits both paths.
|
|
1111
|
+
*/
|
|
1112
|
+
async function scanOneForProactiveMetadata(photonName, filePath, workingDir) {
|
|
1113
|
+
const core = await import('@portel/photon-core');
|
|
1114
|
+
const SchemaExtractor = core.SchemaExtractor;
|
|
1115
|
+
let source;
|
|
1116
|
+
try {
|
|
1117
|
+
const fsp = await import('fs/promises');
|
|
1118
|
+
source = await fsp.readFile(filePath, 'utf-8');
|
|
1119
|
+
}
|
|
1120
|
+
catch (err) {
|
|
1121
|
+
// File may have been deleted/renamed mid-flight — drop its declarations.
|
|
1122
|
+
// Scope to workingDir so a missing copy in base A doesn't wipe base B's
|
|
1123
|
+
// declarations of the same photon name.
|
|
1124
|
+
return dropProactiveMetadataFor(photonName, workingDir);
|
|
1125
|
+
}
|
|
1126
|
+
if (!/@(scheduled|cron|webhook)\b/.test(source) && !/\basync\s+handle[A-Z]/.test(source)) {
|
|
1127
|
+
// No proactive tags anymore — clear anything we had for this photon.
|
|
1128
|
+
return dropProactiveMetadataFor(photonName, workingDir);
|
|
1129
|
+
}
|
|
1130
|
+
let changed = false;
|
|
1131
|
+
let meta;
|
|
1132
|
+
try {
|
|
1133
|
+
const extractor = new SchemaExtractor();
|
|
1134
|
+
meta = extractor.extractAllFromSource(source);
|
|
1135
|
+
}
|
|
1136
|
+
catch (err) {
|
|
1137
|
+
logger.warn('Metadata re-scan: extract failed', {
|
|
1138
|
+
photon: photonName,
|
|
1139
|
+
path: filePath,
|
|
1140
|
+
error: getErrorMessage(err),
|
|
1141
|
+
});
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
// Collect the new shape, then diff against current state so callers
|
|
1145
|
+
// only fire change events on actual differences.
|
|
1146
|
+
// Typed with the branded ScheduleKey so the diff loop against
|
|
1147
|
+
// declaredSchedules below can feed its keys straight into .get/.set.
|
|
1148
|
+
// See src/daemon/registry-keys.ts for the branded-key rationale.
|
|
1149
|
+
const nextSchedules = new Map();
|
|
1150
|
+
const nextRoutes = new Map(); // routePath → methodName
|
|
1151
|
+
for (const rawTool of meta.tools) {
|
|
1152
|
+
const tool = rawTool;
|
|
1153
|
+
if (tool.scheduled) {
|
|
1154
|
+
nextSchedules.set(declaredKey(photonName, tool.name, workingDir), {
|
|
1155
|
+
photon: photonName,
|
|
1156
|
+
method: tool.name,
|
|
1157
|
+
cron: tool.scheduled,
|
|
1158
|
+
photonPath: filePath,
|
|
1159
|
+
workingDir,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (tool.webhook !== undefined) {
|
|
1163
|
+
const routePath = typeof tool.webhook === 'string' ? tool.webhook : tool.name;
|
|
1164
|
+
nextRoutes.set(routePath, tool.name);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// Schedules diff — add/update new, remove stale entries owned by this photon
|
|
1168
|
+
// AT THIS PHOTON_DIR. Scoping by workingDir matters: another base may have
|
|
1169
|
+
// the same photon+method scheduled and we must not stomp on it.
|
|
1170
|
+
const resolvedWorkingDir = workingDir ? path.resolve(workingDir) : undefined;
|
|
1171
|
+
const existingScheduleKeys = new Set(Array.from(declaredSchedules.entries())
|
|
1172
|
+
.filter(([, v]) => {
|
|
1173
|
+
if (v.photon !== photonName)
|
|
1174
|
+
return false;
|
|
1175
|
+
const vBase = v.workingDir ? path.resolve(v.workingDir) : undefined;
|
|
1176
|
+
return vBase === resolvedWorkingDir;
|
|
1177
|
+
})
|
|
1178
|
+
.map(([k]) => k));
|
|
1179
|
+
for (const [key, decl] of nextSchedules) {
|
|
1180
|
+
const prev = declaredSchedules.get(key);
|
|
1181
|
+
if (!prev ||
|
|
1182
|
+
prev.cron !== decl.cron ||
|
|
1183
|
+
prev.photonPath !== decl.photonPath ||
|
|
1184
|
+
prev.workingDir !== decl.workingDir) {
|
|
1185
|
+
declaredSchedules.set(key, decl);
|
|
1186
|
+
changed = true;
|
|
1187
|
+
}
|
|
1188
|
+
existingScheduleKeys.delete(key);
|
|
1189
|
+
}
|
|
1190
|
+
for (const staleKey of existingScheduleKeys) {
|
|
1191
|
+
declaredSchedules.delete(staleKey);
|
|
1192
|
+
changed = true;
|
|
1193
|
+
}
|
|
1194
|
+
// Webhooks diff — keyed per (base, photon) so multi-base setups can
|
|
1195
|
+
// register the same photon name in two PHOTON_DIRs without collision.
|
|
1196
|
+
const wKey = webhookKey(photonName, workingDir);
|
|
1197
|
+
const prevEntry = webhookRoutes.get(wKey);
|
|
1198
|
+
const prevRoutes = prevEntry?.routes;
|
|
1199
|
+
const prevKeys = prevRoutes ? Array.from(prevRoutes.keys()) : [];
|
|
1200
|
+
const nextKeys = Array.from(nextRoutes.keys());
|
|
1201
|
+
if (nextKeys.length > 0) {
|
|
1202
|
+
webhookRoutes.set(wKey, { photon: photonName, routes: nextRoutes, workingDir });
|
|
1203
|
+
if (prevKeys.length !== nextKeys.length ||
|
|
1204
|
+
prevKeys.some((k) => nextRoutes.get(k) !== prevRoutes?.get(k)) ||
|
|
1205
|
+
prevEntry?.workingDir !== workingDir) {
|
|
1206
|
+
changed = true;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
else if (prevEntry) {
|
|
1210
|
+
webhookRoutes.delete(wKey);
|
|
1211
|
+
changed = true;
|
|
1212
|
+
}
|
|
1213
|
+
proactivePhotonLocations.set(locationKey(photonName, workingDir), {
|
|
1214
|
+
photon: photonName,
|
|
1215
|
+
photonPath: filePath,
|
|
1216
|
+
workingDir,
|
|
1217
|
+
});
|
|
1218
|
+
return changed;
|
|
1219
|
+
}
|
|
1220
|
+
/** Remove all proactive-metadata entries owned by a photon. */
|
|
1221
|
+
/**
|
|
1222
|
+
* Drop proactive declarations for a photon. When `workingDir` is supplied,
|
|
1223
|
+
* scope the deletion to that PHOTON_DIR — required for multi-base setups
|
|
1224
|
+
* where the same photon name can legitimately exist in two registered
|
|
1225
|
+
* bases. Without scoping, deleting (or briefly failing to read) one copy
|
|
1226
|
+
* would silently wipe the other base's schedules + webhooks.
|
|
1227
|
+
*
|
|
1228
|
+
* webhookRoutes is currently keyed by photon name only and isn't multi-base
|
|
1229
|
+
* aware; we only delete that map's entry when no working dir is supplied
|
|
1230
|
+
* (the legacy "wipe everything" path).
|
|
1231
|
+
*/
|
|
1232
|
+
function dropProactiveMetadataFor(photonName, workingDir) {
|
|
1233
|
+
let changed = false;
|
|
1234
|
+
const resolvedDir = workingDir ? path.resolve(workingDir) : undefined;
|
|
1235
|
+
for (const [key, decl] of declaredSchedules.entries()) {
|
|
1236
|
+
if (decl.photon !== photonName)
|
|
1237
|
+
continue;
|
|
1238
|
+
if (resolvedDir) {
|
|
1239
|
+
const declDir = decl.workingDir ? path.resolve(decl.workingDir) : undefined;
|
|
1240
|
+
if (declDir !== resolvedDir)
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
declaredSchedules.delete(key);
|
|
1244
|
+
changed = true;
|
|
1245
|
+
}
|
|
1246
|
+
// Webhook routes are keyed by (base, photon). If a workingDir was
|
|
1247
|
+
// supplied, delete only that exact key; otherwise remove every entry
|
|
1248
|
+
// for this photon name across bases (legacy "wipe everything" path).
|
|
1249
|
+
if (resolvedDir) {
|
|
1250
|
+
const wKey = webhookKey(photonName, resolvedDir);
|
|
1251
|
+
if (webhookRoutes.delete(wKey))
|
|
1252
|
+
changed = true;
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
for (const key of Array.from(webhookRoutes.keys())) {
|
|
1256
|
+
if (webhookRoutes.get(key)?.photon === photonName) {
|
|
1257
|
+
webhookRoutes.delete(key);
|
|
1258
|
+
changed = true;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return changed;
|
|
1263
|
+
}
|
|
1264
|
+
async function discoverProactiveMetadataAtBoot() {
|
|
1265
|
+
const baseCandidates = new Set();
|
|
1266
|
+
const defaultBase = path.resolve(getDefaultContext().baseDir);
|
|
1267
|
+
baseCandidates.add(defaultBase);
|
|
1268
|
+
for (const base of listActiveBases())
|
|
1269
|
+
baseCandidates.add(base.path);
|
|
1270
|
+
let schedulesRegistered = 0;
|
|
1271
|
+
let webhooksRegistered = 0;
|
|
1272
|
+
let photonsScanned = 0;
|
|
1273
|
+
// Lazy import to keep daemon startup cheap for users with no bases.
|
|
1274
|
+
const core = await import('@portel/photon-core');
|
|
1275
|
+
for (const basePath of baseCandidates) {
|
|
1276
|
+
let photons;
|
|
1277
|
+
try {
|
|
1278
|
+
photons = await core.listPhotonFilesWithNamespace(basePath);
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
logger.warn('Boot discovery: could not list photons in base', {
|
|
1282
|
+
base: basePath,
|
|
1283
|
+
error: getErrorMessage(err),
|
|
1284
|
+
});
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
// Always pass basePath so declaredKey produces a stable resolved-path
|
|
1288
|
+
// key. Previously this set workingDir=undefined for the default base,
|
|
1289
|
+
// which yielded `-::photon:method` keys at boot — but the restore-from-
|
|
1290
|
+
// active path used `declaredKey(entry, basePath)` and produced
|
|
1291
|
+
// `<defaultBase>::photon:method`, so default-base schedules never
|
|
1292
|
+
// re-armed after a daemon restart.
|
|
1293
|
+
const workingDir = basePath;
|
|
1294
|
+
for (const p of photons) {
|
|
1295
|
+
photonsScanned++;
|
|
1296
|
+
const before = {
|
|
1297
|
+
schedules: Array.from(declaredSchedules.values()).filter((d) => d.photon === p.name).length,
|
|
1298
|
+
routes: findWebhookEntriesFor(p.name).reduce((n, e) => n + e.routes.size, 0),
|
|
1299
|
+
};
|
|
1300
|
+
try {
|
|
1301
|
+
await scanOneForProactiveMetadata(p.name, p.filePath, workingDir);
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
logger.warn('Boot discovery: failed to extract metadata', {
|
|
1305
|
+
photon: p.name,
|
|
1306
|
+
path: p.filePath,
|
|
1307
|
+
error: getErrorMessage(err),
|
|
1308
|
+
});
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const after = {
|
|
1312
|
+
schedules: Array.from(declaredSchedules.values()).filter((d) => d.photon === p.name).length,
|
|
1313
|
+
routes: findWebhookEntriesFor(p.name).reduce((n, e) => n + e.routes.size, 0),
|
|
1314
|
+
};
|
|
1315
|
+
schedulesRegistered += Math.max(0, after.schedules - before.schedules);
|
|
1316
|
+
webhooksRegistered += Math.max(0, after.routes - before.routes);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (schedulesRegistered > 0 || webhooksRegistered > 0) {
|
|
1320
|
+
logger.info('Boot discovery complete', {
|
|
1321
|
+
photonsScanned,
|
|
1322
|
+
schedulesDeclared: schedulesRegistered,
|
|
1323
|
+
webhooksRegistered,
|
|
1324
|
+
bases: baseCandidates.size,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Watch each registered base directory for `.photon.ts` create/modify
|
|
1330
|
+
* events, re-extract proactive metadata on each hit, and reconcile the
|
|
1331
|
+
* declared-set + webhook routes live. Closes the gap where an edit to
|
|
1332
|
+
* `@scheduled` cron expressions or `@webhook` additions only reached
|
|
1333
|
+
* the daemon at restart.
|
|
1334
|
+
*
|
|
1335
|
+
* Deduped by filename via the existing watchDebounce map (~150 ms) so
|
|
1336
|
+
* editors that save-twice don't trigger two scans. Skips files that
|
|
1337
|
+
* aren't `.photon.ts`.
|
|
1338
|
+
*/
|
|
1339
|
+
function watchBaseForProactiveMetadata(basePath, _isDefaultBase) {
|
|
1340
|
+
if (baseDirWatchers.has(basePath))
|
|
1341
|
+
return;
|
|
1342
|
+
try {
|
|
1343
|
+
if (!fs.existsSync(basePath))
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
// Always use the resolved basePath so hot-rescan keys match boot
|
|
1350
|
+
// discovery (see the same pattern in discoverProactiveMetadataAtBoot).
|
|
1351
|
+
// Previously this was `isDefaultBase ? undefined : basePath`, which
|
|
1352
|
+
// produced `-::photon:method` keys on edit and left the original
|
|
1353
|
+
// `<defaultBase>::photon:method` declaration stale — schedule changes
|
|
1354
|
+
// in the default base were ignored until daemon restart.
|
|
1355
|
+
const workingDir = basePath;
|
|
1356
|
+
const onChange = (filename) => {
|
|
1357
|
+
if (!filename || !filename.endsWith('.photon.ts'))
|
|
1358
|
+
return;
|
|
1359
|
+
// Only watch files at the base root for now. Namespace subdirs get
|
|
1360
|
+
// picked up the next time the user runs a photon from there (the
|
|
1361
|
+
// normal registration flow touches the bases registry).
|
|
1362
|
+
const debounceKey = `base:${basePath}:${filename}`;
|
|
1363
|
+
const existing = watchDebounce.get(debounceKey);
|
|
1364
|
+
if (existing)
|
|
1365
|
+
clearTimeout(existing);
|
|
1366
|
+
const timer = setTimeout(() => {
|
|
1367
|
+
void (async () => {
|
|
1368
|
+
const currentTimer = watchDebounce.get(debounceKey);
|
|
1369
|
+
if (currentTimer === timer)
|
|
1370
|
+
watchDebounce.delete(debounceKey);
|
|
1371
|
+
const photonName = filename.replace(/\.photon\.ts$/, '');
|
|
1372
|
+
const filePath = path.join(basePath, filename);
|
|
685
1373
|
try {
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const lastActivity = Math.max(lastExec, created);
|
|
701
|
-
if (lastActivity > 0 && Date.now() - lastActivity > ttlMs) {
|
|
702
|
-
logger.info('Removing expired schedule (TTL)', {
|
|
703
|
-
jobId: task.id,
|
|
704
|
-
lastActivity: new Date(lastActivity).toISOString(),
|
|
1374
|
+
const changed = await scanOneForProactiveMetadata(photonName, filePath, workingDir);
|
|
1375
|
+
if (changed) {
|
|
1376
|
+
// Re-sync timers so cron edits take effect immediately.
|
|
1377
|
+
syncActiveSchedulesAtBoot();
|
|
1378
|
+
publishToChannel('system:*', {
|
|
1379
|
+
event: 'schedules-changed',
|
|
1380
|
+
photon: photonName,
|
|
1381
|
+
base: basePath,
|
|
1382
|
+
timestamp: Date.now(),
|
|
1383
|
+
});
|
|
1384
|
+
logger.info('Proactive metadata updated', {
|
|
1385
|
+
photon: photonName,
|
|
1386
|
+
base: basePath,
|
|
1387
|
+
path: filePath,
|
|
705
1388
|
});
|
|
706
|
-
try {
|
|
707
|
-
fs.unlinkSync(filePath);
|
|
708
|
-
}
|
|
709
|
-
catch {
|
|
710
|
-
/* ignore */
|
|
711
|
-
}
|
|
712
|
-
skippedCount++;
|
|
713
|
-
continue;
|
|
714
|
-
}
|
|
715
|
-
// Skip if already registered (ScheduleProvider may have loaded it)
|
|
716
|
-
if (scheduledJobs.has(task.id))
|
|
717
|
-
continue;
|
|
718
|
-
const job = {
|
|
719
|
-
id: task.id,
|
|
720
|
-
method: task.method,
|
|
721
|
-
args: task.args || {},
|
|
722
|
-
cron: task.cron,
|
|
723
|
-
runCount: task.executionCount || 0,
|
|
724
|
-
createdAt: created || Date.now(),
|
|
725
|
-
createdBy: task.createdBy,
|
|
726
|
-
photonName: task.photonName,
|
|
727
|
-
workingDir: task.workingDir,
|
|
728
|
-
};
|
|
729
|
-
if (scheduleJob(job)) {
|
|
730
|
-
loadedCount++;
|
|
731
|
-
}
|
|
732
|
-
else {
|
|
733
|
-
logger.warn('Failed to schedule persisted job (invalid cron?)', { jobId: task.id });
|
|
734
|
-
skippedCount++;
|
|
735
1389
|
}
|
|
736
1390
|
}
|
|
737
1391
|
catch (err) {
|
|
738
|
-
logger.warn('
|
|
739
|
-
|
|
1392
|
+
logger.warn('Live rescan failed', {
|
|
1393
|
+
photon: photonName,
|
|
1394
|
+
base: basePath,
|
|
740
1395
|
error: getErrorMessage(err),
|
|
741
1396
|
});
|
|
742
|
-
skippedCount++;
|
|
743
1397
|
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
1398
|
+
})();
|
|
1399
|
+
}, 150);
|
|
1400
|
+
timer.unref();
|
|
1401
|
+
watchDebounce.set(debounceKey, timer);
|
|
1402
|
+
};
|
|
1403
|
+
try {
|
|
1404
|
+
const watcher = safeWatchDir(basePath, (_eventType, filename) => onChange(filename));
|
|
1405
|
+
baseDirWatchers.set(basePath, watcher);
|
|
746
1406
|
}
|
|
747
1407
|
catch (err) {
|
|
748
|
-
logger.
|
|
749
|
-
|
|
1408
|
+
logger.debug('Could not watch base for proactive metadata', {
|
|
1409
|
+
base: basePath,
|
|
750
1410
|
error: getErrorMessage(err),
|
|
751
1411
|
});
|
|
752
1412
|
}
|
|
753
|
-
|
|
754
|
-
|
|
1413
|
+
}
|
|
1414
|
+
/** Install watchers on every registered base. Safe to call multiple times. */
|
|
1415
|
+
function startBaseWatchers() {
|
|
1416
|
+
const defaultBase = path.resolve(getDefaultContext().baseDir);
|
|
1417
|
+
watchBaseForProactiveMetadata(defaultBase, true);
|
|
1418
|
+
for (const b of listActiveBases()) {
|
|
1419
|
+
watchBaseForProactiveMetadata(b.path, path.resolve(b.path) === defaultBase);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Sync declared schedules against each base's active-schedules file.
|
|
1424
|
+
*
|
|
1425
|
+
* Two-step scheduling: declaring `@scheduled` doesn't activate. The user
|
|
1426
|
+
* must explicitly enroll a declaration via `photon ps enable`, which
|
|
1427
|
+
* writes to `{PHOTON_DIR}/.data/.active-schedules.json`. On boot we read
|
|
1428
|
+
* each base's active list and register cron timers only for those
|
|
1429
|
+
* entries.
|
|
1430
|
+
*
|
|
1431
|
+
* First-boot migration: for bases that have never gone through this
|
|
1432
|
+
* sync, every currently-declared `@scheduled` gets auto-enrolled so
|
|
1433
|
+
* existing behavior is preserved across the upgrade. The active file
|
|
1434
|
+
* records `migratedFromAutoRegister: true` so the auto-enroll is a
|
|
1435
|
+
* one-time event — subsequent boots respect user-chosen enrollment.
|
|
1436
|
+
*/
|
|
1437
|
+
function syncActiveSchedulesAtBoot() {
|
|
1438
|
+
const defaultBase = path.resolve(getDefaultContext().baseDir);
|
|
1439
|
+
const bases = new Set([defaultBase]);
|
|
1440
|
+
for (const b of listActiveBases())
|
|
1441
|
+
bases.add(b.path);
|
|
1442
|
+
let registered = 0;
|
|
1443
|
+
let missingRefs = 0;
|
|
1444
|
+
for (const basePath of bases) {
|
|
1445
|
+
const file = readActiveSchedulesFile(basePath);
|
|
1446
|
+
let dirty = false;
|
|
1447
|
+
// One-time migration: seed the active list from the current
|
|
1448
|
+
// declarations for this base so upgrade doesn't silently disable
|
|
1449
|
+
// previously-auto-registered schedules.
|
|
1450
|
+
if (!file.migratedFromAutoRegister) {
|
|
1451
|
+
const now = new Date().toISOString();
|
|
1452
|
+
for (const decl of declaredSchedules.values()) {
|
|
1453
|
+
const declBase = decl.workingDir ? path.resolve(decl.workingDir) : defaultBase;
|
|
1454
|
+
if (declBase !== basePath)
|
|
1455
|
+
continue;
|
|
1456
|
+
const alreadyActive = file.active.some((e) => e.photon === decl.photon && e.method === decl.method);
|
|
1457
|
+
if (!alreadyActive) {
|
|
1458
|
+
file.active.push({
|
|
1459
|
+
photon: decl.photon,
|
|
1460
|
+
method: decl.method,
|
|
1461
|
+
enabledAt: now,
|
|
1462
|
+
enabledBy: 'auto-migrate',
|
|
1463
|
+
});
|
|
1464
|
+
dirty = true;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
file.migratedFromAutoRegister = true;
|
|
1468
|
+
dirty = true;
|
|
1469
|
+
}
|
|
1470
|
+
if (dirty) {
|
|
1471
|
+
try {
|
|
1472
|
+
writeActiveSchedulesFile(basePath, file);
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
logger.warn('Could not write active-schedules file', {
|
|
1476
|
+
base: basePath,
|
|
1477
|
+
error: getErrorMessage(err),
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
// Register timers for active (non-paused) entries. Keys are scoped to
|
|
1482
|
+
// basePath so two PHOTON_DIRs with the same photon:method don't collide.
|
|
1483
|
+
for (const entry of file.active) {
|
|
1484
|
+
if (entry.paused)
|
|
1485
|
+
continue;
|
|
1486
|
+
const key = declaredKey(entry.photon, entry.method, basePath);
|
|
1487
|
+
const decl = declaredSchedules.get(key);
|
|
1488
|
+
if (!decl) {
|
|
1489
|
+
missingRefs++;
|
|
1490
|
+
logger.warn('Active schedule references a declaration that no longer exists', {
|
|
1491
|
+
base: basePath,
|
|
1492
|
+
photon: entry.photon,
|
|
1493
|
+
method: entry.method,
|
|
1494
|
+
hint: '`photon ps disable` to remove or add the @scheduled tag back',
|
|
1495
|
+
});
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (scheduledJobs.has(key))
|
|
1499
|
+
continue;
|
|
1500
|
+
const ok = scheduleJob({
|
|
1501
|
+
id: key,
|
|
1502
|
+
method: decl.method,
|
|
1503
|
+
args: {},
|
|
1504
|
+
cron: decl.cron,
|
|
1505
|
+
runCount: 0,
|
|
1506
|
+
createdAt: Date.now(),
|
|
1507
|
+
createdBy: 'active-list',
|
|
1508
|
+
photonName: decl.photon,
|
|
1509
|
+
workingDir: decl.workingDir,
|
|
1510
|
+
photonPath: decl.photonPath,
|
|
1511
|
+
});
|
|
1512
|
+
if (ok)
|
|
1513
|
+
registered++;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (registered > 0 || missingRefs > 0) {
|
|
1517
|
+
logger.info('Active schedules synced', {
|
|
1518
|
+
registered,
|
|
1519
|
+
declared: declaredSchedules.size,
|
|
1520
|
+
missingRefs,
|
|
1521
|
+
});
|
|
755
1522
|
}
|
|
756
1523
|
}
|
|
757
1524
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -759,8 +1526,16 @@ function loadAllPersistedSchedules() {
|
|
|
759
1526
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
760
1527
|
let webhookServer = null;
|
|
761
1528
|
const WEBHOOK_PORT = parseInt(process.env.PHOTON_WEBHOOK_PORT || '0');
|
|
762
|
-
// Security: rate limiter for webhook endpoint
|
|
763
|
-
|
|
1529
|
+
// Security: rate limiter for webhook endpoint. Default 60/min per source IP;
|
|
1530
|
+
// override via PHOTON_WEBHOOK_RATE_LIMIT / PHOTON_WEBHOOK_RATE_WINDOW_MS.
|
|
1531
|
+
const WEBHOOK_RATE_LIMIT = Math.max(1, parseInt(process.env.PHOTON_WEBHOOK_RATE_LIMIT || '60', 10) || 60);
|
|
1532
|
+
const WEBHOOK_RATE_WINDOW_MS = Math.max(1_000, parseInt(process.env.PHOTON_WEBHOOK_RATE_WINDOW_MS || '60000', 10) || 60_000);
|
|
1533
|
+
const webhookRateLimiter = new SimpleRateLimiter(WEBHOOK_RATE_LIMIT, WEBHOOK_RATE_WINDOW_MS);
|
|
1534
|
+
// Optional per-source IP allowlist. When PHOTON_WEBHOOK_ALLOWED_IPS is
|
|
1535
|
+
// set, requests from any address outside the listed CIDRs (or exact
|
|
1536
|
+
// IPv6 literals) are rejected before any auth work. Empty env → allow
|
|
1537
|
+
// all, keeping the default unchanged.
|
|
1538
|
+
const WEBHOOK_ALLOWED_IPS = parseAllowlistEnv(process.env.PHOTON_WEBHOOK_ALLOWED_IPS);
|
|
764
1539
|
function startWebhookServer(port) {
|
|
765
1540
|
if (port <= 0)
|
|
766
1541
|
return;
|
|
@@ -774,11 +1549,29 @@ function startWebhookServer(port) {
|
|
|
774
1549
|
res.end();
|
|
775
1550
|
return;
|
|
776
1551
|
}
|
|
777
|
-
// Security: rate limiting
|
|
1552
|
+
// Security: CIDR allowlist (runs before rate limiting so blocked
|
|
1553
|
+
// addresses don't consume the per-IP budget).
|
|
778
1554
|
const clientKey = req.socket?.remoteAddress || 'unknown';
|
|
1555
|
+
if (WEBHOOK_ALLOWED_IPS.length > 0 && !ipInAllowlist(clientKey, WEBHOOK_ALLOWED_IPS)) {
|
|
1556
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1557
|
+
res.end(JSON.stringify({
|
|
1558
|
+
error: 'Source IP not in webhook allowlist',
|
|
1559
|
+
sourceIp: clientKey,
|
|
1560
|
+
}));
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
// Security: rate limiting
|
|
779
1564
|
if (!webhookRateLimiter.isAllowed(clientKey)) {
|
|
780
|
-
|
|
781
|
-
res.
|
|
1565
|
+
const retryAfter = Math.ceil(WEBHOOK_RATE_WINDOW_MS / 1000);
|
|
1566
|
+
res.writeHead(429, {
|
|
1567
|
+
'Content-Type': 'application/json',
|
|
1568
|
+
'Retry-After': String(retryAfter),
|
|
1569
|
+
});
|
|
1570
|
+
res.end(JSON.stringify({
|
|
1571
|
+
error: 'Too many requests',
|
|
1572
|
+
limit: WEBHOOK_RATE_LIMIT,
|
|
1573
|
+
windowMs: WEBHOOK_RATE_WINDOW_MS,
|
|
1574
|
+
}));
|
|
782
1575
|
return;
|
|
783
1576
|
}
|
|
784
1577
|
// Parse URL: /webhook/{photonName}/{method}
|
|
@@ -829,29 +1622,75 @@ function startWebhookServer(port) {
|
|
|
829
1622
|
res.end(JSON.stringify({ error: status === 413 ? 'Request body too large' : 'Invalid JSON body' }));
|
|
830
1623
|
return;
|
|
831
1624
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
// Resolve method from webhook route map (if routes are registered)
|
|
1625
|
+
// Resolve method from webhook route map BEFORE lazy-load. In
|
|
1626
|
+
// multi-base setups the same photon name may be registered in
|
|
1627
|
+
// several PHOTON_DIRs; search every matching entry for one that
|
|
1628
|
+
// actually exposes this route rather than picking an arbitrary
|
|
1629
|
+
// first entry (which would 404 routes that only exist in a
|
|
1630
|
+
// different base's copy).
|
|
839
1631
|
let resolvedMethod = method;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1632
|
+
let resolvingEntry;
|
|
1633
|
+
const webhookEntries = findWebhookEntriesFor(photonName);
|
|
1634
|
+
const registeredEntries = webhookEntries.filter((e) => e.routes.size > 0);
|
|
1635
|
+
if (registeredEntries.length > 0) {
|
|
1636
|
+
const matchingEntries = registeredEntries.filter((e) => e.routes.has(method));
|
|
1637
|
+
if (matchingEntries.length === 0) {
|
|
1638
|
+
const allRoutes = new Set();
|
|
1639
|
+
for (const e of registeredEntries)
|
|
1640
|
+
for (const k of e.routes.keys())
|
|
1641
|
+
allRoutes.add(k);
|
|
845
1642
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
846
1643
|
res.end(JSON.stringify({
|
|
847
1644
|
error: `No webhook route '${method}' on photon '${photonName}'`,
|
|
848
|
-
availableRoutes: [...
|
|
1645
|
+
availableRoutes: [...allRoutes],
|
|
849
1646
|
}));
|
|
850
1647
|
return;
|
|
851
1648
|
}
|
|
852
|
-
|
|
1649
|
+
if (matchingEntries.length > 1) {
|
|
1650
|
+
logger.warn('Webhook route exists in multiple PHOTON_DIRs; routing to first match', {
|
|
1651
|
+
photon: photonName,
|
|
1652
|
+
method,
|
|
1653
|
+
bases: matchingEntries.map((e) => e.workingDir ?? '-'),
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
resolvingEntry = matchingEntries[0];
|
|
1657
|
+
resolvedMethod = resolvingEntry.routes.get(method);
|
|
1658
|
+
}
|
|
1659
|
+
// Use the resolving entry's workingDir to pick the right file
|
|
1660
|
+
// location (proactivePhotonLocations is keyed by base+photon).
|
|
1661
|
+
const webhookBase = resolvingEntry?.workingDir;
|
|
1662
|
+
// Scope sessionManagers lookup by (photon, webhookBase) so a webhook
|
|
1663
|
+
// for base B doesn't reuse an existing base-A session when the two
|
|
1664
|
+
// PHOTON_DIRs share a photon name.
|
|
1665
|
+
const sessionKey = compositeKey(photonName, webhookBase);
|
|
1666
|
+
let sessionManager = sessionManagers.get(sessionKey);
|
|
1667
|
+
if (!sessionManager) {
|
|
1668
|
+
const candidates = findLocationsFor(photonName);
|
|
1669
|
+
const loc = (webhookBase && candidates.find((c) => c.workingDir === webhookBase)) ?? candidates[0];
|
|
1670
|
+
if (loc) {
|
|
1671
|
+
try {
|
|
1672
|
+
sessionManager =
|
|
1673
|
+
(await getOrCreateSessionManager(photonName, loc.photonPath, loc.workingDir)) ??
|
|
1674
|
+
undefined;
|
|
1675
|
+
}
|
|
1676
|
+
catch (err) {
|
|
1677
|
+
logger.warn('Lazy-load for webhook request failed', {
|
|
1678
|
+
photon: photonName,
|
|
1679
|
+
error: getErrorMessage(err),
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
853
1683
|
}
|
|
854
|
-
|
|
1684
|
+
if (!sessionManager) {
|
|
1685
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1686
|
+
res.end(JSON.stringify({ error: `Photon '${photonName}' not initialized` }));
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
// Scope the in-flight tracker to the same base the session manager
|
|
1690
|
+
// is keyed by. Without webhookBase here, drain/reload logic waiting
|
|
1691
|
+
// on the base-scoped key wouldn't see this execution and could
|
|
1692
|
+
// hot-reload the photon mid-webhook.
|
|
1693
|
+
const webhookKey = compositeKey(photonName, webhookBase);
|
|
855
1694
|
trackExecution(webhookKey);
|
|
856
1695
|
try {
|
|
857
1696
|
const session = await sessionManager.getOrCreateSession('webhook', 'webhook');
|
|
@@ -971,13 +1810,55 @@ function publishToChannel(channel, message, excludeSocket) {
|
|
|
971
1810
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
972
1811
|
async function getOrCreateSessionManager(photonName, photonPath, workingDir) {
|
|
973
1812
|
const key = compositeKey(photonName, workingDir);
|
|
974
|
-
let manager =
|
|
975
|
-
|
|
976
|
-
|
|
1813
|
+
let manager = null;
|
|
1814
|
+
// Marketplace → global walk for the session manager. Name resolution is
|
|
1815
|
+
// always caller's marketplace first, then `~/.photon`. This is what makes
|
|
1816
|
+
// `this.call('peer.method')` from within one marketplace reach a peer
|
|
1817
|
+
// that only lives in the global install — same rule the outer CLI uses.
|
|
1818
|
+
const defaultBase = getDefaultContext().baseDir;
|
|
1819
|
+
const resolved = resolveWithGlobalFallback(photonName, workingDir, defaultBase, (k) => sessionManagers.get(k));
|
|
1820
|
+
if (resolved) {
|
|
1821
|
+
if (resolved.key !== key) {
|
|
1822
|
+
logger.debug('Session manager resolved via global fallback', {
|
|
1823
|
+
photonName,
|
|
1824
|
+
callerBase: workingDir,
|
|
1825
|
+
resolvedKey: resolved.key,
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
return resolved.value;
|
|
1829
|
+
}
|
|
1830
|
+
// Need photonPath to initialize. Walk the path hint the same way — a
|
|
1831
|
+
// globally-registered path serves a caller from any marketplace.
|
|
1832
|
+
const resolvedPath = resolveWithGlobalFallback(photonName, workingDir, defaultBase, (k) => photonPaths.get(k));
|
|
1833
|
+
const storedPath = resolvedPath?.value;
|
|
1834
|
+
let pathToUse = photonPath || storedPath;
|
|
1835
|
+
// Disk fallback. When the in-memory maps miss (peer wasn't pre-loaded
|
|
1836
|
+
// or auto-registered), walk the filesystem the same way the outer CLI
|
|
1837
|
+
// does: caller's marketplace first, then the daemon's default base.
|
|
1838
|
+
// This closes the same-marketplace `this.call('peer.method')` gap where
|
|
1839
|
+
// the caller and the peer live side-by-side on disk but the peer was
|
|
1840
|
+
// never loaded by anyone, so neither in-memory map had it.
|
|
1841
|
+
if (!pathToUse) {
|
|
1842
|
+
if (workingDir) {
|
|
1843
|
+
const localResolved = await resolvePhotonPath(photonName, workingDir);
|
|
1844
|
+
if (localResolved)
|
|
1845
|
+
pathToUse = localResolved;
|
|
1846
|
+
}
|
|
1847
|
+
if (!pathToUse && defaultBase && defaultBase !== workingDir) {
|
|
1848
|
+
const globalResolved = await resolvePhotonPath(photonName, defaultBase);
|
|
1849
|
+
if (globalResolved)
|
|
1850
|
+
pathToUse = globalResolved;
|
|
1851
|
+
}
|
|
1852
|
+
if (pathToUse) {
|
|
1853
|
+
// Cache at the caller's composite key so the next call hits fast.
|
|
1854
|
+
photonPaths.set(key, pathToUse);
|
|
1855
|
+
logger.info('Peer photon resolved via disk fallback', {
|
|
1856
|
+
photonName,
|
|
1857
|
+
callerBase: workingDir,
|
|
1858
|
+
resolvedPath: pathToUse,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
977
1861
|
}
|
|
978
|
-
// Need photonPath to initialize
|
|
979
|
-
const storedPath = photonPaths.get(key);
|
|
980
|
-
const pathToUse = photonPath || storedPath;
|
|
981
1862
|
if (!pathToUse) {
|
|
982
1863
|
logger.warn('Cannot initialize photon - no path provided', { photonName, key });
|
|
983
1864
|
return null;
|
|
@@ -1046,8 +1927,14 @@ async function getOrCreateSessionManager(photonName, photonPath, workingDir) {
|
|
|
1046
1927
|
logger.info('Spawning worker thread for @worker photon', { photonName, key });
|
|
1047
1928
|
const info = await workerManager.spawn(key, photonName, pathToUse, workingDir);
|
|
1048
1929
|
photonPaths.set(key, pathToUse);
|
|
1049
|
-
|
|
1050
|
-
|
|
1930
|
+
// Also mirror under the bare photon name so legacy helpers
|
|
1931
|
+
// (snapshotState, persistInstanceState) without a workingDir still
|
|
1932
|
+
// resolve in the default-base case. `compositeKey(name, undefined)`
|
|
1933
|
+
// collapses to the bare name and is branded, so this round-trips
|
|
1934
|
+
// through the type system without a raw cast.
|
|
1935
|
+
const legacyKey = compositeKey(photonName);
|
|
1936
|
+
if (!photonPaths.has(legacyKey))
|
|
1937
|
+
photonPaths.set(legacyKey, pathToUse);
|
|
1051
1938
|
if (workingDir)
|
|
1052
1939
|
workingDirs.set(key, workingDir);
|
|
1053
1940
|
watchPhotonFile(photonName, pathToUse);
|
|
@@ -1153,9 +2040,25 @@ async function getOrCreateSessionManager(photonName, photonPath, workingDir) {
|
|
|
1153
2040
|
};
|
|
1154
2041
|
sessionManagers.set(key, manager);
|
|
1155
2042
|
photonPaths.set(key, pathToUse);
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1158
|
-
|
|
2043
|
+
// Legacy callers without a workingDir (snapshotState, persistInstanceState)
|
|
2044
|
+
// reach this map via bare photon name. compositeKey(name, undefined)
|
|
2045
|
+
// collapses to that bare name for the default base and is branded, so
|
|
2046
|
+
// the mirror entry preserves the legacy lookup path type-safely.
|
|
2047
|
+
const legacyKey = compositeKey(photonName);
|
|
2048
|
+
if (!photonPaths.has(legacyKey)) {
|
|
2049
|
+
photonPaths.set(legacyKey, pathToUse);
|
|
2050
|
+
}
|
|
2051
|
+
// Baseline the stat-gate for this session so subsequent dispatches can
|
|
2052
|
+
// detect source edits the file watcher hasn't processed yet.
|
|
2053
|
+
recordPhotonSourceStat(key, pathToUse);
|
|
2054
|
+
// Record this PHOTON_DIR in the bases registry so future daemon startups
|
|
2055
|
+
// can scan it for schedules and other per-base data. See
|
|
2056
|
+
// docs/internals/PHOTON-DIR-AND-NAMESPACE.md §8.
|
|
2057
|
+
try {
|
|
2058
|
+
touchBase(workingDir || getDefaultContext().baseDir);
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
logger.warn('Failed to update bases registry', { error: getErrorMessage(err) });
|
|
1159
2062
|
}
|
|
1160
2063
|
if (workingDir) {
|
|
1161
2064
|
workingDirs.set(key, workingDir);
|
|
@@ -1179,24 +2082,117 @@ async function getOrCreateSessionManager(photonName, photonPath, workingDir) {
|
|
|
1179
2082
|
}
|
|
1180
2083
|
// Track which photons have had their metadata auto-registered
|
|
1181
2084
|
const autoRegistered = new Set();
|
|
1182
|
-
//
|
|
2085
|
+
// Typed with WebhookRouteKey so bare-string .get() no longer compiles —
|
|
2086
|
+
// every access must go through webhookKey(photon, workingDir) or
|
|
2087
|
+
// findByPhoton(webhookRoutes, photon). This is the compile-time
|
|
2088
|
+
// guardrail against the cascade that hit us post-v1.22.1.
|
|
1183
2089
|
const webhookRoutes = new Map();
|
|
2090
|
+
/** Composite key for the webhookRoutes map. Delegates to registry-keys.ts. */
|
|
2091
|
+
const webhookKey = _webhookKey;
|
|
2092
|
+
/** Find every webhook entry registered under `photonName` (any base). */
|
|
2093
|
+
function findWebhookEntriesFor(photonName) {
|
|
2094
|
+
return findByPhoton(webhookRoutes, photonName);
|
|
2095
|
+
}
|
|
2096
|
+
// Keyed by `<base>::<photonName>` so multi-base discovery doesn't clobber:
|
|
2097
|
+
// two PHOTON_DIRs can host a photon with the same name and the webhook
|
|
2098
|
+
// server must be able to lazy-load each one from its own file.
|
|
2099
|
+
const proactivePhotonLocations = new Map();
|
|
2100
|
+
const locationKey = _locationKey;
|
|
2101
|
+
/**
|
|
2102
|
+
* Find every proactive-location entry registered for this photon name.
|
|
2103
|
+
* The HTTP webhook handler uses this plus the resolving webhook entry's
|
|
2104
|
+
* base to pick the right file.
|
|
2105
|
+
*/
|
|
2106
|
+
function findLocationsFor(photonName) {
|
|
2107
|
+
return findByPhoton(proactivePhotonLocations, photonName);
|
|
2108
|
+
}
|
|
2109
|
+
const declaredSchedules = new Map();
|
|
2110
|
+
/**
|
|
2111
|
+
* Identity key for declared schedules and scheduled jobs. Delegates to
|
|
2112
|
+
* registry-keys.ts — see that module for the key-shape rationale.
|
|
2113
|
+
*/
|
|
2114
|
+
const declaredKey = _declaredKey;
|
|
2115
|
+
/**
|
|
2116
|
+
* Scan declaredSchedules for the declaration matching (photon, method).
|
|
2117
|
+
* When an IPC caller doesn't know which PHOTON_DIR owns the schedule (the CLI
|
|
2118
|
+
* hits a per-user daemon but the user may have multiple bases), search all
|
|
2119
|
+
* declarations. If `preferredBase` is supplied, prefer that match. If multiple
|
|
2120
|
+
* declarations match across bases, return them so the caller can surface an
|
|
2121
|
+
* ambiguity error instead of silently picking one.
|
|
2122
|
+
*/
|
|
2123
|
+
function findDeclarationsFor(photon, method, preferredBase) {
|
|
2124
|
+
const matches = [];
|
|
2125
|
+
const preferredResolved = preferredBase ? path.resolve(preferredBase) : undefined;
|
|
2126
|
+
for (const [key, decl] of declaredSchedules.entries()) {
|
|
2127
|
+
if (decl.photon !== photon || decl.method !== method)
|
|
2128
|
+
continue;
|
|
2129
|
+
const declBase = decl.workingDir ? path.resolve(decl.workingDir) : undefined;
|
|
2130
|
+
if (preferredResolved && declBase && declBase !== preferredResolved)
|
|
2131
|
+
continue;
|
|
2132
|
+
matches.push({ key, decl });
|
|
2133
|
+
}
|
|
2134
|
+
return matches;
|
|
2135
|
+
}
|
|
2136
|
+
function activeSchedulesPath(baseDir) {
|
|
2137
|
+
return path.join(baseDir, '.data', '.active-schedules.json');
|
|
2138
|
+
}
|
|
2139
|
+
function readActiveSchedulesFile(baseDir) {
|
|
2140
|
+
const file = activeSchedulesPath(baseDir);
|
|
2141
|
+
try {
|
|
2142
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
2143
|
+
const parsed = JSON.parse(raw);
|
|
2144
|
+
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.active)) {
|
|
2145
|
+
return {
|
|
2146
|
+
version: 1,
|
|
2147
|
+
active: parsed.active.filter((e) => e &&
|
|
2148
|
+
typeof e.photon === 'string' &&
|
|
2149
|
+
typeof e.method === 'string' &&
|
|
2150
|
+
typeof e.enabledAt === 'string'),
|
|
2151
|
+
migratedFromAutoRegister: !!parsed.migratedFromAutoRegister,
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
catch {
|
|
2156
|
+
// Missing or malformed — treated as empty.
|
|
2157
|
+
}
|
|
2158
|
+
return { version: 1, active: [] };
|
|
2159
|
+
}
|
|
2160
|
+
function writeActiveSchedulesFile(baseDir, data) {
|
|
2161
|
+
const file = activeSchedulesPath(baseDir);
|
|
2162
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2163
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
2164
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...data, version: 1 }, null, 2));
|
|
2165
|
+
fs.renameSync(tmp, file);
|
|
2166
|
+
}
|
|
1184
2167
|
/**
|
|
1185
2168
|
* Auto-register scheduled jobs and webhook routes from tool metadata.
|
|
1186
2169
|
* Called once per photon on first session manager creation.
|
|
1187
2170
|
*/
|
|
1188
2171
|
async function autoRegisterFromMetadata(photonName, manager) {
|
|
1189
|
-
|
|
2172
|
+
// Key by (photon, base) so loading photon foo from base B doesn't skip
|
|
2173
|
+
// auto-registration just because foo from base A already registered.
|
|
2174
|
+
// Without this, the second copy silently loses its @scheduled and
|
|
2175
|
+
// @webhook metadata until daemon restart.
|
|
2176
|
+
const autoKey = compositeKey(photonName, manager.loader?.baseDir);
|
|
2177
|
+
if (autoRegistered.has(autoKey))
|
|
1190
2178
|
return;
|
|
1191
|
-
autoRegistered.add(
|
|
2179
|
+
autoRegistered.add(autoKey);
|
|
1192
2180
|
try {
|
|
1193
2181
|
// Get a session to access the loaded photon's tools
|
|
1194
2182
|
const session = await manager.getOrCreateSession('__autoregister', 'system');
|
|
1195
2183
|
const tools = session.instance?.tools || [];
|
|
1196
|
-
// Auto-register @scheduled jobs
|
|
2184
|
+
// Auto-register @scheduled jobs. Use the same base-scoped key format as
|
|
2185
|
+
// declaredKey so a photon enabled via `photon ps enable` doesn't end up
|
|
2186
|
+
// running twice — once under the legacy `${photon}:${method}` ID and
|
|
2187
|
+
// once under the new `<base>::${photon}:${method}` ID.
|
|
2188
|
+
//
|
|
2189
|
+
// SessionManager.getOrCreateSession() doesn't attach workingDir to the
|
|
2190
|
+
// session object, so read it from the underlying loader — the same
|
|
2191
|
+
// source of truth used for the per-base schedules dir below.
|
|
2192
|
+
const sessionWorkingDir = manager.loader?.baseDir;
|
|
1197
2193
|
for (const tool of tools) {
|
|
1198
2194
|
if (tool.scheduled) {
|
|
1199
|
-
const jobId =
|
|
2195
|
+
const jobId = declaredKey(photonName, tool.name, sessionWorkingDir);
|
|
1200
2196
|
if (!scheduledJobs.has(jobId)) {
|
|
1201
2197
|
const job = {
|
|
1202
2198
|
id: jobId,
|
|
@@ -1207,6 +2203,7 @@ async function autoRegisterFromMetadata(photonName, manager) {
|
|
|
1207
2203
|
createdAt: Date.now(),
|
|
1208
2204
|
createdBy: 'auto',
|
|
1209
2205
|
photonName,
|
|
2206
|
+
workingDir: sessionWorkingDir,
|
|
1210
2207
|
};
|
|
1211
2208
|
const ok = scheduleJob(job);
|
|
1212
2209
|
if (ok) {
|
|
@@ -1219,30 +2216,52 @@ async function autoRegisterFromMetadata(photonName, manager) {
|
|
|
1219
2216
|
}
|
|
1220
2217
|
}
|
|
1221
2218
|
}
|
|
1222
|
-
// Build webhook route map
|
|
2219
|
+
// Build webhook route map using the base-scoped key so two
|
|
2220
|
+
// PHOTON_DIRs with the same photon name don't clobber each other.
|
|
1223
2221
|
if (tool.webhook !== undefined) {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
2222
|
+
const wKey = webhookKey(photonName, sessionWorkingDir);
|
|
2223
|
+
let entry = webhookRoutes.get(wKey);
|
|
2224
|
+
if (!entry) {
|
|
2225
|
+
entry = { photon: photonName, routes: new Map(), workingDir: sessionWorkingDir };
|
|
2226
|
+
webhookRoutes.set(wKey, entry);
|
|
1228
2227
|
}
|
|
1229
2228
|
// Custom path from @webhook <path>, or method name
|
|
1230
2229
|
const routePath = typeof tool.webhook === 'string' ? tool.webhook : tool.name;
|
|
1231
|
-
routes.set(routePath, tool.name);
|
|
2230
|
+
entry.routes.set(routePath, tool.name);
|
|
1232
2231
|
}
|
|
1233
2232
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
2233
|
+
const autoKey = webhookKey(photonName, sessionWorkingDir);
|
|
2234
|
+
if (webhookRoutes.has(autoKey)) {
|
|
2235
|
+
const routes = webhookRoutes.get(autoKey).routes;
|
|
1236
2236
|
logger.info('Auto-registered webhook routes from @webhook tags', {
|
|
1237
2237
|
photon: photonName,
|
|
1238
2238
|
routes: [...routes.keys()],
|
|
1239
2239
|
});
|
|
1240
2240
|
}
|
|
1241
|
-
// Load persisted schedule files
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
2241
|
+
// Load persisted schedule files created by this.schedule.create() at
|
|
2242
|
+
// runtime. Scan the new per-base location first, then the legacy
|
|
2243
|
+
// ~/.photon/schedules/ location for backwards compatibility.
|
|
2244
|
+
const workingDir = manager.loader?.baseDir;
|
|
2245
|
+
const schedulesDirs = [
|
|
2246
|
+
resolveScheduleDir(photonName, workingDir),
|
|
2247
|
+
resolveLegacyScheduleDir(photonName),
|
|
2248
|
+
];
|
|
2249
|
+
for (const schedulesDir of schedulesDirs) {
|
|
2250
|
+
let scheduleFiles;
|
|
2251
|
+
try {
|
|
2252
|
+
scheduleFiles = fs.readdirSync(schedulesDir).filter((f) => f.endsWith('.json'));
|
|
2253
|
+
}
|
|
2254
|
+
catch (err) {
|
|
2255
|
+
// ENOENT is fine — no schedules dir means no persisted schedules here
|
|
2256
|
+
if (err.code !== 'ENOENT') {
|
|
2257
|
+
logger.warn('Failed to load persisted schedules', {
|
|
2258
|
+
photon: photonName,
|
|
2259
|
+
dir: schedulesDir,
|
|
2260
|
+
error: getErrorMessage(err),
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
1246
2265
|
for (const file of scheduleFiles) {
|
|
1247
2266
|
try {
|
|
1248
2267
|
const content = fs.readFileSync(path.join(schedulesDir, file), 'utf-8');
|
|
@@ -1250,7 +2269,7 @@ async function autoRegisterFromMetadata(photonName, manager) {
|
|
|
1250
2269
|
if (task.status !== 'active')
|
|
1251
2270
|
continue;
|
|
1252
2271
|
const jobId = `${photonName}:sched:${task.id}`;
|
|
1253
|
-
if (scheduledJobs.has(jobId))
|
|
2272
|
+
if (scheduledJobs.has(asScheduleKey(jobId)))
|
|
1254
2273
|
continue;
|
|
1255
2274
|
const job = {
|
|
1256
2275
|
id: jobId,
|
|
@@ -1278,15 +2297,6 @@ async function autoRegisterFromMetadata(photonName, manager) {
|
|
|
1278
2297
|
}
|
|
1279
2298
|
}
|
|
1280
2299
|
}
|
|
1281
|
-
catch (err) {
|
|
1282
|
-
// ENOENT is fine — no schedules dir means no persisted schedules
|
|
1283
|
-
if (err.code !== 'ENOENT') {
|
|
1284
|
-
logger.warn('Failed to load persisted schedules', {
|
|
1285
|
-
photon: photonName,
|
|
1286
|
-
error: getErrorMessage(err),
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
2300
|
}
|
|
1291
2301
|
catch (error) {
|
|
1292
2302
|
logger.warn('Failed to auto-register metadata', {
|
|
@@ -1645,7 +2655,7 @@ async function handleRequest(request, socket) {
|
|
|
1645
2655
|
const ipcJobId = dirHash
|
|
1646
2656
|
? `${photonName}:${dirHash}:ipc:${request.jobId}`
|
|
1647
2657
|
: `${photonName}:ipc:${request.jobId}`;
|
|
1648
|
-
const existing = scheduledJobs.get(ipcJobId);
|
|
2658
|
+
const existing = scheduledJobs.get(asScheduleKey(ipcJobId));
|
|
1649
2659
|
const job = {
|
|
1650
2660
|
id: ipcJobId,
|
|
1651
2661
|
method: request.method,
|
|
@@ -1672,7 +2682,9 @@ async function handleRequest(request, socket) {
|
|
|
1672
2682
|
}
|
|
1673
2683
|
// Handle job unscheduling
|
|
1674
2684
|
if (request.type === 'unschedule') {
|
|
1675
|
-
|
|
2685
|
+
// IPC input: coerce at the boundary. Job IDs shipped over the protocol
|
|
2686
|
+
// are already ScheduleKey-shaped (<base>::<photon>:<method>).
|
|
2687
|
+
const jobId = asScheduleKey(request.jobId);
|
|
1676
2688
|
// Try exact match first, then look for IPC-prefixed version
|
|
1677
2689
|
let actualJobId = jobId;
|
|
1678
2690
|
if (!scheduledJobs.has(jobId)) {
|
|
@@ -1696,11 +2708,311 @@ async function handleRequest(request, socket) {
|
|
|
1696
2708
|
data: { unscheduled, jobId: actualJobId },
|
|
1697
2709
|
};
|
|
1698
2710
|
}
|
|
1699
|
-
// Handle list jobs
|
|
2711
|
+
// Handle list jobs (legacy shape — just the active cron jobs)
|
|
1700
2712
|
if (request.type === 'list_jobs') {
|
|
1701
2713
|
const jobs = Array.from(scheduledJobs.values());
|
|
1702
2714
|
return { type: 'result', id: request.id, success: true, data: { jobs } };
|
|
1703
2715
|
}
|
|
2716
|
+
// `photon ps` full snapshot: active timers, declared-but-dormant schedules,
|
|
2717
|
+
// webhook routes, loaded sessions. Agent-friendly (structured) and compact.
|
|
2718
|
+
if (request.type === 'ps') {
|
|
2719
|
+
const defaultBase = path.resolve(getDefaultContext().baseDir);
|
|
2720
|
+
const active = Array.from(scheduledJobs.values()).map((j) => ({
|
|
2721
|
+
id: j.id,
|
|
2722
|
+
photon: j.photonName,
|
|
2723
|
+
method: j.method,
|
|
2724
|
+
cron: j.cron,
|
|
2725
|
+
nextRun: j.nextRun ?? null,
|
|
2726
|
+
lastRun: j.lastRun ?? null,
|
|
2727
|
+
runCount: j.runCount,
|
|
2728
|
+
photonPath: j.photonPath,
|
|
2729
|
+
workingDir: j.workingDir ?? defaultBase,
|
|
2730
|
+
createdBy: j.createdBy,
|
|
2731
|
+
}));
|
|
2732
|
+
const declared = Array.from(declaredSchedules.values()).map((d) => {
|
|
2733
|
+
const k = declaredKey(d.photon, d.method, d.workingDir);
|
|
2734
|
+
return {
|
|
2735
|
+
key: k,
|
|
2736
|
+
photon: d.photon,
|
|
2737
|
+
method: d.method,
|
|
2738
|
+
cron: d.cron,
|
|
2739
|
+
photonPath: d.photonPath,
|
|
2740
|
+
workingDir: d.workingDir ?? defaultBase,
|
|
2741
|
+
active: scheduledJobs.has(k),
|
|
2742
|
+
};
|
|
2743
|
+
});
|
|
2744
|
+
const webhooks = [];
|
|
2745
|
+
for (const entry of webhookRoutes.values()) {
|
|
2746
|
+
const loc = proactivePhotonLocations.get(locationKey(entry.photon, entry.workingDir));
|
|
2747
|
+
const owningDir = entry.workingDir ?? loc?.workingDir ?? defaultBase;
|
|
2748
|
+
for (const [route, methodName] of entry.routes.entries()) {
|
|
2749
|
+
webhooks.push({
|
|
2750
|
+
photon: entry.photon,
|
|
2751
|
+
route,
|
|
2752
|
+
method: methodName,
|
|
2753
|
+
workingDir: owningDir,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
const sessions = [];
|
|
2758
|
+
for (const [key, mgr] of sessionManagers.entries()) {
|
|
2759
|
+
const stored = workingDirs.get(key);
|
|
2760
|
+
const photonFromKey = key.includes(':') ? key.split(':')[0] : key;
|
|
2761
|
+
sessions.push({
|
|
2762
|
+
photon: photonFromKey,
|
|
2763
|
+
workingDir: stored ?? defaultBase,
|
|
2764
|
+
key,
|
|
2765
|
+
instanceCount: mgr.getSessions().length,
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
return {
|
|
2769
|
+
type: 'result',
|
|
2770
|
+
id: request.id,
|
|
2771
|
+
success: true,
|
|
2772
|
+
data: { active, declared, webhooks, sessions },
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
// Enroll a declared @scheduled method into the active list.
|
|
2776
|
+
if (request.type === 'enable_schedule') {
|
|
2777
|
+
const photon = request.photonName;
|
|
2778
|
+
const method = request.method;
|
|
2779
|
+
if (!photon || !method) {
|
|
2780
|
+
return {
|
|
2781
|
+
type: 'error',
|
|
2782
|
+
id: request.id,
|
|
2783
|
+
error: '`enable_schedule` requires photonName and method',
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
const preferredBase = request.workingDir;
|
|
2787
|
+
const matches = findDeclarationsFor(photon, method, preferredBase);
|
|
2788
|
+
if (matches.length === 0) {
|
|
2789
|
+
return {
|
|
2790
|
+
type: 'error',
|
|
2791
|
+
id: request.id,
|
|
2792
|
+
error: `No @scheduled declaration found for ${photon}:${method}. ` +
|
|
2793
|
+
`Did the source file get renamed or deleted?`,
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
if (matches.length > 1) {
|
|
2797
|
+
return {
|
|
2798
|
+
type: 'error',
|
|
2799
|
+
id: request.id,
|
|
2800
|
+
error: `Ambiguous: ${photon}:${method} is declared in multiple PHOTON_DIRs ` +
|
|
2801
|
+
`(${matches.map((m) => m.decl.workingDir ?? '-').join(', ')}). ` +
|
|
2802
|
+
`Pass workingDir to target one.`,
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
const { key, decl } = matches[0];
|
|
2806
|
+
const base = path.resolve(decl.workingDir || getDefaultContext().baseDir);
|
|
2807
|
+
const file = readActiveSchedulesFile(base);
|
|
2808
|
+
const existing = file.active.find((e) => e.photon === photon && e.method === method);
|
|
2809
|
+
if (existing) {
|
|
2810
|
+
if (existing.paused)
|
|
2811
|
+
existing.paused = false;
|
|
2812
|
+
}
|
|
2813
|
+
else {
|
|
2814
|
+
file.active.push({
|
|
2815
|
+
photon,
|
|
2816
|
+
method,
|
|
2817
|
+
enabledAt: new Date().toISOString(),
|
|
2818
|
+
enabledBy: request.source || 'rpc',
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
writeActiveSchedulesFile(base, file);
|
|
2822
|
+
// Also record this base so daemon restarts find it.
|
|
2823
|
+
try {
|
|
2824
|
+
touchBase(base);
|
|
2825
|
+
}
|
|
2826
|
+
catch {
|
|
2827
|
+
/* non-fatal */
|
|
2828
|
+
}
|
|
2829
|
+
if (!scheduledJobs.has(key)) {
|
|
2830
|
+
scheduleJob({
|
|
2831
|
+
id: key,
|
|
2832
|
+
method: decl.method,
|
|
2833
|
+
args: {},
|
|
2834
|
+
cron: decl.cron,
|
|
2835
|
+
runCount: 0,
|
|
2836
|
+
createdAt: Date.now(),
|
|
2837
|
+
createdBy: 'ps-enable',
|
|
2838
|
+
photonName: decl.photon,
|
|
2839
|
+
workingDir: decl.workingDir,
|
|
2840
|
+
photonPath: decl.photonPath,
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
return {
|
|
2844
|
+
type: 'result',
|
|
2845
|
+
id: request.id,
|
|
2846
|
+
success: true,
|
|
2847
|
+
data: { photon, method, base, cron: decl.cron, status: 'active' },
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
// Remove a schedule from the active list (drops the timer and the file entry).
|
|
2851
|
+
if (request.type === 'disable_schedule') {
|
|
2852
|
+
const photon = request.photonName;
|
|
2853
|
+
const method = request.method;
|
|
2854
|
+
if (!photon || !method) {
|
|
2855
|
+
return {
|
|
2856
|
+
type: 'error',
|
|
2857
|
+
id: request.id,
|
|
2858
|
+
error: '`disable_schedule` requires photonName and method',
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
const preferredBase = request.workingDir;
|
|
2862
|
+
const matches = findDeclarationsFor(photon, method, preferredBase);
|
|
2863
|
+
if (matches.length > 1) {
|
|
2864
|
+
return {
|
|
2865
|
+
type: 'error',
|
|
2866
|
+
id: request.id,
|
|
2867
|
+
error: `Ambiguous: ${photon}:${method} is declared in multiple PHOTON_DIRs ` +
|
|
2868
|
+
`(${matches.map((m) => m.decl.workingDir ?? '-').join(', ')}). ` +
|
|
2869
|
+
`Pass workingDir to target one.`,
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
const match = matches[0];
|
|
2873
|
+
// Disable tolerates a missing declaration so the caller can clean up
|
|
2874
|
+
// orphan active-schedule rows after the source file is deleted.
|
|
2875
|
+
const base = path.resolve(match?.decl.workingDir ?? preferredBase ?? getDefaultContext().baseDir);
|
|
2876
|
+
const file = readActiveSchedulesFile(base);
|
|
2877
|
+
const before = file.active.length;
|
|
2878
|
+
file.active = file.active.filter((e) => !(e.photon === photon && e.method === method));
|
|
2879
|
+
const removed = before - file.active.length;
|
|
2880
|
+
writeActiveSchedulesFile(base, file);
|
|
2881
|
+
// Clear the timer if one is running. Use the declaration's key when we
|
|
2882
|
+
// have it; otherwise scan to find any scheduled job for this (photon, method)
|
|
2883
|
+
// that's scoped to the resolved base. unscheduleJob does the atomic
|
|
2884
|
+
// clearTimeout + scheduledJobs.delete + jobTimers.delete dance; calling
|
|
2885
|
+
// the three map ops manually was the class of bug that codex round 9
|
|
2886
|
+
// flagged.
|
|
2887
|
+
const key = match?.key ?? declaredKey(photon, method, base);
|
|
2888
|
+
unscheduleJob(key);
|
|
2889
|
+
// Defensive sweep: during upgrade transitions a daemon may still hold
|
|
2890
|
+
// timers registered under the pre-base-scoping `${photon}:${method}` key
|
|
2891
|
+
// format. Report-success-but-keep-firing is worse than a noisy sweep, so
|
|
2892
|
+
// also drop any job matching this request — but only when the job is
|
|
2893
|
+
// scoped to THIS base (or the legacy "no base" form). Without this check,
|
|
2894
|
+
// disabling foo:bar under base X would also stop foo:bar under base Y,
|
|
2895
|
+
// silently breaking the other PHOTON_DIR's timer.
|
|
2896
|
+
for (const staleKey of Array.from(scheduledJobs.keys())) {
|
|
2897
|
+
const job = scheduledJobs.get(staleKey);
|
|
2898
|
+
if (!job)
|
|
2899
|
+
continue;
|
|
2900
|
+
if (job.photonName !== photon || job.method !== method)
|
|
2901
|
+
continue;
|
|
2902
|
+
if (staleKey === key)
|
|
2903
|
+
continue; // already handled above
|
|
2904
|
+
// Only sweep legacy keys (no base prefix) and keys scoped to `base`.
|
|
2905
|
+
const jobBase = job.workingDir ? path.resolve(job.workingDir) : undefined;
|
|
2906
|
+
if (jobBase && jobBase !== base)
|
|
2907
|
+
continue;
|
|
2908
|
+
unscheduleJob(staleKey);
|
|
2909
|
+
}
|
|
2910
|
+
return {
|
|
2911
|
+
type: 'result',
|
|
2912
|
+
id: request.id,
|
|
2913
|
+
success: true,
|
|
2914
|
+
data: { photon, method, base, removed: removed > 0, status: 'disabled' },
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
// Pause: keep the enrollment record but cancel the timer.
|
|
2918
|
+
if (request.type === 'pause_schedule' || request.type === 'resume_schedule') {
|
|
2919
|
+
const photon = request.photonName;
|
|
2920
|
+
const method = request.method;
|
|
2921
|
+
const pause = request.type === 'pause_schedule';
|
|
2922
|
+
if (!photon || !method) {
|
|
2923
|
+
return {
|
|
2924
|
+
type: 'error',
|
|
2925
|
+
id: request.id,
|
|
2926
|
+
error: `\`${pause ? 'pause_schedule' : 'resume_schedule'}\` requires photonName and method`,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
const preferredBase = request.workingDir;
|
|
2930
|
+
const matches = findDeclarationsFor(photon, method, preferredBase);
|
|
2931
|
+
if (matches.length > 1) {
|
|
2932
|
+
return {
|
|
2933
|
+
type: 'error',
|
|
2934
|
+
id: request.id,
|
|
2935
|
+
error: `Ambiguous: ${photon}:${method} is declared in multiple PHOTON_DIRs ` +
|
|
2936
|
+
`(${matches.map((m) => m.decl.workingDir ?? '-').join(', ')}). ` +
|
|
2937
|
+
`Pass workingDir to target one.`,
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
const match = matches[0];
|
|
2941
|
+
const decl = match?.decl;
|
|
2942
|
+
const base = path.resolve(decl?.workingDir ?? preferredBase ?? getDefaultContext().baseDir);
|
|
2943
|
+
const key = match?.key ?? declaredKey(photon, method, base);
|
|
2944
|
+
const file = readActiveSchedulesFile(base);
|
|
2945
|
+
const entry = file.active.find((e) => e.photon === photon && e.method === method);
|
|
2946
|
+
if (!entry) {
|
|
2947
|
+
return {
|
|
2948
|
+
type: 'error',
|
|
2949
|
+
id: request.id,
|
|
2950
|
+
error: `No active enrollment for ${photon}:${method} under ${base}. Enable it first.`,
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
entry.paused = pause;
|
|
2954
|
+
writeActiveSchedulesFile(base, file);
|
|
2955
|
+
if (pause) {
|
|
2956
|
+
unscheduleJob(key);
|
|
2957
|
+
}
|
|
2958
|
+
else if (decl) {
|
|
2959
|
+
scheduleJob({
|
|
2960
|
+
id: key,
|
|
2961
|
+
method: decl.method,
|
|
2962
|
+
args: {},
|
|
2963
|
+
cron: decl.cron,
|
|
2964
|
+
runCount: 0,
|
|
2965
|
+
createdAt: Date.now(),
|
|
2966
|
+
createdBy: 'ps-resume',
|
|
2967
|
+
photonName: decl.photon,
|
|
2968
|
+
workingDir: decl.workingDir,
|
|
2969
|
+
photonPath: decl.photonPath,
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
return {
|
|
2973
|
+
type: 'result',
|
|
2974
|
+
id: request.id,
|
|
2975
|
+
success: true,
|
|
2976
|
+
data: { photon, method, base, status: pause ? 'paused' : 'active' },
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
// Return recent firings recorded by recordExecution() — scoped to one
|
|
2980
|
+
// photon:method, newest first, filterable by time window.
|
|
2981
|
+
if (request.type === 'get_execution_history') {
|
|
2982
|
+
const photon = request.photonName;
|
|
2983
|
+
const method = request.method;
|
|
2984
|
+
if (!photon || !method) {
|
|
2985
|
+
return {
|
|
2986
|
+
type: 'error',
|
|
2987
|
+
id: request.id,
|
|
2988
|
+
error: '`get_execution_history` requires photonName and method',
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
// Prefer the caller-supplied workingDir; fall back to the declaration's
|
|
2992
|
+
// base; finally to the default base. Multiple PHOTON_DIRs can legitimately
|
|
2993
|
+
// carry the same photon:method — if workingDir wasn't provided and there's
|
|
2994
|
+
// more than one declaration, return an ambiguity error so the caller picks.
|
|
2995
|
+
const preferredBase = request.workingDir;
|
|
2996
|
+
const historyMatches = findDeclarationsFor(photon, method, preferredBase);
|
|
2997
|
+
if (historyMatches.length > 1 && !preferredBase) {
|
|
2998
|
+
return {
|
|
2999
|
+
type: 'error',
|
|
3000
|
+
id: request.id,
|
|
3001
|
+
error: `Ambiguous: ${photon}:${method} is declared in multiple PHOTON_DIRs ` +
|
|
3002
|
+
`(${historyMatches.map((m) => m.decl.workingDir ?? '-').join(', ')}). ` +
|
|
3003
|
+
`Pass workingDir to target one.`,
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
const decl = historyMatches[0]?.decl;
|
|
3007
|
+
const workingDir = preferredBase || decl?.workingDir || getDefaultContext().baseDir;
|
|
3008
|
+
const entries = readExecutionHistory(photon, { method, limit: request.limit, sinceTs: request.sinceTs }, workingDir);
|
|
3009
|
+
return {
|
|
3010
|
+
type: 'result',
|
|
3011
|
+
id: request.id,
|
|
3012
|
+
success: true,
|
|
3013
|
+
data: { photon, method, entries },
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
1704
3016
|
if (request.type === 'get_circuit_health') {
|
|
1705
3017
|
// Aggregate circuit breaker states from all loaded photon loaders
|
|
1706
3018
|
const circuits = {};
|
|
@@ -1818,6 +3130,11 @@ async function handleRequest(request, socket) {
|
|
|
1818
3130
|
error: `Cannot initialize photon '${photonName}'. Provide photonPath in request.`,
|
|
1819
3131
|
};
|
|
1820
3132
|
}
|
|
3133
|
+
// Stat-gate: if the source file has changed since we last loaded it,
|
|
3134
|
+
// synchronously reload before dispatching. Closes the race window where
|
|
3135
|
+
// a request arrives between a file write and the watcher's debounce.
|
|
3136
|
+
const dispatchPhotonPath = request.photonPath || photonPaths.get(cmdKey);
|
|
3137
|
+
await statGate(cmdKey, photonName, dispatchPhotonPath, request.workingDir);
|
|
1821
3138
|
try {
|
|
1822
3139
|
const session = await sessionManager.getOrCreateSession(request.sessionId, request.clientType);
|
|
1823
3140
|
// ── Auto-recover from instance drift ─────────────────────────
|
|
@@ -2179,6 +3496,9 @@ const stateKeysCache = new Map();
|
|
|
2179
3496
|
* State params: non-primitive with default on @stateful photon.
|
|
2180
3497
|
*/
|
|
2181
3498
|
async function getStateKeys(photonName, photonPath) {
|
|
3499
|
+
// stateKeysCache is photon-code-derived (independent of base), so it's
|
|
3500
|
+
// safe to use bare photonName as the cache key regardless of which
|
|
3501
|
+
// PHOTON_DIR loaded the file — all copies produce the same state schema.
|
|
2182
3502
|
if (stateKeysCache.has(photonName)) {
|
|
2183
3503
|
return stateKeysCache.get(photonName);
|
|
2184
3504
|
}
|
|
@@ -2208,8 +3528,10 @@ async function getStateKeys(photonName, photonPath) {
|
|
|
2208
3528
|
* Returns null for non-stateful photons (no state keys).
|
|
2209
3529
|
* Used for JSON Patch diffing (pre/post execution).
|
|
2210
3530
|
*/
|
|
2211
|
-
async function snapshotState(instance, photonName) {
|
|
2212
|
-
|
|
3531
|
+
async function snapshotState(instance, photonName, workingDir) {
|
|
3532
|
+
// Composite key collapses to bare photon name for the default base, so
|
|
3533
|
+
// pre-multi-base callers that passed just photonName still resolve.
|
|
3534
|
+
const photonPath = photonPaths.get(compositeKey(photonName, workingDir));
|
|
2213
3535
|
if (!photonPath)
|
|
2214
3536
|
return null;
|
|
2215
3537
|
const keys = await getStateKeys(photonName, photonPath);
|
|
@@ -2238,7 +3560,7 @@ async function snapshotState(instance, photonName) {
|
|
|
2238
3560
|
*/
|
|
2239
3561
|
async function persistInstanceState(instance, photonName, instanceName, workingDir) {
|
|
2240
3562
|
try {
|
|
2241
|
-
const photonPath = photonPaths.get(photonName);
|
|
3563
|
+
const photonPath = photonPaths.get(compositeKey(photonName, workingDir));
|
|
2242
3564
|
if (!photonPath)
|
|
2243
3565
|
return;
|
|
2244
3566
|
const keys = await getStateKeys(photonName, photonPath);
|
|
@@ -2284,13 +3606,14 @@ async function persistInstanceState(instance, photonName, instanceName, workingD
|
|
|
2284
3606
|
const eventLogSeq = new Map();
|
|
2285
3607
|
const EVENT_LOG_MAX_SIZE = parseInt(process.env.PHOTON_EVENT_LOG_MAX_SIZE || '', 10) || 10 * 1024 * 1024; // 10MB default
|
|
2286
3608
|
/**
|
|
2287
|
-
* Get the event log path for a photon instance
|
|
2288
|
-
*
|
|
3609
|
+
* Get the event log path for a photon instance under the Option B layout:
|
|
3610
|
+
* `{PHOTON_DIR}/.data/{photon}/state/{instance}/state.log`. Delegates to
|
|
3611
|
+
* the canonical photon-core helper so all callers land on the same path.
|
|
3612
|
+
* Namespace is threaded through as empty (flat root) — sub-namespace log
|
|
3613
|
+
* routing lands as part of the later namespace-aware instance work.
|
|
2289
3614
|
*/
|
|
2290
3615
|
function getInstanceLogPath(photonName, instanceName, baseDir) {
|
|
2291
|
-
|
|
2292
|
-
const dir = baseDir || getDefaultContext().baseDir;
|
|
2293
|
-
return path.join(dir, 'state', photonName, `${name}.log`);
|
|
3616
|
+
return getPhotonStateLogPath('', photonName, instanceName || 'default', baseDir || getDefaultContext().baseDir);
|
|
2294
3617
|
}
|
|
2295
3618
|
/**
|
|
2296
3619
|
* Append an event entry to the JSONL event log.
|
|
@@ -2362,10 +3685,10 @@ function pushUndoEntry(photonName, instance, entry) {
|
|
|
2362
3685
|
* Apply a JSON Patch to a live photon instance's state.
|
|
2363
3686
|
* Snapshots state as plain JSON, applies patch, then rehydrates instance properties.
|
|
2364
3687
|
*/
|
|
2365
|
-
async function applyPatchToInstance(instance, photonName, ops) {
|
|
3688
|
+
async function applyPatchToInstance(instance, photonName, ops, workingDir) {
|
|
2366
3689
|
// Use the already-imported fastJsonPatch module
|
|
2367
3690
|
const applyPatch = fastJsonPatch.applyPatch.bind(fastJsonPatch);
|
|
2368
|
-
const photonPath = photonPaths.get(photonName);
|
|
3691
|
+
const photonPath = photonPaths.get(compositeKey(photonName, workingDir));
|
|
2369
3692
|
if (!photonPath)
|
|
2370
3693
|
return;
|
|
2371
3694
|
const keys = await getStateKeys(photonName, photonPath);
|
|
@@ -2998,18 +4321,13 @@ async function doReloadPhoton(photonName, newPhotonPath, workingDir, key) {
|
|
|
2998
4321
|
// This covers in-memory state (maps, arrays, flags, caches) without requiring
|
|
2999
4322
|
// every photon to manually copy fields in onInitialize. Photons only need
|
|
3000
4323
|
// onInitialize for non-copyable resources (sockets, timers, DB connections).
|
|
4324
|
+
//
|
|
4325
|
+
// Exception: loader-injected settings fields carry the OLD schema/backing.
|
|
4326
|
+
// The fresh instance has already been wired with the new schema and reloaded
|
|
4327
|
+
// the persisted values from disk — overwriting them here keeps the daemon
|
|
4328
|
+
// serving the pre-reload settings shape until a full restart.
|
|
3001
4329
|
if (oldMcp?.instance && newMcp?.instance && typeof oldMcp.instance === 'object') {
|
|
3002
|
-
|
|
3003
|
-
const value = oldMcp.instance[key];
|
|
3004
|
-
if (typeof value !== 'function' && key !== 'constructor') {
|
|
3005
|
-
try {
|
|
3006
|
-
newMcp.instance[key] = value;
|
|
3007
|
-
}
|
|
3008
|
-
catch {
|
|
3009
|
-
// Some properties may be read-only (e.g. settings proxy)
|
|
3010
|
-
}
|
|
3011
|
-
}
|
|
3012
|
-
}
|
|
4330
|
+
transferHotReloadState(oldMcp.instance, newMcp.instance);
|
|
3013
4331
|
}
|
|
3014
4332
|
// Call onInitialize on the new instance with hot-reload context.
|
|
3015
4333
|
// Always passes oldInstance so lifecycle photons can transfer non-copyable
|
|
@@ -3044,6 +4362,9 @@ async function doReloadPhoton(photonName, newPhotonPath, workingDir, key) {
|
|
|
3044
4362
|
timestamp: Date.now(),
|
|
3045
4363
|
sessionsUpdated: updatedCount,
|
|
3046
4364
|
});
|
|
4365
|
+
// Refresh the stat-gate baseline so the next dispatch's stat check
|
|
4366
|
+
// sees the file it just loaded (not the stat from before this reload).
|
|
4367
|
+
recordPhotonSourceStat(key, newPhotonPath);
|
|
3047
4368
|
logger.info('Photon reloaded successfully', { photonName, sessionsUpdated: updatedCount });
|
|
3048
4369
|
return { success: true, sessionsUpdated: updatedCount };
|
|
3049
4370
|
}
|
|
@@ -3188,7 +4509,7 @@ function startupWatchPhotons() {
|
|
|
3188
4509
|
continue;
|
|
3189
4510
|
const photonName = entry.name.slice(0, -ext.length);
|
|
3190
4511
|
const photonPath = path.join(photonDir, entry.name);
|
|
3191
|
-
photonPaths.set(photonName, photonPath);
|
|
4512
|
+
photonPaths.set(compositeKey(photonName), photonPath);
|
|
3192
4513
|
watchPhotonFile(photonName, photonPath);
|
|
3193
4514
|
}
|
|
3194
4515
|
logger.info('Startup watch registered', { count: fileWatchers.size, dir: photonDir });
|
|
@@ -3260,19 +4581,21 @@ function startupWatchPhotons() {
|
|
|
3260
4581
|
return;
|
|
3261
4582
|
const photonName = filename.slice(0, -ext.length);
|
|
3262
4583
|
const filePath = path.join(photonDir, filename);
|
|
4584
|
+
const photonKey = compositeKey(photonName);
|
|
3263
4585
|
// New file added — register and watch it
|
|
3264
|
-
if (!photonPaths.has(
|
|
3265
|
-
photonPaths.set(
|
|
4586
|
+
if (!photonPaths.has(photonKey) && fs.existsSync(filePath)) {
|
|
4587
|
+
photonPaths.set(photonKey, filePath);
|
|
3266
4588
|
watchPhotonFile(photonName, filePath);
|
|
3267
4589
|
logger.info('Auto-discovered new photon', { photonName, path: filePath });
|
|
3268
4590
|
}
|
|
3269
4591
|
// File removed — clean up
|
|
3270
|
-
if (photonPaths.has(
|
|
3271
|
-
photonPaths.delete(
|
|
3272
|
-
|
|
4592
|
+
if (photonPaths.has(photonKey) && !fs.existsSync(filePath)) {
|
|
4593
|
+
photonPaths.delete(photonKey);
|
|
4594
|
+
// fileWatchers is keyed by file path, not composite key
|
|
4595
|
+
const watcher = fileWatchers.get(filePath);
|
|
3273
4596
|
if (watcher) {
|
|
3274
4597
|
watcher.close();
|
|
3275
|
-
fileWatchers.delete(
|
|
4598
|
+
fileWatchers.delete(filePath);
|
|
3276
4599
|
}
|
|
3277
4600
|
logger.info('Photon file removed', { photonName, path: filePath });
|
|
3278
4601
|
}
|
|
@@ -3552,7 +4875,19 @@ void (async () => {
|
|
|
3552
4875
|
await claimExclusiveOwnership();
|
|
3553
4876
|
startupWatchPhotons();
|
|
3554
4877
|
startServer();
|
|
4878
|
+
migrateLegacyIpcSchedules();
|
|
3555
4879
|
loadAllPersistedSchedules();
|
|
4880
|
+
void (async () => {
|
|
4881
|
+
await discoverProactiveMetadataAtBoot();
|
|
4882
|
+
syncActiveSchedulesAtBoot();
|
|
4883
|
+
startBaseWatchers();
|
|
4884
|
+
try {
|
|
4885
|
+
sweepExecutionHistoryBases(listActiveBases().map((b) => b.path));
|
|
4886
|
+
}
|
|
4887
|
+
catch (err) {
|
|
4888
|
+
logger.debug('Execution-history sweep skipped', { error: getErrorMessage(err) });
|
|
4889
|
+
}
|
|
4890
|
+
})();
|
|
3556
4891
|
startWebhookServer(WEBHOOK_PORT);
|
|
3557
4892
|
startIdleTimer();
|
|
3558
4893
|
startHealthMonitor();
|