deepline 0.3.87 → 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 (69) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +532 -2
  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/play.ts +15 -1
  6. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  7. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  8. package/dist/bundling-sources/sdk/src/types.ts +83 -4
  9. package/dist/bundling-sources/shared_libs/monitors/deactivation-reasons.ts +16 -0
  10. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +263 -0
  11. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +9 -1
  12. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-state.ts +122 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/activity-observation.ts +6 -11
  14. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +90 -12
  15. package/dist/bundling-sources/shared_libs/play-runtime/async-play-invocation.ts +78 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/checkpoint-boundaries.ts +70 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +407 -53
  18. package/dist/bundling-sources/shared_libs/play-runtime/coordinator-headers.ts +1 -32
  19. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +57 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +20 -1
  21. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +6 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/execution-protocol.ts +7 -1
  23. package/dist/bundling-sources/shared_libs/play-runtime/lane-retirement-gate.ts +189 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-semantics.ts +28 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +2 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +23 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +1 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +18 -4
  29. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-start-admission.ts +68 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +2 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +1 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +10 -8
  33. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +20 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +6 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +24 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +30 -1
  37. package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +6 -3
  38. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +152 -17
  39. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +19 -4
  40. package/dist/bundling-sources/shared_libs/plays/contracts.ts +6 -14
  41. package/dist/bundling-sources/shared_libs/plays/core.ts +11 -6
  42. package/dist/bundling-sources/shared_libs/plays/docflow-binding-owner.ts +16 -11
  43. package/dist/bundling-sources/shared_libs/plays/resolve-static-pipeline.ts +1 -1
  44. package/dist/bundling-sources/shared_libs/plays/slug.ts +115 -0
  45. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +44 -14
  46. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +877 -0
  47. package/dist/bundling-sources/shared_libs/product-notifications/configuration-draft.ts +456 -0
  48. package/dist/bundling-sources/shared_libs/product-notifications/configuration.ts +560 -0
  49. package/dist/bundling-sources/shared_libs/product-notifications/content.ts +28 -0
  50. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +59 -0
  51. package/dist/bundling-sources/shared_libs/product-notifications/email.ts +246 -0
  52. package/dist/bundling-sources/shared_libs/product-notifications/evaluation.ts +280 -0
  53. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +433 -0
  54. package/dist/bundling-sources/shared_libs/product-notifications/legacy-slack.ts +80 -0
  55. package/dist/bundling-sources/shared_libs/product-notifications/workspace.ts +38 -0
  56. package/dist/bundling-sources/shared_libs/security/internal-actor.ts +50 -0
  57. package/dist/cli/index.js +1065 -190
  58. package/dist/cli/index.mjs +1052 -177
  59. package/dist/{compiler-manifest-Dbov16F7.d.mts → compiler-manifest-DsLbQaew.d.mts} +132 -8
  60. package/dist/{compiler-manifest-Dbov16F7.d.ts → compiler-manifest-DsLbQaew.d.ts} +132 -8
  61. package/dist/index.d.mts +833 -11
  62. package/dist/index.d.ts +833 -11
  63. package/dist/index.js +467 -38
  64. package/dist/index.mjs +467 -38
  65. package/dist/install-integrity.json +20 -2
  66. package/dist/plays/bundle-play-file.d.mts +8 -3
  67. package/dist/plays/bundle-play-file.d.ts +8 -3
  68. package/dist/plays/bundle-play-file.mjs +100 -20
  69. package/package.json +1 -1
@@ -0,0 +1,246 @@
1
+ import {
2
+ normalizeProductNotificationEmail,
3
+ normalizeProductNotificationEmailList,
4
+ type ProductNotificationDestinationSettings,
5
+ } from './configuration';
6
+ import {
7
+ workspaceAppUrl,
8
+ notificationSettingsUrl,
9
+ type NotificationWorkspace,
10
+ } from './workspace';
11
+ import { notificationText, type NotificationText } from './content';
12
+
13
+ export {
14
+ normalizeProductNotificationEmail,
15
+ normalizeProductNotificationEmailList,
16
+ };
17
+
18
+ export type ProductNotificationEmailRequest = {
19
+ to: string[];
20
+ cc?: string[];
21
+ bcc?: string[];
22
+ headers?: Record<string, string>;
23
+ subject: string;
24
+ html: string;
25
+ text: string;
26
+ };
27
+
28
+ type ProductNotificationEmailContent = {
29
+ groupName: string;
30
+ title: string;
31
+ summary: NotificationText;
32
+ details: NotificationText[];
33
+ targetUrl?: string;
34
+ };
35
+
36
+ const MAX_RECIPIENTS = 50;
37
+
38
+ export function productNotificationEmailAddressVersion(email: string): string {
39
+ const normalized = normalizeProductNotificationEmail(email);
40
+ if (!normalized) throw new Error('Notification email address is invalid.');
41
+ let hash = 0x811c9dc5;
42
+ for (const character of normalized) {
43
+ hash ^= character.charCodeAt(0);
44
+ hash = Math.imul(hash, 0x01000193);
45
+ }
46
+ return `email-v1-${(hash >>> 0).toString(36)}`;
47
+ }
48
+
49
+ function escapeHtml(value: string): string {
50
+ return value.replace(/[&<>"']/g, (character) => {
51
+ if (character === '&') return '&amp;';
52
+ if (character === '<') return '&lt;';
53
+ if (character === '>') return '&gt;';
54
+ if (character === '"') return '&quot;';
55
+ return '&#39;';
56
+ });
57
+ }
58
+
59
+ function unsubscribeUrl(value: string): string {
60
+ const url = new URL(value);
61
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
62
+ throw new Error('Notification unsubscribe URL must use HTTP(S).');
63
+ }
64
+ return url.toString();
65
+ }
66
+
67
+ function contentHtml(
68
+ content: NotificationText,
69
+ workspaceName?: string,
70
+ ): string {
71
+ if (typeof content === 'string') return escapeHtml(content);
72
+ return content
73
+ .map((part) => {
74
+ if (typeof part === 'string') return escapeHtml(part);
75
+ if ('strong' in part)
76
+ return `<strong>${escapeHtml(part.strong)}</strong>`;
77
+ return workspaceName
78
+ ? `workspace <strong>${escapeHtml(workspaceName)}</strong>`
79
+ : 'workspace';
80
+ })
81
+ .join('');
82
+ }
83
+
84
+ export function renderProductNotificationEmail(
85
+ input: NotificationWorkspace & {
86
+ groupKey: string;
87
+ content: ProductNotificationEmailContent;
88
+ settings: Extract<
89
+ ProductNotificationDestinationSettings,
90
+ { kind: 'email_addresses' }
91
+ >;
92
+ unsubscribeUrl?: string;
93
+ oneClickUnsubscribeUrl?: string;
94
+ },
95
+ ): ProductNotificationEmailRequest {
96
+ const to = normalizeProductNotificationEmailList(
97
+ input.settings.to,
98
+ 'Email recipients',
99
+ );
100
+ const cc = normalizeProductNotificationEmailList(
101
+ input.settings.cc,
102
+ 'Email CC',
103
+ );
104
+ const bcc = normalizeProductNotificationEmailList(
105
+ input.settings.bcc,
106
+ 'Email BCC',
107
+ );
108
+ if (to.length === 0) throw new Error('Email delivery requires a recipient.');
109
+ if (to.length + cc.length + bcc.length > MAX_RECIPIENTS) {
110
+ throw new Error(
111
+ `Email delivery exceeds the ${MAX_RECIPIENTS}-recipient limit.`,
112
+ );
113
+ }
114
+
115
+ const targetUrl = input.content.targetUrl
116
+ ? workspaceAppUrl(input.appUrl, input.workspaceId, input.content.targetUrl)
117
+ : null;
118
+ const settingsUrl = notificationSettingsUrl(input, input.groupKey);
119
+ const logoUrl =
120
+ targetUrl ?? workspaceAppUrl(input.appUrl, input.workspaceId, '/dashboard');
121
+ const optOutUrl = input.unsubscribeUrl
122
+ ? unsubscribeUrl(input.unsubscribeUrl)
123
+ : null;
124
+ const oneClickOptOutUrl = input.oneClickUnsubscribeUrl
125
+ ? unsubscribeUrl(input.oneClickUnsubscribeUrl)
126
+ : optOutUrl;
127
+ const text = [
128
+ input.content.title,
129
+ `Notification group: ${input.content.groupName}`,
130
+ ...(targetUrl ? [`Open Deepline: ${targetUrl}`] : []),
131
+ '',
132
+ notificationText(input.content.summary, input.workspaceName),
133
+ ...input.content.details.map((detail) =>
134
+ notificationText(detail, input.workspaceName),
135
+ ),
136
+ '',
137
+ `Manage notifications: ${settingsUrl}`,
138
+ ...(optOutUrl
139
+ ? [`Unsubscribe from ${input.content.groupName} email: ${optOutUrl}`]
140
+ : []),
141
+ ].join('\n');
142
+ const html = [
143
+ '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:#fff;color:#111827"><tr><td align="center" style="padding:32px 16px">',
144
+ '<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px"><tr><td style="font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;text-align:left">',
145
+ `<a href="${escapeHtml(logoUrl)}"><img src="https://code.deepline.com/brand/logo_full_dark.png" width="140" height="26" alt="Deepline" style="display:block;border:0;margin:0 0 32px"></a>`,
146
+ `<h1 style="font-size:24px;line-height:1.25;font-weight:600;letter-spacing:-0.02em;margin:0 0 4px">${escapeHtml(input.content.title)}</h1>`,
147
+ '<div style="padding-left:20px">',
148
+ `<p style="margin:0 0 4px"><strong>Notification group:</strong> ${escapeHtml(input.content.groupName)}</p>`,
149
+ ...(targetUrl
150
+ ? [
151
+ `<p style="margin:0"><a href="${escapeHtml(targetUrl)}" style="color:#1919ff">Open Deepline</a></p>`,
152
+ ]
153
+ : []),
154
+ '</div>',
155
+ '<div style="margin:24px 0;padding:20px;border:1px solid #1919ff;border-radius:8px;color:#111827;font-size:18px;line-height:1.5">',
156
+ `<p style="margin:0">${contentHtml(input.content.summary, input.workspaceName)}</p>`,
157
+ ...input.content.details.map(
158
+ (detail) =>
159
+ `<p style="margin:4px 0 0">${contentHtml(detail, input.workspaceName)}</p>`,
160
+ ),
161
+ '</div>',
162
+ '<hr style="border:0;border-top:1px solid #e5e7eb;margin:32px 0 24px">',
163
+ `<p style="color:#666;font-size:12px"><a href="${escapeHtml(settingsUrl)}" style="color:#666">Manage notifications</a></p>`,
164
+ ...(optOutUrl
165
+ ? [
166
+ `<p style="color:#666;font-size:12px"><a href="${escapeHtml(optOutUrl)}">Unsubscribe from ${escapeHtml(input.content.groupName)} email</a></p>`,
167
+ ]
168
+ : []),
169
+ '</td></tr></table></td></tr></table>',
170
+ ].join('');
171
+
172
+ return {
173
+ to,
174
+ ...(cc.length > 0 ? { cc } : {}),
175
+ ...(bcc.length > 0 ? { bcc } : {}),
176
+ ...(oneClickOptOutUrl
177
+ ? {
178
+ headers: {
179
+ 'List-Unsubscribe': `<${oneClickOptOutUrl}>`,
180
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
181
+ },
182
+ }
183
+ : {}),
184
+ subject: input.workspaceName
185
+ ? `${input.content.title} — ${input.workspaceName}`
186
+ : input.content.title,
187
+ html,
188
+ text,
189
+ };
190
+ }
191
+
192
+ export function parseProductNotificationEmailRequest(
193
+ value: string,
194
+ ): ProductNotificationEmailRequest {
195
+ let parsed: unknown;
196
+ try {
197
+ parsed = JSON.parse(value);
198
+ } catch {
199
+ throw new Error('Email delivery request is not valid JSON.');
200
+ }
201
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
202
+ throw new Error('Email delivery request is invalid.');
203
+ }
204
+ const request = parsed as Partial<ProductNotificationEmailRequest>;
205
+ if (
206
+ !Array.isArray(request.to) ||
207
+ typeof request.subject !== 'string' ||
208
+ !request.subject.trim() ||
209
+ typeof request.html !== 'string' ||
210
+ !request.html ||
211
+ typeof request.text !== 'string' ||
212
+ !request.text
213
+ ) {
214
+ throw new Error('Email delivery request is incomplete.');
215
+ }
216
+ const to = normalizeProductNotificationEmailList(
217
+ request.to,
218
+ 'Email recipients',
219
+ );
220
+ const cc = normalizeProductNotificationEmailList(request.cc, 'Email CC');
221
+ const bcc = normalizeProductNotificationEmailList(request.bcc, 'Email BCC');
222
+ const headers = request.headers;
223
+ if (
224
+ headers !== undefined &&
225
+ (!headers ||
226
+ typeof headers !== 'object' ||
227
+ Array.isArray(headers) ||
228
+ Object.entries(headers).some(
229
+ ([name, value]) => !name.trim() || typeof value !== 'string',
230
+ ))
231
+ ) {
232
+ throw new Error('Email delivery headers are invalid.');
233
+ }
234
+ if (to.length === 0 || to.length + cc.length + bcc.length > MAX_RECIPIENTS) {
235
+ throw new Error('Email delivery recipient count is invalid.');
236
+ }
237
+ return {
238
+ to,
239
+ ...(cc.length > 0 ? { cc } : {}),
240
+ ...(bcc.length > 0 ? { bcc } : {}),
241
+ ...(headers ? { headers } : {}),
242
+ subject: request.subject,
243
+ html: request.html,
244
+ text: request.text,
245
+ };
246
+ }
@@ -0,0 +1,280 @@
1
+ import type {
2
+ ProductNotificationFacts,
3
+ ProductNotificationTrigger,
4
+ } from './configuration';
5
+
6
+ export const PRODUCT_NOTIFICATION_DECISION_HORIZON_MS =
7
+ 30 * 24 * 60 * 60 * 1_000;
8
+
9
+ export type ProductNotificationOutcomeFact = {
10
+ orderingKey: string;
11
+ outcome: string;
12
+ occurredAt: number;
13
+ sourceEventId: string;
14
+ };
15
+
16
+ export type ProductNotificationObservation =
17
+ | { kind: 'match' }
18
+ | {
19
+ kind: 'state_transition';
20
+ previousState?: string;
21
+ currentState: string;
22
+ previousReasonCode?: string;
23
+ currentReasonCode?: string;
24
+ /** Compatibility alias for currentReasonCode. */
25
+ reasonCode?: string;
26
+ }
27
+ | {
28
+ kind: 'threshold_crossing';
29
+ previousValue: number;
30
+ currentValue: number;
31
+ unit?: string;
32
+ }
33
+ | { kind: 'threshold_state'; currentValue: number; unit?: string }
34
+ | {
35
+ kind: 'outcome_history';
36
+ /** Canonical newest-first history, beginning with the current event. */
37
+ outcomes: ProductNotificationOutcomeFact[];
38
+ };
39
+
40
+ export type ProductNotificationTriggerDecision = {
41
+ fires: boolean;
42
+ reason:
43
+ | 'each_match'
44
+ | 'entered_state'
45
+ | 'crossed_threshold'
46
+ | 'condition_active'
47
+ | 'completed_streak'
48
+ | 'observation_mismatch'
49
+ | 'reason_mismatch'
50
+ | 'unit_mismatch'
51
+ | 'not_a_transition'
52
+ | 'not_a_crossing'
53
+ | 'insufficient_history'
54
+ | 'streak_already_active';
55
+ };
56
+
57
+ export function productNotificationFactsMatch(
58
+ filter: ProductNotificationFacts,
59
+ facts: ProductNotificationFacts,
60
+ ): boolean {
61
+ return Object.entries(filter).every(([key, value]) => facts[key] === value);
62
+ }
63
+
64
+ function compareCanonicalOrder(
65
+ left: ProductNotificationOutcomeFact,
66
+ right: ProductNotificationOutcomeFact,
67
+ ) {
68
+ return (
69
+ left.occurredAt - right.occurredAt ||
70
+ left.sourceEventId.localeCompare(right.sourceEventId)
71
+ );
72
+ }
73
+
74
+ function validateOutcomeHistory(
75
+ outcomes: ProductNotificationOutcomeFact[],
76
+ maximum: number,
77
+ ) {
78
+ if (outcomes.length > maximum) {
79
+ throw new Error(`Outcome history exceeds the ${maximum}-row bound.`);
80
+ }
81
+ const orderingKey = outcomes[0]?.orderingKey;
82
+ for (let index = 0; index < outcomes.length; index += 1) {
83
+ const outcome = outcomes[index]!;
84
+ if (
85
+ !outcome.orderingKey ||
86
+ outcome.orderingKey !== orderingKey ||
87
+ !outcome.outcome ||
88
+ !outcome.sourceEventId ||
89
+ !Number.isFinite(outcome.occurredAt)
90
+ ) {
91
+ throw new Error(
92
+ 'Outcome history contains an invalid or cross-stream fact.',
93
+ );
94
+ }
95
+ const next = outcomes[index + 1];
96
+ if (next && compareCanonicalOrder(outcome, next) <= 0) {
97
+ throw new Error(
98
+ 'Outcome history must be in canonical newest-first order.',
99
+ );
100
+ }
101
+ }
102
+ }
103
+
104
+ function decision(
105
+ fires: boolean,
106
+ reason: ProductNotificationTriggerDecision['reason'],
107
+ ): ProductNotificationTriggerDecision {
108
+ return { fires, reason };
109
+ }
110
+
111
+ export type ProductNotificationCondition = {
112
+ previousActive?: boolean;
113
+ currentActive: boolean;
114
+ };
115
+
116
+ function thresholdIsActive(
117
+ trigger: Extract<ProductNotificationTrigger, { kind: 'threshold_crossing' }>,
118
+ value: number,
119
+ ) {
120
+ return trigger.direction === 'below'
121
+ ? value < trigger.threshold
122
+ : trigger.direction === 'at_or_above'
123
+ ? value >= trigger.threshold
124
+ : value > trigger.threshold;
125
+ }
126
+
127
+ /** Map an observation to the one shared condition shape when it has durable state. */
128
+ export function productNotificationCondition(input: {
129
+ trigger: ProductNotificationTrigger;
130
+ observation: ProductNotificationObservation;
131
+ }): ProductNotificationCondition | null {
132
+ const { trigger, observation } = input;
133
+ if (trigger.kind === 'state_transition') {
134
+ if (observation.kind !== 'state_transition') return null;
135
+ const matches = (state: string, reasonCode: string | undefined) =>
136
+ state === trigger.targetState &&
137
+ (trigger.reasonCode === undefined || reasonCode === trigger.reasonCode);
138
+ const currentReasonCode =
139
+ observation.currentReasonCode ?? observation.reasonCode;
140
+ const previousReasonCode =
141
+ observation.previousReasonCode ??
142
+ (observation.previousState === observation.currentState
143
+ ? currentReasonCode
144
+ : undefined);
145
+ return {
146
+ previousActive:
147
+ observation.previousState === undefined
148
+ ? undefined
149
+ : matches(observation.previousState, previousReasonCode),
150
+ currentActive: matches(observation.currentState, currentReasonCode),
151
+ };
152
+ }
153
+ if (trigger.kind === 'threshold_crossing') {
154
+ if (
155
+ observation.kind !== 'threshold_crossing' &&
156
+ observation.kind !== 'threshold_state'
157
+ ) {
158
+ return null;
159
+ }
160
+ if (trigger.unit !== undefined && observation.unit !== trigger.unit) {
161
+ return null;
162
+ }
163
+ if (observation.kind === 'threshold_state') {
164
+ return {
165
+ currentActive: thresholdIsActive(trigger, observation.currentValue),
166
+ };
167
+ }
168
+ return {
169
+ previousActive: thresholdIsActive(trigger, observation.previousValue),
170
+ currentActive: thresholdIsActive(trigger, observation.currentValue),
171
+ };
172
+ }
173
+ if (trigger.kind !== 'consecutive_outcomes') return null;
174
+ if (observation.kind !== 'outcome_history') return null;
175
+ validateOutcomeHistory(observation.outcomes, trigger.maxLookback + 1);
176
+ const currentOccurredAt = observation.outcomes[0]?.occurredAt;
177
+ const outcomes =
178
+ currentOccurredAt === undefined
179
+ ? []
180
+ : observation.outcomes.filter(
181
+ (outcome) =>
182
+ outcome.occurredAt >=
183
+ currentOccurredAt - PRODUCT_NOTIFICATION_DECISION_HORIZON_MS,
184
+ );
185
+ const active = (offset: number) => {
186
+ const streak = outcomes.slice(offset, offset + trigger.count);
187
+ return (
188
+ streak.length === trigger.count &&
189
+ streak.every((outcome) => outcome.outcome === trigger.outcome)
190
+ );
191
+ };
192
+ return {
193
+ previousActive: active(1),
194
+ currentActive: active(0),
195
+ };
196
+ }
197
+
198
+ /** Deterministic provider-independent evaluation for the four V1 policies. */
199
+ export function evaluateProductNotificationTrigger(input: {
200
+ trigger: ProductNotificationTrigger;
201
+ observation: ProductNotificationObservation;
202
+ occurredAt: number;
203
+ }): ProductNotificationTriggerDecision {
204
+ const { trigger, observation, occurredAt } = input;
205
+ if (!Number.isFinite(occurredAt)) {
206
+ throw new Error('occurredAt must be finite.');
207
+ }
208
+ if (trigger.kind === 'each_match') return decision(true, 'each_match');
209
+
210
+ if (trigger.kind === 'state_transition') {
211
+ if (observation.kind !== 'state_transition') {
212
+ return decision(false, 'observation_mismatch');
213
+ }
214
+ const condition = productNotificationCondition({ trigger, observation });
215
+ const currentReasonCode =
216
+ observation.currentReasonCode ?? observation.reasonCode;
217
+ if (
218
+ trigger.reasonCode !== undefined &&
219
+ currentReasonCode !== trigger.reasonCode
220
+ ) {
221
+ return decision(false, 'reason_mismatch');
222
+ }
223
+ const fires =
224
+ condition?.previousActive === false && condition.currentActive === true;
225
+ return decision(fires, fires ? 'entered_state' : 'not_a_transition');
226
+ }
227
+
228
+ if (trigger.kind === 'threshold_crossing') {
229
+ if (
230
+ observation.kind !== 'threshold_crossing' &&
231
+ observation.kind !== 'threshold_state'
232
+ ) {
233
+ return decision(false, 'observation_mismatch');
234
+ }
235
+ const condition = productNotificationCondition({ trigger, observation });
236
+ if (!condition) {
237
+ return decision(false, 'unit_mismatch');
238
+ }
239
+ if (observation.kind === 'threshold_state') {
240
+ return decision(
241
+ condition.currentActive,
242
+ condition.currentActive ? 'condition_active' : 'not_a_crossing',
243
+ );
244
+ }
245
+ const fires =
246
+ condition.previousActive === false && condition.currentActive === true;
247
+ return decision(fires, fires ? 'crossed_threshold' : 'not_a_crossing');
248
+ }
249
+
250
+ if (observation.kind !== 'outcome_history') {
251
+ return decision(false, 'observation_mismatch');
252
+ }
253
+ if (
254
+ !Number.isInteger(trigger.count) ||
255
+ trigger.count < 1 ||
256
+ !Number.isInteger(trigger.maxLookback) ||
257
+ trigger.maxLookback < trigger.count ||
258
+ trigger.maxLookback > 100
259
+ ) {
260
+ throw new Error('Consecutive-outcome bounds are invalid.');
261
+ }
262
+ validateOutcomeHistory(observation.outcomes, trigger.maxLookback + 1);
263
+ const withinHorizon = observation.outcomes.filter(
264
+ (outcome) =>
265
+ outcome.occurredAt <= occurredAt &&
266
+ outcome.occurredAt >=
267
+ occurredAt - PRODUCT_NOTIFICATION_DECISION_HORIZON_MS,
268
+ );
269
+ const streak = withinHorizon.slice(0, trigger.count);
270
+ if (
271
+ streak.length < trigger.count ||
272
+ streak.some((outcome) => outcome.outcome !== trigger.outcome)
273
+ ) {
274
+ return decision(false, 'insufficient_history');
275
+ }
276
+ if (withinHorizon[trigger.count]?.outcome === trigger.outcome) {
277
+ return decision(false, 'streak_already_active');
278
+ }
279
+ return decision(true, 'completed_streak');
280
+ }