@portel/photon 1.23.0 → 1.24.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 (63) hide show
  1. package/README.md +66 -0
  2. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  3. package/dist/auto-ui/streamable-http-transport.js +262 -18
  4. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  5. package/dist/beam.bundle.js +58287 -56177
  6. package/dist/beam.bundle.js.map +4 -4
  7. package/dist/capability-negotiator.d.ts +9 -0
  8. package/dist/capability-negotiator.d.ts.map +1 -1
  9. package/dist/capability-negotiator.js +14 -0
  10. package/dist/capability-negotiator.js.map +1 -1
  11. package/dist/cli/commands/claim.d.ts +17 -0
  12. package/dist/cli/commands/claim.d.ts.map +1 -0
  13. package/dist/cli/commands/claim.js +124 -0
  14. package/dist/cli/commands/claim.js.map +1 -0
  15. package/dist/cli/commands/run.d.ts.map +1 -1
  16. package/dist/cli/commands/run.js +2 -0
  17. package/dist/cli/commands/run.js.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +2 -0
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/daemon/claims.d.ts +108 -0
  22. package/dist/daemon/claims.d.ts.map +1 -0
  23. package/dist/daemon/claims.js +245 -0
  24. package/dist/daemon/claims.js.map +1 -0
  25. package/dist/daemon/client.d.ts.map +1 -1
  26. package/dist/daemon/client.js +15 -29
  27. package/dist/daemon/client.js.map +1 -1
  28. package/dist/daemon/cron.d.ts +36 -0
  29. package/dist/daemon/cron.d.ts.map +1 -0
  30. package/dist/daemon/cron.js +216 -0
  31. package/dist/daemon/cron.js.map +1 -0
  32. package/dist/daemon/schedule-loader.d.ts +76 -0
  33. package/dist/daemon/schedule-loader.d.ts.map +1 -0
  34. package/dist/daemon/schedule-loader.js +124 -0
  35. package/dist/daemon/schedule-loader.js.map +1 -0
  36. package/dist/daemon/server.js +76 -226
  37. package/dist/daemon/server.js.map +1 -1
  38. package/dist/deploy/cloudflare.d.ts.map +1 -1
  39. package/dist/deploy/cloudflare.js +68 -3
  40. package/dist/deploy/cloudflare.js.map +1 -1
  41. package/dist/loader.d.ts +22 -1
  42. package/dist/loader.d.ts.map +1 -1
  43. package/dist/loader.js +162 -7
  44. package/dist/loader.js.map +1 -1
  45. package/dist/photon-cli-runner.d.ts.map +1 -1
  46. package/dist/photon-cli-runner.js +17 -0
  47. package/dist/photon-cli-runner.js.map +1 -1
  48. package/dist/server.d.ts +10 -0
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +50 -1
  51. package/dist/server.js.map +1 -1
  52. package/dist/shared/memory-sqlite.d.ts +37 -0
  53. package/dist/shared/memory-sqlite.d.ts.map +1 -0
  54. package/dist/shared/memory-sqlite.js +143 -0
  55. package/dist/shared/memory-sqlite.js.map +1 -0
  56. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  57. package/dist/shared/sqlite-runtime.js +12 -2
  58. package/dist/shared/sqlite-runtime.js.map +1 -1
  59. package/dist/tsx-compiler.d.ts.map +1 -1
  60. package/dist/tsx-compiler.js +18 -1
  61. package/dist/tsx-compiler.js.map +1 -1
  62. package/package.json +6 -2
  63. package/templates/cloudflare/worker.ts.template +44 -73
@@ -17,6 +17,8 @@ import * as crypto from 'crypto';
17
17
  import { SessionManager } from './session-manager.js';
18
18
  import { transferHotReloadState } from './hot-reload-state.js';
19
19
  import { resolveWithGlobalFallback } from './session-resolver.js';
20
+ import { loadPersistedSchedulesFromDir } from './schedule-loader.js';
21
+ import { parseCron, computeMissedRun } from './cron.js';
20
22
  import { isValidDaemonRequest, } from './protocol.js';
21
23
  import { setPromptHandler, setBroker, touchBase, getPhotonSchedulesDir, getPhotonStateLogPath, listActiveBases, pruneBasesRegistry, resolvePhotonPath, } from '@portel/photon-core';
22
24
  import { getDefaultContext } from '../context.js';
@@ -435,156 +437,7 @@ staleMapCleanupInterval.unref();
435
437
  */
436
438
  const scheduledJobs = new Map();
437
439
  const jobTimers = new Map();
438
- function parseCronField(field, min, max) {
439
- if (field === '*') {
440
- const values = [];
441
- for (let i = min; i <= max; i++)
442
- values.push(i);
443
- return values;
444
- }
445
- // Comma-separated list
446
- if (field.includes(',')) {
447
- const values = new Set();
448
- for (const part of field.split(',')) {
449
- const partValues = parseCronField(part, min, max);
450
- if (!partValues)
451
- return null;
452
- partValues.forEach((v) => values.add(v));
453
- }
454
- return Array.from(values).sort((a, b) => a - b);
455
- }
456
- // Step values: */n, start/n, start-end/n
457
- if (field.includes('/')) {
458
- const slashIdx = field.indexOf('/');
459
- const range = field.slice(0, slashIdx);
460
- const step = parseInt(field.slice(slashIdx + 1));
461
- if (isNaN(step) || step <= 0)
462
- return null;
463
- let start = min;
464
- let end = max;
465
- if (range !== '*') {
466
- if (range.includes('-')) {
467
- const [s, e] = range.split('-').map(Number);
468
- if (isNaN(s) || isNaN(e))
469
- return null;
470
- start = s;
471
- end = e;
472
- }
473
- else {
474
- start = parseInt(range);
475
- if (isNaN(start))
476
- return null;
477
- }
478
- }
479
- const values = [];
480
- for (let i = start; i <= end; i += step)
481
- values.push(i);
482
- return values;
483
- }
484
- // Range: n-m
485
- if (field.includes('-')) {
486
- const [s, e] = field.split('-').map(Number);
487
- if (isNaN(s) || isNaN(e) || s < min || e > max)
488
- return null;
489
- const values = [];
490
- for (let i = s; i <= e; i++)
491
- values.push(i);
492
- return values;
493
- }
494
- // Single value
495
- const value = parseInt(field);
496
- if (isNaN(value) || value < min || value > max)
497
- return null;
498
- return [value];
499
- }
500
- function parseCron(cron) {
501
- const parts = cron.trim().split(/\s+/);
502
- if (parts.length !== 5) {
503
- return { isValid: false, nextRun: 0 };
504
- }
505
- const [minuteField, hourField, domField, monthField, dowField] = parts;
506
- const minutes = parseCronField(minuteField, 0, 59);
507
- const hours = parseCronField(hourField, 0, 23);
508
- const doms = parseCronField(domField, 1, 31);
509
- const months = parseCronField(monthField, 1, 12); // cron months are 1-indexed
510
- const dows = parseCronField(dowField, 0, 7); // 0 and 7 both mean Sunday
511
- if (!minutes || !hours || !doms || !months || !dows) {
512
- return { isValid: false, nextRun: 0 };
513
- }
514
- const minuteSet = new Set(minutes);
515
- const hourSet = new Set(hours);
516
- const domSet = new Set(doms);
517
- const monthSet = new Set(months);
518
- // Normalize: 7 → 0 (both mean Sunday)
519
- const dowSet = new Set(dows.map((d) => (d === 7 ? 0 : d)));
520
- const domIsWild = domField === '*';
521
- const dowIsWild = dowField === '*';
522
- // Advance to next minute boundary from now
523
- const candidate = new Date();
524
- candidate.setSeconds(0);
525
- candidate.setMilliseconds(0);
526
- candidate.setMinutes(candidate.getMinutes() + 1);
527
- const limit = new Date(candidate);
528
- limit.setFullYear(limit.getFullYear() + 4);
529
- while (candidate < limit) {
530
- // Check month (JS: 0-indexed, cron: 1-indexed)
531
- if (!monthSet.has(candidate.getMonth() + 1)) {
532
- candidate.setMonth(candidate.getMonth() + 1);
533
- candidate.setDate(1);
534
- candidate.setHours(0);
535
- candidate.setMinutes(0);
536
- continue;
537
- }
538
- // Day check: if both dom and dow are non-wild, standard cron uses OR semantics
539
- let dayMatch;
540
- if (domIsWild && dowIsWild) {
541
- dayMatch = true;
542
- }
543
- else if (domIsWild) {
544
- dayMatch = dowSet.has(candidate.getDay());
545
- }
546
- else if (dowIsWild) {
547
- dayMatch = domSet.has(candidate.getDate());
548
- }
549
- else {
550
- dayMatch = domSet.has(candidate.getDate()) || dowSet.has(candidate.getDay());
551
- }
552
- if (!dayMatch) {
553
- candidate.setDate(candidate.getDate() + 1);
554
- candidate.setHours(0);
555
- candidate.setMinutes(0);
556
- continue;
557
- }
558
- // Check hour
559
- if (!hourSet.has(candidate.getHours())) {
560
- const nextHour = [...hourSet].find((h) => h > candidate.getHours());
561
- if (nextHour !== undefined) {
562
- candidate.setHours(nextHour);
563
- candidate.setMinutes(0);
564
- }
565
- else {
566
- candidate.setDate(candidate.getDate() + 1);
567
- candidate.setHours(0);
568
- candidate.setMinutes(0);
569
- }
570
- continue;
571
- }
572
- // Check minute
573
- if (!minuteSet.has(candidate.getMinutes())) {
574
- const nextMinute = [...minuteSet].find((m) => m > candidate.getMinutes());
575
- if (nextMinute !== undefined) {
576
- candidate.setMinutes(nextMinute);
577
- }
578
- else {
579
- candidate.setHours(candidate.getHours() + 1);
580
- candidate.setMinutes(0);
581
- }
582
- continue;
583
- }
584
- return { isValid: true, nextRun: candidate.getTime() };
585
- }
586
- return { isValid: false, nextRun: 0 };
587
- }
440
+ // parseCron moved to ./cron.ts so the boot loader and tests can reuse it.
588
441
  function scheduleJob(job) {
589
442
  const { isValid, nextRun } = parseCron(job.cron);
590
443
  if (!isValid) {
@@ -619,15 +472,39 @@ async function runJob(jobId) {
619
472
  if (!job)
620
473
  return;
621
474
  const key = compositeKey(job.photonName, job.workingDir);
475
+ // Phantom prune: when a ScheduleProvider-sourced job's backing file
476
+ // has been deleted (e.g. `this.schedule.cancel()` ran the unlink
477
+ // but the in-memory registration survived daemon restart), stop
478
+ // firing and evict the stale registration. Without this, a cancelled
479
+ // schedule keeps triggering every interval forever — the main reason
480
+ // we saw `Cannot run job` logs for jobIds that had no backing file.
481
+ if (job.sourceFile && !fs.existsSync(job.sourceFile)) {
482
+ logger.info('Dropping orphan scheduled job — backing file gone', {
483
+ jobId,
484
+ photon: job.photonName,
485
+ sourceFile: job.sourceFile,
486
+ });
487
+ unscheduleJob(jobId);
488
+ return;
489
+ }
622
490
  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) {
491
+ // Lazy-load: if the photon isn't loaded yet (idle-unloaded, or
492
+ // boot-loaded schedules that predate any CLI invocation), spin up
493
+ // the session on demand. `getOrCreateSessionManager` falls back to
494
+ // a disk resolve when `job.photonPath` is absent, so the scheduler
495
+ // no longer needs the path cached on the job to work — it is purely
496
+ // an optimisation to skip the disk walk on each fire.
497
+ if (!sessionManager) {
627
498
  try {
628
499
  sessionManager =
629
500
  (await getOrCreateSessionManager(job.photonName, job.photonPath, job.workingDir)) ??
630
501
  undefined;
502
+ // Cache the path we used so subsequent fires skip the resolve.
503
+ if (sessionManager && !job.photonPath) {
504
+ const resolved = photonPaths.get(key);
505
+ if (resolved)
506
+ job.photonPath = resolved;
507
+ }
631
508
  }
632
509
  catch (err) {
633
510
  logger.warn('Lazy-load for scheduled job failed', {
@@ -842,79 +719,47 @@ function deletePersistedIpcSchedule(jobId, photonName) {
842
719
  * Process IPC schedule files from one photon's schedules directory.
843
720
  * Mutates loadedCount / skippedCount via the returned counters.
844
721
  */
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;
722
+ /**
723
+ * Wire the pure loader from `schedule-loader.ts` to this module's
724
+ * scheduleJob + scheduledJobs. The loader is kept pure (no daemon
725
+ * globals, no logger) so tests can import it without triggering the
726
+ * daemon bootstrap IIFE. See schedule-loader.ts for format details.
727
+ */
728
+ function loadDaemonSchedulesFromDir(schedulesPath, ttlMs, photonNameHint, workingDirHint) {
729
+ return loadPersistedSchedulesFromDir(schedulesPath, ttlMs, photonNameHint, workingDirHint, {
730
+ alreadyRegistered: (id) => scheduledJobs.has(asScheduleKey(id)),
731
+ register: (job) => {
732
+ // Seed in-memory lastRun from persisted lastExecutionAt so the post-run
733
+ // reschedule path keeps updating the same field instead of starting at 0.
734
+ const scheduledJob = job;
735
+ if (scheduledJob.lastRun === undefined && job.lastRun !== undefined) {
736
+ scheduledJob.lastRun = job.lastRun;
868
737
  }
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 */
738
+ const ok = scheduleJob(scheduledJob);
739
+ if (!ok)
740
+ return false;
741
+ // If the schedule was supposed to fire while the daemon was down, run
742
+ // one catch-up for the most recent missed occurrence. We intentionally
743
+ // fire at most once per boot to avoid a flood after long outages; older
744
+ // windows are dropped.
745
+ if (job.lastRun !== undefined) {
746
+ const missed = computeMissedRun(job.cron, job.lastRun, Date.now());
747
+ if (missed !== null) {
748
+ logger.info('Catch-up fire for missed schedule window', {
749
+ jobId: job.id,
750
+ photon: job.photonName,
751
+ method: job.method,
752
+ lastRun: new Date(job.lastRun).toISOString(),
753
+ missedAt: new Date(missed).toISOString(),
754
+ });
755
+ void runJob(asScheduleKey(job.id));
883
756
  }
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
757
  }
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 };
758
+ return true;
759
+ },
760
+ warn: (msg, ctx) => logger.warn(msg, ctx),
761
+ info: (msg, ctx) => logger.info(msg, ctx),
762
+ });
918
763
  }
919
764
  /**
920
765
  * One-time sweep of legacy IPC schedules from ~/.photon/schedules/ to the
@@ -1027,7 +872,9 @@ function loadAllPersistedSchedules() {
1027
872
  if (scannedDirs.has(schedulesPath))
1028
873
  continue;
1029
874
  scannedDirs.add(schedulesPath);
1030
- const result = loadIpcSchedulesFromDir(schedulesPath, ttlMs);
875
+ // Legacy flat root: {root}/<photonName>/*.json — photon name IS
876
+ // the directory name.
877
+ const result = loadDaemonSchedulesFromDir(schedulesPath, ttlMs, dir.name, undefined);
1031
878
  loadedCount += result.loaded;
1032
879
  skippedCount += result.skipped;
1033
880
  }
@@ -1060,7 +907,10 @@ function loadAllPersistedSchedules() {
1060
907
  if (scannedDirs.has(schedulesPath))
1061
908
  continue;
1062
909
  scannedDirs.add(schedulesPath);
1063
- const result = loadIpcSchedulesFromDir(schedulesPath, ttlMs);
910
+ // Per-base layout: {baseDir}/.data/<photonName>/schedules/*.json.
911
+ // Photon name is the entry name, working dir is the base itself
912
+ // (used for session resolution when the cron fires).
913
+ const result = loadDaemonSchedulesFromDir(schedulesPath, ttlMs, entry.name, baseDir);
1064
914
  loadedCount += result.loaded;
1065
915
  skippedCount += result.skipped;
1066
916
  }