@holon-run/agentinbox 0.1.4 → 0.3.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 +1 -0
- package/dist/src/adapters.js +33 -2
- package/dist/src/cli.js +173 -14
- package/dist/src/current_agent.js +1 -0
- package/dist/src/http.js +242 -8
- package/dist/src/runtime_gate.js +464 -0
- package/dist/src/service.js +1114 -23
- package/dist/src/source_resolution.js +100 -0
- package/dist/src/source_schema.js +23 -1
- package/dist/src/sources/github.js +73 -1
- package/dist/src/sources/remote.js +24 -0
- package/dist/src/sources/remote_profiles.js +133 -0
- package/dist/src/store.js +234 -9
- package/dist/src/terminal.js +12 -4
- package/drizzle/migrations/0002_subscription_cleanup_policy.sql +45 -0
- package/drizzle/migrations/0003_subscription_lifecycle_retirements.sql +14 -0
- package/drizzle/migrations/0004_source_idle_states.sql +10 -0
- package/drizzle/migrations/0005_timers.sql +22 -0
- package/drizzle/migrations/0006_terminal_runtime_pid.sql +1 -0
- package/drizzle/schema.ts +45 -2
- package/package.json +1 -1
package/dist/src/service.js
CHANGED
|
@@ -4,8 +4,10 @@ exports.ActivationDispatcher = exports.AgentInboxService = void 0;
|
|
|
4
4
|
const backend_1 = require("./backend");
|
|
5
5
|
const util_1 = require("./util");
|
|
6
6
|
const source_schema_1 = require("./source_schema");
|
|
7
|
+
const source_resolution_1 = require("./source_resolution");
|
|
7
8
|
const filter_1 = require("./filter");
|
|
8
9
|
const terminal_1 = require("./terminal");
|
|
10
|
+
const runtime_gate_1 = require("./runtime_gate");
|
|
9
11
|
const DEFAULT_SUBSCRIPTION_POLL_LIMIT = 100;
|
|
10
12
|
const DEFAULT_ACTIVATION_WINDOW_MS = 3_000;
|
|
11
13
|
const DEFAULT_ACTIVATION_MAX_ITEMS = 20;
|
|
@@ -14,9 +16,16 @@ const DEFAULT_NOTIFY_RETRY_MS = 5_000;
|
|
|
14
16
|
const DEFAULT_ACKED_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
15
17
|
const DEFAULT_OFFLINE_AGENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
18
|
const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
|
|
19
|
+
const DEFAULT_SYNC_INTERVAL_MS = 2_000;
|
|
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;
|
|
17
27
|
const WEBHOOK_ACTIVATION_MODES = new Set(["activation_only", "activation_with_items"]);
|
|
18
28
|
const SUBSCRIPTION_START_POLICIES = new Set(["latest", "earliest", "at_offset", "at_time"]);
|
|
19
|
-
const SUBSCRIPTION_LIFECYCLE_MODES = new Set(["standing", "temporary"]);
|
|
20
29
|
class AgentInboxService {
|
|
21
30
|
store;
|
|
22
31
|
adapters;
|
|
@@ -28,10 +37,12 @@ class AgentInboxService {
|
|
|
28
37
|
notificationBuffers = new Map();
|
|
29
38
|
inboxWatchers = new Map();
|
|
30
39
|
syncInterval = null;
|
|
40
|
+
timerSyncTimeout = null;
|
|
31
41
|
stopping = false;
|
|
32
42
|
lastAckedInboxGcAt = 0;
|
|
43
|
+
lastLifecycleCleanupAt = 0;
|
|
33
44
|
lastOfflineAgentGcAt = 0;
|
|
34
|
-
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()) {
|
|
35
46
|
this.store = store;
|
|
36
47
|
this.adapters = adapters;
|
|
37
48
|
this.activationDispatcher = activationDispatcher;
|
|
@@ -40,9 +51,11 @@ class AgentInboxService {
|
|
|
40
51
|
this.activationMaxItems = activationPolicy?.maxItems ?? DEFAULT_ACTIVATION_MAX_ITEMS;
|
|
41
52
|
this.ackedRetentionMs = DEFAULT_ACKED_RETENTION_MS;
|
|
42
53
|
this.terminalDispatcher = terminalDispatcher;
|
|
54
|
+
this.activationGate = activationGate;
|
|
43
55
|
}
|
|
44
56
|
activationDispatcher;
|
|
45
57
|
terminalDispatcher;
|
|
58
|
+
activationGate;
|
|
46
59
|
async start() {
|
|
47
60
|
if (this.syncInterval) {
|
|
48
61
|
return;
|
|
@@ -53,11 +66,13 @@ class AgentInboxService {
|
|
|
53
66
|
void this.syncActivationDispatchStates();
|
|
54
67
|
void this.runAckedInboxGcIfDue();
|
|
55
68
|
void this.syncLifecycleGc();
|
|
56
|
-
},
|
|
69
|
+
}, DEFAULT_SYNC_INTERVAL_MS);
|
|
70
|
+
this.refreshTimersOnStart();
|
|
57
71
|
await this.syncAllSubscriptions();
|
|
58
72
|
await this.syncActivationDispatchStates();
|
|
59
73
|
await this.runAckedInboxGcIfDue(true);
|
|
60
74
|
await this.syncLifecycleGc();
|
|
75
|
+
await this.syncTimers();
|
|
61
76
|
}
|
|
62
77
|
async stop() {
|
|
63
78
|
this.stopping = true;
|
|
@@ -71,7 +86,12 @@ class AgentInboxService {
|
|
|
71
86
|
clearInterval(this.syncInterval);
|
|
72
87
|
this.syncInterval = null;
|
|
73
88
|
}
|
|
89
|
+
if (this.timerSyncTimeout) {
|
|
90
|
+
clearTimeout(this.timerSyncTimeout);
|
|
91
|
+
this.timerSyncTimeout = null;
|
|
92
|
+
}
|
|
74
93
|
await this.flushAllPendingNotifications();
|
|
94
|
+
await this.awaitInFlightNotificationBuffers();
|
|
75
95
|
}
|
|
76
96
|
async registerSource(input) {
|
|
77
97
|
const existing = this.store.getSourceByKey(input.sourceType, input.sourceKey);
|
|
@@ -105,30 +125,91 @@ class AgentInboxService {
|
|
|
105
125
|
}
|
|
106
126
|
return source;
|
|
107
127
|
}
|
|
108
|
-
getSourceDetails(sourceId) {
|
|
128
|
+
async getSourceDetails(sourceId) {
|
|
109
129
|
const source = this.getSource(sourceId);
|
|
130
|
+
const fallbackSchema = (0, source_schema_1.getSourceSchema)(source.sourceType);
|
|
131
|
+
let resolvedIdentity = null;
|
|
132
|
+
let resolutionError = null;
|
|
133
|
+
let schema = fallbackSchema;
|
|
134
|
+
try {
|
|
135
|
+
resolvedIdentity = await this.adapters.resolveSourceIdentity(source);
|
|
136
|
+
schema = await this.adapters.resolveSourceSchema(source);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
resolutionError = error instanceof Error ? error.message : String(error);
|
|
140
|
+
}
|
|
141
|
+
const resolvedSchema = resolvedIdentity
|
|
142
|
+
? ("hostType" in schema
|
|
143
|
+
? schema
|
|
144
|
+
: (0, source_resolution_1.withResolvedIdentity)(source.sourceId, fallbackSchema, resolvedIdentity))
|
|
145
|
+
: fallbackSchema;
|
|
110
146
|
return {
|
|
111
147
|
source,
|
|
112
|
-
|
|
148
|
+
resolvedIdentity,
|
|
149
|
+
...(resolutionError ? { resolutionError } : {}),
|
|
150
|
+
schema: resolvedSchema,
|
|
113
151
|
stream: this.store.getStreamBySourceId(sourceId),
|
|
114
152
|
subscriptions: this.store.listSubscriptionsForSource(sourceId),
|
|
153
|
+
idleState: this.store.getSourceIdleState(sourceId),
|
|
115
154
|
};
|
|
116
155
|
}
|
|
117
156
|
getSourceSchema(sourceType) {
|
|
118
157
|
return (0, source_schema_1.getSourceSchema)(sourceType);
|
|
119
158
|
}
|
|
120
|
-
async
|
|
159
|
+
async getResolvedSourceSchema(sourceId) {
|
|
160
|
+
const source = this.getSource(sourceId);
|
|
161
|
+
return this.adapters.resolveSourceSchema(source);
|
|
162
|
+
}
|
|
163
|
+
async previewSourceSchema(input) {
|
|
164
|
+
try {
|
|
165
|
+
const source = buildPreviewSource(input);
|
|
166
|
+
await this.adapters.sourceAdapterFor(source.sourceType).validateSource?.(source);
|
|
167
|
+
const schema = await this.adapters.resolveSourceSchema(source);
|
|
168
|
+
if (input.sourceRef.startsWith("remote:")) {
|
|
169
|
+
const expectedImplementationId = input.sourceRef.slice("remote:".length);
|
|
170
|
+
if (schema.implementationId !== expectedImplementationId) {
|
|
171
|
+
throw new Error(`preview source kind ${input.sourceRef} resolved to implementation ${schema.implementationId}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const { sourceId: _sourceId, ...preview } = schema;
|
|
175
|
+
return preview;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
if (message.startsWith("preview failed: ") || message.startsWith("preview source kind ")) {
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`preview failed: ${message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async removeSource(sourceId, options = {}) {
|
|
121
186
|
const source = this.store.getSource(sourceId);
|
|
122
187
|
if (!source) {
|
|
123
|
-
return { removed: false };
|
|
188
|
+
return { removed: false, sourceId, removedSubscriptions: 0, pausedSource: false };
|
|
124
189
|
}
|
|
125
190
|
const subscriptions = this.store.listSubscriptionsForSource(sourceId);
|
|
126
|
-
if (subscriptions.length > 0) {
|
|
127
|
-
throw new Error("source remove requires no active subscriptions");
|
|
191
|
+
if (subscriptions.length > 0 && !options.withSubscriptions) {
|
|
192
|
+
throw new Error("source remove requires no active subscriptions; retry with --with-subscriptions or with_subscriptions=true");
|
|
193
|
+
}
|
|
194
|
+
let pausedSource = false;
|
|
195
|
+
if (options.withSubscriptions) {
|
|
196
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
197
|
+
if (sourceAdapter.pauseSource) {
|
|
198
|
+
await this.pauseSource(sourceId);
|
|
199
|
+
pausedSource = true;
|
|
200
|
+
}
|
|
201
|
+
for (const subscription of subscriptions) {
|
|
202
|
+
await this.removeSubscription(subscription.subscriptionId);
|
|
203
|
+
}
|
|
128
204
|
}
|
|
129
205
|
await this.adapters.removeSource(source);
|
|
130
206
|
this.store.deleteSource(sourceId);
|
|
131
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
removed: true,
|
|
209
|
+
sourceId,
|
|
210
|
+
removedSubscriptions: subscriptions.length,
|
|
211
|
+
pausedSource,
|
|
212
|
+
};
|
|
132
213
|
}
|
|
133
214
|
async updateSource(sourceId, input) {
|
|
134
215
|
const source = this.store.getSource(sourceId);
|
|
@@ -183,6 +264,7 @@ class AgentInboxService {
|
|
|
183
264
|
if (this.store.getSource(sourceId)?.status !== "paused") {
|
|
184
265
|
this.store.updateSourceRuntime(sourceId, { status: "paused" });
|
|
185
266
|
}
|
|
267
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
186
268
|
return {
|
|
187
269
|
paused: true,
|
|
188
270
|
source: this.getSource(sourceId),
|
|
@@ -197,6 +279,7 @@ class AgentInboxService {
|
|
|
197
279
|
if (this.store.getSource(sourceId)?.status === "paused") {
|
|
198
280
|
this.store.updateSourceRuntime(sourceId, { status: "active" });
|
|
199
281
|
}
|
|
282
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
200
283
|
return {
|
|
201
284
|
resumed: true,
|
|
202
285
|
source: this.getSource(sourceId),
|
|
@@ -253,6 +336,7 @@ class AgentInboxService {
|
|
|
253
336
|
return this.registerAgent({
|
|
254
337
|
runtimeKind: detected.runtimeKind,
|
|
255
338
|
runtimeSessionId: detected.runtimeSessionId ?? null,
|
|
339
|
+
runtimePid: detected.runtimePid ?? null,
|
|
256
340
|
backend: detected.backend,
|
|
257
341
|
mode: "agent_prompt",
|
|
258
342
|
tmuxPaneId: detected.tmuxPaneId ?? null,
|
|
@@ -285,7 +369,11 @@ class AgentInboxService {
|
|
|
285
369
|
}
|
|
286
370
|
removeAgent(agentId) {
|
|
287
371
|
this.getAgent(agentId);
|
|
372
|
+
const affectedSourceIds = Array.from(new Set(this.store.listSubscriptionsForAgent(agentId).map((subscription) => subscription.sourceId)));
|
|
288
373
|
this.store.deleteAgent(agentId);
|
|
374
|
+
for (const sourceId of affectedSourceIds) {
|
|
375
|
+
this.refreshSourceIdleState(sourceId);
|
|
376
|
+
}
|
|
289
377
|
this.notificationBuffers.forEach((buffer, key) => {
|
|
290
378
|
if (key.startsWith(`${agentId}:`)) {
|
|
291
379
|
if (buffer.timer) {
|
|
@@ -295,8 +383,106 @@ class AgentInboxService {
|
|
|
295
383
|
}
|
|
296
384
|
});
|
|
297
385
|
this.inboxWatchers.delete(agentId);
|
|
386
|
+
this.rescheduleTimerSync();
|
|
298
387
|
return { removed: true };
|
|
299
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
|
+
}
|
|
300
486
|
addWebhookActivationTarget(agentId, input) {
|
|
301
487
|
this.getAgent(agentId);
|
|
302
488
|
validateNotifyLeaseMs(input.notifyLeaseMs);
|
|
@@ -346,23 +532,47 @@ class AgentInboxService {
|
|
|
346
532
|
return { removed: true };
|
|
347
533
|
}
|
|
348
534
|
async registerSubscription(input) {
|
|
535
|
+
if (input.shortcut) {
|
|
536
|
+
const source = this.store.getSource(input.sourceId);
|
|
537
|
+
if (!source) {
|
|
538
|
+
throw new Error(`unknown source: ${input.sourceId}`);
|
|
539
|
+
}
|
|
540
|
+
if (hasSubscriptionFieldOverride(input)) {
|
|
541
|
+
throw new Error("subscription add shortcut does not allow filter, trackedResourceRef, or cleanupPolicy overrides");
|
|
542
|
+
}
|
|
543
|
+
const expanded = await this.adapters.expandSubscriptionShortcut(source, input.shortcut);
|
|
544
|
+
if (!expanded) {
|
|
545
|
+
throw new Error(`unknown subscription shortcut ${input.shortcut.name} for source ${input.sourceId}`);
|
|
546
|
+
}
|
|
547
|
+
return this.registerSubscription({
|
|
548
|
+
...input,
|
|
549
|
+
shortcut: undefined,
|
|
550
|
+
filter: expanded.filter,
|
|
551
|
+
trackedResourceRef: expanded.trackedResourceRef ?? null,
|
|
552
|
+
cleanupPolicy: expanded.cleanupPolicy,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
349
555
|
this.getAgent(input.agentId);
|
|
350
556
|
const source = this.store.getSource(input.sourceId);
|
|
351
557
|
if (!source) {
|
|
352
558
|
throw new Error(`unknown source: ${input.sourceId}`);
|
|
353
559
|
}
|
|
354
560
|
await (0, filter_1.validateSubscriptionFilter)(input.filter ?? {});
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
561
|
+
const cleanupPolicy = normalizeCleanupPolicy(input.cleanupPolicy ?? null);
|
|
562
|
+
const idleState = this.store.getSourceIdleState(source.sourceId);
|
|
563
|
+
if (idleState?.autoPausedAt) {
|
|
564
|
+
await this.resumeSource(source.sourceId);
|
|
565
|
+
}
|
|
566
|
+
else if (idleState) {
|
|
567
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
358
568
|
}
|
|
359
569
|
const subscription = {
|
|
360
570
|
subscriptionId: (0, util_1.generateId)("sub"),
|
|
361
571
|
agentId: input.agentId,
|
|
362
572
|
sourceId: input.sourceId,
|
|
363
573
|
filter: input.filter ?? {},
|
|
364
|
-
|
|
365
|
-
|
|
574
|
+
trackedResourceRef: normalizeTrackedResourceRef(input.trackedResourceRef),
|
|
575
|
+
cleanupPolicy,
|
|
366
576
|
startPolicy: input.startPolicy ?? "latest",
|
|
367
577
|
startOffset: input.startOffset ?? null,
|
|
368
578
|
startTime: input.startTime ?? null,
|
|
@@ -379,6 +589,7 @@ class AgentInboxService {
|
|
|
379
589
|
startOffset: subscription.startOffset ?? null,
|
|
380
590
|
startTime: subscription.startTime ?? null,
|
|
381
591
|
});
|
|
592
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
382
593
|
return subscription;
|
|
383
594
|
}
|
|
384
595
|
async removeSubscription(subscriptionId) {
|
|
@@ -386,6 +597,7 @@ class AgentInboxService {
|
|
|
386
597
|
await this.backend.deleteConsumer({ subscriptionId });
|
|
387
598
|
this.store.deleteSubscription(subscriptionId);
|
|
388
599
|
this.clearSubscriptionRuntimeState(subscription);
|
|
600
|
+
this.refreshSourceIdleState(subscription.sourceId);
|
|
389
601
|
return { removed: true, subscriptionId };
|
|
390
602
|
}
|
|
391
603
|
listSubscriptions(filters) {
|
|
@@ -482,6 +694,109 @@ class AgentInboxService {
|
|
|
482
694
|
listInboxItems(agentId, options) {
|
|
483
695
|
return this.store.listInboxItems(this.ensureInboxForAgent(agentId).inboxId, options);
|
|
484
696
|
}
|
|
697
|
+
async addDirectInboxTextMessage(agentId, input, env = process.env) {
|
|
698
|
+
this.getAgent(agentId);
|
|
699
|
+
const message = normalizeDirectInboxMessage(input.message);
|
|
700
|
+
const sender = normalizeDirectInboxSender(input.sender) ?? this.detectDirectInboxSender(env);
|
|
701
|
+
return this.materializeAgentInboxItem(agentId, {
|
|
702
|
+
sourceId: DIRECT_INBOX_SOURCE_ID,
|
|
703
|
+
sourceNativeId: (0, util_1.generateId)("direct"),
|
|
704
|
+
eventVariant: DIRECT_INBOX_EVENT_VARIANT,
|
|
705
|
+
summary: summarizeDirectInboxMessage(sender),
|
|
706
|
+
rawPayload: {
|
|
707
|
+
type: "direct_text_message",
|
|
708
|
+
message,
|
|
709
|
+
sender,
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async fireTimer(timer, now) {
|
|
714
|
+
const scheduledFireAt = timer.nextFireAt ?? now;
|
|
715
|
+
this.materializeAgentInboxItem(timer.agentId, {
|
|
716
|
+
sourceId: TIMER_INBOX_SOURCE_ID,
|
|
717
|
+
sourceNativeId: `${timer.scheduleId}:${scheduledFireAt}`,
|
|
718
|
+
eventVariant: TIMER_INBOX_EVENT_VARIANT,
|
|
719
|
+
summary: summarizeTimerMessage(timer.scheduleId),
|
|
720
|
+
rawPayload: {
|
|
721
|
+
type: "timer_fired",
|
|
722
|
+
scheduleId: timer.scheduleId,
|
|
723
|
+
message: timer.message,
|
|
724
|
+
sender: timer.sender ?? DEFAULT_TIMER_SENDER,
|
|
725
|
+
},
|
|
726
|
+
occurredAt: scheduledFireAt,
|
|
727
|
+
allowDuplicate: true,
|
|
728
|
+
});
|
|
729
|
+
const nextFireAt = computeNextTimerFire({
|
|
730
|
+
mode: timer.mode,
|
|
731
|
+
at: timer.at ?? null,
|
|
732
|
+
intervalMs: timer.intervalMs ?? null,
|
|
733
|
+
cronExpr: timer.cronExpr ?? null,
|
|
734
|
+
timezone: timer.timezone,
|
|
735
|
+
fromIso: scheduledFireAt,
|
|
736
|
+
lastFiredAt: scheduledFireAt,
|
|
737
|
+
restartRecovery: false,
|
|
738
|
+
});
|
|
739
|
+
this.store.updateTimer(timer.scheduleId, {
|
|
740
|
+
status: nextFireAt ? "active" : "paused",
|
|
741
|
+
nextFireAt,
|
|
742
|
+
lastFiredAt: now,
|
|
743
|
+
updatedAt: now,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
materializeAgentInboxItem(agentId, input) {
|
|
747
|
+
const inbox = this.ensureInboxForAgent(agentId);
|
|
748
|
+
const occurredAt = input.occurredAt ?? (0, util_1.nowIso)();
|
|
749
|
+
const item = {
|
|
750
|
+
itemId: (0, util_1.generateId)("item"),
|
|
751
|
+
sourceId: input.sourceId,
|
|
752
|
+
sourceNativeId: input.sourceNativeId,
|
|
753
|
+
eventVariant: input.eventVariant,
|
|
754
|
+
inboxId: inbox.inboxId,
|
|
755
|
+
occurredAt,
|
|
756
|
+
metadata: {},
|
|
757
|
+
rawPayload: input.rawPayload,
|
|
758
|
+
deliveryHandle: null,
|
|
759
|
+
ackedAt: null,
|
|
760
|
+
};
|
|
761
|
+
const inserted = this.store.insertInboxItem(item);
|
|
762
|
+
if (!inserted && !input.allowDuplicate) {
|
|
763
|
+
throw new Error(`failed to persist direct inbox message item: ${item.itemId}`);
|
|
764
|
+
}
|
|
765
|
+
if (!inserted) {
|
|
766
|
+
return {
|
|
767
|
+
itemId: item.itemId,
|
|
768
|
+
inboxId: inbox.inboxId,
|
|
769
|
+
activated: false,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
this.notifyInboxWatchers(agentId, [item]);
|
|
773
|
+
const activationItem = {
|
|
774
|
+
itemId: item.itemId,
|
|
775
|
+
sourceId: item.sourceId,
|
|
776
|
+
sourceNativeId: item.sourceNativeId,
|
|
777
|
+
eventVariant: item.eventVariant,
|
|
778
|
+
inboxId: item.inboxId,
|
|
779
|
+
occurredAt: item.occurredAt,
|
|
780
|
+
metadata: item.metadata,
|
|
781
|
+
rawPayload: item.rawPayload,
|
|
782
|
+
deliveryHandle: item.deliveryHandle,
|
|
783
|
+
};
|
|
784
|
+
const targets = this.store.listActivationTargetsForAgent(agentId).filter((target) => target.status === "active");
|
|
785
|
+
for (const target of targets) {
|
|
786
|
+
this.enqueueActivationTarget(target, {
|
|
787
|
+
agentId,
|
|
788
|
+
subscriptionId: null,
|
|
789
|
+
sourceId: input.sourceId,
|
|
790
|
+
summary: input.summary,
|
|
791
|
+
item: activationItem,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
itemId: item.itemId,
|
|
796
|
+
inboxId: inbox.inboxId,
|
|
797
|
+
activated: targets.length > 0,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
485
800
|
watchInbox(agentId, options, onEvent) {
|
|
486
801
|
const inbox = this.ensureInboxForAgent(agentId);
|
|
487
802
|
const pendingItems = [];
|
|
@@ -598,11 +913,13 @@ class AgentInboxService {
|
|
|
598
913
|
}
|
|
599
914
|
gc() {
|
|
600
915
|
const acked = this.gcAckedInboxItems();
|
|
601
|
-
const lifecycle = this.
|
|
916
|
+
const lifecycle = this.runLifecycleCleanupPass(Date.now());
|
|
917
|
+
const offlineAgents = this.gcOfflineAgents();
|
|
602
918
|
return {
|
|
603
919
|
deleted: acked.deleted,
|
|
604
920
|
retentionMs: acked.retentionMs,
|
|
605
|
-
removedAgents:
|
|
921
|
+
removedAgents: offlineAgents.removedAgents,
|
|
922
|
+
removedSubscriptions: lifecycle.removedSubscriptions,
|
|
606
923
|
};
|
|
607
924
|
}
|
|
608
925
|
async pollSource(sourceId) {
|
|
@@ -645,9 +962,11 @@ class AgentInboxService {
|
|
|
645
962
|
let inboxItemsCreated = 0;
|
|
646
963
|
let lastProcessedOffset = null;
|
|
647
964
|
const insertedItems = [];
|
|
965
|
+
const lifecycleSignals = new Map();
|
|
648
966
|
try {
|
|
649
967
|
for (const event of batch.events) {
|
|
650
968
|
lastProcessedOffset = event.offset;
|
|
969
|
+
await this.collectLifecycleSignal(source, event.rawPayload, lifecycleSignals, event.occurredAt);
|
|
651
970
|
const match = await (0, filter_1.matchSubscriptionFilter)(subscription.filter, {
|
|
652
971
|
metadata: event.metadata,
|
|
653
972
|
payload: event.rawPayload,
|
|
@@ -720,6 +1039,9 @@ class AgentInboxService {
|
|
|
720
1039
|
committedOffset: lastProcessedOffset,
|
|
721
1040
|
});
|
|
722
1041
|
}
|
|
1042
|
+
for (const signal of lifecycleSignals.values()) {
|
|
1043
|
+
this.scheduleLifecycleRetirements(source, subscription, signal);
|
|
1044
|
+
}
|
|
723
1045
|
return {
|
|
724
1046
|
subscriptionId: subscription.subscriptionId,
|
|
725
1047
|
sourceId: subscription.sourceId,
|
|
@@ -818,6 +1140,52 @@ class AgentInboxService {
|
|
|
818
1140
|
backend: "sqlite",
|
|
819
1141
|
});
|
|
820
1142
|
}
|
|
1143
|
+
async collectLifecycleSignal(source, rawPayload, signals, fallbackOccurredAt) {
|
|
1144
|
+
const signal = await this.adapters.projectLifecycleSignal(source, rawPayload);
|
|
1145
|
+
if (!signal || !signal.terminal) {
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const normalized = normalizeLifecycleSignal(signal, fallbackOccurredAt);
|
|
1149
|
+
if (!normalized) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const existing = signals.get(normalized.ref);
|
|
1153
|
+
if (!existing) {
|
|
1154
|
+
signals.set(normalized.ref, normalized);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const existingAt = Date.parse(existing.occurredAt ?? "");
|
|
1158
|
+
const normalizedAt = Date.parse(normalized.occurredAt ?? "");
|
|
1159
|
+
if (Number.isNaN(existingAt) || (!Number.isNaN(normalizedAt) && normalizedAt > existingAt)) {
|
|
1160
|
+
signals.set(normalized.ref, normalized);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
scheduleLifecycleRetirements(source, subscription, signal) {
|
|
1164
|
+
if (!signal.terminal) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (!subscription.trackedResourceRef || subscription.trackedResourceRef !== signal.ref) {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const signalOccurredAt = signal.occurredAt ?? (0, util_1.nowIso)();
|
|
1171
|
+
const signalOccurredAtMs = Date.parse(signalOccurredAt);
|
|
1172
|
+
const retireAt = lifecycleRetireAtForSignal(subscription.cleanupPolicy, signalOccurredAtMs);
|
|
1173
|
+
if (!retireAt) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const now = (0, util_1.nowIso)();
|
|
1177
|
+
this.store.upsertSubscriptionLifecycleRetirement({
|
|
1178
|
+
subscriptionId: subscription.subscriptionId,
|
|
1179
|
+
sourceId: source.sourceId,
|
|
1180
|
+
trackedResourceRef: signal.ref,
|
|
1181
|
+
retireAt,
|
|
1182
|
+
terminalState: signal.state ?? null,
|
|
1183
|
+
terminalResult: signal.result ?? null,
|
|
1184
|
+
terminalOccurredAt: signalOccurredAt,
|
|
1185
|
+
createdAt: now,
|
|
1186
|
+
updatedAt: now,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
821
1189
|
notifyInboxWatchers(agentId, items) {
|
|
822
1190
|
const watchers = this.inboxWatchers.get(agentId);
|
|
823
1191
|
if (!watchers || watchers.size === 0) {
|
|
@@ -855,6 +1223,7 @@ class AgentInboxService {
|
|
|
855
1223
|
const target = this.store.updateTerminalActivationTargetHeartbeat(existing.targetId, {
|
|
856
1224
|
runtimeKind: input.runtimeKind ?? "unknown",
|
|
857
1225
|
runtimeSessionId: input.runtimeSessionId ?? null,
|
|
1226
|
+
runtimePid: input.runtimePid ?? null,
|
|
858
1227
|
tmuxPaneId: input.tmuxPaneId ?? null,
|
|
859
1228
|
tty: input.tty ?? null,
|
|
860
1229
|
termProgram: input.termProgram ?? null,
|
|
@@ -881,6 +1250,7 @@ class AgentInboxService {
|
|
|
881
1250
|
notifyLeaseMs: input.notifyLeaseMs ?? DEFAULT_NOTIFY_LEASE_MS,
|
|
882
1251
|
runtimeKind: input.runtimeKind ?? "unknown",
|
|
883
1252
|
runtimeSessionId: input.runtimeSessionId ?? null,
|
|
1253
|
+
runtimePid: input.runtimePid ?? null,
|
|
884
1254
|
backend: input.backend,
|
|
885
1255
|
tmuxPaneId: input.tmuxPaneId ?? null,
|
|
886
1256
|
tty: input.tty ?? null,
|
|
@@ -954,6 +1324,7 @@ class AgentInboxService {
|
|
|
954
1324
|
// state so future subscriptions do not inherit a stale notified window.
|
|
955
1325
|
this.store.deleteActivationDispatchState(state.agentId, state.targetId);
|
|
956
1326
|
}
|
|
1327
|
+
this.store.deleteSubscriptionLifecycleRetirement(subscription.subscriptionId);
|
|
957
1328
|
}
|
|
958
1329
|
async flushAllPendingNotifications() {
|
|
959
1330
|
const keys = Array.from(this.notificationBuffers.keys());
|
|
@@ -961,6 +1332,15 @@ class AgentInboxService {
|
|
|
961
1332
|
await this.flushNotificationBuffer(key);
|
|
962
1333
|
}
|
|
963
1334
|
}
|
|
1335
|
+
async awaitInFlightNotificationBuffers() {
|
|
1336
|
+
while (true) {
|
|
1337
|
+
const activeBuffers = Array.from(this.notificationBuffers.values());
|
|
1338
|
+
if (activeBuffers.every((buffer) => !buffer.inFlight)) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
964
1344
|
async flushNotificationBuffer(key) {
|
|
965
1345
|
const buffer = this.notificationBuffers.get(key);
|
|
966
1346
|
if (!buffer || buffer.inFlight || buffer.pending.length === 0) {
|
|
@@ -980,7 +1360,7 @@ class AgentInboxService {
|
|
|
980
1360
|
targetId: buffer.targetId,
|
|
981
1361
|
newItemCount: entries.length,
|
|
982
1362
|
summary: entries[0]?.summary ?? null,
|
|
983
|
-
subscriptionIds:
|
|
1363
|
+
subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
|
|
984
1364
|
sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
|
|
985
1365
|
items: entries.map((entry) => entry.item),
|
|
986
1366
|
});
|
|
@@ -996,7 +1376,7 @@ class AgentInboxService {
|
|
|
996
1376
|
leaseExpiresAt: state.leaseExpiresAt,
|
|
997
1377
|
pendingNewItemCount: state.pendingNewItemCount + entries.length,
|
|
998
1378
|
pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
|
|
999
|
-
pendingSubscriptionIds:
|
|
1379
|
+
pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
|
|
1000
1380
|
pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
|
|
1001
1381
|
updatedAt: (0, util_1.nowIso)(),
|
|
1002
1382
|
});
|
|
@@ -1004,9 +1384,11 @@ class AgentInboxService {
|
|
|
1004
1384
|
const hasPendingDuringFlight = buffer.pending.length > 0;
|
|
1005
1385
|
buffer.inFlight = false;
|
|
1006
1386
|
if (hasPendingDuringFlight) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1387
|
+
if (!this.stopping) {
|
|
1388
|
+
buffer.timer = setTimeout(() => {
|
|
1389
|
+
void this.flushNotificationBuffer(key);
|
|
1390
|
+
}, this.activationWindowMs);
|
|
1391
|
+
}
|
|
1010
1392
|
return;
|
|
1011
1393
|
}
|
|
1012
1394
|
this.notificationBuffers.delete(key);
|
|
@@ -1083,6 +1465,26 @@ class AgentInboxService {
|
|
|
1083
1465
|
const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
|
|
1084
1466
|
try {
|
|
1085
1467
|
if (target.kind === "terminal") {
|
|
1468
|
+
const gate = await this.activationGate.evaluate(target);
|
|
1469
|
+
if (gate.outcome === "offline") {
|
|
1470
|
+
this.markActivationTargetOffline(target.targetId, `runtime gate: ${gate.reason}`);
|
|
1471
|
+
this.reconcileAgentStatus(target.agentId);
|
|
1472
|
+
return "offline";
|
|
1473
|
+
}
|
|
1474
|
+
if (gate.outcome === "defer") {
|
|
1475
|
+
this.store.upsertActivationDispatchState({
|
|
1476
|
+
agentId: input.agentId,
|
|
1477
|
+
targetId: input.targetId,
|
|
1478
|
+
status: "dirty",
|
|
1479
|
+
leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
|
|
1480
|
+
pendingNewItemCount: input.newItemCount,
|
|
1481
|
+
pendingSummary: input.summary,
|
|
1482
|
+
pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
|
|
1483
|
+
pendingSourceIds: uniqueSorted(input.sourceIds),
|
|
1484
|
+
updatedAt: (0, util_1.nowIso)(),
|
|
1485
|
+
});
|
|
1486
|
+
return "deferred";
|
|
1487
|
+
}
|
|
1086
1488
|
const prompt = (0, terminal_1.renderAgentPrompt)({
|
|
1087
1489
|
inboxId: inbox.inboxId,
|
|
1088
1490
|
newItemCount: input.newItemCount,
|
|
@@ -1147,11 +1549,37 @@ class AgentInboxService {
|
|
|
1147
1549
|
leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
|
|
1148
1550
|
pendingNewItemCount: entries.length,
|
|
1149
1551
|
pendingSummary: entries[0]?.summary ?? null,
|
|
1150
|
-
pendingSubscriptionIds:
|
|
1552
|
+
pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
|
|
1151
1553
|
pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
|
|
1152
1554
|
updatedAt: (0, util_1.nowIso)(),
|
|
1153
1555
|
});
|
|
1154
1556
|
}
|
|
1557
|
+
detectDirectInboxSender(env) {
|
|
1558
|
+
try {
|
|
1559
|
+
const detected = (0, terminal_1.detectTerminalContext)(env);
|
|
1560
|
+
const target = findExistingTerminalActivationTarget(this.store, {
|
|
1561
|
+
runtimeKind: detected.runtimeKind,
|
|
1562
|
+
runtimeSessionId: detected.runtimeSessionId ?? null,
|
|
1563
|
+
runtimePid: detected.runtimePid ?? null,
|
|
1564
|
+
backend: detected.backend,
|
|
1565
|
+
tmuxPaneId: detected.tmuxPaneId ?? null,
|
|
1566
|
+
tty: detected.tty ?? null,
|
|
1567
|
+
termProgram: detected.termProgram ?? null,
|
|
1568
|
+
itermSessionId: detected.itermSessionId ?? null,
|
|
1569
|
+
});
|
|
1570
|
+
if (target) {
|
|
1571
|
+
return target.agentId;
|
|
1572
|
+
}
|
|
1573
|
+
return detected.runtimeSessionId
|
|
1574
|
+
?? detected.tmuxPaneId
|
|
1575
|
+
?? detected.itermSessionId
|
|
1576
|
+
?? detected.tty
|
|
1577
|
+
?? null;
|
|
1578
|
+
}
|
|
1579
|
+
catch {
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1155
1583
|
markActivationTargetDelivered(targetId) {
|
|
1156
1584
|
const now = (0, util_1.nowIso)();
|
|
1157
1585
|
this.store.updateActivationTargetRuntime(targetId, {
|
|
@@ -1216,10 +1644,97 @@ class AgentInboxService {
|
|
|
1216
1644
|
lastSeenAt: agent.lastSeenAt,
|
|
1217
1645
|
});
|
|
1218
1646
|
}
|
|
1647
|
+
runLifecycleCleanupPass(nowMs) {
|
|
1648
|
+
this.lastLifecycleCleanupAt = nowMs;
|
|
1649
|
+
const removed = new Set();
|
|
1650
|
+
const affectedSourceIds = new Set();
|
|
1651
|
+
for (const subscription of this.store.listSubscriptions()) {
|
|
1652
|
+
if (removed.has(subscription.subscriptionId)) {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const deadline = lifecycleDeadlineAt(subscription.cleanupPolicy);
|
|
1656
|
+
if (!deadline) {
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
const deadlineMs = Date.parse(deadline);
|
|
1660
|
+
if (Number.isNaN(deadlineMs) || deadlineMs > nowMs) {
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (this.store.deleteSubscription(subscription.subscriptionId)) {
|
|
1664
|
+
removed.add(subscription.subscriptionId);
|
|
1665
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1666
|
+
this.clearSubscriptionRuntimeState(subscription);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
const dueRetirements = this.store.listSubscriptionLifecycleRetirementsDue(new Date(nowMs).toISOString());
|
|
1670
|
+
for (const retirement of dueRetirements) {
|
|
1671
|
+
if (removed.has(retirement.subscriptionId)) {
|
|
1672
|
+
this.store.deleteSubscriptionLifecycleRetirement(retirement.subscriptionId);
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
const subscription = this.store.getSubscription(retirement.subscriptionId);
|
|
1676
|
+
if (!subscription) {
|
|
1677
|
+
this.store.deleteSubscriptionLifecycleRetirement(retirement.subscriptionId);
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
if (this.store.deleteSubscription(retirement.subscriptionId)) {
|
|
1681
|
+
removed.add(retirement.subscriptionId);
|
|
1682
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1683
|
+
this.clearSubscriptionRuntimeState(subscription);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
removedSubscriptions: removed.size,
|
|
1688
|
+
affectedSourceIds: [...affectedSourceIds],
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
async runIdleSourceCleanupPass(nowMs) {
|
|
1692
|
+
const now = new Date(nowMs).toISOString();
|
|
1693
|
+
const due = this.store.listSourceIdleStatesDue(now);
|
|
1694
|
+
for (const idleState of due) {
|
|
1695
|
+
const source = this.store.getSource(idleState.sourceId);
|
|
1696
|
+
if (!source) {
|
|
1697
|
+
this.store.deleteSourceIdleState(idleState.sourceId);
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
if (this.store.listSubscriptionsForSource(source.sourceId).length > 0) {
|
|
1701
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
1705
|
+
if (!sourceAdapter.pauseSource) {
|
|
1706
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
if (source.status === "paused") {
|
|
1710
|
+
if (!idleState.autoPausedAt) {
|
|
1711
|
+
this.store.upsertSourceIdleState({
|
|
1712
|
+
...idleState,
|
|
1713
|
+
autoPausedAt: now,
|
|
1714
|
+
updatedAt: now,
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
await this.adapters.pauseSource(source);
|
|
1720
|
+
if (this.store.getSource(source.sourceId)?.status !== "paused") {
|
|
1721
|
+
this.store.updateSourceRuntime(source.sourceId, { status: "paused" });
|
|
1722
|
+
}
|
|
1723
|
+
this.store.upsertSourceIdleState({
|
|
1724
|
+
...idleState,
|
|
1725
|
+
autoPausedAt: now,
|
|
1726
|
+
updatedAt: now,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1219
1730
|
gcOfflineAgents(now = Date.now()) {
|
|
1220
1731
|
const cutoffIso = new Date(now - DEFAULT_OFFLINE_AGENT_TTL_MS).toISOString();
|
|
1221
1732
|
const agents = this.store.listOfflineAgentsOlderThan(cutoffIso);
|
|
1733
|
+
const affectedSourceIds = new Set();
|
|
1222
1734
|
for (const agent of agents) {
|
|
1735
|
+
for (const subscription of this.store.listSubscriptionsForAgent(agent.agentId)) {
|
|
1736
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1737
|
+
}
|
|
1223
1738
|
this.store.deleteAgent(agent.agentId, { persist: false });
|
|
1224
1739
|
this.notificationBuffers.forEach((buffer, key) => {
|
|
1225
1740
|
if (key.startsWith(`${agent.agentId}:`)) {
|
|
@@ -1231,9 +1746,15 @@ class AgentInboxService {
|
|
|
1231
1746
|
});
|
|
1232
1747
|
this.inboxWatchers.delete(agent.agentId);
|
|
1233
1748
|
}
|
|
1749
|
+
for (const sourceId of affectedSourceIds) {
|
|
1750
|
+
this.refreshSourceIdleState(sourceId);
|
|
1751
|
+
}
|
|
1234
1752
|
if (agents.length > 0) {
|
|
1235
1753
|
this.store.save();
|
|
1236
1754
|
}
|
|
1755
|
+
if (agents.length > 0) {
|
|
1756
|
+
this.rescheduleTimerSync();
|
|
1757
|
+
}
|
|
1237
1758
|
return { removedAgents: agents.length };
|
|
1238
1759
|
}
|
|
1239
1760
|
async syncAllSubscriptions() {
|
|
@@ -1268,6 +1789,18 @@ class AgentInboxService {
|
|
|
1268
1789
|
}
|
|
1269
1790
|
async syncLifecycleGc() {
|
|
1270
1791
|
const now = Date.now();
|
|
1792
|
+
if (now - this.lastLifecycleCleanupAt >= DEFAULT_GC_INTERVAL_MS) {
|
|
1793
|
+
try {
|
|
1794
|
+
const lifecycle = this.runLifecycleCleanupPass(now);
|
|
1795
|
+
for (const sourceId of lifecycle.affectedSourceIds) {
|
|
1796
|
+
this.refreshSourceIdleState(sourceId);
|
|
1797
|
+
}
|
|
1798
|
+
await this.runIdleSourceCleanupPass(now);
|
|
1799
|
+
}
|
|
1800
|
+
catch (error) {
|
|
1801
|
+
console.warn("subscription lifecycle gc failed:", error);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1271
1804
|
if (now - this.lastOfflineAgentGcAt < DEFAULT_GC_INTERVAL_MS) {
|
|
1272
1805
|
return;
|
|
1273
1806
|
}
|
|
@@ -1279,8 +1812,267 @@ class AgentInboxService {
|
|
|
1279
1812
|
console.warn("offline agent gc failed:", error);
|
|
1280
1813
|
}
|
|
1281
1814
|
}
|
|
1815
|
+
async syncTimers() {
|
|
1816
|
+
const now = (0, util_1.nowIso)();
|
|
1817
|
+
const due = this.store.listDueTimers(now);
|
|
1818
|
+
for (const timer of due) {
|
|
1819
|
+
try {
|
|
1820
|
+
await this.fireTimer(timer, now);
|
|
1821
|
+
}
|
|
1822
|
+
catch (error) {
|
|
1823
|
+
console.warn(`timer fire failed for ${timer.scheduleId}:`, error);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
this.rescheduleTimerSync();
|
|
1827
|
+
}
|
|
1828
|
+
refreshTimersOnStart() {
|
|
1829
|
+
const now = (0, util_1.nowIso)();
|
|
1830
|
+
for (const timer of this.store.listTimers()) {
|
|
1831
|
+
if (timer.status !== "active") {
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
const nextFireAt = computeNextTimerFire({
|
|
1835
|
+
mode: timer.mode,
|
|
1836
|
+
at: timer.at ?? null,
|
|
1837
|
+
intervalMs: timer.intervalMs ?? null,
|
|
1838
|
+
cronExpr: timer.cronExpr ?? null,
|
|
1839
|
+
timezone: timer.timezone,
|
|
1840
|
+
fromIso: now,
|
|
1841
|
+
lastFiredAt: timer.lastFiredAt ?? null,
|
|
1842
|
+
restartRecovery: true,
|
|
1843
|
+
});
|
|
1844
|
+
this.store.updateTimer(timer.scheduleId, {
|
|
1845
|
+
nextFireAt,
|
|
1846
|
+
status: nextFireAt ? "active" : "paused",
|
|
1847
|
+
updatedAt: now,
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
rescheduleTimerSync() {
|
|
1852
|
+
if (this.timerSyncTimeout) {
|
|
1853
|
+
clearTimeout(this.timerSyncTimeout);
|
|
1854
|
+
this.timerSyncTimeout = null;
|
|
1855
|
+
}
|
|
1856
|
+
if (this.stopping) {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const nearest = this.store.getNearestActiveTimer();
|
|
1860
|
+
if (!nearest?.nextFireAt) {
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
const dueAt = Date.parse(nearest.nextFireAt);
|
|
1864
|
+
if (Number.isNaN(dueAt)) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const delay = Math.max(0, dueAt - Date.now());
|
|
1868
|
+
this.timerSyncTimeout = setTimeout(() => {
|
|
1869
|
+
this.timerSyncTimeout = null;
|
|
1870
|
+
void this.syncTimers();
|
|
1871
|
+
}, delay);
|
|
1872
|
+
}
|
|
1873
|
+
refreshSourceIdleState(sourceId) {
|
|
1874
|
+
const source = this.store.getSource(sourceId);
|
|
1875
|
+
if (!source) {
|
|
1876
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
1880
|
+
if (!sourceAdapter.pauseSource) {
|
|
1881
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (source.status === "paused") {
|
|
1885
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const remainingSubscriptions = this.store.listSubscriptionsForSource(sourceId).length;
|
|
1889
|
+
if (remainingSubscriptions > 0) {
|
|
1890
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
const now = (0, util_1.nowIso)();
|
|
1894
|
+
this.store.upsertSourceIdleState({
|
|
1895
|
+
sourceId,
|
|
1896
|
+
idleSince: now,
|
|
1897
|
+
autoPauseAt: new Date(Date.parse(now) + DEFAULT_IDLE_SOURCE_GRACE_MS).toISOString(),
|
|
1898
|
+
autoPausedAt: null,
|
|
1899
|
+
updatedAt: now,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1282
1902
|
}
|
|
1283
1903
|
exports.AgentInboxService = AgentInboxService;
|
|
1904
|
+
function hasSubscriptionFieldOverride(input) {
|
|
1905
|
+
if (input.trackedResourceRef != null || input.cleanupPolicy != null) {
|
|
1906
|
+
return true;
|
|
1907
|
+
}
|
|
1908
|
+
return input.filter != null && Object.keys(input.filter).length > 0;
|
|
1909
|
+
}
|
|
1910
|
+
function buildPreviewSource(input) {
|
|
1911
|
+
const sourceType = sourceTypeForPreviewRef(input.sourceRef);
|
|
1912
|
+
const now = (0, util_1.nowIso)();
|
|
1913
|
+
return {
|
|
1914
|
+
sourceId: "__preview__",
|
|
1915
|
+
sourceType,
|
|
1916
|
+
sourceKey: `preview:${input.sourceRef}`,
|
|
1917
|
+
configRef: input.configRef ?? null,
|
|
1918
|
+
config: input.config ?? {},
|
|
1919
|
+
status: "active",
|
|
1920
|
+
checkpoint: null,
|
|
1921
|
+
createdAt: now,
|
|
1922
|
+
updatedAt: now,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
function sourceTypeForPreviewRef(sourceRef) {
|
|
1926
|
+
if (sourceRef === "local_event" || sourceRef === "remote_source" || sourceRef === "github_repo" || sourceRef === "github_repo_ci" || sourceRef === "feishu_bot") {
|
|
1927
|
+
return sourceRef;
|
|
1928
|
+
}
|
|
1929
|
+
if (sourceRef.startsWith("remote:")) {
|
|
1930
|
+
return "remote_source";
|
|
1931
|
+
}
|
|
1932
|
+
throw new Error(`unknown source kind or type for preview: ${sourceRef}`);
|
|
1933
|
+
}
|
|
1934
|
+
function normalizeTrackedResourceRef(value) {
|
|
1935
|
+
if (value == null) {
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
const trimmed = value.trim();
|
|
1939
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1940
|
+
}
|
|
1941
|
+
function normalizeCleanupPolicy(input) {
|
|
1942
|
+
if (!input) {
|
|
1943
|
+
return { mode: "manual" };
|
|
1944
|
+
}
|
|
1945
|
+
if (input.mode === "manual") {
|
|
1946
|
+
if ("at" in input || "gracePeriodSecs" in input) {
|
|
1947
|
+
throw new Error("cleanupPolicy mode manual does not allow at or gracePeriodSecs");
|
|
1948
|
+
}
|
|
1949
|
+
return { mode: "manual" };
|
|
1950
|
+
}
|
|
1951
|
+
if (input.mode === "at") {
|
|
1952
|
+
if (!isValidIsoTimestamp(input.at)) {
|
|
1953
|
+
throw new Error("cleanupPolicy mode at requires a valid ISO8601 at timestamp");
|
|
1954
|
+
}
|
|
1955
|
+
if ("gracePeriodSecs" in input) {
|
|
1956
|
+
throw new Error("cleanupPolicy mode at does not allow gracePeriodSecs");
|
|
1957
|
+
}
|
|
1958
|
+
return { mode: "at", at: canonicalIsoTimestamp(input.at) };
|
|
1959
|
+
}
|
|
1960
|
+
if (input.mode === "on_terminal") {
|
|
1961
|
+
if ("at" in input) {
|
|
1962
|
+
throw new Error("cleanupPolicy mode on_terminal does not allow at");
|
|
1963
|
+
}
|
|
1964
|
+
return {
|
|
1965
|
+
mode: "on_terminal",
|
|
1966
|
+
...(input.gracePeriodSecs != null ? { gracePeriodSecs: normalizeGracePeriodSecs(input.gracePeriodSecs) } : {}),
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
if (input.mode === "on_terminal_or_at") {
|
|
1970
|
+
if (!isValidIsoTimestamp(input.at)) {
|
|
1971
|
+
throw new Error("cleanupPolicy mode on_terminal_or_at requires a valid ISO8601 at timestamp");
|
|
1972
|
+
}
|
|
1973
|
+
return {
|
|
1974
|
+
mode: "on_terminal_or_at",
|
|
1975
|
+
at: canonicalIsoTimestamp(input.at),
|
|
1976
|
+
...(input.gracePeriodSecs != null ? { gracePeriodSecs: normalizeGracePeriodSecs(input.gracePeriodSecs) } : {}),
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
throw new Error(`unsupported cleanup policy mode: ${input.mode ?? "unknown"}`);
|
|
1980
|
+
}
|
|
1981
|
+
function normalizeGracePeriodSecs(value) {
|
|
1982
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1983
|
+
throw new Error("cleanupPolicy gracePeriodSecs must be a non-negative integer");
|
|
1984
|
+
}
|
|
1985
|
+
return value;
|
|
1986
|
+
}
|
|
1987
|
+
function normalizeLifecycleSignal(signal, fallbackOccurredAt) {
|
|
1988
|
+
const ref = typeof signal.ref === "string" ? signal.ref.trim() : "";
|
|
1989
|
+
if (ref.length === 0) {
|
|
1990
|
+
return null;
|
|
1991
|
+
}
|
|
1992
|
+
return {
|
|
1993
|
+
ref,
|
|
1994
|
+
terminal: signal.terminal,
|
|
1995
|
+
state: signal.state ?? null,
|
|
1996
|
+
result: signal.result ?? null,
|
|
1997
|
+
occurredAt: normalizeLifecycleOccurredAt(signal.occurredAt, fallbackOccurredAt),
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
function normalizeLifecycleOccurredAt(value, fallback) {
|
|
2001
|
+
if (value && isValidIsoTimestamp(value)) {
|
|
2002
|
+
return value;
|
|
2003
|
+
}
|
|
2004
|
+
if (fallback && isValidIsoTimestamp(fallback)) {
|
|
2005
|
+
return fallback;
|
|
2006
|
+
}
|
|
2007
|
+
return (0, util_1.nowIso)();
|
|
2008
|
+
}
|
|
2009
|
+
function lifecycleRetireAtForSignal(cleanupPolicy, signalOccurredAtMs) {
|
|
2010
|
+
if (cleanupPolicy.mode === "manual" || cleanupPolicy.mode === "at") {
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
if (cleanupPolicy.mode === "on_terminal") {
|
|
2014
|
+
return lifecycleSignalRetireAt(signalOccurredAtMs, cleanupPolicy.gracePeriodSecs ?? null);
|
|
2015
|
+
}
|
|
2016
|
+
return minIsoTimestamps(cleanupPolicy.at, lifecycleSignalRetireAt(signalOccurredAtMs, cleanupPolicy.gracePeriodSecs ?? null));
|
|
2017
|
+
}
|
|
2018
|
+
function lifecycleSignalRetireAt(signalOccurredAtMs, gracePeriodSecs) {
|
|
2019
|
+
const graceMs = Math.max(0, gracePeriodSecs ?? 0) * 1000;
|
|
2020
|
+
return new Date(signalOccurredAtMs + graceMs).toISOString();
|
|
2021
|
+
}
|
|
2022
|
+
function lifecycleDeadlineAt(cleanupPolicy) {
|
|
2023
|
+
if (cleanupPolicy.mode === "at" || cleanupPolicy.mode === "on_terminal_or_at") {
|
|
2024
|
+
return cleanupPolicy.at;
|
|
2025
|
+
}
|
|
2026
|
+
return null;
|
|
2027
|
+
}
|
|
2028
|
+
function minIsoTimestamps(left, right) {
|
|
2029
|
+
return new Date(Math.min(Date.parse(left), Date.parse(right))).toISOString();
|
|
2030
|
+
}
|
|
2031
|
+
function canonicalIsoTimestamp(value) {
|
|
2032
|
+
return new Date(value).toISOString();
|
|
2033
|
+
}
|
|
2034
|
+
function isValidIsoTimestamp(value) {
|
|
2035
|
+
if (typeof value !== "string") {
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.(\d{1,3}))?(Z|([+-])(\d{2}):(\d{2}))$/.exec(value);
|
|
2039
|
+
if (!match) {
|
|
2040
|
+
return false;
|
|
2041
|
+
}
|
|
2042
|
+
const year = Number(match[1]);
|
|
2043
|
+
const month = Number(match[2]);
|
|
2044
|
+
const day = Number(match[3]);
|
|
2045
|
+
const hour = Number(match[4]);
|
|
2046
|
+
const minute = Number(match[5]);
|
|
2047
|
+
const second = Number(match[6]);
|
|
2048
|
+
const fractional = match[8] ?? "";
|
|
2049
|
+
const timezone = match[9];
|
|
2050
|
+
const offsetSign = match[10];
|
|
2051
|
+
const offsetHour = match[11] != null ? Number(match[11]) : 0;
|
|
2052
|
+
const offsetMinute = match[12] != null ? Number(match[12]) : 0;
|
|
2053
|
+
const millisecond = fractional.length === 0 ? 0 : Number((fractional + "000").slice(0, 3));
|
|
2054
|
+
if (month < 1 ||
|
|
2055
|
+
month > 12 ||
|
|
2056
|
+
day < 1 ||
|
|
2057
|
+
day > 31 ||
|
|
2058
|
+
hour > 23 ||
|
|
2059
|
+
minute > 59 ||
|
|
2060
|
+
second > 59 ||
|
|
2061
|
+
offsetHour > 23 ||
|
|
2062
|
+
offsetMinute > 59) {
|
|
2063
|
+
return false;
|
|
2064
|
+
}
|
|
2065
|
+
const parsed = new Date(value);
|
|
2066
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
2067
|
+
return false;
|
|
2068
|
+
}
|
|
2069
|
+
const offsetMinutes = timezone === "Z"
|
|
2070
|
+
? 0
|
|
2071
|
+
: (offsetSign === "+" ? 1 : -1) * ((offsetHour * 60) + offsetMinute);
|
|
2072
|
+
const expectedTime = Date.UTC(year, month - 1, day, hour, minute, second, millisecond)
|
|
2073
|
+
- (offsetMinutes * 60_000);
|
|
2074
|
+
return parsed.getTime() === expectedTime;
|
|
2075
|
+
}
|
|
1284
2076
|
function retentionCutoffIso(retentionMs) {
|
|
1285
2077
|
return new Date(Date.now() - retentionMs).toISOString();
|
|
1286
2078
|
}
|
|
@@ -1388,6 +2180,9 @@ function isSameTerminalIdentity(target, input) {
|
|
|
1388
2180
|
function uniqueSorted(values) {
|
|
1389
2181
|
return Array.from(new Set(values)).sort();
|
|
1390
2182
|
}
|
|
2183
|
+
function uniqueSortedNullable(values) {
|
|
2184
|
+
return uniqueSorted(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
2185
|
+
}
|
|
1391
2186
|
function notificationBufferKey(agentId, targetId) {
|
|
1392
2187
|
return `${agentId}::${targetId}`;
|
|
1393
2188
|
}
|
|
@@ -1420,3 +2215,299 @@ function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
|
|
|
1420
2215
|
}
|
|
1421
2216
|
return `${sourceType}:${sourceKey}:${eventVariant}`;
|
|
1422
2217
|
}
|
|
2218
|
+
function summarizeDirectInboxMessage(sender) {
|
|
2219
|
+
if (sender) {
|
|
2220
|
+
return `direct_text_message:${sender}`;
|
|
2221
|
+
}
|
|
2222
|
+
return "direct_text_message";
|
|
2223
|
+
}
|
|
2224
|
+
function summarizeTimerMessage(scheduleId) {
|
|
2225
|
+
return `timer:${scheduleId}`;
|
|
2226
|
+
}
|
|
2227
|
+
function normalizeDirectInboxMessage(message) {
|
|
2228
|
+
if (typeof message !== "string") {
|
|
2229
|
+
throw new Error("direct inbox message must be a string");
|
|
2230
|
+
}
|
|
2231
|
+
const trimmed = message.trim();
|
|
2232
|
+
if (trimmed.length === 0) {
|
|
2233
|
+
throw new Error("direct inbox message must not be empty");
|
|
2234
|
+
}
|
|
2235
|
+
return trimmed;
|
|
2236
|
+
}
|
|
2237
|
+
function normalizeDirectInboxSender(sender) {
|
|
2238
|
+
if (sender == null) {
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
if (typeof sender !== "string") {
|
|
2242
|
+
throw new Error("direct inbox sender must be a string");
|
|
2243
|
+
}
|
|
2244
|
+
const trimmed = sender.trim();
|
|
2245
|
+
if (trimmed.length === 0) {
|
|
2246
|
+
throw new Error("direct inbox sender must not be empty");
|
|
2247
|
+
}
|
|
2248
|
+
return trimmed;
|
|
2249
|
+
}
|
|
2250
|
+
function normalizeTimerInput(input) {
|
|
2251
|
+
const modes = [input.at != null, input.every != null, input.cron != null].filter(Boolean).length;
|
|
2252
|
+
if (modes !== 1) {
|
|
2253
|
+
throw new Error("timers require exactly one of at, every, or cron");
|
|
2254
|
+
}
|
|
2255
|
+
if (input.at != null) {
|
|
2256
|
+
const at = normalizeIsoTimestamp(input.at, "timer at");
|
|
2257
|
+
return { mode: "at", at, timezone: input.timezone ?? null };
|
|
2258
|
+
}
|
|
2259
|
+
if (input.every != null) {
|
|
2260
|
+
if (!Number.isInteger(input.every) || input.every < MIN_TIMER_INTERVAL_MS) {
|
|
2261
|
+
throw new Error("timer every interval must be an integer number of milliseconds and at least 60000");
|
|
2262
|
+
}
|
|
2263
|
+
return { mode: "every", every: input.every, timezone: input.timezone ?? null };
|
|
2264
|
+
}
|
|
2265
|
+
const cron = normalizeCronExpression(input.cron);
|
|
2266
|
+
return { mode: "cron", cron, timezone: input.timezone ?? null };
|
|
2267
|
+
}
|
|
2268
|
+
function normalizeIsoTimestamp(value, label) {
|
|
2269
|
+
const parsed = Date.parse(value);
|
|
2270
|
+
if (Number.isNaN(parsed)) {
|
|
2271
|
+
throw new Error(`${label} must be a valid ISO8601 timestamp`);
|
|
2272
|
+
}
|
|
2273
|
+
return new Date(parsed).toISOString();
|
|
2274
|
+
}
|
|
2275
|
+
function normalizeCronExpression(value) {
|
|
2276
|
+
if (typeof value !== "string") {
|
|
2277
|
+
throw new Error("timer cron must be a string");
|
|
2278
|
+
}
|
|
2279
|
+
const trimmed = value.trim().replace(/\s+/g, " ");
|
|
2280
|
+
const fields = trimmed.split(" ");
|
|
2281
|
+
if (fields.length !== 5) {
|
|
2282
|
+
throw new Error("timer cron must use standard 5-field syntax");
|
|
2283
|
+
}
|
|
2284
|
+
for (const field of fields) {
|
|
2285
|
+
if (/[LW#@]/.test(field)) {
|
|
2286
|
+
throw new Error("timer cron does not support extensions like L, W, #, or @reboot");
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
parseCronExpression(trimmed);
|
|
2290
|
+
return trimmed;
|
|
2291
|
+
}
|
|
2292
|
+
function detectHostTimezone() {
|
|
2293
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
2294
|
+
}
|
|
2295
|
+
function assertValidTimeZone(timezone) {
|
|
2296
|
+
try {
|
|
2297
|
+
Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
|
|
2298
|
+
}
|
|
2299
|
+
catch {
|
|
2300
|
+
throw new Error(`invalid timezone: ${timezone}`);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
function computeNextTimerFire(input) {
|
|
2304
|
+
const fromMs = Date.parse(input.fromIso);
|
|
2305
|
+
if (Number.isNaN(fromMs)) {
|
|
2306
|
+
return null;
|
|
2307
|
+
}
|
|
2308
|
+
if (input.mode === "at") {
|
|
2309
|
+
if (!input.at) {
|
|
2310
|
+
return null;
|
|
2311
|
+
}
|
|
2312
|
+
const atMs = Date.parse(input.at);
|
|
2313
|
+
if (Number.isNaN(atMs)) {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
if (input.lastFiredAt) {
|
|
2317
|
+
return null;
|
|
2318
|
+
}
|
|
2319
|
+
if (atMs <= fromMs) {
|
|
2320
|
+
return input.restartRecovery ? input.at : input.at;
|
|
2321
|
+
}
|
|
2322
|
+
return input.at;
|
|
2323
|
+
}
|
|
2324
|
+
if (input.mode === "every") {
|
|
2325
|
+
if (!input.intervalMs) {
|
|
2326
|
+
return null;
|
|
2327
|
+
}
|
|
2328
|
+
if (!input.lastFiredAt) {
|
|
2329
|
+
return new Date(fromMs + input.intervalMs).toISOString();
|
|
2330
|
+
}
|
|
2331
|
+
const lastFiredMs = Date.parse(input.lastFiredAt);
|
|
2332
|
+
if (Number.isNaN(lastFiredMs)) {
|
|
2333
|
+
return new Date(fromMs + input.intervalMs).toISOString();
|
|
2334
|
+
}
|
|
2335
|
+
const base = input.restartRecovery ? fromMs : Math.max(fromMs, lastFiredMs);
|
|
2336
|
+
return new Date(base + input.intervalMs).toISOString();
|
|
2337
|
+
}
|
|
2338
|
+
if (!input.cronExpr) {
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
return nextCronOccurrence(input.cronExpr, input.timezone, new Date(fromMs));
|
|
2342
|
+
}
|
|
2343
|
+
function parseCronExpression(expr) {
|
|
2344
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = expr.split(" ");
|
|
2345
|
+
return {
|
|
2346
|
+
minute: parseCronField(minute, 0, 59),
|
|
2347
|
+
hour: parseCronField(hour, 0, 23),
|
|
2348
|
+
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
|
|
2349
|
+
month: parseCronField(month, 1, 12),
|
|
2350
|
+
dayOfWeek: parseCronField(dayOfWeek, 0, 7, true),
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
function parseCronField(field, min, max, normalizeSunday = false) {
|
|
2354
|
+
if (field === "*") {
|
|
2355
|
+
return { any: true, values: new Set() };
|
|
2356
|
+
}
|
|
2357
|
+
const values = new Set();
|
|
2358
|
+
for (const part of field.split(",")) {
|
|
2359
|
+
const [rangePart, stepPart] = part.split("/");
|
|
2360
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
2361
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
2362
|
+
throw new Error(`invalid cron field: ${field}`);
|
|
2363
|
+
}
|
|
2364
|
+
const [startRaw, endRaw] = rangePart === "*" ? [String(min), String(max)] : rangePart.split("-");
|
|
2365
|
+
const start = Number(startRaw);
|
|
2366
|
+
const end = endRaw != null ? Number(endRaw) : start;
|
|
2367
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
2368
|
+
throw new Error(`invalid cron field: ${field}`);
|
|
2369
|
+
}
|
|
2370
|
+
for (let value = start; value <= end; value += step) {
|
|
2371
|
+
values.add(normalizeSunday && value === 7 ? 0 : value);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return { any: false, values };
|
|
2375
|
+
}
|
|
2376
|
+
function nextCronOccurrence(expr, timezone, after) {
|
|
2377
|
+
const parsed = parseCronExpression(expr);
|
|
2378
|
+
let candidate = addMinutes(after, 1);
|
|
2379
|
+
candidate.setUTCSeconds(0, 0);
|
|
2380
|
+
for (let guard = 0; guard < 10_000; guard += 1) {
|
|
2381
|
+
const zoned = zonedDateParts(candidate, timezone);
|
|
2382
|
+
const nextMonth = nextAllowedValue(parsed.month, zoned.month, 1, 12);
|
|
2383
|
+
if (nextMonth == null) {
|
|
2384
|
+
candidate = utcDateForZoned(timezone, zoned.year + 1, firstAllowedValue(parsed.month, 1, 12), 1, 0, 0);
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if (nextMonth !== zoned.month) {
|
|
2388
|
+
candidate = utcDateForZoned(timezone, zoned.year, nextMonth, 1, 0, 0);
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
if (!matchesCronDay(parsed, zoned)) {
|
|
2392
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, 0, 0);
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const nextHour = nextAllowedValue(parsed.hour, zoned.hour, 0, 23);
|
|
2396
|
+
if (nextHour == null) {
|
|
2397
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, firstAllowedValue(parsed.hour, 0, 23), 0);
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
if (nextHour !== zoned.hour) {
|
|
2401
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, nextHour, 0);
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
const nextMinute = nextAllowedValue(parsed.minute, zoned.minute, 0, 59);
|
|
2405
|
+
if (nextMinute == null) {
|
|
2406
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour + 1, firstAllowedValue(parsed.minute, 0, 59));
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
if (nextMinute !== zoned.minute) {
|
|
2410
|
+
candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour, nextMinute);
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
if (matchesCron(parsed, zoned)) {
|
|
2414
|
+
return candidate.toISOString();
|
|
2415
|
+
}
|
|
2416
|
+
candidate = addMinutes(candidate, 1);
|
|
2417
|
+
}
|
|
2418
|
+
return null;
|
|
2419
|
+
}
|
|
2420
|
+
function matchesCron(parsed, parts) {
|
|
2421
|
+
const monthMatches = parsed.month.any || parsed.month.values.has(parts.month);
|
|
2422
|
+
const hourMatches = parsed.hour.any || parsed.hour.values.has(parts.hour);
|
|
2423
|
+
const minuteMatches = parsed.minute.any || parsed.minute.values.has(parts.minute);
|
|
2424
|
+
if (!monthMatches || !hourMatches || !minuteMatches) {
|
|
2425
|
+
return false;
|
|
2426
|
+
}
|
|
2427
|
+
const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
|
|
2428
|
+
const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
|
|
2429
|
+
if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
|
|
2430
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
2431
|
+
}
|
|
2432
|
+
return dayOfMonthMatches && dayOfWeekMatches;
|
|
2433
|
+
}
|
|
2434
|
+
function matchesCronDay(parsed, parts) {
|
|
2435
|
+
const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
|
|
2436
|
+
const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
|
|
2437
|
+
if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
|
|
2438
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
2439
|
+
}
|
|
2440
|
+
return dayOfMonthMatches && dayOfWeekMatches;
|
|
2441
|
+
}
|
|
2442
|
+
function zonedDateParts(date, timezone) {
|
|
2443
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
2444
|
+
timeZone: timezone,
|
|
2445
|
+
year: "numeric",
|
|
2446
|
+
month: "2-digit",
|
|
2447
|
+
day: "2-digit",
|
|
2448
|
+
hour: "2-digit",
|
|
2449
|
+
minute: "2-digit",
|
|
2450
|
+
hourCycle: "h23",
|
|
2451
|
+
weekday: "short",
|
|
2452
|
+
});
|
|
2453
|
+
const entries = Object.fromEntries(formatter.formatToParts(date)
|
|
2454
|
+
.filter((part) => part.type !== "literal")
|
|
2455
|
+
.map((part) => [part.type, part.value]));
|
|
2456
|
+
const hour = Number(entries.hour);
|
|
2457
|
+
return {
|
|
2458
|
+
year: Number(entries.year),
|
|
2459
|
+
month: Number(entries.month),
|
|
2460
|
+
day: Number(entries.day),
|
|
2461
|
+
// Some ICU builds format midnight as 24:00 even for exact timestamps.
|
|
2462
|
+
// Normalize that representation so cron matching stays cross-platform.
|
|
2463
|
+
hour: hour === 24 ? 0 : hour,
|
|
2464
|
+
minute: Number(entries.minute),
|
|
2465
|
+
dayOfWeek: weekdayToNumber(entries.weekday),
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
function nextAllowedValue(field, current, min, max) {
|
|
2469
|
+
if (field.any) {
|
|
2470
|
+
return current;
|
|
2471
|
+
}
|
|
2472
|
+
for (let value = current; value <= max; value += 1) {
|
|
2473
|
+
if (field.values.has(value)) {
|
|
2474
|
+
return value;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
return null;
|
|
2478
|
+
}
|
|
2479
|
+
function firstAllowedValue(field, min, max) {
|
|
2480
|
+
if (field.any) {
|
|
2481
|
+
return min;
|
|
2482
|
+
}
|
|
2483
|
+
for (let value = min; value <= max; value += 1) {
|
|
2484
|
+
if (field.values.has(value)) {
|
|
2485
|
+
return value;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
return min;
|
|
2489
|
+
}
|
|
2490
|
+
function addMinutes(date, minutes) {
|
|
2491
|
+
return new Date(date.getTime() + minutes * 60_000);
|
|
2492
|
+
}
|
|
2493
|
+
function utcDateForZoned(timezone, year, month, day, hour, minute) {
|
|
2494
|
+
const approxUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
|
2495
|
+
const approx = new Date(approxUtc);
|
|
2496
|
+
const zoned = zonedDateParts(approx, timezone);
|
|
2497
|
+
const targetNaiveUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
|
2498
|
+
const zonedNaiveUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0, 0);
|
|
2499
|
+
return new Date(approxUtc - (zonedNaiveUtc - targetNaiveUtc));
|
|
2500
|
+
}
|
|
2501
|
+
function weekdayToNumber(value) {
|
|
2502
|
+
switch (value) {
|
|
2503
|
+
case "Sun": return 0;
|
|
2504
|
+
case "Mon": return 1;
|
|
2505
|
+
case "Tue": return 2;
|
|
2506
|
+
case "Wed": return 3;
|
|
2507
|
+
case "Thu": return 4;
|
|
2508
|
+
case "Fri": return 5;
|
|
2509
|
+
case "Sat": return 6;
|
|
2510
|
+
default:
|
|
2511
|
+
throw new Error(`unsupported weekday: ${value}`);
|
|
2512
|
+
}
|
|
2513
|
+
}
|