deepline 0.3.113 → 0.3.114

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.
Files changed (48) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +3 -1
  2. package/dist/bundling-sources/sdk/src/compat.ts +167 -11
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/skills-version.ts +27 -1
  5. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +6 -2
  6. package/dist/bundling-sources/shared_libs/observability/queue-health.ts +291 -6
  7. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
  18. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1394 -22
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
  29. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
  30. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
  31. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
  32. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
  33. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
  34. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
  35. package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
  36. package/dist/cli/index.js +574 -114
  37. package/dist/cli/index.mjs +592 -133
  38. package/dist/index.d.mts +54 -1
  39. package/dist/index.d.ts +54 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.mjs +8 -3
  42. package/dist/install-integrity.json +7 -0
  43. package/dist/plays/bundle-play-file.mjs +1 -1
  44. package/dist/release.d.mts +1 -1
  45. package/dist/release.d.ts +1 -1
  46. package/dist/release.js +1 -1
  47. package/dist/release.mjs +1 -1
  48. package/package.json +1 -1
@@ -34,11 +34,24 @@ const RECOVERY_FAILURE_RETRY_MAX_MS = 60_000;
34
34
  export const NOTIFY_UNAVAILABLE_SAFETY_WAKE_MS = 1_000;
35
35
  const MAX_TIMEOUT_MS = 2_147_483_647;
36
36
  /**
37
- * Two short claim/deliver waves match each projector's bounded scheduler pool.
38
- * The claim query uses SKIP LOCKED, so the wave can advance separate runs
39
- * without splitting the ordered lifecycle of any one run.
37
+ * One projector can keep this many independent Run Ledger deliveries in
38
+ * flight after it has found durable work. Each delivery owns only short
39
+ * Postgres checkouts: claim, token-context read, and acknowledgement. It does
40
+ * not hold a scheduler connection across either gateway HTTP request. This is
41
+ * therefore a network/projection concurrency budget, deliberately independent
42
+ * of the projector's much smaller Postgres pool.
43
+ *
44
+ * A single successful probe starts a bounded wave; an idle projector still
45
+ * performs only one indexed head read per safety wake. `SKIP LOCKED` leases
46
+ * distinct runs, retaining strict causal ordering within every run.
40
47
  */
41
- export const RUN_LEDGER_OUTBOX_RECOVERY_MAX_IN_FLIGHT = 2;
48
+ // `run.created` has one event per admitted Run, so a burst must project
49
+ // independently rather than wait for execution. Each Fly delivery first asks
50
+ // the launch-pinned Vercel deployment for a lease-fenced executor token,
51
+ // though. Keep that remote fan-out inside the Vercel control-plane budget:
52
+ // the Fly checkout is short, but 128 simultaneous token requests previously
53
+ // saturated the shared serverless scheduler pool before projection began.
54
+ export const RUN_LEDGER_OUTBOX_RECOVERY_MAX_IN_FLIGHT = 32;
42
55
 
43
56
  type RecoveryTimer = ReturnType<typeof setTimeout>;
44
57
 
@@ -226,12 +239,33 @@ export function startRunLedgerOutboxRecovery(
226
239
  timer.unref?.();
227
240
  };
228
241
 
229
- const drain = async (_reason: string): Promise<void> => {
242
+ const drain = async (reason: string): Promise<void> => {
230
243
  while (requested && !closed) {
231
244
  requested = false;
232
- const settled = await Promise.allSettled(
233
- Array.from({ length: parallelism }, () => deps.dispatchOnce()),
234
- );
245
+ // Probe first so an idle projector issues one small indexed read per
246
+ // wake rather than `parallelism` identical empty scans. Once the probe
247
+ // claims a row, the remaining slots can safely lease unrelated runs;
248
+ // each dispatcher releases its database client before gateway I/O.
249
+ let first: PostgresSchedulerOutboxProjectionResult;
250
+ try {
251
+ first = await deps.dispatchOnce();
252
+ } catch (error) {
253
+ scheduleFailureRetry('dispatch', error);
254
+ return;
255
+ }
256
+ const settled = [
257
+ {
258
+ status: 'fulfilled' as const,
259
+ value: first,
260
+ },
261
+ ...(first.attempted === 0
262
+ ? []
263
+ : await Promise.allSettled(
264
+ Array.from({ length: parallelism - 1 }, () =>
265
+ deps.dispatchOnce(),
266
+ ),
267
+ )),
268
+ ];
235
269
  const rejected = settled.find(
236
270
  (result): result is PromiseRejectedResult =>
237
271
  result.status === 'rejected',
@@ -251,6 +285,18 @@ export function startRunLedgerOutboxRecovery(
251
285
  (total, result) => total + result.retrying,
252
286
  0,
253
287
  );
288
+ const attempted = results.reduce(
289
+ (total, result) => total + result.attempted,
290
+ 0,
291
+ );
292
+ const projected = results.reduce(
293
+ (total, result) => total + result.projected,
294
+ 0,
295
+ );
296
+ const blocked = results.reduce(
297
+ (total, result) => total + result.failed,
298
+ 0,
299
+ );
254
300
 
255
301
  if (retrying > 0) {
256
302
  // The failed row owns its own durable retry clock. Do NOT stop this
@@ -263,11 +309,25 @@ export function startRunLedgerOutboxRecovery(
263
309
  failureCount = 0;
264
310
  }
265
311
 
312
+ if (attempted > 0) {
313
+ // This is the durable queue's high-signal throughput event. It makes
314
+ // a delayed public Run observable as a projector/outbox problem,
315
+ // without emitting one noisy log line per normal database checkout.
316
+ logger.info('[run-ledger-outbox.recovery_wave]', {
317
+ reason,
318
+ parallelism,
319
+ attempted,
320
+ projected,
321
+ retrying,
322
+ blocked,
323
+ });
324
+ }
325
+
266
326
  // Continue until no event was claimable. A transient failure schedules
267
327
  // its own durable retry, so the next pass naturally skips that run while
268
328
  // moving unrelated runs forward. A permanent failure is settled once and
269
329
  // likewise cannot block another run's causal prefix.
270
- if (results.some((result) => result.attempted > 0)) {
330
+ if (attempted > 0) {
271
331
  requested = true;
272
332
  continue;
273
333
  }
@@ -324,10 +384,11 @@ export function startRunLedgerOutboxRecoveryController(input: {
324
384
  { tokenAuthority: 'gateway', limit: 8 },
325
385
  schedulerOptions,
326
386
  ),
327
- parallelism: Math.min(
328
- RUN_LEDGER_OUTBOX_RECOVERY_MAX_IN_FLIGHT,
329
- Math.max(1, input.poolOptions.maxClients ?? 1),
330
- ),
387
+ // `dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope` releases each
388
+ // short scheduler checkout before it calls the Vercel gateway. Keep the
389
+ // durable projection wave large enough for a webhook burst without
390
+ // expanding the process' Postgres connection budget.
391
+ parallelism: RUN_LEDGER_OUTBOX_RECOVERY_MAX_IN_FLIGHT,
331
392
  readNextDueAt: () =>
332
393
  withRuntimeSchedulerClient(input.poolOptions, (client) =>
333
394
  readPostgresSchedulerOutboxNextDueAt(client, schedulerOptions),
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Process-local admission before the atomic Postgres launch transaction.
3
3
  *
4
- * A Vercel app process uses a deliberately tiny two-client scheduler pool.
5
- * Bulk submissions used to consume both clients while executor-token lease
6
- * reads needed one, so a burst turned a retryable token lookup into a failed
7
- * engine leg. One bulk submit at a time keeps a control-plane client free.
4
+ * An ordinary Vercel launch holds one submit permit. The private Workpool
5
+ * batch adapter may widen that permit to the already-fenced request pool
6
+ * capacity minus one reserved control-plane client. The request still owns a
7
+ * single global transport leader, so this does not turn customer webhook
8
+ * bursts into unbounded PgBouncer connections.
8
9
  *
9
10
  * This is intentionally not durable state and does not acknowledge a launch.
10
11
  * The existing submit transaction remains the only admission boundary. If the
@@ -23,6 +24,13 @@ export const RUNTIME_SCHEDULER_SUBMIT_ADMISSION = {
23
24
  maxWaitMs: 60_000,
24
25
  } as const;
25
26
 
27
+ // The private Vercel batch has at most 256 source lifecycles in flight. It
28
+ // may therefore need to park 225 of them behind its 31 reserved scheduler
29
+ // submit clients. Keep the per-process override at that same finite boundary:
30
+ // it is a backpressure buffer, not another durable transport or an unlimited
31
+ // way to consume PgBouncer connections.
32
+ const MAX_RUNTIME_SCHEDULER_SUBMIT_ADMISSION_QUEUED = 256;
33
+
26
34
  type Waiter = {
27
35
  enqueuedAt: number;
28
36
  resolve: (permit: RuntimeSchedulerSubmitAdmissionPermit) => void;
@@ -32,6 +40,8 @@ type Waiter = {
32
40
 
33
41
  type AdmissionEntry = {
34
42
  active: number;
43
+ maxActive: number;
44
+ maxQueued: number;
35
45
  waiters: Waiter[];
36
46
  };
37
47
 
@@ -54,6 +64,18 @@ export type RuntimeSchedulerSubmitAdmissionParams = {
54
64
  /** Kept in-memory only. Do not log this value because it contains DB auth. */
55
65
  schedulerUrl: string;
56
66
  schedulerSchema: string | null | undefined;
67
+ /**
68
+ * The private Vercel batch adapter may widen the local submitter to its
69
+ * bounded scheduler pool. Ordinary launchers omit this and retain one-at-a-
70
+ * time admission.
71
+ */
72
+ maxActive?: number;
73
+ /**
74
+ * A private, already-bounded batch may widen its local wait buffer to its
75
+ * lifecycle budget. Ordinary launchers omit this and retain the 128-entry
76
+ * transient queue.
77
+ */
78
+ maxQueued?: number;
57
79
  };
58
80
 
59
81
  export type RuntimeSchedulerSubmitAdmissionPermit = {
@@ -79,6 +101,37 @@ function pruneEntry(key: string, entry: AdmissionEntry): void {
79
101
  }
80
102
  }
81
103
 
104
+ function maxActiveFor(params: RuntimeSchedulerSubmitAdmissionParams): number {
105
+ const maxActive =
106
+ params.maxActive ?? RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxActive;
107
+ if (!Number.isInteger(maxActive) || maxActive < 1 || maxActive > 32) {
108
+ throw new Error('Runtime scheduler submit admission capacity is invalid.');
109
+ }
110
+ return maxActive;
111
+ }
112
+
113
+ function maxQueuedFor(params: RuntimeSchedulerSubmitAdmissionParams): number {
114
+ const maxQueued =
115
+ params.maxQueued ?? RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxQueued;
116
+ if (
117
+ !Number.isInteger(maxQueued) ||
118
+ maxQueued < 0 ||
119
+ maxQueued > MAX_RUNTIME_SCHEDULER_SUBMIT_ADMISSION_QUEUED
120
+ ) {
121
+ throw new Error('Runtime scheduler submit admission queue is invalid.');
122
+ }
123
+ return maxQueued;
124
+ }
125
+
126
+ function drainEntry(key: string, entry: AdmissionEntry): void {
127
+ while (entry.active < entry.maxActive && entry.waiters.length > 0) {
128
+ const next = entry.waiters.shift()!;
129
+ clearTimeout(next.timeout);
130
+ entry.active += 1;
131
+ next.resolve(permitFor(key, entry, entry.waiters.length, next.enqueuedAt));
132
+ }
133
+ }
134
+
82
135
  function permitFor(
83
136
  key: string,
84
137
  entry: AdmissionEntry,
@@ -92,15 +145,8 @@ function permitFor(
92
145
  release: () => {
93
146
  if (released) return;
94
147
  released = true;
95
- const next = entry.waiters.shift();
96
- if (next) {
97
- clearTimeout(next.timeout);
98
- next.resolve(
99
- permitFor(key, entry, entry.waiters.length, next.enqueuedAt),
100
- );
101
- return;
102
- }
103
148
  entry.active -= 1;
149
+ drainEntry(key, entry);
104
150
  pruneEntry(key, entry);
105
151
  },
106
152
  };
@@ -116,16 +162,34 @@ export async function acquireRuntimeSchedulerSubmitAdmission(
116
162
  ): Promise<RuntimeSchedulerSubmitAdmissionPermit> {
117
163
  const key = keyFor(params);
118
164
  const entries = registry();
119
- const entry = entries.get(key) ?? { active: 0, waiters: [] };
165
+ const requestedMaxActive = maxActiveFor(params);
166
+ const requestedMaxQueued = maxQueuedFor(params);
167
+ const entry = entries.get(key) ?? {
168
+ active: 0,
169
+ maxActive: requestedMaxActive,
170
+ maxQueued: requestedMaxQueued,
171
+ waiters: [],
172
+ };
120
173
  entries.set(key, entry);
121
174
  const enqueuedAt = Date.now();
122
175
 
123
- if (entry.active < RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxActive) {
176
+ // A private batch may arrive after ordinary work has established this lane.
177
+ // Upgrade its shared gate in place so both call paths stay under one actual
178
+ // pool ceiling instead of creating independent per-protocol queues.
179
+ if (requestedMaxActive > entry.maxActive) {
180
+ entry.maxActive = requestedMaxActive;
181
+ drainEntry(key, entry);
182
+ }
183
+ if (requestedMaxQueued > entry.maxQueued) {
184
+ entry.maxQueued = requestedMaxQueued;
185
+ }
186
+
187
+ if (entry.active < entry.maxActive && entry.waiters.length === 0) {
124
188
  entry.active += 1;
125
189
  return permitFor(key, entry, 0, enqueuedAt);
126
190
  }
127
191
 
128
- if (entry.waiters.length >= RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxQueued) {
192
+ if (entry.waiters.length >= entry.maxQueued) {
129
193
  pruneEntry(key, entry);
130
194
  throw new RuntimeSchedulerSubmissionAdmissionError(
131
195
  'full',
@@ -1,26 +1,29 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import {
3
- PLAY_SECRET_UNAVAILABLE,
4
- playSecretUnavailableErrorFrom,
5
- } from '../plays/secrets/errors';
3
+ classifyPlayAdmissionFailure,
4
+ PLAY_RUN_PRE_RUN_VALIDATION_FAILED,
5
+ RUNTIME_DB_SESSION_PRELOAD_INCOMPLETE,
6
+ } from './/play-admission-outcome';
7
+
8
+ export {
9
+ PLAY_RUN_PRE_RUN_VALIDATION_FAILED,
10
+ RUNTIME_DB_SESSION_PRELOAD_INCOMPLETE,
11
+ };
6
12
 
7
13
  const RETRY_BASE_MS = 1_000;
8
14
  const RETRY_CAP_MS = 15 * 60 * 1_000;
9
15
 
10
16
  /**
11
- * Admission has not called a customer provider or started user code. Unknown
12
- * failures therefore stay retryable: an internal 5xx must not discard a
13
- * source delivery. Only a proven local configuration error is blocked.
17
+ * Compatibility facade for the old webhook-named import. New source adapters
18
+ * use `classifyPlayAdmissionFailure`, while an older Vercel build can keep its
19
+ * boolean result shape during the Convex-before-Vercel rollout window.
14
20
  */
15
- export function classifyWebhookReceiptAdmissionFailure(error: unknown): {
16
- retryable: boolean;
17
- code: 'INTERNAL_ADMISSION_FAILURE' | typeof PLAY_SECRET_UNAVAILABLE;
18
- } {
19
- const unavailableSecret = playSecretUnavailableErrorFrom(error);
20
- if (unavailableSecret) {
21
- return { retryable: false, code: PLAY_SECRET_UNAVAILABLE };
22
- }
23
- return { retryable: true, code: 'INTERNAL_ADMISSION_FAILURE' };
21
+ export function classifyWebhookReceiptAdmissionFailure(error: unknown) {
22
+ const failure = classifyPlayAdmissionFailure(error);
23
+ return {
24
+ retryable: failure.disposition === 'retry',
25
+ code: failure.code,
26
+ };
24
27
  }
25
28
 
26
29
  /**
@@ -40,11 +43,8 @@ export function webhookReceiptRetryDelayMs(input: {
40
43
  .update(String(input.attempt))
41
44
  .digest();
42
45
  // Keep a nonzero delay and give concurrent deliveries independent stable
43
- // placement across the exponential window. The first retry spans 1–2s;
44
- // otherwise a burst of first-attempt failures would synchronise again.
46
+ // placement across the exponential window. Do not multiply a capped ceiling:
47
+ // that would pin every long-lived retry to the same 15-minute boundary.
45
48
  const fraction = hash.readUInt32BE(0) / 0xffff_ffff;
46
- return Math.min(
47
- RETRY_CAP_MS,
48
- Math.max(RETRY_BASE_MS, Math.round(ceiling * (1 + fraction))),
49
- );
49
+ return Math.round(RETRY_BASE_MS + (ceiling - RETRY_BASE_MS) * fraction);
50
50
  }
@@ -1572,7 +1572,10 @@ function docflowObservedExpression(input: {
1572
1572
  ? `async()=>(${input.expression})`
1573
1573
  : `()=>(${input.expression})`;
1574
1574
  const observation = `((runtimeContext,inputCaptures,execute)=>typeof runtimeContext.__deeplineObserveDocflowNode==="function"?runtimeContext.__deeplineObserveDocflowNode(${JSON.stringify(input.nodeId)},inputCaptures,${JSON.stringify(input.outputs)},execute):execute())(${input.contextName},${docflowInputCaptureSource(input.inputs)},${callback})`;
1575
- return input.awaitsResult ? `await ${observation}` : observation;
1575
+ // Parenthesize the awaited form so it is self-delimiting at every source
1576
+ // boundary, including compact `return{...}` syntax. This avoids relying on
1577
+ // whitespace repair around a character-offset replacement.
1578
+ return input.awaitsResult ? `(await ${observation})` : observation;
1576
1579
  }
1577
1580
 
1578
1581
  function expressionContainsTopLevelAwait(expression: AstNode): boolean {
@@ -425,6 +425,70 @@ const PRODUCT_NOTIFICATION_TYPE_REGISTRY = [
425
425
  },
426
426
  },
427
427
  },
428
+ {
429
+ id: 'billing.auto_topup.limit_reached',
430
+ eventName: ProductNotificationEvent.AutoTopupLimitObserved,
431
+ requiredFilter: {},
432
+ allowedTriggers: ['state_transition'],
433
+ catalog: {
434
+ name: 'Auto top-up purchase limit reached',
435
+ category: 'credits',
436
+ description:
437
+ 'Notify when the rolling 30-day purchase cap prevents an automatic top-up.',
438
+ allowMultiple: false,
439
+ editor: { kind: 'none' },
440
+ },
441
+ rule: {
442
+ default: {
443
+ ruleKey: 'default.auto_topup_limit_reached',
444
+ groupKey: 'default',
445
+ },
446
+ conditionSubject: 'workspace',
447
+ create: (_input, base) => ({
448
+ ...base,
449
+ trigger: { kind: 'state_transition', targetState: 'blocked' },
450
+ }),
451
+ validate: (trigger) => {
452
+ if (
453
+ trigger.kind !== 'state_transition' ||
454
+ trigger.targetState !== 'blocked' ||
455
+ trigger.reasonCode !== undefined
456
+ ) {
457
+ throw new Error(
458
+ 'Auto-top-up limit rules must enter the blocked state.',
459
+ );
460
+ }
461
+ },
462
+ present: ({ payload }) => ({
463
+ title: 'Auto top-up purchase limit reached',
464
+ summary: [
465
+ 'An automatic top-up for ',
466
+ { workspace: true },
467
+ ' was blocked by its rolling 30-day purchase cap.',
468
+ ],
469
+ details: [
470
+ [
471
+ 'Purchase cap: ',
472
+ {
473
+ strong: `${formatCredits(Number(payload.purchaseLimitCredits))} credits`,
474
+ },
475
+ ],
476
+ [
477
+ 'Remaining purchase allowance: ',
478
+ {
479
+ strong: `${formatCredits(Number(payload.remainingCredits))} credits`,
480
+ },
481
+ ],
482
+ 'The remaining allowance cannot cover the minimum top-up. Pending automatic purchases also reserve allowance.',
483
+ 'You can continue using your remaining credits, subject to your usage cap. Increase or remove the purchase cap in Billing, or wait for earlier purchases to leave the rolling 30-day window.',
484
+ ],
485
+ targetUrl: String(payload.targetUrl),
486
+ subjectKind: 'workspace',
487
+ subjectId: String(payload.accountId),
488
+ severity: 'warning',
489
+ }),
490
+ },
491
+ },
428
492
  {
429
493
  id: 'credits.low',
430
494
  eventName: ProductNotificationEvent.CreditsBalanceChanged,
@@ -14,7 +14,7 @@ import {
14
14
  * they create sparse copy-on-write overrides.
15
15
  */
16
16
  export const CURRENT_PRODUCT_NOTIFICATION_DEFAULT_POLICY = {
17
- version: 6,
17
+ version: 7,
18
18
  groups: [
19
19
  {
20
20
  groupKey: 'default',
@@ -17,6 +17,7 @@ export const ProductNotificationEvent = {
17
17
  CreditsBalanceChanged: 'credits.balance.changed',
18
18
  CreditsPurchaseCompleted: 'credits.purchase.completed',
19
19
  CreditsUsageRecorded: 'credits.usage.recorded',
20
+ AutoTopupLimitObserved: 'billing.auto_topup.limit_observed',
20
21
  BillingPlanPurchased: 'billing.plan.purchased',
21
22
  MonitorLifecycleChanged: 'monitor.lifecycle.changed',
22
23
  PlayLifecycleChanged: 'play.lifecycle.changed',
@@ -56,6 +57,7 @@ export const PRODUCT_NOTIFICATION_EVENT_NAMES = [
56
57
  ProductNotificationEvent.PlayRunCompleted,
57
58
  ProductNotificationEvent.WebhookDeliveryCompleted,
58
59
  ProductNotificationEvent.CreditsUsageRecorded,
60
+ ProductNotificationEvent.AutoTopupLimitObserved,
59
61
  ProductNotificationEvent.CreditsPurchaseCompleted,
60
62
  ProductNotificationEvent.BillingPlanPurchased,
61
63
  ] as const;
@@ -180,6 +182,20 @@ export const PRODUCT_NOTIFICATION_EVENT_DEFINITIONS: readonly ProductNotificatio
180
182
  allowedTriggers: ['threshold_crossing'],
181
183
  allowedThresholdUnits: ['percent', 'usage'],
182
184
  },
185
+ {
186
+ eventName: ProductNotificationEvent.AutoTopupLimitObserved,
187
+ producerKey: ProductNotificationProducerKey.BillingCredits,
188
+ requiredFacts: {
189
+ accountId: 'string',
190
+ currentState: 'string',
191
+ purchaseLimitCredits: 'number',
192
+ committedCredits: 'number',
193
+ remainingCredits: 'number',
194
+ targetUrl: 'string',
195
+ },
196
+ orderingSubjectFact: 'accountId',
197
+ allowedTriggers: ['state_transition'],
198
+ },
183
199
  {
184
200
  // One paid credit purchase (manual or automatic recharge) settled.
185
201
  eventName: ProductNotificationEvent.CreditsPurchaseCompleted,
@@ -389,7 +405,10 @@ export function adaptProductNotificationObservation(input: {
389
405
  if (trigger.kind === 'state_transition') {
390
406
  return {
391
407
  kind: 'state_transition',
392
- previousState: String(event.payload.previousState),
408
+ previousState:
409
+ typeof event.payload.previousState === 'string'
410
+ ? event.payload.previousState
411
+ : undefined,
393
412
  currentState: String(event.payload.currentState),
394
413
  previousReasonCode:
395
414
  typeof event.payload.previousReasonCode === 'string'