@holon-run/agentinbox 0.1.4 → 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.
@@ -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
- }, 2_000);
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
- schema: (0, source_schema_1.getSourceSchema)(source.sourceType),
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 removeSource(sourceId) {
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 { removed: true };
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 lifecycleMode = input.lifecycleMode ?? "standing";
356
- if (!SUBSCRIPTION_LIFECYCLE_MODES.has(lifecycleMode)) {
357
- throw new Error(`unsupported lifecycle mode: ${lifecycleMode}`);
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
- lifecycleMode,
365
- expiresAt: input.expiresAt ?? null,
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.gcOfflineAgents();
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: lifecycle.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
  }