deepline 0.3.88 → 0.3.89

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 (121) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +534 -4
  2. package/dist/bundling-sources/sdk/src/config.ts +77 -0
  3. package/dist/bundling-sources/sdk/src/http.ts +76 -30
  4. package/dist/bundling-sources/sdk/src/index.ts +6 -0
  5. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +1 -1
  6. package/dist/bundling-sources/sdk/src/play.ts +15 -1
  7. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +2 -2
  8. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  9. package/dist/bundling-sources/sdk/src/types.ts +83 -4
  10. package/dist/bundling-sources/shared_libs/integration-contracts/AGENTS.md +9 -0
  11. package/dist/bundling-sources/shared_libs/integration-contracts/README.md +3 -0
  12. package/dist/bundling-sources/shared_libs/integration-contracts/index.ts +2 -0
  13. package/dist/bundling-sources/shared_libs/monitors/analytics-install-guides.ts +77 -0
  14. package/dist/bundling-sources/shared_libs/monitors/billing/charges.ts +125 -0
  15. package/dist/bundling-sources/shared_libs/monitors/billing/policy-builders.ts +686 -0
  16. package/dist/bundling-sources/shared_libs/monitors/billing/pricing.ts +54 -0
  17. package/dist/bundling-sources/shared_libs/monitors/billing/public-pricing.ts +72 -0
  18. package/dist/bundling-sources/shared_libs/monitors/billing/session.ts +45 -0
  19. package/dist/bundling-sources/shared_libs/monitors/billing/state.ts +454 -0
  20. package/dist/bundling-sources/shared_libs/monitors/billing/tamradar.ts +260 -0
  21. package/dist/bundling-sources/shared_libs/monitors/billing/types.ts +215 -0
  22. package/dist/bundling-sources/shared_libs/monitors/callback-security.ts +333 -0
  23. package/dist/bundling-sources/shared_libs/monitors/cursor-page.ts +42 -0
  24. package/dist/bundling-sources/shared_libs/monitors/deactivation-reasons.ts +16 -0
  25. package/dist/bundling-sources/shared_libs/monitors/framework-managed-payload-fields.ts +71 -0
  26. package/dist/bundling-sources/shared_libs/monitors/generated/provider-monitor-callback-admission-targets.ts +16 -0
  27. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +267 -0
  28. package/dist/bundling-sources/shared_libs/monitors/inbound-publish-key.ts +39 -0
  29. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +82 -0
  30. package/dist/bundling-sources/shared_libs/monitors/lifecycle-notifications.ts +473 -0
  31. package/dist/bundling-sources/shared_libs/monitors/org-monitor-limits.ts +1 -1
  32. package/dist/bundling-sources/shared_libs/monitors/package.json +29 -0
  33. package/dist/bundling-sources/shared_libs/monitors/provider-callback-verification.ts +1125 -0
  34. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +263 -0
  35. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-capability.ts +964 -0
  36. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-consumer-health.ts +56 -0
  37. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-errors.ts +131 -0
  38. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +535 -0
  39. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-legacy-aliases.ts +16 -0
  40. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-priority-execution.ts +56 -0
  41. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-public-aliases.ts +28 -0
  42. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-runtime-read-eligibility.ts +38 -0
  43. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-state.ts +122 -0
  44. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-status.ts +10 -0
  45. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-upstream-identity.ts +112 -0
  46. package/dist/bundling-sources/shared_libs/monitors/provider-webhook-monitor-runtime.ts +300 -0
  47. package/dist/bundling-sources/shared_libs/monitors/quickstart-prompts.ts +22 -0
  48. package/dist/bundling-sources/shared_libs/monitors/snitcher-install-validation.ts +93 -0
  49. package/dist/bundling-sources/shared_libs/monitors/snitcher-ip2company.ts +332 -0
  50. package/dist/bundling-sources/shared_libs/monitors/snitcher-radar.ts +221 -0
  51. package/dist/bundling-sources/shared_libs/monitors/starter-templates.ts +105 -0
  52. package/dist/bundling-sources/shared_libs/monitors/tracking-provider-guidance.ts +73 -0
  53. package/dist/bundling-sources/shared_libs/monitors/tracking-provider-integrations.ts +81 -0
  54. package/dist/bundling-sources/shared_libs/monitors/tsconfig.json +10 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/activity-observation.ts +6 -11
  56. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +90 -12
  57. package/dist/bundling-sources/shared_libs/play-runtime/async-play-invocation.ts +78 -0
  58. package/dist/bundling-sources/shared_libs/play-runtime/checkpoint-boundaries.ts +70 -0
  59. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +407 -53
  60. package/dist/bundling-sources/shared_libs/play-runtime/coordinator-headers.ts +1 -32
  61. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +57 -0
  62. package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +20 -1
  63. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +6 -0
  64. package/dist/bundling-sources/shared_libs/play-runtime/execution-protocol.ts +7 -1
  65. package/dist/bundling-sources/shared_libs/play-runtime/lane-retirement-gate.ts +189 -0
  66. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-semantics.ts +28 -0
  67. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +2 -0
  68. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +23 -0
  69. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +1 -1
  70. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +18 -4
  71. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-start-admission.ts +68 -0
  72. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +2 -0
  73. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +1 -0
  74. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +10 -8
  75. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +20 -0
  76. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +6 -0
  77. package/dist/bundling-sources/shared_libs/play-runtime/runtime-env.ts +6 -0
  78. package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +24 -0
  79. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +30 -1
  80. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +1 -1
  81. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +1 -1
  82. package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +6 -3
  83. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +454 -17
  84. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +19 -4
  85. package/dist/bundling-sources/shared_libs/plays/contracts.ts +6 -14
  86. package/dist/bundling-sources/shared_libs/plays/core.ts +11 -6
  87. package/dist/bundling-sources/shared_libs/plays/docflow-binding-owner.ts +16 -11
  88. package/dist/bundling-sources/shared_libs/plays/email-status.ts +1 -1
  89. package/dist/bundling-sources/shared_libs/plays/resolve-static-pipeline.ts +1 -1
  90. package/dist/bundling-sources/shared_libs/plays/slug.ts +115 -0
  91. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +44 -14
  92. package/dist/bundling-sources/shared_libs/plays/tool-category-descriptions.ts +2 -2
  93. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +877 -0
  94. package/dist/bundling-sources/shared_libs/product-notifications/configuration-draft.ts +456 -0
  95. package/dist/bundling-sources/shared_libs/product-notifications/configuration.ts +560 -0
  96. package/dist/bundling-sources/shared_libs/product-notifications/content.ts +28 -0
  97. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +59 -0
  98. package/dist/bundling-sources/shared_libs/product-notifications/email.ts +246 -0
  99. package/dist/bundling-sources/shared_libs/product-notifications/evaluation.ts +280 -0
  100. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +433 -0
  101. package/dist/bundling-sources/shared_libs/product-notifications/legacy-slack.ts +80 -0
  102. package/dist/bundling-sources/shared_libs/product-notifications/workspace.ts +38 -0
  103. package/dist/bundling-sources/shared_libs/runtime-env.ts +6 -0
  104. package/dist/bundling-sources/shared_libs/security/header-safe-env.ts +28 -0
  105. package/dist/bundling-sources/shared_libs/security/internal-actor.ts +50 -0
  106. package/dist/bundling-sources/shared_libs/security/secret-redaction.ts +267 -0
  107. package/dist/cli/index.js +1070 -193
  108. package/dist/cli/index.mjs +1057 -180
  109. package/dist/{compiler-manifest-Dbov16F7.d.mts → compiler-manifest-DsLbQaew.d.mts} +132 -8
  110. package/dist/{compiler-manifest-Dbov16F7.d.ts → compiler-manifest-DsLbQaew.d.ts} +132 -8
  111. package/dist/index.d.mts +834 -12
  112. package/dist/index.d.ts +834 -12
  113. package/dist/index.js +469 -40
  114. package/dist/index.mjs +469 -40
  115. package/dist/install-integrity.json +65 -4
  116. package/dist/plays/bundle-play-file.d.mts +8 -3
  117. package/dist/plays/bundle-play-file.d.ts +8 -3
  118. package/dist/plays/bundle-play-file.mjs +100 -20
  119. package/package.json +3 -1
  120. /package/dist/bundling-sources/shared_libs/{integrations/bettercontact-execution-policy.ts → integration-contracts/bettercontact/execution-policy.ts} +0 -0
  121. /package/dist/bundling-sources/shared_libs/{integrations/theirstack-execution-policy.ts → integration-contracts/theirstack/execution-policy.ts} +0 -0
@@ -0,0 +1,877 @@
1
+ import type {
2
+ ProductNotificationDefaultRule,
3
+ ProductNotificationFacts,
4
+ ProductNotificationRepeatMode,
5
+ ProductNotificationTrigger,
6
+ } from './configuration';
7
+ import { PRODUCT_NOTIFICATION_EVENT_CATALOG } from './contract';
8
+ import {
9
+ ProductNotificationEvent,
10
+ getProductNotificationEventDefinition,
11
+ productNotificationTriggerIsAllowed,
12
+ type ProductNotificationEventName,
13
+ } from './events';
14
+ import { legacySlackNotificationType } from './legacy-slack';
15
+ import type { NotificationText } from './content';
16
+
17
+ type NotificationTypeDefinition = {
18
+ id: string;
19
+ eventName: ProductNotificationEventName;
20
+ requiredFilter: ProductNotificationFacts;
21
+ allowedTriggers: ProductNotificationTrigger['kind'][];
22
+ };
23
+
24
+ /**
25
+ * The sections the configuration surface skims by. Declared here so UI, API,
26
+ * and CLI order and label them identically.
27
+ */
28
+ export const PRODUCT_NOTIFICATION_CATEGORIES = [
29
+ { id: 'credits', name: 'Credits and usage' },
30
+ { id: 'plays', name: 'Play runs' },
31
+ ] as const;
32
+
33
+ export type ProductNotificationCategoryId =
34
+ (typeof PRODUCT_NOTIFICATION_CATEGORIES)[number]['id'];
35
+
36
+ type ProductNotificationTypeCatalogMetadata = {
37
+ name: string;
38
+ description: string;
39
+ category: ProductNotificationCategoryId;
40
+ allowMultiple: boolean;
41
+ editor:
42
+ | {
43
+ kind: 'threshold';
44
+ label: string;
45
+ defaultThreshold: number;
46
+ minimum: number;
47
+ maximum?: number;
48
+ suffix?: string;
49
+ /**
50
+ * When set, editors show and accept `remainingOf - threshold` (for
51
+ * example percent remaining) while the stored trigger stays in the
52
+ * runtime's "used" terms. Bounds and defaults are stored values.
53
+ */
54
+ remainingOf?: number;
55
+ }
56
+ | { kind: 'play_failure'; defaultFailureCount: number }
57
+ | { kind: 'play_scope' }
58
+ | { kind: 'none' };
59
+ };
60
+
61
+ export type ProductNotificationPresentation = {
62
+ title: string;
63
+ summary: NotificationText;
64
+ details: NotificationText[];
65
+ targetUrl: string;
66
+ subjectKind: string;
67
+ subjectId: string;
68
+ };
69
+
70
+ type ProductNotificationRuleInput = {
71
+ ruleKey: string;
72
+ groupKey: string;
73
+ notificationType: ConfigurableProductNotificationType;
74
+ threshold?: number;
75
+ playFailure?: ProductNotificationPlayFailureSettings;
76
+ };
77
+
78
+ type ProductNotificationRuleBase = Omit<
79
+ ProductNotificationDefaultRule,
80
+ 'trigger'
81
+ >;
82
+
83
+ type ProductNotificationRuleDefinition = {
84
+ default?: { ruleKey: string; groupKey: string };
85
+ conditionSubject?: 'workspace';
86
+ rearm?: {
87
+ eventName: ProductNotificationEventName;
88
+ matches: (payload: ProductNotificationFacts) => boolean;
89
+ };
90
+ create?: (
91
+ input: ProductNotificationRuleInput,
92
+ base: ProductNotificationRuleBase,
93
+ editor: ProductNotificationTypeCatalogMetadata['editor'],
94
+ ) => ProductNotificationDefaultRule;
95
+ validate?: (trigger: ProductNotificationTrigger) => void;
96
+ present?: (input: {
97
+ payload: ProductNotificationFacts;
98
+ trigger: ProductNotificationTrigger;
99
+ }) => ProductNotificationPresentation;
100
+ };
101
+
102
+ type NotificationTypeRegistration = NotificationTypeDefinition & {
103
+ catalog?: ProductNotificationTypeCatalogMetadata;
104
+ rule?: ProductNotificationRuleDefinition;
105
+ };
106
+
107
+ function validateInsufficientCreditDisable(
108
+ trigger: ProductNotificationTrigger,
109
+ ) {
110
+ if (
111
+ trigger.kind !== 'state_transition' ||
112
+ trigger.targetState !== 'disabled' ||
113
+ trigger.reasonCode !== 'insufficient_credits'
114
+ ) {
115
+ throw new Error(
116
+ 'Insufficient-credit rules must enter disabled for insufficient_credits.',
117
+ );
118
+ }
119
+ }
120
+
121
+ function validateFailedOutcome(trigger: ProductNotificationTrigger) {
122
+ if (trigger.kind === 'consecutive_outcomes' && trigger.outcome !== 'failed') {
123
+ throw new Error('Failure rules require failed consecutive outcomes.');
124
+ }
125
+ }
126
+
127
+ function playRunPresentation(
128
+ payload: ProductNotificationFacts,
129
+ succeeded: boolean,
130
+ ): ProductNotificationPresentation {
131
+ return {
132
+ title: succeeded ? 'Play succeeded' : 'Play failed',
133
+ summary: [
134
+ `${payload.playName} ${succeeded ? 'succeeded' : 'failed'} in your `,
135
+ { workspace: true },
136
+ '.',
137
+ ],
138
+ details: [`Run: ${payload.runId}`],
139
+ targetUrl: String(payload.targetUrl),
140
+ subjectKind: 'play',
141
+ subjectId: String(payload.playId),
142
+ };
143
+ }
144
+
145
+ function webhookDeliveryPresentation(
146
+ payload: ProductNotificationFacts,
147
+ succeeded: boolean,
148
+ ): ProductNotificationPresentation {
149
+ return {
150
+ title: succeeded ? 'Webhook delivery succeeded' : 'Webhook delivery failed',
151
+ summary: [
152
+ `${payload.endpointName} delivery ${succeeded ? 'succeeded' : 'failed'} in your `,
153
+ { workspace: true },
154
+ '.',
155
+ ],
156
+ details: [`Delivery: ${payload.deliveryId}`],
157
+ targetUrl: String(payload.targetUrl),
158
+ subjectKind: 'webhook',
159
+ subjectId: String(payload.endpointId),
160
+ };
161
+ }
162
+
163
+ /** Narrow a Play rule to all Plays, one trigger kind, or a single Play. */
164
+ function applyPlayScope(
165
+ eventFilter: ProductNotificationFacts,
166
+ settings: ProductNotificationPlayFailureSettings,
167
+ ) {
168
+ if (settings.scope === 'cron') eventFilter.triggerKind = 'cron';
169
+ if (settings.scope === 'webhook') eventFilter.triggerKind = 'webhook';
170
+ if (settings.scope === 'specific') {
171
+ if (!settings.playId) throw new Error('Choose a specific Play.');
172
+ eventFilter.playId = settings.playId;
173
+ if (settings.playName) eventFilter.playName = settings.playName;
174
+ }
175
+ return eventFilter;
176
+ }
177
+
178
+ function defaultThreshold(
179
+ editor: ProductNotificationTypeCatalogMetadata['editor'],
180
+ ) {
181
+ if (editor.kind !== 'threshold') {
182
+ throw new Error('Threshold rules require a threshold editor.');
183
+ }
184
+ return editor.defaultThreshold;
185
+ }
186
+
187
+ function formatCredits(value: number): string {
188
+ return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
189
+ }
190
+
191
+ /** One canonical registry owns every notification type's runtime and UI metadata. */
192
+ const PRODUCT_NOTIFICATION_TYPE_REGISTRY = [
193
+ {
194
+ id: 'monitor.disabled.insufficient_credits',
195
+ eventName: ProductNotificationEvent.MonitorLifecycleChanged,
196
+ requiredFilter: {},
197
+ allowedTriggers: ['state_transition'],
198
+ catalog: {
199
+ name: 'Monitor disabled due to insufficient credits',
200
+ category: 'credits',
201
+ description:
202
+ 'Notify when Deepline disables a Monitor because the workspace ran out of credits.',
203
+ allowMultiple: false,
204
+ editor: { kind: 'none' },
205
+ },
206
+ rule: {
207
+ conditionSubject: 'workspace',
208
+ rearm: {
209
+ eventName: ProductNotificationEvent.CreditsBalanceChanged,
210
+ matches: (payload) =>
211
+ Number(payload.currentCredits) > Number(payload.previousCredits),
212
+ },
213
+ create: (_input, base) => ({
214
+ ...base,
215
+ trigger: {
216
+ kind: 'state_transition',
217
+ targetState: 'disabled',
218
+ reasonCode: 'insufficient_credits',
219
+ },
220
+ }),
221
+ validate: validateInsufficientCreditDisable,
222
+ present: ({ payload }) => ({
223
+ title: 'Monitor disabled due to insufficient credits',
224
+ summary: [
225
+ `${payload.monitorName} was disabled because your `,
226
+ { workspace: true },
227
+ ' ran out of credits.',
228
+ ],
229
+ details: [],
230
+ targetUrl: String(payload.targetUrl),
231
+ subjectKind: 'monitor',
232
+ subjectId: String(payload.monitorId),
233
+ }),
234
+ },
235
+ },
236
+ {
237
+ id: 'play.disabled.insufficient_credits',
238
+ eventName: ProductNotificationEvent.PlayLifecycleChanged,
239
+ requiredFilter: {},
240
+ allowedTriggers: ['state_transition'],
241
+ catalog: {
242
+ name: 'Play temporarily disabled due to insufficient credits',
243
+ category: 'credits',
244
+ description:
245
+ 'Notify when Deepline temporarily disables a scheduled Play because the workspace ran out of credits. Future runs resume automatically once the workspace has enough credits.',
246
+ allowMultiple: false,
247
+ editor: { kind: 'none' },
248
+ },
249
+ rule: {
250
+ default: { ruleKey: 'default.play_disabled', groupKey: 'default' },
251
+ conditionSubject: 'workspace',
252
+ rearm: {
253
+ eventName: ProductNotificationEvent.CreditsBalanceChanged,
254
+ matches: (payload) =>
255
+ Number(payload.currentCredits) > Number(payload.previousCredits),
256
+ },
257
+ create: (_input, base) => ({
258
+ ...base,
259
+ trigger: {
260
+ kind: 'state_transition',
261
+ targetState: 'disabled',
262
+ reasonCode: 'insufficient_credits',
263
+ },
264
+ }),
265
+ validate: validateInsufficientCreditDisable,
266
+ present: ({ payload }) => ({
267
+ title: 'Play temporarily disabled due to insufficient credits',
268
+ summary: [
269
+ `${payload.playName} was temporarily disabled because your `,
270
+ { workspace: true },
271
+ ' ran out of credits.',
272
+ ],
273
+ details: [
274
+ 'Future scheduled runs will resume automatically once the workspace has enough credits.',
275
+ 'Missed runs will not be replayed.',
276
+ ],
277
+ targetUrl: String(payload.targetUrl),
278
+ subjectKind: 'play',
279
+ subjectId: String(payload.playId),
280
+ }),
281
+ },
282
+ },
283
+ {
284
+ id: 'usage.monthly.limit_reached',
285
+ eventName: ProductNotificationEvent.CreditsUsageRecorded,
286
+ requiredFilter: {},
287
+ allowedTriggers: ['threshold_crossing'],
288
+ catalog: {
289
+ name: 'Usage limit reached',
290
+ category: 'credits',
291
+ description: 'Notify when monthly usage reaches the workspace limit.',
292
+ allowMultiple: false,
293
+ editor: { kind: 'none' },
294
+ },
295
+ rule: {
296
+ default: {
297
+ ruleKey: 'default.usage_limit_reached',
298
+ groupKey: 'default',
299
+ },
300
+ conditionSubject: 'workspace',
301
+ create: (_input, base) => ({
302
+ ...base,
303
+ trigger: {
304
+ kind: 'threshold_crossing',
305
+ direction: 'at_or_above',
306
+ threshold: 100,
307
+ unit: 'percent',
308
+ },
309
+ }),
310
+ validate: (trigger) => {
311
+ if (
312
+ trigger.kind !== 'threshold_crossing' ||
313
+ trigger.direction !== 'at_or_above' ||
314
+ trigger.unit !== 'percent' ||
315
+ trigger.threshold !== 100
316
+ ) {
317
+ throw new Error('Usage-limit-reached rules must cross 100 percent.');
318
+ }
319
+ },
320
+ present: ({ payload }) => {
321
+ const actualUsage = Number(payload.actualUsage ?? payload.currentUsage);
322
+ return {
323
+ title: 'Usage limit reached',
324
+ summary:
325
+ payload.limitDenied === true
326
+ ? [
327
+ 'An operation was blocked by your ',
328
+ { workspace: true },
329
+ ' monthly usage limit.',
330
+ ]
331
+ : [
332
+ 'Your ',
333
+ { workspace: true },
334
+ ' reached its monthly usage limit.',
335
+ ],
336
+ details: [
337
+ [
338
+ 'Current usage: ',
339
+ {
340
+ strong: `${formatCredits(actualUsage)} of ${formatCredits(Number(payload.limit))}`,
341
+ },
342
+ ` ${payload.unit}`,
343
+ ],
344
+ ],
345
+ targetUrl: String(payload.targetUrl),
346
+ subjectKind: 'workspace',
347
+ subjectId: String(payload.accountId),
348
+ };
349
+ },
350
+ },
351
+ },
352
+ {
353
+ id: 'credits.low',
354
+ eventName: ProductNotificationEvent.CreditsBalanceChanged,
355
+ requiredFilter: {},
356
+ allowedTriggers: ['threshold_crossing'],
357
+ catalog: {
358
+ name: 'Low credit warning',
359
+ category: 'credits',
360
+ description:
361
+ 'Notify when the workspace credit balance crosses below a threshold.',
362
+ allowMultiple: false,
363
+ editor: {
364
+ kind: 'threshold',
365
+ label: 'Credit threshold',
366
+ defaultThreshold: 10,
367
+ minimum: 0,
368
+ },
369
+ },
370
+ rule: {
371
+ default: { ruleKey: 'default.low_credits', groupKey: 'default' },
372
+ conditionSubject: 'workspace',
373
+ create: (input, base, editor) => ({
374
+ ...base,
375
+ trigger: {
376
+ kind: 'threshold_crossing',
377
+ direction: 'below',
378
+ threshold: input.threshold ?? defaultThreshold(editor),
379
+ unit: 'credits',
380
+ },
381
+ }),
382
+ validate: (trigger) => {
383
+ if (
384
+ trigger.kind !== 'threshold_crossing' ||
385
+ trigger.direction !== 'below' ||
386
+ (trigger.unit !== undefined && trigger.unit !== 'credits')
387
+ ) {
388
+ throw new Error(
389
+ 'Low-credit rules must cross below a credit threshold.',
390
+ );
391
+ }
392
+ if (!Number.isFinite(trigger.threshold) || trigger.threshold < 0) {
393
+ throw new Error(
394
+ 'Low-credit thresholds must be finite and nonnegative.',
395
+ );
396
+ }
397
+ },
398
+ present: ({ payload, trigger }) => {
399
+ if (trigger.kind !== 'threshold_crossing') {
400
+ throw new Error('Low-credit presentation requires a threshold.');
401
+ }
402
+ return {
403
+ title: 'Low credit balance',
404
+ summary: [
405
+ 'Your ',
406
+ { workspace: true },
407
+ ` credit balance crossed below ${formatCredits(trigger.threshold)} credits.`,
408
+ ],
409
+ details: [
410
+ [
411
+ 'Current balance: ',
412
+ { strong: formatCredits(Number(payload.currentCredits)) },
413
+ ' credits',
414
+ ],
415
+ ],
416
+ targetUrl: String(payload.targetUrl),
417
+ subjectKind: 'workspace',
418
+ subjectId: String(payload.accountId),
419
+ };
420
+ },
421
+ },
422
+ },
423
+ {
424
+ id: 'usage.monthly.nearing_limit',
425
+ eventName: ProductNotificationEvent.CreditsUsageRecorded,
426
+ requiredFilter: {},
427
+ allowedTriggers: ['threshold_crossing'],
428
+ catalog: {
429
+ name: 'Usage limit warning',
430
+ category: 'credits',
431
+ description:
432
+ 'Notify when only a configurable percentage of the monthly usage limit remains.',
433
+ allowMultiple: false,
434
+ editor: {
435
+ kind: 'threshold',
436
+ label: 'Remaining usage',
437
+ // Stored as percent used; shown as percent remaining (default 10%).
438
+ defaultThreshold: 90,
439
+ minimum: 1,
440
+ maximum: 99,
441
+ suffix: '%',
442
+ remainingOf: 100,
443
+ },
444
+ },
445
+ rule: {
446
+ conditionSubject: 'workspace',
447
+ create: (input, base, editor) => ({
448
+ ...base,
449
+ trigger: {
450
+ kind: 'threshold_crossing',
451
+ direction: 'at_or_above',
452
+ threshold: input.threshold ?? defaultThreshold(editor),
453
+ unit: 'percent',
454
+ },
455
+ }),
456
+ validate: (trigger) => {
457
+ if (
458
+ trigger.kind !== 'threshold_crossing' ||
459
+ trigger.direction !== 'at_or_above' ||
460
+ trigger.unit !== 'percent'
461
+ ) {
462
+ throw new Error(
463
+ 'Usage-near-limit rules must cross above a percentage threshold.',
464
+ );
465
+ }
466
+ if (
467
+ !Number.isFinite(trigger.threshold) ||
468
+ trigger.threshold < 1 ||
469
+ trigger.threshold > 99
470
+ ) {
471
+ throw new Error(
472
+ 'Usage-near-limit thresholds must be between 1 and 99 percent.',
473
+ );
474
+ }
475
+ },
476
+ present: ({ payload, trigger }) => {
477
+ if (trigger.kind !== 'threshold_crossing') {
478
+ throw new Error(
479
+ 'Usage-near-limit presentation requires a threshold.',
480
+ );
481
+ }
482
+ const percent =
483
+ (Number(payload.currentUsage) / Number(payload.limit)) * 100;
484
+ return {
485
+ title: 'Usage near limit',
486
+ summary: [
487
+ `Only ${100 - trigger.threshold}% of your `,
488
+ { workspace: true },
489
+ ' monthly usage limit remains.',
490
+ ],
491
+ details: [['Current usage: ', { strong: `${Math.round(percent)}%` }]],
492
+ targetUrl: String(payload.targetUrl),
493
+ subjectKind: 'workspace',
494
+ subjectId: String(payload.accountId),
495
+ };
496
+ },
497
+ },
498
+ },
499
+ {
500
+ id: 'play.failed',
501
+ eventName: ProductNotificationEvent.PlayRunCompleted,
502
+ requiredFilter: { outcome: 'failed' },
503
+ allowedTriggers: ['each_match', 'consecutive_outcomes'],
504
+ catalog: {
505
+ name: 'Play failed',
506
+ category: 'plays',
507
+ description:
508
+ 'Notify for all, scheduled, webhook-triggered, or specific Play failures.',
509
+ allowMultiple: true,
510
+ editor: { kind: 'play_failure', defaultFailureCount: 1 },
511
+ },
512
+ rule: {
513
+ create: (input, base) => {
514
+ const settings = input.playFailure ?? {
515
+ scope: 'all' as const,
516
+ failureCount: 1,
517
+ };
518
+ const eventFilter = applyPlayScope({ ...base.eventFilter }, settings);
519
+ const count = Math.max(1, Math.floor(settings.failureCount));
520
+ return {
521
+ ...base,
522
+ eventFilter,
523
+ repeatMode: count === 1 ? 'every_match' : 'on_enter',
524
+ trigger:
525
+ count === 1
526
+ ? { kind: 'each_match' }
527
+ : {
528
+ kind: 'consecutive_outcomes',
529
+ count,
530
+ outcome: 'failed',
531
+ maxLookback: count,
532
+ },
533
+ };
534
+ },
535
+ validate: validateFailedOutcome,
536
+ present: ({ payload }) => playRunPresentation(payload, false),
537
+ },
538
+ },
539
+ {
540
+ id: 'play.succeeded',
541
+ eventName: ProductNotificationEvent.PlayRunCompleted,
542
+ requiredFilter: { outcome: 'succeeded' },
543
+ allowedTriggers: ['each_match'],
544
+ catalog: {
545
+ name: 'Play succeeded',
546
+ category: 'plays',
547
+ description:
548
+ 'Notify for all, scheduled, webhook-triggered, or specific Play successes.',
549
+ allowMultiple: true,
550
+ editor: { kind: 'play_scope' },
551
+ },
552
+ rule: {
553
+ create: (input, base) => ({
554
+ ...base,
555
+ eventFilter: applyPlayScope(
556
+ { ...base.eventFilter },
557
+ input.playFailure ?? { scope: 'all', failureCount: 1 },
558
+ ),
559
+ repeatMode: 'every_match',
560
+ trigger: { kind: 'each_match' },
561
+ }),
562
+ present: ({ payload }) => playRunPresentation(payload, true),
563
+ },
564
+ },
565
+ {
566
+ id: 'webhook.delivery.failed',
567
+ eventName: ProductNotificationEvent.WebhookDeliveryCompleted,
568
+ requiredFilter: { outcome: 'failed' },
569
+ allowedTriggers: ['each_match', 'consecutive_outcomes'],
570
+ // Nothing emits webhook.delivery.completed yet, so these stay out of the
571
+ // customer catalog until a producer exists.
572
+ catalog: undefined,
573
+ rule: {
574
+ validate: validateFailedOutcome,
575
+ present: ({ payload }) => webhookDeliveryPresentation(payload, false),
576
+ },
577
+ },
578
+ {
579
+ id: 'webhook.delivery.succeeded',
580
+ eventName: ProductNotificationEvent.WebhookDeliveryCompleted,
581
+ requiredFilter: { outcome: 'succeeded' },
582
+ allowedTriggers: ['each_match'],
583
+ catalog: undefined,
584
+ rule: {
585
+ present: ({ payload }) => webhookDeliveryPresentation(payload, true),
586
+ },
587
+ },
588
+ ] as const satisfies readonly NotificationTypeRegistration[];
589
+
590
+ export type ProductNotificationType =
591
+ (typeof PRODUCT_NOTIFICATION_TYPE_REGISTRY)[number]['id'];
592
+ type ConfigurableProductNotificationRegistration = Extract<
593
+ (typeof PRODUCT_NOTIFICATION_TYPE_REGISTRY)[number],
594
+ { catalog: ProductNotificationTypeCatalogMetadata }
595
+ >;
596
+ export type ConfigurableProductNotificationType =
597
+ ConfigurableProductNotificationRegistration['id'];
598
+ export type ProductNotificationTypeCatalogItem =
599
+ ProductNotificationTypeCatalogMetadata & {
600
+ id: ConfigurableProductNotificationType;
601
+ };
602
+
603
+ /**
604
+ * Rows that stand for several types at once. Two events say one thing to a
605
+ * reader — Deepline stopped something because the workspace ran out of credits
606
+ * — so they share a row while their rules stay separate underneath.
607
+ */
608
+ export const PRODUCT_NOTIFICATION_MERGED_ROWS: readonly {
609
+ key: string;
610
+ name: string;
611
+ category: ProductNotificationCategoryId;
612
+ typeIds: readonly ConfigurableProductNotificationType[];
613
+ }[] = [
614
+ {
615
+ key: 'out-of-credits',
616
+ name: 'Out of credits',
617
+ category: 'credits',
618
+ typeIds: [
619
+ 'monitor.disabled.insufficient_credits',
620
+ 'play.disabled.insufficient_credits',
621
+ ],
622
+ },
623
+ ];
624
+
625
+ export const PRODUCT_NOTIFICATION_TYPE_DEFINITIONS: readonly NotificationTypeDefinition[] =
626
+ PRODUCT_NOTIFICATION_TYPE_REGISTRY.map((registration) => ({
627
+ id: registration.id,
628
+ eventName: registration.eventName,
629
+ requiredFilter: registration.requiredFilter,
630
+ allowedTriggers: registration.allowedTriggers,
631
+ }));
632
+
633
+ export type ProductNotificationPlayFailureSettings = {
634
+ scope: 'all' | 'cron' | 'webhook' | 'specific';
635
+ playId?: string;
636
+ playName?: string;
637
+ failureCount: number;
638
+ };
639
+
640
+ /** Build the canonical stored rule for one customer-facing type. */
641
+ export function createProductNotificationRule(input: {
642
+ ruleKey: string;
643
+ groupKey: string;
644
+ notificationType: ConfigurableProductNotificationType;
645
+ threshold?: number;
646
+ playFailure?: ProductNotificationPlayFailureSettings;
647
+ repeatMode?: ProductNotificationRepeatMode;
648
+ }): ProductNotificationDefaultRule {
649
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
650
+ (candidate) => candidate.id === input.notificationType,
651
+ )!;
652
+ const rule = (registration as NotificationTypeRegistration).rule;
653
+ const base = {
654
+ ruleKey: input.ruleKey,
655
+ groupKey: input.groupKey,
656
+ enabled: true,
657
+ notificationType: input.notificationType,
658
+ eventName: registration.eventName,
659
+ eventFilter: { ...registration.requiredFilter },
660
+ repeatMode: input.repeatMode ?? 'on_enter',
661
+ conditionGeneration: 1,
662
+ };
663
+ if (rule?.create) {
664
+ const editor = registration.catalog?.editor;
665
+ if (!editor) {
666
+ throw new Error(`${input.notificationType} is not configurable.`);
667
+ }
668
+ return rule.create(input, base, editor);
669
+ }
670
+ throw new Error(
671
+ `Notification type ${input.notificationType} has no rule builder.`,
672
+ );
673
+ }
674
+
675
+ /** Customer-facing catalog used by UI, API, and CLI configuration surfaces. */
676
+ export const PRODUCT_NOTIFICATION_TYPE_CATALOG: readonly ProductNotificationTypeCatalogItem[] =
677
+ PRODUCT_NOTIFICATION_TYPE_REGISTRY.flatMap(({ id, catalog }) =>
678
+ catalog ? [{ id, ...catalog }] : [],
679
+ );
680
+
681
+ /** Code-owned defaults declared beside the notification types they enable. */
682
+ export const PRODUCT_NOTIFICATION_DEFAULT_RULES =
683
+ PRODUCT_NOTIFICATION_TYPE_REGISTRY.flatMap((registration) => {
684
+ const defaultRule = (registration as NotificationTypeRegistration).rule
685
+ ?.default;
686
+ return defaultRule
687
+ ? [
688
+ createProductNotificationRule({
689
+ ...defaultRule,
690
+ notificationType:
691
+ registration.id as ConfigurableProductNotificationType,
692
+ }),
693
+ ]
694
+ : [];
695
+ });
696
+
697
+ /** Render type-specific content without teaching the processor each rule name. */
698
+ export function presentProductNotification(input: {
699
+ notificationType: string;
700
+ payload: ProductNotificationFacts;
701
+ trigger: ProductNotificationTrigger;
702
+ }): ProductNotificationPresentation {
703
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
704
+ (candidate) => candidate.id === input.notificationType,
705
+ ) as NotificationTypeRegistration | undefined;
706
+ const present = registration?.rule?.present;
707
+ if (!present) {
708
+ throw new Error(
709
+ `No notification presentation exists for ${input.notificationType}.`,
710
+ );
711
+ }
712
+ return present({ payload: input.payload, trigger: input.trigger });
713
+ }
714
+
715
+ /** Return a registered override for condition arming-state scope. */
716
+ export function productNotificationConditionSubject(
717
+ notificationType: string,
718
+ ): 'workspace' | undefined {
719
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
720
+ (candidate) => candidate.id === notificationType,
721
+ ) as NotificationTypeRegistration | undefined;
722
+ return registration?.rule?.conditionSubject;
723
+ }
724
+
725
+ /** A rule with an external rearm event ignores inactive observations itself. */
726
+ export function productNotificationHasExternalRearm(notificationType: string) {
727
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
728
+ (candidate) => candidate.id === notificationType,
729
+ ) as NotificationTypeRegistration | undefined;
730
+ return Boolean(registration?.rule?.rearm);
731
+ }
732
+
733
+ /** Decide whether this event rearms a registered rule condition. */
734
+ export function productNotificationRearmsOnEvent(input: {
735
+ notificationType: string;
736
+ eventName: ProductNotificationEventName;
737
+ payload: ProductNotificationFacts;
738
+ }) {
739
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
740
+ (candidate) => candidate.id === input.notificationType,
741
+ ) as NotificationTypeRegistration | undefined;
742
+ const rearm = registration?.rule?.rearm;
743
+ return Boolean(
744
+ rearm &&
745
+ rearm.eventName === input.eventName &&
746
+ rearm.matches(input.payload),
747
+ );
748
+ }
749
+
750
+ export const PRODUCT_NOTIFICATION_COMPATIBILITY_TYPE_DEFINITIONS: readonly NotificationTypeDefinition[] =
751
+ PRODUCT_NOTIFICATION_EVENT_CATALOG.map(({ id: eventName }) => ({
752
+ id: legacySlackNotificationType(eventName),
753
+ eventName,
754
+ requiredFilter: {},
755
+ allowedTriggers: ['each_match'],
756
+ }));
757
+
758
+ function definitionFor(notificationType: string) {
759
+ const definition = [
760
+ ...PRODUCT_NOTIFICATION_TYPE_DEFINITIONS,
761
+ ...PRODUCT_NOTIFICATION_COMPATIBILITY_TYPE_DEFINITIONS,
762
+ ].find((candidate) => candidate.id === notificationType);
763
+ if (!definition) {
764
+ throw new Error(`Unknown notification type ${notificationType}.`);
765
+ }
766
+ return definition;
767
+ }
768
+
769
+ function validateSharedBounds(
770
+ rule: Pick<ProductNotificationDefaultRule, 'eventFilter' | 'trigger'>,
771
+ ) {
772
+ const filterEntries = Object.entries(rule.eventFilter);
773
+ if (filterEntries.length > 20) {
774
+ throw new Error('Notification event filters exceed the 20-fact bound.');
775
+ }
776
+ for (const [key, value] of filterEntries) {
777
+ if (!key || key.length > 128) {
778
+ throw new Error(
779
+ 'Notification event filter keys are outside their bounds.',
780
+ );
781
+ }
782
+ if (typeof value === 'string' && (!value.trim() || value.length > 512)) {
783
+ throw new Error(
784
+ `Notification event filter ${key} is outside its bounds.`,
785
+ );
786
+ }
787
+ if (typeof value === 'number' && !Number.isFinite(value)) {
788
+ throw new Error(`Notification event filter ${key} must be finite.`);
789
+ }
790
+ }
791
+ if (
792
+ rule.trigger.kind === 'consecutive_outcomes' &&
793
+ (!Number.isInteger(rule.trigger.count) ||
794
+ rule.trigger.count < 1 ||
795
+ !Number.isInteger(rule.trigger.maxLookback) ||
796
+ rule.trigger.maxLookback < rule.trigger.count ||
797
+ rule.trigger.maxLookback > 100 ||
798
+ !rule.trigger.outcome ||
799
+ rule.trigger.outcome.length > 128)
800
+ ) {
801
+ throw new Error('Consecutive-outcome bounds are invalid.');
802
+ }
803
+ }
804
+
805
+ /** Reject rules that cannot be interpreted by one registered V1 composition. */
806
+ export function validateProductNotificationRuleDefinition(
807
+ rule: Pick<
808
+ ProductNotificationDefaultRule,
809
+ | 'notificationType'
810
+ | 'eventName'
811
+ | 'eventFilter'
812
+ | 'trigger'
813
+ | 'repeatMode'
814
+ | 'conditionGeneration'
815
+ >,
816
+ ) {
817
+ if (
818
+ rule.repeatMode !== undefined &&
819
+ rule.repeatMode !== 'on_enter' &&
820
+ rule.repeatMode !== 'every_match'
821
+ ) {
822
+ throw new Error('Notification repeat mode is invalid.');
823
+ }
824
+ if (
825
+ rule.conditionGeneration !== undefined &&
826
+ (!Number.isInteger(rule.conditionGeneration) ||
827
+ rule.conditionGeneration < 1)
828
+ ) {
829
+ throw new Error('Notification condition generation must be positive.');
830
+ }
831
+ validateSharedBounds(rule);
832
+ const definition = definitionFor(rule.notificationType);
833
+ if (rule.eventName !== definition.eventName) {
834
+ throw new Error(
835
+ `${rule.notificationType} requires event ${definition.eventName}.`,
836
+ );
837
+ }
838
+ for (const [key, value] of Object.entries(definition.requiredFilter)) {
839
+ if (rule.eventFilter[key] !== value) {
840
+ throw new Error(
841
+ `${rule.notificationType} requires filter ${key}=${value}.`,
842
+ );
843
+ }
844
+ }
845
+ const eventDefinition = getProductNotificationEventDefinition(
846
+ definition.eventName,
847
+ );
848
+ const filterFields = {
849
+ ...eventDefinition.requiredFacts,
850
+ ...eventDefinition.optionalFacts,
851
+ };
852
+ for (const [key, value] of Object.entries(rule.eventFilter)) {
853
+ if (!filterFields[key]) {
854
+ throw new Error(
855
+ `${rule.notificationType} does not support filter ${key}.`,
856
+ );
857
+ }
858
+ if (typeof value !== filterFields[key]) {
859
+ throw new Error(
860
+ `${rule.notificationType} filter ${key} must be ${filterFields[key]}.`,
861
+ );
862
+ }
863
+ }
864
+ if (
865
+ !definition.allowedTriggers.includes(rule.trigger.kind) ||
866
+ !productNotificationTriggerIsAllowed(definition.eventName, rule.trigger)
867
+ ) {
868
+ throw new Error(
869
+ `${rule.trigger.kind} is not supported by ${rule.notificationType}.`,
870
+ );
871
+ }
872
+ const registration = PRODUCT_NOTIFICATION_TYPE_REGISTRY.find(
873
+ (candidate) => candidate.id === rule.notificationType,
874
+ ) as NotificationTypeRegistration | undefined;
875
+ registration?.rule?.validate?.(rule.trigger);
876
+ return definition;
877
+ }