@holon-run/agentinbox 0.1.3 → 0.2.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 +63 -14
- package/dist/src/http.js +85 -7
- package/dist/src/service.js +469 -16
- 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 +129 -6
- 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/schema.ts +24 -2
- package/package.json +2 -2
package/dist/src/service.js
CHANGED
|
@@ -4,6 +4,7 @@ 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");
|
|
9
10
|
const DEFAULT_SUBSCRIPTION_POLL_LIMIT = 100;
|
|
@@ -14,9 +15,10 @@ const DEFAULT_NOTIFY_RETRY_MS = 5_000;
|
|
|
14
15
|
const DEFAULT_ACKED_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
15
16
|
const DEFAULT_OFFLINE_AGENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
17
|
const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
|
|
18
|
+
const DEFAULT_SYNC_INTERVAL_MS = 2_000;
|
|
19
|
+
const DEFAULT_IDLE_SOURCE_GRACE_MS = 5 * 60 * 1000;
|
|
17
20
|
const WEBHOOK_ACTIVATION_MODES = new Set(["activation_only", "activation_with_items"]);
|
|
18
21
|
const SUBSCRIPTION_START_POLICIES = new Set(["latest", "earliest", "at_offset", "at_time"]);
|
|
19
|
-
const SUBSCRIPTION_LIFECYCLE_MODES = new Set(["standing", "temporary"]);
|
|
20
22
|
class AgentInboxService {
|
|
21
23
|
store;
|
|
22
24
|
adapters;
|
|
@@ -30,6 +32,7 @@ class AgentInboxService {
|
|
|
30
32
|
syncInterval = null;
|
|
31
33
|
stopping = false;
|
|
32
34
|
lastAckedInboxGcAt = 0;
|
|
35
|
+
lastLifecycleCleanupAt = 0;
|
|
33
36
|
lastOfflineAgentGcAt = 0;
|
|
34
37
|
constructor(store, adapters, activationDispatcher = new ActivationDispatcher(), backend, activationPolicy, terminalDispatcher = new terminal_1.TerminalDispatcher()) {
|
|
35
38
|
this.store = store;
|
|
@@ -53,7 +56,7 @@ class AgentInboxService {
|
|
|
53
56
|
void this.syncActivationDispatchStates();
|
|
54
57
|
void this.runAckedInboxGcIfDue();
|
|
55
58
|
void this.syncLifecycleGc();
|
|
56
|
-
},
|
|
59
|
+
}, DEFAULT_SYNC_INTERVAL_MS);
|
|
57
60
|
await this.syncAllSubscriptions();
|
|
58
61
|
await this.syncActivationDispatchStates();
|
|
59
62
|
await this.runAckedInboxGcIfDue(true);
|
|
@@ -105,30 +108,91 @@ class AgentInboxService {
|
|
|
105
108
|
}
|
|
106
109
|
return source;
|
|
107
110
|
}
|
|
108
|
-
getSourceDetails(sourceId) {
|
|
111
|
+
async getSourceDetails(sourceId) {
|
|
109
112
|
const source = this.getSource(sourceId);
|
|
113
|
+
const fallbackSchema = (0, source_schema_1.getSourceSchema)(source.sourceType);
|
|
114
|
+
let resolvedIdentity = null;
|
|
115
|
+
let resolutionError = null;
|
|
116
|
+
let schema = fallbackSchema;
|
|
117
|
+
try {
|
|
118
|
+
resolvedIdentity = await this.adapters.resolveSourceIdentity(source);
|
|
119
|
+
schema = await this.adapters.resolveSourceSchema(source);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
resolutionError = error instanceof Error ? error.message : String(error);
|
|
123
|
+
}
|
|
124
|
+
const resolvedSchema = resolvedIdentity
|
|
125
|
+
? ("hostType" in schema
|
|
126
|
+
? schema
|
|
127
|
+
: (0, source_resolution_1.withResolvedIdentity)(source.sourceId, fallbackSchema, resolvedIdentity))
|
|
128
|
+
: fallbackSchema;
|
|
110
129
|
return {
|
|
111
130
|
source,
|
|
112
|
-
|
|
131
|
+
resolvedIdentity,
|
|
132
|
+
...(resolutionError ? { resolutionError } : {}),
|
|
133
|
+
schema: resolvedSchema,
|
|
113
134
|
stream: this.store.getStreamBySourceId(sourceId),
|
|
114
135
|
subscriptions: this.store.listSubscriptionsForSource(sourceId),
|
|
136
|
+
idleState: this.store.getSourceIdleState(sourceId),
|
|
115
137
|
};
|
|
116
138
|
}
|
|
117
139
|
getSourceSchema(sourceType) {
|
|
118
140
|
return (0, source_schema_1.getSourceSchema)(sourceType);
|
|
119
141
|
}
|
|
120
|
-
async
|
|
142
|
+
async getResolvedSourceSchema(sourceId) {
|
|
143
|
+
const source = this.getSource(sourceId);
|
|
144
|
+
return this.adapters.resolveSourceSchema(source);
|
|
145
|
+
}
|
|
146
|
+
async previewSourceSchema(input) {
|
|
147
|
+
try {
|
|
148
|
+
const source = buildPreviewSource(input);
|
|
149
|
+
await this.adapters.sourceAdapterFor(source.sourceType).validateSource?.(source);
|
|
150
|
+
const schema = await this.adapters.resolveSourceSchema(source);
|
|
151
|
+
if (input.sourceRef.startsWith("remote:")) {
|
|
152
|
+
const expectedImplementationId = input.sourceRef.slice("remote:".length);
|
|
153
|
+
if (schema.implementationId !== expectedImplementationId) {
|
|
154
|
+
throw new Error(`preview source kind ${input.sourceRef} resolved to implementation ${schema.implementationId}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const { sourceId: _sourceId, ...preview } = schema;
|
|
158
|
+
return preview;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
if (message.startsWith("preview failed: ") || message.startsWith("preview source kind ")) {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`preview failed: ${message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async removeSource(sourceId, options = {}) {
|
|
121
169
|
const source = this.store.getSource(sourceId);
|
|
122
170
|
if (!source) {
|
|
123
|
-
return { removed: false };
|
|
171
|
+
return { removed: false, sourceId, removedSubscriptions: 0, pausedSource: false };
|
|
124
172
|
}
|
|
125
173
|
const subscriptions = this.store.listSubscriptionsForSource(sourceId);
|
|
126
|
-
if (subscriptions.length > 0) {
|
|
127
|
-
throw new Error("source remove requires no active subscriptions");
|
|
174
|
+
if (subscriptions.length > 0 && !options.withSubscriptions) {
|
|
175
|
+
throw new Error("source remove requires no active subscriptions; retry with --with-subscriptions or with_subscriptions=true");
|
|
176
|
+
}
|
|
177
|
+
let pausedSource = false;
|
|
178
|
+
if (options.withSubscriptions) {
|
|
179
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
180
|
+
if (sourceAdapter.pauseSource) {
|
|
181
|
+
await this.pauseSource(sourceId);
|
|
182
|
+
pausedSource = true;
|
|
183
|
+
}
|
|
184
|
+
for (const subscription of subscriptions) {
|
|
185
|
+
await this.removeSubscription(subscription.subscriptionId);
|
|
186
|
+
}
|
|
128
187
|
}
|
|
129
188
|
await this.adapters.removeSource(source);
|
|
130
189
|
this.store.deleteSource(sourceId);
|
|
131
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
removed: true,
|
|
192
|
+
sourceId,
|
|
193
|
+
removedSubscriptions: subscriptions.length,
|
|
194
|
+
pausedSource,
|
|
195
|
+
};
|
|
132
196
|
}
|
|
133
197
|
async updateSource(sourceId, input) {
|
|
134
198
|
const source = this.store.getSource(sourceId);
|
|
@@ -183,6 +247,7 @@ class AgentInboxService {
|
|
|
183
247
|
if (this.store.getSource(sourceId)?.status !== "paused") {
|
|
184
248
|
this.store.updateSourceRuntime(sourceId, { status: "paused" });
|
|
185
249
|
}
|
|
250
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
186
251
|
return {
|
|
187
252
|
paused: true,
|
|
188
253
|
source: this.getSource(sourceId),
|
|
@@ -197,6 +262,7 @@ class AgentInboxService {
|
|
|
197
262
|
if (this.store.getSource(sourceId)?.status === "paused") {
|
|
198
263
|
this.store.updateSourceRuntime(sourceId, { status: "active" });
|
|
199
264
|
}
|
|
265
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
200
266
|
return {
|
|
201
267
|
resumed: true,
|
|
202
268
|
source: this.getSource(sourceId),
|
|
@@ -285,7 +351,11 @@ class AgentInboxService {
|
|
|
285
351
|
}
|
|
286
352
|
removeAgent(agentId) {
|
|
287
353
|
this.getAgent(agentId);
|
|
354
|
+
const affectedSourceIds = Array.from(new Set(this.store.listSubscriptionsForAgent(agentId).map((subscription) => subscription.sourceId)));
|
|
288
355
|
this.store.deleteAgent(agentId);
|
|
356
|
+
for (const sourceId of affectedSourceIds) {
|
|
357
|
+
this.refreshSourceIdleState(sourceId);
|
|
358
|
+
}
|
|
289
359
|
this.notificationBuffers.forEach((buffer, key) => {
|
|
290
360
|
if (key.startsWith(`${agentId}:`)) {
|
|
291
361
|
if (buffer.timer) {
|
|
@@ -346,23 +416,47 @@ class AgentInboxService {
|
|
|
346
416
|
return { removed: true };
|
|
347
417
|
}
|
|
348
418
|
async registerSubscription(input) {
|
|
419
|
+
if (input.shortcut) {
|
|
420
|
+
const source = this.store.getSource(input.sourceId);
|
|
421
|
+
if (!source) {
|
|
422
|
+
throw new Error(`unknown source: ${input.sourceId}`);
|
|
423
|
+
}
|
|
424
|
+
if (hasSubscriptionFieldOverride(input)) {
|
|
425
|
+
throw new Error("subscription add shortcut does not allow filter, trackedResourceRef, or cleanupPolicy overrides");
|
|
426
|
+
}
|
|
427
|
+
const expanded = await this.adapters.expandSubscriptionShortcut(source, input.shortcut);
|
|
428
|
+
if (!expanded) {
|
|
429
|
+
throw new Error(`unknown subscription shortcut ${input.shortcut.name} for source ${input.sourceId}`);
|
|
430
|
+
}
|
|
431
|
+
return this.registerSubscription({
|
|
432
|
+
...input,
|
|
433
|
+
shortcut: undefined,
|
|
434
|
+
filter: expanded.filter,
|
|
435
|
+
trackedResourceRef: expanded.trackedResourceRef ?? null,
|
|
436
|
+
cleanupPolicy: expanded.cleanupPolicy,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
349
439
|
this.getAgent(input.agentId);
|
|
350
440
|
const source = this.store.getSource(input.sourceId);
|
|
351
441
|
if (!source) {
|
|
352
442
|
throw new Error(`unknown source: ${input.sourceId}`);
|
|
353
443
|
}
|
|
354
444
|
await (0, filter_1.validateSubscriptionFilter)(input.filter ?? {});
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
445
|
+
const cleanupPolicy = normalizeCleanupPolicy(input.cleanupPolicy ?? null);
|
|
446
|
+
const idleState = this.store.getSourceIdleState(source.sourceId);
|
|
447
|
+
if (idleState?.autoPausedAt) {
|
|
448
|
+
await this.resumeSource(source.sourceId);
|
|
449
|
+
}
|
|
450
|
+
else if (idleState) {
|
|
451
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
358
452
|
}
|
|
359
453
|
const subscription = {
|
|
360
454
|
subscriptionId: (0, util_1.generateId)("sub"),
|
|
361
455
|
agentId: input.agentId,
|
|
362
456
|
sourceId: input.sourceId,
|
|
363
457
|
filter: input.filter ?? {},
|
|
364
|
-
|
|
365
|
-
|
|
458
|
+
trackedResourceRef: normalizeTrackedResourceRef(input.trackedResourceRef),
|
|
459
|
+
cleanupPolicy,
|
|
366
460
|
startPolicy: input.startPolicy ?? "latest",
|
|
367
461
|
startOffset: input.startOffset ?? null,
|
|
368
462
|
startTime: input.startTime ?? null,
|
|
@@ -379,6 +473,7 @@ class AgentInboxService {
|
|
|
379
473
|
startOffset: subscription.startOffset ?? null,
|
|
380
474
|
startTime: subscription.startTime ?? null,
|
|
381
475
|
});
|
|
476
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
382
477
|
return subscription;
|
|
383
478
|
}
|
|
384
479
|
async removeSubscription(subscriptionId) {
|
|
@@ -386,6 +481,7 @@ class AgentInboxService {
|
|
|
386
481
|
await this.backend.deleteConsumer({ subscriptionId });
|
|
387
482
|
this.store.deleteSubscription(subscriptionId);
|
|
388
483
|
this.clearSubscriptionRuntimeState(subscription);
|
|
484
|
+
this.refreshSourceIdleState(subscription.sourceId);
|
|
389
485
|
return { removed: true, subscriptionId };
|
|
390
486
|
}
|
|
391
487
|
listSubscriptions(filters) {
|
|
@@ -598,11 +694,13 @@ class AgentInboxService {
|
|
|
598
694
|
}
|
|
599
695
|
gc() {
|
|
600
696
|
const acked = this.gcAckedInboxItems();
|
|
601
|
-
const lifecycle = this.
|
|
697
|
+
const lifecycle = this.runLifecycleCleanupPass(Date.now());
|
|
698
|
+
const offlineAgents = this.gcOfflineAgents();
|
|
602
699
|
return {
|
|
603
700
|
deleted: acked.deleted,
|
|
604
701
|
retentionMs: acked.retentionMs,
|
|
605
|
-
removedAgents:
|
|
702
|
+
removedAgents: offlineAgents.removedAgents,
|
|
703
|
+
removedSubscriptions: lifecycle.removedSubscriptions,
|
|
606
704
|
};
|
|
607
705
|
}
|
|
608
706
|
async pollSource(sourceId) {
|
|
@@ -645,9 +743,11 @@ class AgentInboxService {
|
|
|
645
743
|
let inboxItemsCreated = 0;
|
|
646
744
|
let lastProcessedOffset = null;
|
|
647
745
|
const insertedItems = [];
|
|
746
|
+
const lifecycleSignals = new Map();
|
|
648
747
|
try {
|
|
649
748
|
for (const event of batch.events) {
|
|
650
749
|
lastProcessedOffset = event.offset;
|
|
750
|
+
await this.collectLifecycleSignal(source, event.rawPayload, lifecycleSignals, event.occurredAt);
|
|
651
751
|
const match = await (0, filter_1.matchSubscriptionFilter)(subscription.filter, {
|
|
652
752
|
metadata: event.metadata,
|
|
653
753
|
payload: event.rawPayload,
|
|
@@ -720,6 +820,9 @@ class AgentInboxService {
|
|
|
720
820
|
committedOffset: lastProcessedOffset,
|
|
721
821
|
});
|
|
722
822
|
}
|
|
823
|
+
for (const signal of lifecycleSignals.values()) {
|
|
824
|
+
this.scheduleLifecycleRetirements(source, subscription, signal);
|
|
825
|
+
}
|
|
723
826
|
return {
|
|
724
827
|
subscriptionId: subscription.subscriptionId,
|
|
725
828
|
sourceId: subscription.sourceId,
|
|
@@ -818,6 +921,52 @@ class AgentInboxService {
|
|
|
818
921
|
backend: "sqlite",
|
|
819
922
|
});
|
|
820
923
|
}
|
|
924
|
+
async collectLifecycleSignal(source, rawPayload, signals, fallbackOccurredAt) {
|
|
925
|
+
const signal = await this.adapters.projectLifecycleSignal(source, rawPayload);
|
|
926
|
+
if (!signal || !signal.terminal) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const normalized = normalizeLifecycleSignal(signal, fallbackOccurredAt);
|
|
930
|
+
if (!normalized) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const existing = signals.get(normalized.ref);
|
|
934
|
+
if (!existing) {
|
|
935
|
+
signals.set(normalized.ref, normalized);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const existingAt = Date.parse(existing.occurredAt ?? "");
|
|
939
|
+
const normalizedAt = Date.parse(normalized.occurredAt ?? "");
|
|
940
|
+
if (Number.isNaN(existingAt) || (!Number.isNaN(normalizedAt) && normalizedAt > existingAt)) {
|
|
941
|
+
signals.set(normalized.ref, normalized);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
scheduleLifecycleRetirements(source, subscription, signal) {
|
|
945
|
+
if (!signal.terminal) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!subscription.trackedResourceRef || subscription.trackedResourceRef !== signal.ref) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const signalOccurredAt = signal.occurredAt ?? (0, util_1.nowIso)();
|
|
952
|
+
const signalOccurredAtMs = Date.parse(signalOccurredAt);
|
|
953
|
+
const retireAt = lifecycleRetireAtForSignal(subscription.cleanupPolicy, signalOccurredAtMs);
|
|
954
|
+
if (!retireAt) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const now = (0, util_1.nowIso)();
|
|
958
|
+
this.store.upsertSubscriptionLifecycleRetirement({
|
|
959
|
+
subscriptionId: subscription.subscriptionId,
|
|
960
|
+
sourceId: source.sourceId,
|
|
961
|
+
trackedResourceRef: signal.ref,
|
|
962
|
+
retireAt,
|
|
963
|
+
terminalState: signal.state ?? null,
|
|
964
|
+
terminalResult: signal.result ?? null,
|
|
965
|
+
terminalOccurredAt: signalOccurredAt,
|
|
966
|
+
createdAt: now,
|
|
967
|
+
updatedAt: now,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
821
970
|
notifyInboxWatchers(agentId, items) {
|
|
822
971
|
const watchers = this.inboxWatchers.get(agentId);
|
|
823
972
|
if (!watchers || watchers.size === 0) {
|
|
@@ -954,6 +1103,7 @@ class AgentInboxService {
|
|
|
954
1103
|
// state so future subscriptions do not inherit a stale notified window.
|
|
955
1104
|
this.store.deleteActivationDispatchState(state.agentId, state.targetId);
|
|
956
1105
|
}
|
|
1106
|
+
this.store.deleteSubscriptionLifecycleRetirement(subscription.subscriptionId);
|
|
957
1107
|
}
|
|
958
1108
|
async flushAllPendingNotifications() {
|
|
959
1109
|
const keys = Array.from(this.notificationBuffers.keys());
|
|
@@ -1216,10 +1366,97 @@ class AgentInboxService {
|
|
|
1216
1366
|
lastSeenAt: agent.lastSeenAt,
|
|
1217
1367
|
});
|
|
1218
1368
|
}
|
|
1369
|
+
runLifecycleCleanupPass(nowMs) {
|
|
1370
|
+
this.lastLifecycleCleanupAt = nowMs;
|
|
1371
|
+
const removed = new Set();
|
|
1372
|
+
const affectedSourceIds = new Set();
|
|
1373
|
+
for (const subscription of this.store.listSubscriptions()) {
|
|
1374
|
+
if (removed.has(subscription.subscriptionId)) {
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
const deadline = lifecycleDeadlineAt(subscription.cleanupPolicy);
|
|
1378
|
+
if (!deadline) {
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const deadlineMs = Date.parse(deadline);
|
|
1382
|
+
if (Number.isNaN(deadlineMs) || deadlineMs > nowMs) {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
if (this.store.deleteSubscription(subscription.subscriptionId)) {
|
|
1386
|
+
removed.add(subscription.subscriptionId);
|
|
1387
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1388
|
+
this.clearSubscriptionRuntimeState(subscription);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const dueRetirements = this.store.listSubscriptionLifecycleRetirementsDue(new Date(nowMs).toISOString());
|
|
1392
|
+
for (const retirement of dueRetirements) {
|
|
1393
|
+
if (removed.has(retirement.subscriptionId)) {
|
|
1394
|
+
this.store.deleteSubscriptionLifecycleRetirement(retirement.subscriptionId);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
const subscription = this.store.getSubscription(retirement.subscriptionId);
|
|
1398
|
+
if (!subscription) {
|
|
1399
|
+
this.store.deleteSubscriptionLifecycleRetirement(retirement.subscriptionId);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
if (this.store.deleteSubscription(retirement.subscriptionId)) {
|
|
1403
|
+
removed.add(retirement.subscriptionId);
|
|
1404
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1405
|
+
this.clearSubscriptionRuntimeState(subscription);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
removedSubscriptions: removed.size,
|
|
1410
|
+
affectedSourceIds: [...affectedSourceIds],
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
async runIdleSourceCleanupPass(nowMs) {
|
|
1414
|
+
const now = new Date(nowMs).toISOString();
|
|
1415
|
+
const due = this.store.listSourceIdleStatesDue(now);
|
|
1416
|
+
for (const idleState of due) {
|
|
1417
|
+
const source = this.store.getSource(idleState.sourceId);
|
|
1418
|
+
if (!source) {
|
|
1419
|
+
this.store.deleteSourceIdleState(idleState.sourceId);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (this.store.listSubscriptionsForSource(source.sourceId).length > 0) {
|
|
1423
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
1427
|
+
if (!sourceAdapter.pauseSource) {
|
|
1428
|
+
this.store.deleteSourceIdleState(source.sourceId);
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
if (source.status === "paused") {
|
|
1432
|
+
if (!idleState.autoPausedAt) {
|
|
1433
|
+
this.store.upsertSourceIdleState({
|
|
1434
|
+
...idleState,
|
|
1435
|
+
autoPausedAt: now,
|
|
1436
|
+
updatedAt: now,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
await this.adapters.pauseSource(source);
|
|
1442
|
+
if (this.store.getSource(source.sourceId)?.status !== "paused") {
|
|
1443
|
+
this.store.updateSourceRuntime(source.sourceId, { status: "paused" });
|
|
1444
|
+
}
|
|
1445
|
+
this.store.upsertSourceIdleState({
|
|
1446
|
+
...idleState,
|
|
1447
|
+
autoPausedAt: now,
|
|
1448
|
+
updatedAt: now,
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1219
1452
|
gcOfflineAgents(now = Date.now()) {
|
|
1220
1453
|
const cutoffIso = new Date(now - DEFAULT_OFFLINE_AGENT_TTL_MS).toISOString();
|
|
1221
1454
|
const agents = this.store.listOfflineAgentsOlderThan(cutoffIso);
|
|
1455
|
+
const affectedSourceIds = new Set();
|
|
1222
1456
|
for (const agent of agents) {
|
|
1457
|
+
for (const subscription of this.store.listSubscriptionsForAgent(agent.agentId)) {
|
|
1458
|
+
affectedSourceIds.add(subscription.sourceId);
|
|
1459
|
+
}
|
|
1223
1460
|
this.store.deleteAgent(agent.agentId, { persist: false });
|
|
1224
1461
|
this.notificationBuffers.forEach((buffer, key) => {
|
|
1225
1462
|
if (key.startsWith(`${agent.agentId}:`)) {
|
|
@@ -1231,6 +1468,9 @@ class AgentInboxService {
|
|
|
1231
1468
|
});
|
|
1232
1469
|
this.inboxWatchers.delete(agent.agentId);
|
|
1233
1470
|
}
|
|
1471
|
+
for (const sourceId of affectedSourceIds) {
|
|
1472
|
+
this.refreshSourceIdleState(sourceId);
|
|
1473
|
+
}
|
|
1234
1474
|
if (agents.length > 0) {
|
|
1235
1475
|
this.store.save();
|
|
1236
1476
|
}
|
|
@@ -1268,6 +1508,18 @@ class AgentInboxService {
|
|
|
1268
1508
|
}
|
|
1269
1509
|
async syncLifecycleGc() {
|
|
1270
1510
|
const now = Date.now();
|
|
1511
|
+
if (now - this.lastLifecycleCleanupAt >= DEFAULT_GC_INTERVAL_MS) {
|
|
1512
|
+
try {
|
|
1513
|
+
const lifecycle = this.runLifecycleCleanupPass(now);
|
|
1514
|
+
for (const sourceId of lifecycle.affectedSourceIds) {
|
|
1515
|
+
this.refreshSourceIdleState(sourceId);
|
|
1516
|
+
}
|
|
1517
|
+
await this.runIdleSourceCleanupPass(now);
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
console.warn("subscription lifecycle gc failed:", error);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1271
1523
|
if (now - this.lastOfflineAgentGcAt < DEFAULT_GC_INTERVAL_MS) {
|
|
1272
1524
|
return;
|
|
1273
1525
|
}
|
|
@@ -1279,8 +1531,209 @@ class AgentInboxService {
|
|
|
1279
1531
|
console.warn("offline agent gc failed:", error);
|
|
1280
1532
|
}
|
|
1281
1533
|
}
|
|
1534
|
+
refreshSourceIdleState(sourceId) {
|
|
1535
|
+
const source = this.store.getSource(sourceId);
|
|
1536
|
+
if (!source) {
|
|
1537
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const sourceAdapter = this.adapters.sourceAdapterFor(source.sourceType);
|
|
1541
|
+
if (!sourceAdapter.pauseSource) {
|
|
1542
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (source.status === "paused") {
|
|
1546
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const remainingSubscriptions = this.store.listSubscriptionsForSource(sourceId).length;
|
|
1550
|
+
if (remainingSubscriptions > 0) {
|
|
1551
|
+
this.store.deleteSourceIdleState(sourceId);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const now = (0, util_1.nowIso)();
|
|
1555
|
+
this.store.upsertSourceIdleState({
|
|
1556
|
+
sourceId,
|
|
1557
|
+
idleSince: now,
|
|
1558
|
+
autoPauseAt: new Date(Date.parse(now) + DEFAULT_IDLE_SOURCE_GRACE_MS).toISOString(),
|
|
1559
|
+
autoPausedAt: null,
|
|
1560
|
+
updatedAt: now,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1282
1563
|
}
|
|
1283
1564
|
exports.AgentInboxService = AgentInboxService;
|
|
1565
|
+
function hasSubscriptionFieldOverride(input) {
|
|
1566
|
+
if (input.trackedResourceRef != null || input.cleanupPolicy != null) {
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
return input.filter != null && Object.keys(input.filter).length > 0;
|
|
1570
|
+
}
|
|
1571
|
+
function buildPreviewSource(input) {
|
|
1572
|
+
const sourceType = sourceTypeForPreviewRef(input.sourceRef);
|
|
1573
|
+
const now = (0, util_1.nowIso)();
|
|
1574
|
+
return {
|
|
1575
|
+
sourceId: "__preview__",
|
|
1576
|
+
sourceType,
|
|
1577
|
+
sourceKey: `preview:${input.sourceRef}`,
|
|
1578
|
+
configRef: input.configRef ?? null,
|
|
1579
|
+
config: input.config ?? {},
|
|
1580
|
+
status: "active",
|
|
1581
|
+
checkpoint: null,
|
|
1582
|
+
createdAt: now,
|
|
1583
|
+
updatedAt: now,
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
function sourceTypeForPreviewRef(sourceRef) {
|
|
1587
|
+
if (sourceRef === "local_event" || sourceRef === "remote_source" || sourceRef === "github_repo" || sourceRef === "github_repo_ci" || sourceRef === "feishu_bot") {
|
|
1588
|
+
return sourceRef;
|
|
1589
|
+
}
|
|
1590
|
+
if (sourceRef.startsWith("remote:")) {
|
|
1591
|
+
return "remote_source";
|
|
1592
|
+
}
|
|
1593
|
+
throw new Error(`unknown source kind or type for preview: ${sourceRef}`);
|
|
1594
|
+
}
|
|
1595
|
+
function normalizeTrackedResourceRef(value) {
|
|
1596
|
+
if (value == null) {
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
const trimmed = value.trim();
|
|
1600
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1601
|
+
}
|
|
1602
|
+
function normalizeCleanupPolicy(input) {
|
|
1603
|
+
if (!input) {
|
|
1604
|
+
return { mode: "manual" };
|
|
1605
|
+
}
|
|
1606
|
+
if (input.mode === "manual") {
|
|
1607
|
+
if ("at" in input || "gracePeriodSecs" in input) {
|
|
1608
|
+
throw new Error("cleanupPolicy mode manual does not allow at or gracePeriodSecs");
|
|
1609
|
+
}
|
|
1610
|
+
return { mode: "manual" };
|
|
1611
|
+
}
|
|
1612
|
+
if (input.mode === "at") {
|
|
1613
|
+
if (!isValidIsoTimestamp(input.at)) {
|
|
1614
|
+
throw new Error("cleanupPolicy mode at requires a valid ISO8601 at timestamp");
|
|
1615
|
+
}
|
|
1616
|
+
if ("gracePeriodSecs" in input) {
|
|
1617
|
+
throw new Error("cleanupPolicy mode at does not allow gracePeriodSecs");
|
|
1618
|
+
}
|
|
1619
|
+
return { mode: "at", at: canonicalIsoTimestamp(input.at) };
|
|
1620
|
+
}
|
|
1621
|
+
if (input.mode === "on_terminal") {
|
|
1622
|
+
if ("at" in input) {
|
|
1623
|
+
throw new Error("cleanupPolicy mode on_terminal does not allow at");
|
|
1624
|
+
}
|
|
1625
|
+
return {
|
|
1626
|
+
mode: "on_terminal",
|
|
1627
|
+
...(input.gracePeriodSecs != null ? { gracePeriodSecs: normalizeGracePeriodSecs(input.gracePeriodSecs) } : {}),
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
if (input.mode === "on_terminal_or_at") {
|
|
1631
|
+
if (!isValidIsoTimestamp(input.at)) {
|
|
1632
|
+
throw new Error("cleanupPolicy mode on_terminal_or_at requires a valid ISO8601 at timestamp");
|
|
1633
|
+
}
|
|
1634
|
+
return {
|
|
1635
|
+
mode: "on_terminal_or_at",
|
|
1636
|
+
at: canonicalIsoTimestamp(input.at),
|
|
1637
|
+
...(input.gracePeriodSecs != null ? { gracePeriodSecs: normalizeGracePeriodSecs(input.gracePeriodSecs) } : {}),
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
throw new Error(`unsupported cleanup policy mode: ${input.mode ?? "unknown"}`);
|
|
1641
|
+
}
|
|
1642
|
+
function normalizeGracePeriodSecs(value) {
|
|
1643
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1644
|
+
throw new Error("cleanupPolicy gracePeriodSecs must be a non-negative integer");
|
|
1645
|
+
}
|
|
1646
|
+
return value;
|
|
1647
|
+
}
|
|
1648
|
+
function normalizeLifecycleSignal(signal, fallbackOccurredAt) {
|
|
1649
|
+
const ref = typeof signal.ref === "string" ? signal.ref.trim() : "";
|
|
1650
|
+
if (ref.length === 0) {
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
return {
|
|
1654
|
+
ref,
|
|
1655
|
+
terminal: signal.terminal,
|
|
1656
|
+
state: signal.state ?? null,
|
|
1657
|
+
result: signal.result ?? null,
|
|
1658
|
+
occurredAt: normalizeLifecycleOccurredAt(signal.occurredAt, fallbackOccurredAt),
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
function normalizeLifecycleOccurredAt(value, fallback) {
|
|
1662
|
+
if (value && isValidIsoTimestamp(value)) {
|
|
1663
|
+
return value;
|
|
1664
|
+
}
|
|
1665
|
+
if (fallback && isValidIsoTimestamp(fallback)) {
|
|
1666
|
+
return fallback;
|
|
1667
|
+
}
|
|
1668
|
+
return (0, util_1.nowIso)();
|
|
1669
|
+
}
|
|
1670
|
+
function lifecycleRetireAtForSignal(cleanupPolicy, signalOccurredAtMs) {
|
|
1671
|
+
if (cleanupPolicy.mode === "manual" || cleanupPolicy.mode === "at") {
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
if (cleanupPolicy.mode === "on_terminal") {
|
|
1675
|
+
return lifecycleSignalRetireAt(signalOccurredAtMs, cleanupPolicy.gracePeriodSecs ?? null);
|
|
1676
|
+
}
|
|
1677
|
+
return minIsoTimestamps(cleanupPolicy.at, lifecycleSignalRetireAt(signalOccurredAtMs, cleanupPolicy.gracePeriodSecs ?? null));
|
|
1678
|
+
}
|
|
1679
|
+
function lifecycleSignalRetireAt(signalOccurredAtMs, gracePeriodSecs) {
|
|
1680
|
+
const graceMs = Math.max(0, gracePeriodSecs ?? 0) * 1000;
|
|
1681
|
+
return new Date(signalOccurredAtMs + graceMs).toISOString();
|
|
1682
|
+
}
|
|
1683
|
+
function lifecycleDeadlineAt(cleanupPolicy) {
|
|
1684
|
+
if (cleanupPolicy.mode === "at" || cleanupPolicy.mode === "on_terminal_or_at") {
|
|
1685
|
+
return cleanupPolicy.at;
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
function minIsoTimestamps(left, right) {
|
|
1690
|
+
return new Date(Math.min(Date.parse(left), Date.parse(right))).toISOString();
|
|
1691
|
+
}
|
|
1692
|
+
function canonicalIsoTimestamp(value) {
|
|
1693
|
+
return new Date(value).toISOString();
|
|
1694
|
+
}
|
|
1695
|
+
function isValidIsoTimestamp(value) {
|
|
1696
|
+
if (typeof value !== "string") {
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1699
|
+
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);
|
|
1700
|
+
if (!match) {
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
const year = Number(match[1]);
|
|
1704
|
+
const month = Number(match[2]);
|
|
1705
|
+
const day = Number(match[3]);
|
|
1706
|
+
const hour = Number(match[4]);
|
|
1707
|
+
const minute = Number(match[5]);
|
|
1708
|
+
const second = Number(match[6]);
|
|
1709
|
+
const fractional = match[8] ?? "";
|
|
1710
|
+
const timezone = match[9];
|
|
1711
|
+
const offsetSign = match[10];
|
|
1712
|
+
const offsetHour = match[11] != null ? Number(match[11]) : 0;
|
|
1713
|
+
const offsetMinute = match[12] != null ? Number(match[12]) : 0;
|
|
1714
|
+
const millisecond = fractional.length === 0 ? 0 : Number((fractional + "000").slice(0, 3));
|
|
1715
|
+
if (month < 1 ||
|
|
1716
|
+
month > 12 ||
|
|
1717
|
+
day < 1 ||
|
|
1718
|
+
day > 31 ||
|
|
1719
|
+
hour > 23 ||
|
|
1720
|
+
minute > 59 ||
|
|
1721
|
+
second > 59 ||
|
|
1722
|
+
offsetHour > 23 ||
|
|
1723
|
+
offsetMinute > 59) {
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
const parsed = new Date(value);
|
|
1727
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
1728
|
+
return false;
|
|
1729
|
+
}
|
|
1730
|
+
const offsetMinutes = timezone === "Z"
|
|
1731
|
+
? 0
|
|
1732
|
+
: (offsetSign === "+" ? 1 : -1) * ((offsetHour * 60) + offsetMinute);
|
|
1733
|
+
const expectedTime = Date.UTC(year, month - 1, day, hour, minute, second, millisecond)
|
|
1734
|
+
- (offsetMinutes * 60_000);
|
|
1735
|
+
return parsed.getTime() === expectedTime;
|
|
1736
|
+
}
|
|
1284
1737
|
function retentionCutoffIso(retentionMs) {
|
|
1285
1738
|
return new Date(Date.now() - retentionMs).toISOString();
|
|
1286
1739
|
}
|