@holon-run/agentinbox 0.2.0 → 0.4.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 +38 -0
- package/dist/src/cli.js +128 -0
- package/dist/src/current_agent.js +1 -0
- package/dist/src/daemon.js +69 -0
- package/dist/src/http.js +194 -1
- package/dist/src/runtime_gate.js +645 -0
- package/dist/src/service.js +741 -8
- package/dist/src/store.js +105 -3
- package/dist/src/terminal.js +35 -12
- package/drizzle/migrations/0005_timers.sql +22 -0
- package/drizzle/migrations/0006_terminal_runtime_pid.sql +1 -0
- package/drizzle/schema.ts +21 -0
- package/package.json +1 -1
package/dist/src/service.js
CHANGED
|
@@ -7,6 +7,7 @@ const source_schema_1 = require("./source_schema");
|
|
|
7
7
|
const source_resolution_1 = require("./source_resolution");
|
|
8
8
|
const filter_1 = require("./filter");
|
|
9
9
|
const terminal_1 = require("./terminal");
|
|
10
|
+
const runtime_gate_1 = require("./runtime_gate");
|
|
10
11
|
const DEFAULT_SUBSCRIPTION_POLL_LIMIT = 100;
|
|
11
12
|
const DEFAULT_ACTIVATION_WINDOW_MS = 3_000;
|
|
12
13
|
const DEFAULT_ACTIVATION_MAX_ITEMS = 20;
|
|
@@ -17,6 +18,12 @@ const DEFAULT_OFFLINE_AGENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
17
18
|
const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
|
|
18
19
|
const DEFAULT_SYNC_INTERVAL_MS = 2_000;
|
|
19
20
|
const DEFAULT_IDLE_SOURCE_GRACE_MS = 5 * 60 * 1000;
|
|
21
|
+
const DIRECT_INBOX_SOURCE_ID = "__agentinbox_direct_text__";
|
|
22
|
+
const DIRECT_INBOX_EVENT_VARIANT = "agentinbox.direct_text_message";
|
|
23
|
+
const TIMER_INBOX_SOURCE_ID = "__agentinbox_timer__";
|
|
24
|
+
const TIMER_INBOX_EVENT_VARIANT = "agentinbox.timer_fired";
|
|
25
|
+
const DEFAULT_TIMER_SENDER = "timer";
|
|
26
|
+
const MIN_TIMER_INTERVAL_MS = 60 * 1000;
|
|
20
27
|
const WEBHOOK_ACTIVATION_MODES = new Set(["activation_only", "activation_with_items"]);
|
|
21
28
|
const SUBSCRIPTION_START_POLICIES = new Set(["latest", "earliest", "at_offset", "at_time"]);
|
|
22
29
|
class AgentInboxService {
|
|
@@ -30,11 +37,12 @@ class AgentInboxService {
|
|
|
30
37
|
notificationBuffers = new Map();
|
|
31
38
|
inboxWatchers = new Map();
|
|
32
39
|
syncInterval = null;
|
|
40
|
+
timerSyncTimeout = null;
|
|
33
41
|
stopping = false;
|
|
34
42
|
lastAckedInboxGcAt = 0;
|
|
35
43
|
lastLifecycleCleanupAt = 0;
|
|
36
44
|
lastOfflineAgentGcAt = 0;
|
|
37
|
-
constructor(store, adapters, activationDispatcher = new ActivationDispatcher(), backend, activationPolicy, terminalDispatcher = new terminal_1.TerminalDispatcher()) {
|
|
45
|
+
constructor(store, adapters, activationDispatcher = new ActivationDispatcher(), backend, activationPolicy, terminalDispatcher = new terminal_1.TerminalDispatcher(), activationGate = new runtime_gate_1.DefaultActivationGate()) {
|
|
38
46
|
this.store = store;
|
|
39
47
|
this.adapters = adapters;
|
|
40
48
|
this.activationDispatcher = activationDispatcher;
|
|
@@ -43,9 +51,11 @@ class AgentInboxService {
|
|
|
43
51
|
this.activationMaxItems = activationPolicy?.maxItems ?? DEFAULT_ACTIVATION_MAX_ITEMS;
|
|
44
52
|
this.ackedRetentionMs = DEFAULT_ACKED_RETENTION_MS;
|
|
45
53
|
this.terminalDispatcher = terminalDispatcher;
|
|
54
|
+
this.activationGate = activationGate;
|
|
46
55
|
}
|
|
47
56
|
activationDispatcher;
|
|
48
57
|
terminalDispatcher;
|
|
58
|
+
activationGate;
|
|
49
59
|
async start() {
|
|
50
60
|
if (this.syncInterval) {
|
|
51
61
|
return;
|
|
@@ -57,10 +67,12 @@ class AgentInboxService {
|
|
|
57
67
|
void this.runAckedInboxGcIfDue();
|
|
58
68
|
void this.syncLifecycleGc();
|
|
59
69
|
}, DEFAULT_SYNC_INTERVAL_MS);
|
|
70
|
+
this.refreshTimersOnStart();
|
|
60
71
|
await this.syncAllSubscriptions();
|
|
61
72
|
await this.syncActivationDispatchStates();
|
|
62
73
|
await this.runAckedInboxGcIfDue(true);
|
|
63
74
|
await this.syncLifecycleGc();
|
|
75
|
+
await this.syncTimers();
|
|
64
76
|
}
|
|
65
77
|
async stop() {
|
|
66
78
|
this.stopping = true;
|
|
@@ -74,7 +86,12 @@ class AgentInboxService {
|
|
|
74
86
|
clearInterval(this.syncInterval);
|
|
75
87
|
this.syncInterval = null;
|
|
76
88
|
}
|
|
89
|
+
if (this.timerSyncTimeout) {
|
|
90
|
+
clearTimeout(this.timerSyncTimeout);
|
|
91
|
+
this.timerSyncTimeout = null;
|
|
92
|
+
}
|
|
77
93
|
await this.flushAllPendingNotifications();
|
|
94
|
+
await this.awaitInFlightNotificationBuffers();
|
|
78
95
|
}
|
|
79
96
|
async registerSource(input) {
|
|
80
97
|
const existing = this.store.getSourceByKey(input.sourceType, input.sourceKey);
|
|
@@ -319,6 +336,7 @@ class AgentInboxService {
|
|
|
319
336
|
return this.registerAgent({
|
|
320
337
|
runtimeKind: detected.runtimeKind,
|
|
321
338
|
runtimeSessionId: detected.runtimeSessionId ?? null,
|
|
339
|
+
runtimePid: detected.runtimePid ?? null,
|
|
322
340
|
backend: detected.backend,
|
|
323
341
|
mode: "agent_prompt",
|
|
324
342
|
tmuxPaneId: detected.tmuxPaneId ?? null,
|
|
@@ -365,8 +383,106 @@ class AgentInboxService {
|
|
|
365
383
|
}
|
|
366
384
|
});
|
|
367
385
|
this.inboxWatchers.delete(agentId);
|
|
386
|
+
this.rescheduleTimerSync();
|
|
368
387
|
return { removed: true };
|
|
369
388
|
}
|
|
389
|
+
registerTimer(input) {
|
|
390
|
+
this.getAgent(input.agentId);
|
|
391
|
+
const normalized = normalizeTimerInput(input);
|
|
392
|
+
const timezone = normalized.timezone ?? detectHostTimezone();
|
|
393
|
+
assertValidTimeZone(timezone);
|
|
394
|
+
const now = (0, util_1.nowIso)();
|
|
395
|
+
const nextFireAt = computeNextTimerFire({
|
|
396
|
+
mode: normalized.mode,
|
|
397
|
+
at: normalized.at ?? null,
|
|
398
|
+
intervalMs: normalized.every ?? null,
|
|
399
|
+
cronExpr: normalized.cron ?? null,
|
|
400
|
+
timezone,
|
|
401
|
+
fromIso: now,
|
|
402
|
+
lastFiredAt: null,
|
|
403
|
+
restartRecovery: false,
|
|
404
|
+
});
|
|
405
|
+
const timer = {
|
|
406
|
+
scheduleId: (0, util_1.generateId)("sched"),
|
|
407
|
+
agentId: input.agentId,
|
|
408
|
+
status: nextFireAt ? "active" : "paused",
|
|
409
|
+
mode: normalized.mode,
|
|
410
|
+
at: normalized.at ?? null,
|
|
411
|
+
intervalMs: normalized.every ?? null,
|
|
412
|
+
cronExpr: normalized.cron ?? null,
|
|
413
|
+
timezone,
|
|
414
|
+
message: normalizeDirectInboxMessage(input.message),
|
|
415
|
+
sender: normalizeDirectInboxSender(input.sender) ?? DEFAULT_TIMER_SENDER,
|
|
416
|
+
nextFireAt,
|
|
417
|
+
lastFiredAt: null,
|
|
418
|
+
createdAt: now,
|
|
419
|
+
updatedAt: now,
|
|
420
|
+
};
|
|
421
|
+
this.store.insertTimer(timer);
|
|
422
|
+
this.rescheduleTimerSync();
|
|
423
|
+
return timer;
|
|
424
|
+
}
|
|
425
|
+
listTimers(agentId) {
|
|
426
|
+
if (agentId) {
|
|
427
|
+
this.getAgent(agentId);
|
|
428
|
+
return this.store.listTimersForAgent(agentId);
|
|
429
|
+
}
|
|
430
|
+
return this.store.listTimers();
|
|
431
|
+
}
|
|
432
|
+
getTimer(scheduleId) {
|
|
433
|
+
const timer = this.store.getTimer(scheduleId);
|
|
434
|
+
if (!timer) {
|
|
435
|
+
throw new Error(`unknown timer: ${scheduleId}`);
|
|
436
|
+
}
|
|
437
|
+
return timer;
|
|
438
|
+
}
|
|
439
|
+
pauseTimer(scheduleId) {
|
|
440
|
+
const timer = this.getTimer(scheduleId);
|
|
441
|
+
const updated = this.store.updateTimer(scheduleId, {
|
|
442
|
+
status: "paused",
|
|
443
|
+
nextFireAt: null,
|
|
444
|
+
updatedAt: (0, util_1.nowIso)(),
|
|
445
|
+
});
|
|
446
|
+
this.rescheduleTimerSync();
|
|
447
|
+
return {
|
|
448
|
+
updated: timer.status !== "paused",
|
|
449
|
+
timer: updated,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
resumeTimer(scheduleId) {
|
|
453
|
+
const timer = this.getTimer(scheduleId);
|
|
454
|
+
if (timer.mode === "at" && timer.lastFiredAt) {
|
|
455
|
+
throw new Error(`timer ${scheduleId} already fired`);
|
|
456
|
+
}
|
|
457
|
+
const now = (0, util_1.nowIso)();
|
|
458
|
+
const nextFireAt = computeNextTimerFire({
|
|
459
|
+
mode: timer.mode,
|
|
460
|
+
at: timer.at ?? null,
|
|
461
|
+
intervalMs: timer.intervalMs ?? null,
|
|
462
|
+
cronExpr: timer.cronExpr ?? null,
|
|
463
|
+
timezone: timer.timezone,
|
|
464
|
+
fromIso: now,
|
|
465
|
+
lastFiredAt: timer.lastFiredAt ?? null,
|
|
466
|
+
restartRecovery: false,
|
|
467
|
+
});
|
|
468
|
+
const status = nextFireAt ? "active" : "paused";
|
|
469
|
+
const updated = this.store.updateTimer(scheduleId, {
|
|
470
|
+
status,
|
|
471
|
+
nextFireAt,
|
|
472
|
+
updatedAt: now,
|
|
473
|
+
});
|
|
474
|
+
this.rescheduleTimerSync();
|
|
475
|
+
return {
|
|
476
|
+
updated: timer.status !== status,
|
|
477
|
+
timer: updated,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
removeTimer(scheduleId) {
|
|
481
|
+
this.getTimer(scheduleId);
|
|
482
|
+
this.store.deleteTimer(scheduleId);
|
|
483
|
+
this.rescheduleTimerSync();
|
|
484
|
+
return { removed: true, scheduleId };
|
|
485
|
+
}
|
|
370
486
|
addWebhookActivationTarget(agentId, input) {
|
|
371
487
|
this.getAgent(agentId);
|
|
372
488
|
validateNotifyLeaseMs(input.notifyLeaseMs);
|
|
@@ -415,6 +531,57 @@ class AgentInboxService {
|
|
|
415
531
|
this.reconcileAgentStatus(agentId);
|
|
416
532
|
return { removed: true };
|
|
417
533
|
}
|
|
534
|
+
async resumeAgent(agentId) {
|
|
535
|
+
this.getAgent(agentId);
|
|
536
|
+
const targets = this.store.listActivationTargetsForAgent(agentId);
|
|
537
|
+
const results = [];
|
|
538
|
+
for (const target of targets) {
|
|
539
|
+
results.push(await this.resumeActivationTarget(agentId, target.targetId));
|
|
540
|
+
}
|
|
541
|
+
this.reconcileAgentStatus(agentId);
|
|
542
|
+
return {
|
|
543
|
+
resumed: results.some((result) => result.resumed),
|
|
544
|
+
agent: this.getAgent(agentId),
|
|
545
|
+
targets: results,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
async resumeActivationTarget(agentId, targetId) {
|
|
549
|
+
const target = this.getActivationTarget(targetId);
|
|
550
|
+
if (target.agentId !== agentId) {
|
|
551
|
+
throw new Error(`activation target ${targetId} does not belong to agent ${agentId}`);
|
|
552
|
+
}
|
|
553
|
+
if (target.status === "active") {
|
|
554
|
+
return {
|
|
555
|
+
targetId: target.targetId,
|
|
556
|
+
resumed: false,
|
|
557
|
+
status: target.status,
|
|
558
|
+
reason: "already_active",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
let result;
|
|
562
|
+
if (target.kind === "terminal") {
|
|
563
|
+
result = await this.resumeTerminalActivationTarget(target);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const now = (0, util_1.nowIso)();
|
|
567
|
+
this.store.updateActivationTargetRuntime(target.targetId, {
|
|
568
|
+
status: "active",
|
|
569
|
+
offlineSince: null,
|
|
570
|
+
consecutiveFailures: 0,
|
|
571
|
+
lastError: null,
|
|
572
|
+
updatedAt: now,
|
|
573
|
+
lastSeenAt: now,
|
|
574
|
+
});
|
|
575
|
+
result = {
|
|
576
|
+
targetId: target.targetId,
|
|
577
|
+
resumed: true,
|
|
578
|
+
status: "active",
|
|
579
|
+
reason: "webhook_resumed",
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
this.reconcileAgentStatus(agentId);
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
418
585
|
async registerSubscription(input) {
|
|
419
586
|
if (input.shortcut) {
|
|
420
587
|
const source = this.store.getSource(input.sourceId);
|
|
@@ -578,6 +745,109 @@ class AgentInboxService {
|
|
|
578
745
|
listInboxItems(agentId, options) {
|
|
579
746
|
return this.store.listInboxItems(this.ensureInboxForAgent(agentId).inboxId, options);
|
|
580
747
|
}
|
|
748
|
+
async addDirectInboxTextMessage(agentId, input, env = process.env) {
|
|
749
|
+
this.getAgent(agentId);
|
|
750
|
+
const message = normalizeDirectInboxMessage(input.message);
|
|
751
|
+
const sender = normalizeDirectInboxSender(input.sender) ?? this.detectDirectInboxSender(env);
|
|
752
|
+
return this.materializeAgentInboxItem(agentId, {
|
|
753
|
+
sourceId: DIRECT_INBOX_SOURCE_ID,
|
|
754
|
+
sourceNativeId: (0, util_1.generateId)("direct"),
|
|
755
|
+
eventVariant: DIRECT_INBOX_EVENT_VARIANT,
|
|
756
|
+
summary: summarizeDirectInboxMessage(sender),
|
|
757
|
+
rawPayload: {
|
|
758
|
+
type: "direct_text_message",
|
|
759
|
+
message,
|
|
760
|
+
sender,
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
async fireTimer(timer, now) {
|
|
765
|
+
const scheduledFireAt = timer.nextFireAt ?? now;
|
|
766
|
+
this.materializeAgentInboxItem(timer.agentId, {
|
|
767
|
+
sourceId: TIMER_INBOX_SOURCE_ID,
|
|
768
|
+
sourceNativeId: `${timer.scheduleId}:${scheduledFireAt}`,
|
|
769
|
+
eventVariant: TIMER_INBOX_EVENT_VARIANT,
|
|
770
|
+
summary: summarizeTimerMessage(timer.scheduleId),
|
|
771
|
+
rawPayload: {
|
|
772
|
+
type: "timer_fired",
|
|
773
|
+
scheduleId: timer.scheduleId,
|
|
774
|
+
message: timer.message,
|
|
775
|
+
sender: timer.sender ?? DEFAULT_TIMER_SENDER,
|
|
776
|
+
},
|
|
777
|
+
occurredAt: scheduledFireAt,
|
|
778
|
+
allowDuplicate: true,
|
|
779
|
+
});
|
|
780
|
+
const nextFireAt = computeNextTimerFire({
|
|
781
|
+
mode: timer.mode,
|
|
782
|
+
at: timer.at ?? null,
|
|
783
|
+
intervalMs: timer.intervalMs ?? null,
|
|
784
|
+
cronExpr: timer.cronExpr ?? null,
|
|
785
|
+
timezone: timer.timezone,
|
|
786
|
+
fromIso: scheduledFireAt,
|
|
787
|
+
lastFiredAt: scheduledFireAt,
|
|
788
|
+
restartRecovery: false,
|
|
789
|
+
});
|
|
790
|
+
this.store.updateTimer(timer.scheduleId, {
|
|
791
|
+
status: nextFireAt ? "active" : "paused",
|
|
792
|
+
nextFireAt,
|
|
793
|
+
lastFiredAt: now,
|
|
794
|
+
updatedAt: now,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
materializeAgentInboxItem(agentId, input) {
|
|
798
|
+
const inbox = this.ensureInboxForAgent(agentId);
|
|
799
|
+
const occurredAt = input.occurredAt ?? (0, util_1.nowIso)();
|
|
800
|
+
const item = {
|
|
801
|
+
itemId: (0, util_1.generateId)("item"),
|
|
802
|
+
sourceId: input.sourceId,
|
|
803
|
+
sourceNativeId: input.sourceNativeId,
|
|
804
|
+
eventVariant: input.eventVariant,
|
|
805
|
+
inboxId: inbox.inboxId,
|
|
806
|
+
occurredAt,
|
|
807
|
+
metadata: {},
|
|
808
|
+
rawPayload: input.rawPayload,
|
|
809
|
+
deliveryHandle: null,
|
|
810
|
+
ackedAt: null,
|
|
811
|
+
};
|
|
812
|
+
const inserted = this.store.insertInboxItem(item);
|
|
813
|
+
if (!inserted && !input.allowDuplicate) {
|
|
814
|
+
throw new Error(`failed to persist direct inbox message item: ${item.itemId}`);
|
|
815
|
+
}
|
|
816
|
+
if (!inserted) {
|
|
817
|
+
return {
|
|
818
|
+
itemId: item.itemId,
|
|
819
|
+
inboxId: inbox.inboxId,
|
|
820
|
+
activated: false,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
this.notifyInboxWatchers(agentId, [item]);
|
|
824
|
+
const activationItem = {
|
|
825
|
+
itemId: item.itemId,
|
|
826
|
+
sourceId: item.sourceId,
|
|
827
|
+
sourceNativeId: item.sourceNativeId,
|
|
828
|
+
eventVariant: item.eventVariant,
|
|
829
|
+
inboxId: item.inboxId,
|
|
830
|
+
occurredAt: item.occurredAt,
|
|
831
|
+
metadata: item.metadata,
|
|
832
|
+
rawPayload: item.rawPayload,
|
|
833
|
+
deliveryHandle: item.deliveryHandle,
|
|
834
|
+
};
|
|
835
|
+
const targets = this.store.listActivationTargetsForAgent(agentId).filter((target) => target.status === "active");
|
|
836
|
+
for (const target of targets) {
|
|
837
|
+
this.enqueueActivationTarget(target, {
|
|
838
|
+
agentId,
|
|
839
|
+
subscriptionId: null,
|
|
840
|
+
sourceId: input.sourceId,
|
|
841
|
+
summary: input.summary,
|
|
842
|
+
item: activationItem,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
itemId: item.itemId,
|
|
847
|
+
inboxId: inbox.inboxId,
|
|
848
|
+
activated: targets.length > 0,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
581
851
|
watchInbox(agentId, options, onEvent) {
|
|
582
852
|
const inbox = this.ensureInboxForAgent(agentId);
|
|
583
853
|
const pendingItems = [];
|
|
@@ -1004,6 +1274,7 @@ class AgentInboxService {
|
|
|
1004
1274
|
const target = this.store.updateTerminalActivationTargetHeartbeat(existing.targetId, {
|
|
1005
1275
|
runtimeKind: input.runtimeKind ?? "unknown",
|
|
1006
1276
|
runtimeSessionId: input.runtimeSessionId ?? null,
|
|
1277
|
+
runtimePid: input.runtimePid ?? null,
|
|
1007
1278
|
tmuxPaneId: input.tmuxPaneId ?? null,
|
|
1008
1279
|
tty: input.tty ?? null,
|
|
1009
1280
|
termProgram: input.termProgram ?? null,
|
|
@@ -1030,6 +1301,7 @@ class AgentInboxService {
|
|
|
1030
1301
|
notifyLeaseMs: input.notifyLeaseMs ?? DEFAULT_NOTIFY_LEASE_MS,
|
|
1031
1302
|
runtimeKind: input.runtimeKind ?? "unknown",
|
|
1032
1303
|
runtimeSessionId: input.runtimeSessionId ?? null,
|
|
1304
|
+
runtimePid: input.runtimePid ?? null,
|
|
1033
1305
|
backend: input.backend,
|
|
1034
1306
|
tmuxPaneId: input.tmuxPaneId ?? null,
|
|
1035
1307
|
tty: input.tty ?? null,
|
|
@@ -1111,6 +1383,15 @@ class AgentInboxService {
|
|
|
1111
1383
|
await this.flushNotificationBuffer(key);
|
|
1112
1384
|
}
|
|
1113
1385
|
}
|
|
1386
|
+
async awaitInFlightNotificationBuffers() {
|
|
1387
|
+
while (true) {
|
|
1388
|
+
const activeBuffers = Array.from(this.notificationBuffers.values());
|
|
1389
|
+
if (activeBuffers.every((buffer) => !buffer.inFlight)) {
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1114
1395
|
async flushNotificationBuffer(key) {
|
|
1115
1396
|
const buffer = this.notificationBuffers.get(key);
|
|
1116
1397
|
if (!buffer || buffer.inFlight || buffer.pending.length === 0) {
|
|
@@ -1125,12 +1406,14 @@ class AgentInboxService {
|
|
|
1125
1406
|
try {
|
|
1126
1407
|
const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
|
|
1127
1408
|
if (!state) {
|
|
1409
|
+
const inbox = this.ensureInboxForAgent(buffer.agentId);
|
|
1128
1410
|
const dispatched = await this.dispatchActivationTarget({
|
|
1129
1411
|
agentId: buffer.agentId,
|
|
1130
1412
|
targetId: buffer.targetId,
|
|
1131
1413
|
newItemCount: entries.length,
|
|
1414
|
+
totalUnackedCount: this.store.countInboxItems(inbox.inboxId, false),
|
|
1132
1415
|
summary: entries[0]?.summary ?? null,
|
|
1133
|
-
subscriptionIds:
|
|
1416
|
+
subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
|
|
1134
1417
|
sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
|
|
1135
1418
|
items: entries.map((entry) => entry.item),
|
|
1136
1419
|
});
|
|
@@ -1146,7 +1429,7 @@ class AgentInboxService {
|
|
|
1146
1429
|
leaseExpiresAt: state.leaseExpiresAt,
|
|
1147
1430
|
pendingNewItemCount: state.pendingNewItemCount + entries.length,
|
|
1148
1431
|
pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
|
|
1149
|
-
pendingSubscriptionIds:
|
|
1432
|
+
pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
|
|
1150
1433
|
pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
|
|
1151
1434
|
updatedAt: (0, util_1.nowIso)(),
|
|
1152
1435
|
});
|
|
@@ -1154,9 +1437,11 @@ class AgentInboxService {
|
|
|
1154
1437
|
const hasPendingDuringFlight = buffer.pending.length > 0;
|
|
1155
1438
|
buffer.inFlight = false;
|
|
1156
1439
|
if (hasPendingDuringFlight) {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1440
|
+
if (!this.stopping) {
|
|
1441
|
+
buffer.timer = setTimeout(() => {
|
|
1442
|
+
void this.flushNotificationBuffer(key);
|
|
1443
|
+
}, this.activationWindowMs);
|
|
1444
|
+
}
|
|
1160
1445
|
return;
|
|
1161
1446
|
}
|
|
1162
1447
|
this.notificationBuffers.delete(key);
|
|
@@ -1196,6 +1481,7 @@ class AgentInboxService {
|
|
|
1196
1481
|
agentId,
|
|
1197
1482
|
targetId,
|
|
1198
1483
|
newItemCount: state.pendingNewItemCount > 0 ? state.pendingNewItemCount : unacked,
|
|
1484
|
+
totalUnackedCount: unacked,
|
|
1199
1485
|
summary: state.pendingSummary,
|
|
1200
1486
|
subscriptionIds: state.pendingSubscriptionIds,
|
|
1201
1487
|
sourceIds: state.pendingSourceIds,
|
|
@@ -1231,11 +1517,32 @@ class AgentInboxService {
|
|
|
1231
1517
|
}
|
|
1232
1518
|
const inbox = this.ensureInboxForAgent(input.agentId);
|
|
1233
1519
|
const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
|
|
1520
|
+
const totalUnackedCount = input.totalUnackedCount ?? this.store.countInboxItems(inbox.inboxId, false);
|
|
1234
1521
|
try {
|
|
1235
1522
|
if (target.kind === "terminal") {
|
|
1523
|
+
const gate = await this.activationGate.evaluate(target);
|
|
1524
|
+
if (gate.outcome === "offline") {
|
|
1525
|
+
this.markActivationTargetOffline(target.targetId, `runtime gate: ${gate.reason}`);
|
|
1526
|
+
this.reconcileAgentStatus(target.agentId);
|
|
1527
|
+
return "offline";
|
|
1528
|
+
}
|
|
1529
|
+
if (gate.outcome === "defer") {
|
|
1530
|
+
this.store.upsertActivationDispatchState({
|
|
1531
|
+
agentId: input.agentId,
|
|
1532
|
+
targetId: input.targetId,
|
|
1533
|
+
status: "dirty",
|
|
1534
|
+
leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
|
|
1535
|
+
pendingNewItemCount: input.newItemCount,
|
|
1536
|
+
pendingSummary: input.summary,
|
|
1537
|
+
pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
|
|
1538
|
+
pendingSourceIds: uniqueSorted(input.sourceIds),
|
|
1539
|
+
updatedAt: (0, util_1.nowIso)(),
|
|
1540
|
+
});
|
|
1541
|
+
return "deferred";
|
|
1542
|
+
}
|
|
1236
1543
|
const prompt = (0, terminal_1.renderAgentPrompt)({
|
|
1237
1544
|
inboxId: inbox.inboxId,
|
|
1238
|
-
|
|
1545
|
+
totalUnackedCount,
|
|
1239
1546
|
summary: input.summary,
|
|
1240
1547
|
});
|
|
1241
1548
|
await this.terminalDispatcher.dispatch(target, prompt);
|
|
@@ -1297,11 +1604,37 @@ class AgentInboxService {
|
|
|
1297
1604
|
leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
|
|
1298
1605
|
pendingNewItemCount: entries.length,
|
|
1299
1606
|
pendingSummary: entries[0]?.summary ?? null,
|
|
1300
|
-
pendingSubscriptionIds:
|
|
1607
|
+
pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
|
|
1301
1608
|
pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
|
|
1302
1609
|
updatedAt: (0, util_1.nowIso)(),
|
|
1303
1610
|
});
|
|
1304
1611
|
}
|
|
1612
|
+
detectDirectInboxSender(env) {
|
|
1613
|
+
try {
|
|
1614
|
+
const detected = (0, terminal_1.detectTerminalContext)(env);
|
|
1615
|
+
const target = findExistingTerminalActivationTarget(this.store, {
|
|
1616
|
+
runtimeKind: detected.runtimeKind,
|
|
1617
|
+
runtimeSessionId: detected.runtimeSessionId ?? null,
|
|
1618
|
+
runtimePid: detected.runtimePid ?? null,
|
|
1619
|
+
backend: detected.backend,
|
|
1620
|
+
tmuxPaneId: detected.tmuxPaneId ?? null,
|
|
1621
|
+
tty: detected.tty ?? null,
|
|
1622
|
+
termProgram: detected.termProgram ?? null,
|
|
1623
|
+
itermSessionId: detected.itermSessionId ?? null,
|
|
1624
|
+
});
|
|
1625
|
+
if (target) {
|
|
1626
|
+
return target.agentId;
|
|
1627
|
+
}
|
|
1628
|
+
return detected.runtimeSessionId
|
|
1629
|
+
?? detected.tmuxPaneId
|
|
1630
|
+
?? detected.itermSessionId
|
|
1631
|
+
?? detected.tty
|
|
1632
|
+
?? null;
|
|
1633
|
+
}
|
|
1634
|
+
catch {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1305
1638
|
markActivationTargetDelivered(targetId) {
|
|
1306
1639
|
const now = (0, util_1.nowIso)();
|
|
1307
1640
|
this.store.updateActivationTargetRuntime(targetId, {
|
|
@@ -1366,6 +1699,46 @@ class AgentInboxService {
|
|
|
1366
1699
|
lastSeenAt: agent.lastSeenAt,
|
|
1367
1700
|
});
|
|
1368
1701
|
}
|
|
1702
|
+
async resumeTerminalActivationTarget(target) {
|
|
1703
|
+
const probeStatus = await this.terminalDispatcher.probeStatus(target);
|
|
1704
|
+
const now = (0, util_1.nowIso)();
|
|
1705
|
+
if (probeStatus === "available") {
|
|
1706
|
+
this.store.updateActivationTargetRuntime(target.targetId, {
|
|
1707
|
+
status: "active",
|
|
1708
|
+
offlineSince: null,
|
|
1709
|
+
consecutiveFailures: 0,
|
|
1710
|
+
lastError: null,
|
|
1711
|
+
updatedAt: now,
|
|
1712
|
+
lastSeenAt: now,
|
|
1713
|
+
});
|
|
1714
|
+
return {
|
|
1715
|
+
targetId: target.targetId,
|
|
1716
|
+
resumed: true,
|
|
1717
|
+
status: "active",
|
|
1718
|
+
reason: "terminal_available",
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
if (probeStatus === "gone") {
|
|
1722
|
+
this.store.updateActivationTargetRuntime(target.targetId, {
|
|
1723
|
+
status: "offline",
|
|
1724
|
+
offlineSince: target.offlineSince ?? now,
|
|
1725
|
+
updatedAt: now,
|
|
1726
|
+
lastError: target.lastError ?? "terminal gone",
|
|
1727
|
+
});
|
|
1728
|
+
return {
|
|
1729
|
+
targetId: target.targetId,
|
|
1730
|
+
resumed: false,
|
|
1731
|
+
status: "offline",
|
|
1732
|
+
reason: "terminal_gone",
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
targetId: target.targetId,
|
|
1737
|
+
resumed: false,
|
|
1738
|
+
status: "offline",
|
|
1739
|
+
reason: "probe_unknown",
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1369
1742
|
runLifecycleCleanupPass(nowMs) {
|
|
1370
1743
|
this.lastLifecycleCleanupAt = nowMs;
|
|
1371
1744
|
const removed = new Set();
|
|
@@ -1474,6 +1847,9 @@ class AgentInboxService {
|
|
|
1474
1847
|
if (agents.length > 0) {
|
|
1475
1848
|
this.store.save();
|
|
1476
1849
|
}
|
|
1850
|
+
if (agents.length > 0) {
|
|
1851
|
+
this.rescheduleTimerSync();
|
|
1852
|
+
}
|
|
1477
1853
|
return { removedAgents: agents.length };
|
|
1478
1854
|
}
|
|
1479
1855
|
async syncAllSubscriptions() {
|
|
@@ -1531,6 +1907,64 @@ class AgentInboxService {
|
|
|
1531
1907
|
console.warn("offline agent gc failed:", error);
|
|
1532
1908
|
}
|
|
1533
1909
|
}
|
|
1910
|
+
async syncTimers() {
|
|
1911
|
+
const now = (0, util_1.nowIso)();
|
|
1912
|
+
const due = this.store.listDueTimers(now);
|
|
1913
|
+
for (const timer of due) {
|
|
1914
|
+
try {
|
|
1915
|
+
await this.fireTimer(timer, now);
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
console.warn(`timer fire failed for ${timer.scheduleId}:`, error);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
this.rescheduleTimerSync();
|
|
1922
|
+
}
|
|
1923
|
+
refreshTimersOnStart() {
|
|
1924
|
+
const now = (0, util_1.nowIso)();
|
|
1925
|
+
for (const timer of this.store.listTimers()) {
|
|
1926
|
+
if (timer.status !== "active") {
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
const nextFireAt = computeNextTimerFire({
|
|
1930
|
+
mode: timer.mode,
|
|
1931
|
+
at: timer.at ?? null,
|
|
1932
|
+
intervalMs: timer.intervalMs ?? null,
|
|
1933
|
+
cronExpr: timer.cronExpr ?? null,
|
|
1934
|
+
timezone: timer.timezone,
|
|
1935
|
+
fromIso: now,
|
|
1936
|
+
lastFiredAt: timer.lastFiredAt ?? null,
|
|
1937
|
+
restartRecovery: true,
|
|
1938
|
+
});
|
|
1939
|
+
this.store.updateTimer(timer.scheduleId, {
|
|
1940
|
+
nextFireAt,
|
|
1941
|
+
status: nextFireAt ? "active" : "paused",
|
|
1942
|
+
updatedAt: now,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
rescheduleTimerSync() {
|
|
1947
|
+
if (this.timerSyncTimeout) {
|
|
1948
|
+
clearTimeout(this.timerSyncTimeout);
|
|
1949
|
+
this.timerSyncTimeout = null;
|
|
1950
|
+
}
|
|
1951
|
+
if (this.stopping) {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const nearest = this.store.getNearestActiveTimer();
|
|
1955
|
+
if (!nearest?.nextFireAt) {
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const dueAt = Date.parse(nearest.nextFireAt);
|
|
1959
|
+
if (Number.isNaN(dueAt)) {
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
const delay = Math.max(0, dueAt - Date.now());
|
|
1963
|
+
this.timerSyncTimeout = setTimeout(() => {
|
|
1964
|
+
this.timerSyncTimeout = null;
|
|
1965
|
+
void this.syncTimers();
|
|
1966
|
+
}, delay);
|
|
1967
|
+
}
|
|
1534
1968
|
refreshSourceIdleState(sourceId) {
|
|
1535
1969
|
const source = this.store.getSource(sourceId);
|
|
1536
1970
|
if (!source) {
|
|
@@ -1841,6 +2275,9 @@ function isSameTerminalIdentity(target, input) {
|
|
|
1841
2275
|
function uniqueSorted(values) {
|
|
1842
2276
|
return Array.from(new Set(values)).sort();
|
|
1843
2277
|
}
|
|
2278
|
+
function uniqueSortedNullable(values) {
|
|
2279
|
+
return uniqueSorted(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
2280
|
+
}
|
|
1844
2281
|
function notificationBufferKey(agentId, targetId) {
|
|
1845
2282
|
return `${agentId}::${targetId}`;
|
|
1846
2283
|
}
|
|
@@ -1873,3 +2310,299 @@ function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
|
|
|
1873
2310
|
}
|
|
1874
2311
|
return `${sourceType}:${sourceKey}:${eventVariant}`;
|
|
1875
2312
|
}
|
|
2313
|
+
function summarizeDirectInboxMessage(sender) {
|
|
2314
|
+
if (sender) {
|
|
2315
|
+
return `direct_text_message:${sender}`;
|
|
2316
|
+
}
|
|
2317
|
+
return "direct_text_message";
|
|
2318
|
+
}
|
|
2319
|
+
function summarizeTimerMessage(scheduleId) {
|
|
2320
|
+
return `timer:${scheduleId}`;
|
|
2321
|
+
}
|
|
2322
|
+
function normalizeDirectInboxMessage(message) {
|
|
2323
|
+
if (typeof message !== "string") {
|
|
2324
|
+
throw new Error("direct inbox message must be a string");
|
|
2325
|
+
}
|
|
2326
|
+
const trimmed = message.trim();
|
|
2327
|
+
if (trimmed.length === 0) {
|
|
2328
|
+
throw new Error("direct inbox message must not be empty");
|
|
2329
|
+
}
|
|
2330
|
+
return trimmed;
|
|
2331
|
+
}
|
|
2332
|
+
function normalizeDirectInboxSender(sender) {
|
|
2333
|
+
if (sender == null) {
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
if (typeof sender !== "string") {
|
|
2337
|
+
throw new Error("direct inbox sender must be a string");
|
|
2338
|
+
}
|
|
2339
|
+
const trimmed = sender.trim();
|
|
2340
|
+
if (trimmed.length === 0) {
|
|
2341
|
+
throw new Error("direct inbox sender must not be empty");
|
|
2342
|
+
}
|
|
2343
|
+
return trimmed;
|
|
2344
|
+
}
|
|
2345
|
+
function normalizeTimerInput(input) {
|
|
2346
|
+
const modes = [input.at != null, input.every != null, input.cron != null].filter(Boolean).length;
|
|
2347
|
+
if (modes !== 1) {
|
|
2348
|
+
throw new Error("timers require exactly one of at, every, or cron");
|
|
2349
|
+
}
|
|
2350
|
+
if (input.at != null) {
|
|
2351
|
+
const at = normalizeIsoTimestamp(input.at, "timer at");
|
|
2352
|
+
return { mode: "at", at, timezone: input.timezone ?? null };
|
|
2353
|
+
}
|
|
2354
|
+
if (input.every != null) {
|
|
2355
|
+
if (!Number.isInteger(input.every) || input.every < MIN_TIMER_INTERVAL_MS) {
|
|
2356
|
+
throw new Error("timer every interval must be an integer number of milliseconds and at least 60000");
|
|
2357
|
+
}
|
|
2358
|
+
return { mode: "every", every: input.every, timezone: input.timezone ?? null };
|
|
2359
|
+
}
|
|
2360
|
+
const cron = normalizeCronExpression(input.cron);
|
|
2361
|
+
return { mode: "cron", cron, timezone: input.timezone ?? null };
|
|
2362
|
+
}
|
|
2363
|
+
function normalizeIsoTimestamp(value, label) {
|
|
2364
|
+
const parsed = Date.parse(value);
|
|
2365
|
+
if (Number.isNaN(parsed)) {
|
|
2366
|
+
throw new Error(`${label} must be a valid ISO8601 timestamp`);
|
|
2367
|
+
}
|
|
2368
|
+
return new Date(parsed).toISOString();
|
|
2369
|
+
}
|
|
2370
|
+
function normalizeCronExpression(value) {
|
|
2371
|
+
if (typeof value !== "string") {
|
|
2372
|
+
throw new Error("timer cron must be a string");
|
|
2373
|
+
}
|
|
2374
|
+
const trimmed = value.trim().replace(/\s+/g, " ");
|
|
2375
|
+
const fields = trimmed.split(" ");
|
|
2376
|
+
if (fields.length !== 5) {
|
|
2377
|
+
throw new Error("timer cron must use standard 5-field syntax");
|
|
2378
|
+
}
|
|
2379
|
+
for (const field of fields) {
|
|
2380
|
+
if (/[LW#@]/.test(field)) {
|
|
2381
|
+
throw new Error("timer cron does not support extensions like L, W, #, or @reboot");
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
parseCronExpression(trimmed);
|
|
2385
|
+
return trimmed;
|
|
2386
|
+
}
|
|
2387
|
+
function detectHostTimezone() {
|
|
2388
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
2389
|
+
}
|
|
2390
|
+
function assertValidTimeZone(timezone) {
|
|
2391
|
+
try {
|
|
2392
|
+
Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
|
|
2393
|
+
}
|
|
2394
|
+
catch {
|
|
2395
|
+
throw new Error(`invalid timezone: ${timezone}`);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
function computeNextTimerFire(input) {
|
|
2399
|
+
const fromMs = Date.parse(input.fromIso);
|
|
2400
|
+
if (Number.isNaN(fromMs)) {
|
|
2401
|
+
return null;
|
|
2402
|
+
}
|
|
2403
|
+
if (input.mode === "at") {
|
|
2404
|
+
if (!input.at) {
|
|
2405
|
+
return null;
|
|
2406
|
+
}
|
|
2407
|
+
const atMs = Date.parse(input.at);
|
|
2408
|
+
if (Number.isNaN(atMs)) {
|
|
2409
|
+
return null;
|
|
2410
|
+
}
|
|
2411
|
+
if (input.lastFiredAt) {
|
|
2412
|
+
return null;
|
|
2413
|
+
}
|
|
2414
|
+
if (atMs <= fromMs) {
|
|
2415
|
+
return input.restartRecovery ? input.at : input.at;
|
|
2416
|
+
}
|
|
2417
|
+
return input.at;
|
|
2418
|
+
}
|
|
2419
|
+
if (input.mode === "every") {
|
|
2420
|
+
if (!input.intervalMs) {
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2423
|
+
if (!input.lastFiredAt) {
|
|
2424
|
+
return new Date(fromMs + input.intervalMs).toISOString();
|
|
2425
|
+
}
|
|
2426
|
+
const lastFiredMs = Date.parse(input.lastFiredAt);
|
|
2427
|
+
if (Number.isNaN(lastFiredMs)) {
|
|
2428
|
+
return new Date(fromMs + input.intervalMs).toISOString();
|
|
2429
|
+
}
|
|
2430
|
+
const base = input.restartRecovery ? fromMs : Math.max(fromMs, lastFiredMs);
|
|
2431
|
+
return new Date(base + input.intervalMs).toISOString();
|
|
2432
|
+
}
|
|
2433
|
+
if (!input.cronExpr) {
|
|
2434
|
+
return null;
|
|
2435
|
+
}
|
|
2436
|
+
return nextCronOccurrence(input.cronExpr, input.timezone, new Date(fromMs));
|
|
2437
|
+
}
|
|
2438
|
+
function parseCronExpression(expr) {
|
|
2439
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = expr.split(" ");
|
|
2440
|
+
return {
|
|
2441
|
+
minute: parseCronField(minute, 0, 59),
|
|
2442
|
+
hour: parseCronField(hour, 0, 23),
|
|
2443
|
+
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
|
|
2444
|
+
month: parseCronField(month, 1, 12),
|
|
2445
|
+
dayOfWeek: parseCronField(dayOfWeek, 0, 7, true),
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
function parseCronField(field, min, max, normalizeSunday = false) {
|
|
2449
|
+
if (field === "*") {
|
|
2450
|
+
return { any: true, values: new Set() };
|
|
2451
|
+
}
|
|
2452
|
+
const values = new Set();
|
|
2453
|
+
for (const part of field.split(",")) {
|
|
2454
|
+
const [rangePart, stepPart] = part.split("/");
|
|
2455
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
2456
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
2457
|
+
throw new Error(`invalid cron field: ${field}`);
|
|
2458
|
+
}
|
|
2459
|
+
const [startRaw, endRaw] = rangePart === "*" ? [String(min), String(max)] : rangePart.split("-");
|
|
2460
|
+
const start = Number(startRaw);
|
|
2461
|
+
const end = endRaw != null ? Number(endRaw) : start;
|
|
2462
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
2463
|
+
throw new Error(`invalid cron field: ${field}`);
|
|
2464
|
+
}
|
|
2465
|
+
for (let value = start; value <= end; value += step) {
|
|
2466
|
+
values.add(normalizeSunday && value === 7 ? 0 : value);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
return { any: false, values };
|
|
2470
|
+
}
|
|
2471
|
+
function nextCronOccurrence(expr, timezone, after) {
|
|
2472
|
+
const parsed = parseCronExpression(expr);
|
|
2473
|
+
let candidate = addMinutes(after, 1);
|
|
2474
|
+
candidate.setUTCSeconds(0, 0);
|
|
2475
|
+
for (let guard = 0; guard < 10_000; guard += 1) {
|
|
2476
|
+
const zoned = zonedDateParts(candidate, timezone);
|
|
2477
|
+
const nextMonth = nextAllowedValue(parsed.month, zoned.month, 1, 12);
|
|
2478
|
+
if (nextMonth == null) {
|
|
2479
|
+
candidate = utcDateForZoned(timezone, zoned.year + 1, firstAllowedValue(parsed.month, 1, 12), 1, 0, 0);
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
if (nextMonth !== zoned.month) {
|
|
2483
|
+
candidate = utcDateForZoned(timezone, zoned.year, nextMonth, 1, 0, 0);
|
|
2484
|
+
continue;
|
|
2485
|
+
}
|
|
2486
|
+
if (!matchesCronDay(parsed, zoned)) {
|
|
2487
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, 0, 0);
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
const nextHour = nextAllowedValue(parsed.hour, zoned.hour, 0, 23);
|
|
2491
|
+
if (nextHour == null) {
|
|
2492
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, firstAllowedValue(parsed.hour, 0, 23), 0);
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
if (nextHour !== zoned.hour) {
|
|
2496
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, nextHour, 0);
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
const nextMinute = nextAllowedValue(parsed.minute, zoned.minute, 0, 59);
|
|
2500
|
+
if (nextMinute == null) {
|
|
2501
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour + 1, firstAllowedValue(parsed.minute, 0, 59));
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
if (nextMinute !== zoned.minute) {
|
|
2505
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour, nextMinute);
|
|
2506
|
+
continue;
|
|
2507
|
+
}
|
|
2508
|
+
if (matchesCron(parsed, zoned)) {
|
|
2509
|
+
return candidate.toISOString();
|
|
2510
|
+
}
|
|
2511
|
+
candidate = addMinutes(candidate, 1);
|
|
2512
|
+
}
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
function matchesCron(parsed, parts) {
|
|
2516
|
+
const monthMatches = parsed.month.any || parsed.month.values.has(parts.month);
|
|
2517
|
+
const hourMatches = parsed.hour.any || parsed.hour.values.has(parts.hour);
|
|
2518
|
+
const minuteMatches = parsed.minute.any || parsed.minute.values.has(parts.minute);
|
|
2519
|
+
if (!monthMatches || !hourMatches || !minuteMatches) {
|
|
2520
|
+
return false;
|
|
2521
|
+
}
|
|
2522
|
+
const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
|
|
2523
|
+
const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
|
|
2524
|
+
if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
|
|
2525
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
2526
|
+
}
|
|
2527
|
+
return dayOfMonthMatches && dayOfWeekMatches;
|
|
2528
|
+
}
|
|
2529
|
+
function matchesCronDay(parsed, parts) {
|
|
2530
|
+
const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
|
|
2531
|
+
const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
|
|
2532
|
+
if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
|
|
2533
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
2534
|
+
}
|
|
2535
|
+
return dayOfMonthMatches && dayOfWeekMatches;
|
|
2536
|
+
}
|
|
2537
|
+
function zonedDateParts(date, timezone) {
|
|
2538
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
2539
|
+
timeZone: timezone,
|
|
2540
|
+
year: "numeric",
|
|
2541
|
+
month: "2-digit",
|
|
2542
|
+
day: "2-digit",
|
|
2543
|
+
hour: "2-digit",
|
|
2544
|
+
minute: "2-digit",
|
|
2545
|
+
hourCycle: "h23",
|
|
2546
|
+
weekday: "short",
|
|
2547
|
+
});
|
|
2548
|
+
const entries = Object.fromEntries(formatter.formatToParts(date)
|
|
2549
|
+
.filter((part) => part.type !== "literal")
|
|
2550
|
+
.map((part) => [part.type, part.value]));
|
|
2551
|
+
const hour = Number(entries.hour);
|
|
2552
|
+
return {
|
|
2553
|
+
year: Number(entries.year),
|
|
2554
|
+
month: Number(entries.month),
|
|
2555
|
+
day: Number(entries.day),
|
|
2556
|
+
// Some ICU builds format midnight as 24:00 even for exact timestamps.
|
|
2557
|
+
// Normalize that representation so cron matching stays cross-platform.
|
|
2558
|
+
hour: hour === 24 ? 0 : hour,
|
|
2559
|
+
minute: Number(entries.minute),
|
|
2560
|
+
dayOfWeek: weekdayToNumber(entries.weekday),
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function nextAllowedValue(field, current, min, max) {
|
|
2564
|
+
if (field.any) {
|
|
2565
|
+
return current;
|
|
2566
|
+
}
|
|
2567
|
+
for (let value = current; value <= max; value += 1) {
|
|
2568
|
+
if (field.values.has(value)) {
|
|
2569
|
+
return value;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
return null;
|
|
2573
|
+
}
|
|
2574
|
+
function firstAllowedValue(field, min, max) {
|
|
2575
|
+
if (field.any) {
|
|
2576
|
+
return min;
|
|
2577
|
+
}
|
|
2578
|
+
for (let value = min; value <= max; value += 1) {
|
|
2579
|
+
if (field.values.has(value)) {
|
|
2580
|
+
return value;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return min;
|
|
2584
|
+
}
|
|
2585
|
+
function addMinutes(date, minutes) {
|
|
2586
|
+
return new Date(date.getTime() + minutes * 60_000);
|
|
2587
|
+
}
|
|
2588
|
+
function utcDateForZoned(timezone, year, month, day, hour, minute) {
|
|
2589
|
+
const approxUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
|
2590
|
+
const approx = new Date(approxUtc);
|
|
2591
|
+
const zoned = zonedDateParts(approx, timezone);
|
|
2592
|
+
const targetNaiveUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
|
2593
|
+
const zonedNaiveUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0, 0);
|
|
2594
|
+
return new Date(approxUtc - (zonedNaiveUtc - targetNaiveUtc));
|
|
2595
|
+
}
|
|
2596
|
+
function weekdayToNumber(value) {
|
|
2597
|
+
switch (value) {
|
|
2598
|
+
case "Sun": return 0;
|
|
2599
|
+
case "Mon": return 1;
|
|
2600
|
+
case "Tue": return 2;
|
|
2601
|
+
case "Wed": return 3;
|
|
2602
|
+
case "Thu": return 4;
|
|
2603
|
+
case "Fri": return 5;
|
|
2604
|
+
case "Sat": return 6;
|
|
2605
|
+
default:
|
|
2606
|
+
throw new Error(`unsupported weekday: ${value}`);
|
|
2607
|
+
}
|
|
2608
|
+
}
|