@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.
- package/README.md +66 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +262 -18
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/beam.bundle.js +58287 -56177
- package/dist/beam.bundle.js.map +4 -4
- package/dist/capability-negotiator.d.ts +9 -0
- package/dist/capability-negotiator.d.ts.map +1 -1
- package/dist/capability-negotiator.js +14 -0
- package/dist/capability-negotiator.js.map +1 -1
- package/dist/cli/commands/claim.d.ts +17 -0
- package/dist/cli/commands/claim.d.ts.map +1 -0
- package/dist/cli/commands/claim.js +124 -0
- package/dist/cli/commands/claim.js.map +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/claims.d.ts +108 -0
- package/dist/daemon/claims.d.ts.map +1 -0
- package/dist/daemon/claims.js +245 -0
- package/dist/daemon/claims.js.map +1 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +15 -29
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/cron.d.ts +36 -0
- package/dist/daemon/cron.d.ts.map +1 -0
- package/dist/daemon/cron.js +216 -0
- package/dist/daemon/cron.js.map +1 -0
- package/dist/daemon/schedule-loader.d.ts +76 -0
- package/dist/daemon/schedule-loader.d.ts.map +1 -0
- package/dist/daemon/schedule-loader.js +124 -0
- package/dist/daemon/schedule-loader.js.map +1 -0
- package/dist/daemon/server.js +76 -226
- package/dist/daemon/server.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +68 -3
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/loader.d.ts +22 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +162 -7
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +17 -0
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +50 -1
- package/dist/server.js.map +1 -1
- package/dist/shared/memory-sqlite.d.ts +37 -0
- package/dist/shared/memory-sqlite.d.ts.map +1 -0
- package/dist/shared/memory-sqlite.js +143 -0
- package/dist/shared/memory-sqlite.js.map +1 -0
- package/dist/shared/sqlite-runtime.d.ts.map +1 -1
- package/dist/shared/sqlite-runtime.js +12 -2
- package/dist/shared/sqlite-runtime.js.map +1 -1
- package/dist/tsx-compiler.d.ts.map +1 -1
- package/dist/tsx-compiler.js +18 -1
- package/dist/tsx-compiler.js.map +1 -1
- package/package.json +6 -2
- package/templates/cloudflare/worker.ts.template +44 -73
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|