@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.
Files changed (211) hide show
  1. package/README.md +19 -8
  2. package/dist/a2ui/mapper.d.ts +40 -0
  3. package/dist/a2ui/mapper.d.ts.map +1 -0
  4. package/dist/a2ui/mapper.js +286 -0
  5. package/dist/a2ui/mapper.js.map +1 -0
  6. package/dist/a2ui/types.d.ts +129 -0
  7. package/dist/a2ui/types.d.ts.map +1 -0
  8. package/dist/a2ui/types.js +20 -0
  9. package/dist/a2ui/types.js.map +1 -0
  10. package/dist/ag-ui/adapter.d.ts +9 -1
  11. package/dist/ag-ui/adapter.d.ts.map +1 -1
  12. package/dist/ag-ui/adapter.js +33 -16
  13. package/dist/ag-ui/adapter.js.map +1 -1
  14. package/dist/auto-ui/beam/routes/api-daemon.d.ts +18 -0
  15. package/dist/auto-ui/beam/routes/api-daemon.d.ts.map +1 -0
  16. package/dist/auto-ui/beam/routes/api-daemon.js +118 -0
  17. package/dist/auto-ui/beam/routes/api-daemon.js.map +1 -0
  18. package/dist/auto-ui/beam.d.ts.map +1 -1
  19. package/dist/auto-ui/beam.js +34 -34
  20. package/dist/auto-ui/beam.js.map +1 -1
  21. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  22. package/dist/auto-ui/bridge/renderers.js +371 -0
  23. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  24. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  25. package/dist/auto-ui/streamable-http-transport.js +38 -1
  26. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  27. package/dist/auto-ui/types.d.ts +19 -0
  28. package/dist/auto-ui/types.d.ts.map +1 -1
  29. package/dist/auto-ui/types.js.map +1 -1
  30. package/dist/beam.bundle.js +757 -107
  31. package/dist/beam.bundle.js.map +4 -4
  32. package/dist/cli/commands/beam.d.ts.map +1 -1
  33. package/dist/cli/commands/beam.js +2 -0
  34. package/dist/cli/commands/beam.js.map +1 -1
  35. package/dist/cli/commands/build.d.ts.map +1 -1
  36. package/dist/cli/commands/build.js +2 -0
  37. package/dist/cli/commands/build.js.map +1 -1
  38. package/dist/cli/commands/doctor.d.ts.map +1 -1
  39. package/dist/cli/commands/doctor.js +92 -3
  40. package/dist/cli/commands/doctor.js.map +1 -1
  41. package/dist/cli/commands/host.d.ts.map +1 -1
  42. package/dist/cli/commands/host.js +9 -1
  43. package/dist/cli/commands/host.js.map +1 -1
  44. package/dist/cli/commands/info.d.ts.map +1 -1
  45. package/dist/cli/commands/info.js +7 -3
  46. package/dist/cli/commands/info.js.map +1 -1
  47. package/dist/cli/commands/init.d.ts.map +1 -1
  48. package/dist/cli/commands/init.js +4 -0
  49. package/dist/cli/commands/init.js.map +1 -1
  50. package/dist/cli/commands/maker.d.ts +8 -0
  51. package/dist/cli/commands/maker.d.ts.map +1 -1
  52. package/dist/cli/commands/maker.js +113 -46
  53. package/dist/cli/commands/maker.js.map +1 -1
  54. package/dist/cli/commands/marketplace.d.ts.map +1 -1
  55. package/dist/cli/commands/marketplace.js +7 -1
  56. package/dist/cli/commands/marketplace.js.map +1 -1
  57. package/dist/cli/commands/mcp.d.ts +10 -0
  58. package/dist/cli/commands/mcp.d.ts.map +1 -1
  59. package/dist/cli/commands/mcp.js +215 -4
  60. package/dist/cli/commands/mcp.js.map +1 -1
  61. package/dist/cli/commands/package.d.ts.map +1 -1
  62. package/dist/cli/commands/package.js +33 -15
  63. package/dist/cli/commands/package.js.map +1 -1
  64. package/dist/cli/commands/ps.d.ts +16 -0
  65. package/dist/cli/commands/ps.d.ts.map +1 -0
  66. package/dist/cli/commands/ps.js +267 -0
  67. package/dist/cli/commands/ps.js.map +1 -0
  68. package/dist/cli/commands/run.d.ts.map +1 -1
  69. package/dist/cli/commands/run.js +7 -0
  70. package/dist/cli/commands/run.js.map +1 -1
  71. package/dist/cli/commands/update.d.ts.map +1 -1
  72. package/dist/cli/commands/update.js +14 -4
  73. package/dist/cli/commands/update.js.map +1 -1
  74. package/dist/cli/index.d.ts.map +1 -1
  75. package/dist/cli/index.js +9 -4
  76. package/dist/cli/index.js.map +1 -1
  77. package/dist/context-store.d.ts +4 -4
  78. package/dist/context-store.d.ts.map +1 -1
  79. package/dist/context-store.js +20 -17
  80. package/dist/context-store.js.map +1 -1
  81. package/dist/context.d.ts +5 -4
  82. package/dist/context.d.ts.map +1 -1
  83. package/dist/context.js +68 -14
  84. package/dist/context.js.map +1 -1
  85. package/dist/daemon/client.d.ts +60 -0
  86. package/dist/daemon/client.d.ts.map +1 -1
  87. package/dist/daemon/client.js +76 -0
  88. package/dist/daemon/client.js.map +1 -1
  89. package/dist/daemon/execution-history-sqlite.d.ts +50 -0
  90. package/dist/daemon/execution-history-sqlite.d.ts.map +1 -0
  91. package/dist/daemon/execution-history-sqlite.js +165 -0
  92. package/dist/daemon/execution-history-sqlite.js.map +1 -0
  93. package/dist/daemon/execution-history.d.ts +78 -0
  94. package/dist/daemon/execution-history.d.ts.map +1 -0
  95. package/dist/daemon/execution-history.js +246 -0
  96. package/dist/daemon/execution-history.js.map +1 -0
  97. package/dist/daemon/hot-reload-state.d.ts +27 -0
  98. package/dist/daemon/hot-reload-state.d.ts.map +1 -0
  99. package/dist/daemon/hot-reload-state.js +48 -0
  100. package/dist/daemon/hot-reload-state.js.map +1 -0
  101. package/dist/daemon/protocol.d.ts +5 -1
  102. package/dist/daemon/protocol.d.ts.map +1 -1
  103. package/dist/daemon/protocol.js +13 -0
  104. package/dist/daemon/protocol.js.map +1 -1
  105. package/dist/daemon/registry-keys.d.ts +88 -0
  106. package/dist/daemon/registry-keys.d.ts.map +1 -0
  107. package/dist/daemon/registry-keys.js +91 -0
  108. package/dist/daemon/registry-keys.js.map +1 -0
  109. package/dist/daemon/server.js +1521 -186
  110. package/dist/daemon/server.js.map +1 -1
  111. package/dist/daemon/session-resolver.d.ts +28 -0
  112. package/dist/daemon/session-resolver.d.ts.map +1 -0
  113. package/dist/daemon/session-resolver.js +41 -0
  114. package/dist/daemon/session-resolver.js.map +1 -0
  115. package/dist/data-migration.js +20 -9
  116. package/dist/data-migration.js.map +1 -1
  117. package/dist/loader.d.ts +22 -8
  118. package/dist/loader.d.ts.map +1 -1
  119. package/dist/loader.js +214 -94
  120. package/dist/loader.js.map +1 -1
  121. package/dist/marketplace-manager.d.ts.map +1 -1
  122. package/dist/marketplace-manager.js +9 -5
  123. package/dist/marketplace-manager.js.map +1 -1
  124. package/dist/namespace-migration.d.ts.map +1 -1
  125. package/dist/namespace-migration.js +28 -23
  126. package/dist/namespace-migration.js.map +1 -1
  127. package/dist/photon-cli-runner.d.ts.map +1 -1
  128. package/dist/photon-cli-runner.js +57 -8
  129. package/dist/photon-cli-runner.js.map +1 -1
  130. package/dist/serv/auth/auth-store.d.ts +155 -0
  131. package/dist/serv/auth/auth-store.d.ts.map +1 -0
  132. package/dist/serv/auth/auth-store.js +240 -0
  133. package/dist/serv/auth/auth-store.js.map +1 -0
  134. package/dist/serv/auth/endpoints.d.ts +113 -0
  135. package/dist/serv/auth/endpoints.d.ts.map +1 -0
  136. package/dist/serv/auth/endpoints.js +1005 -0
  137. package/dist/serv/auth/endpoints.js.map +1 -0
  138. package/dist/serv/auth/http-adapter.d.ts +60 -0
  139. package/dist/serv/auth/http-adapter.d.ts.map +1 -0
  140. package/dist/serv/auth/http-adapter.js +235 -0
  141. package/dist/serv/auth/http-adapter.js.map +1 -0
  142. package/dist/serv/auth/jwt.d.ts +92 -6
  143. package/dist/serv/auth/jwt.d.ts.map +1 -1
  144. package/dist/serv/auth/jwt.js +226 -24
  145. package/dist/serv/auth/jwt.js.map +1 -1
  146. package/dist/serv/auth/oauth-sqlite-stores.d.ts +48 -0
  147. package/dist/serv/auth/oauth-sqlite-stores.d.ts.map +1 -0
  148. package/dist/serv/auth/oauth-sqlite-stores.js +212 -0
  149. package/dist/serv/auth/oauth-sqlite-stores.js.map +1 -0
  150. package/dist/serv/auth/sqlite-stores.d.ts +85 -0
  151. package/dist/serv/auth/sqlite-stores.d.ts.map +1 -0
  152. package/dist/serv/auth/sqlite-stores.js +446 -0
  153. package/dist/serv/auth/sqlite-stores.js.map +1 -0
  154. package/dist/serv/auth/well-known.d.ts +54 -1
  155. package/dist/serv/auth/well-known.d.ts.map +1 -1
  156. package/dist/serv/auth/well-known.js +166 -17
  157. package/dist/serv/auth/well-known.js.map +1 -1
  158. package/dist/serv/index.d.ts +45 -2
  159. package/dist/serv/index.d.ts.map +1 -1
  160. package/dist/serv/index.js +65 -1
  161. package/dist/serv/index.js.map +1 -1
  162. package/dist/serv/types/index.d.ts +80 -0
  163. package/dist/serv/types/index.d.ts.map +1 -1
  164. package/dist/serv/types/index.js.map +1 -1
  165. package/dist/server.d.ts.map +1 -1
  166. package/dist/server.js +61 -6
  167. package/dist/server.js.map +1 -1
  168. package/dist/shared/announce-context.d.ts +51 -0
  169. package/dist/shared/announce-context.d.ts.map +1 -0
  170. package/dist/shared/announce-context.js +73 -0
  171. package/dist/shared/announce-context.js.map +1 -0
  172. package/dist/shared/audit-sqlite.d.ts +63 -0
  173. package/dist/shared/audit-sqlite.d.ts.map +1 -0
  174. package/dist/shared/audit-sqlite.js +187 -0
  175. package/dist/shared/audit-sqlite.js.map +1 -0
  176. package/dist/shared/audit.d.ts +25 -3
  177. package/dist/shared/audit.d.ts.map +1 -1
  178. package/dist/shared/audit.js +97 -3
  179. package/dist/shared/audit.js.map +1 -1
  180. package/dist/shared/error-handler.d.ts +10 -1
  181. package/dist/shared/error-handler.d.ts.map +1 -1
  182. package/dist/shared/error-handler.js +17 -2
  183. package/dist/shared/error-handler.js.map +1 -1
  184. package/dist/shared/security.d.ts +12 -0
  185. package/dist/shared/security.d.ts.map +1 -1
  186. package/dist/shared/security.js +80 -0
  187. package/dist/shared/security.js.map +1 -1
  188. package/dist/shared/sqlite-runtime.d.ts +46 -0
  189. package/dist/shared/sqlite-runtime.d.ts.map +1 -0
  190. package/dist/shared/sqlite-runtime.js +110 -0
  191. package/dist/shared/sqlite-runtime.js.map +1 -0
  192. package/dist/tasks/store.d.ts +1 -1
  193. package/dist/tasks/store.d.ts.map +1 -1
  194. package/dist/tasks/store.js +29 -15
  195. package/dist/tasks/store.js.map +1 -1
  196. package/dist/telemetry/metrics.d.ts +26 -0
  197. package/dist/telemetry/metrics.d.ts.map +1 -1
  198. package/dist/telemetry/metrics.js +31 -0
  199. package/dist/telemetry/metrics.js.map +1 -1
  200. package/dist/test-runner.d.ts.map +1 -1
  201. package/dist/test-runner.js +3 -3
  202. package/dist/test-runner.js.map +1 -1
  203. package/dist/version-checker.d.ts.map +1 -1
  204. package/dist/version-checker.js +7 -14
  205. package/dist/version-checker.js.map +1 -1
  206. package/dist/version.d.ts +12 -0
  207. package/dist/version.d.ts.map +1 -1
  208. package/dist/version.js +103 -1
  209. package/dist/version.js.map +1 -1
  210. package/package.json +6 -2
  211. package/templates/photon.template.ts +7 -13
@@ -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
- /** Tracks active executeTool calls per composite key */
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
- * When workingDir is the default (~/.photon), returns just the photonName
178
- * for backwards compatibility.
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
- if (!workingDir || workingDir === getDefaultContext().baseDir)
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(job.id, job);
514
- const existingTimer = jobTimers.get(job.id);
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(job.id);
606
+ void runJob(jobKey);
521
607
  }, delay);
522
- jobTimers.set(job.id, timer);
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
- const sessionManager = sessionManagers.get(key);
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
- logger.error('Job failed', { jobId, method: job.method, error: getErrorMessage(error) });
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: getErrorMessage(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 schedulesDir = path.join(process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules'), photonName.replace(/[^a-zA-Z0-9_-]/g, '_'));
597
- const filePath = path.join(schedulesDir, `${taskId}.json`);
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 = path.join(process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules'), job.photonName.replace(/[^a-zA-Z0-9_-]/g, '_'));
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 schedulesDir = path.join(process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules'), photonName.replace(/[^a-zA-Z0-9_-]/g, '_'));
656
- const filePath = path.join(schedulesDir, `${taskId}.json`);
829
+ const workingDir = scheduledJobs.get(asScheduleKey(jobId))?.workingDir;
830
+ const filePath = findPersistedScheduleFile(photonName, taskId, workingDir);
831
+ if (!filePath)
832
+ return;
657
833
  try {
658
- if (fs.existsSync(filePath)) {
659
- fs.unlinkSync(filePath);
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
- /** Load all persisted schedules from disk on daemon startup */
668
- function loadAllPersistedSchedules() {
669
- const baseDir = process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules');
670
- if (!fs.existsSync(baseDir))
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 loadedCount = 0;
673
- let skippedCount = 0;
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
- try {
677
- const photonDirs = fs.readdirSync(baseDir, { withFileTypes: true });
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(baseDir, dir.name);
682
- const files = fs.readdirSync(schedulesPath).filter((f) => f.endsWith('.json'));
683
- for (const file of files) {
684
- const filePath = path.join(schedulesPath, file);
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 content = fs.readFileSync(filePath, 'utf-8');
687
- const task = JSON.parse(content);
688
- // Skip non-IPC jobs (ScheduleProvider handles its own)
689
- if (task.source !== 'ipc')
690
- continue;
691
- // Validate required fields
692
- if (!task.id || !task.method || !task.cron || !task.photonName) {
693
- logger.warn('Skipping invalid persisted schedule', { file: filePath });
694
- skippedCount++;
695
- continue;
696
- }
697
- // TTL check: skip jobs not executed in 30+ days
698
- const lastExec = task.lastExecutionAt ? new Date(task.lastExecutionAt).getTime() : 0;
699
- const created = task.createdAt ? new Date(task.createdAt).getTime() : 0;
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('Failed to load persisted schedule file', {
739
- file: filePath,
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.warn('Failed to scan schedules directory', {
749
- dir: baseDir,
1408
+ logger.debug('Could not watch base for proactive metadata', {
1409
+ base: basePath,
750
1410
  error: getErrorMessage(err),
751
1411
  });
752
1412
  }
753
- if (loadedCount > 0 || skippedCount > 0) {
754
- logger.info('Loaded persisted schedules', { loaded: loadedCount, skipped: skippedCount });
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
- const webhookRateLimiter = new SimpleRateLimiter(30, 60_000);
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
- res.writeHead(429, { 'Content-Type': 'application/json' });
781
- res.end(JSON.stringify({ error: 'Too many requests' }));
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
- const sessionManager = sessionManagers.get(photonName);
833
- if (!sessionManager) {
834
- res.writeHead(503, { 'Content-Type': 'application/json' });
835
- res.end(JSON.stringify({ error: `Photon '${photonName}' not initialized` }));
836
- return;
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
- const routes = webhookRoutes.get(photonName);
841
- if (routes && routes.size > 0) {
842
- // Only allow methods that have @webhook tag
843
- const mapped = routes.get(method);
844
- if (!mapped) {
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: [...routes.keys()],
1645
+ availableRoutes: [...allRoutes],
849
1646
  }));
850
1647
  return;
851
1648
  }
852
- resolvedMethod = mapped;
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
- const webhookKey = compositeKey(photonName);
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 = sessionManagers.get(key);
975
- if (manager) {
976
- return manager;
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
- if (!photonPaths.has(photonName))
1050
- photonPaths.set(photonName, pathToUse);
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
- // Also store under bare photonName so snapshotState/persistInstanceState can find it
1157
- if (!photonPaths.has(photonName)) {
1158
- photonPaths.set(photonName, pathToUse);
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
- // Webhook route map: photonName Set<allowed method names>
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
- if (autoRegistered.has(photonName))
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(photonName);
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 = `${photonName}:${tool.name}`;
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
- let routes = webhookRoutes.get(photonName);
1225
- if (!routes) {
1226
- routes = new Map();
1227
- webhookRoutes.set(photonName, routes);
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
- if (webhookRoutes.has(photonName)) {
1235
- const routes = webhookRoutes.get(photonName);
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 from ~/.photon/schedules/{photonName}/
1242
- // These are created by this.schedule.create() at runtime.
1243
- const schedulesDir = path.join(process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules'), photonName.replace(/[^a-zA-Z0-9_-]/g, '_'));
1244
- try {
1245
- const scheduleFiles = fs.readdirSync(schedulesDir).filter((f) => f.endsWith('.json'));
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
- const jobId = request.jobId;
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
- const photonPath = photonPaths.get(photonName);
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
- * Co-located with state file: ~/.photon/state/{photon}/{instance}.log
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
- const name = instanceName || 'default';
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
- for (const key of Object.keys(oldMcp.instance)) {
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(photonName) && fs.existsSync(filePath)) {
3265
- photonPaths.set(photonName, filePath);
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(photonName) && !fs.existsSync(filePath)) {
3271
- photonPaths.delete(photonName);
3272
- const watcher = fileWatchers.get(photonName);
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(photonName);
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();