deepline 0.3.124 → 0.3.126

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 (31) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +10 -0
  2. package/dist/bundling-sources/sdk/src/compat.ts +17 -2
  3. package/dist/bundling-sources/sdk/src/play.ts +3 -1
  4. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  5. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +61 -10
  6. package/dist/bundling-sources/shared_libs/observability/redaction.ts +3 -0
  7. package/dist/bundling-sources/shared_libs/observability/runtime-console.ts +174 -0
  8. package/dist/bundling-sources/shared_libs/observability/telemetry.ts +26 -2
  9. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +3 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +20 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +1 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +28 -11
  13. package/dist/bundling-sources/shared_libs/play-runtime/runtime-telemetry.ts +121 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +91 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +87 -41
  16. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +4 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-capacity.ts +5 -0
  18. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +1 -0
  19. package/dist/bundling-sources/shared_libs/security/secret-redaction.ts +56 -1
  20. package/dist/cli/index.js +528 -466
  21. package/dist/cli/index.mjs +528 -466
  22. package/dist/index.d.mts +13 -1
  23. package/dist/index.d.ts +13 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.mjs +1 -1
  26. package/dist/install-integrity.json +1 -0
  27. package/dist/release.d.mts +1 -1
  28. package/dist/release.d.ts +1 -1
  29. package/dist/release.js +1 -1
  30. package/dist/release.mjs +1 -1
  31. package/package.json +1 -1
@@ -1086,8 +1086,12 @@ export type MonitorJobReconciliationState =
1086
1086
 
1087
1087
  export type MonitorJobProjection = {
1088
1088
  id: string;
1089
+ /** Durable provider operation identity used to trace Fleet/deploy work. */
1090
+ operation_id?: string;
1089
1091
  kind: 'batch_deploy' | 'fleet_deploy';
1090
1092
  state: MonitorJobState;
1093
+ /** Terminal semantic result; `blocked` is a deterministic Fleet/input refusal, not a provider outage. */
1094
+ outcome?: 'blocked' | 'failed';
1091
1095
  phase: MonitorJobPhase;
1092
1096
  target: {
1093
1097
  monitor_key?: string;
@@ -1135,6 +1139,8 @@ export type MonitorJobResult = {
1135
1139
  ok?: boolean;
1136
1140
  job?: MonitorJobProjection;
1137
1141
  batch?: MonitorNamedBatchProjection;
1142
+ /** Same durable operation identity as `job.operation_id`, for log reads. */
1143
+ operation_id?: string;
1138
1144
  logs?: Array<{
1139
1145
  level: string;
1140
1146
  phase: string;
@@ -1754,6 +1760,10 @@ export type TargetBillingStatusResult = {
1754
1760
  recharge_state: string;
1755
1761
  next_action: string | null;
1756
1762
  pending_plan_sku: string | null;
1763
+ /** Saved transition boundary; optional for older servers. */
1764
+ pending_plan_starts_at?: string | null;
1765
+ /** Next cycle boundary from the current Contract’s retained cadence. */
1766
+ current_period_end?: string | null;
1757
1767
  as_of: string | null;
1758
1768
  };
1759
1769
 
@@ -395,18 +395,33 @@ export async function checkSdkCompatibility(
395
395
  }
396
396
  }
397
397
 
398
+ /**
399
+ * A CI job (`CI` or `GITHUB_ACTIONS`): nobody is at the keyboard to act on an
400
+ * advisory, and the pinned version is deliberate. Agent sessions are also
401
+ * non-TTY but are not CI, and they rely on these advisories, so this keys on
402
+ * the CI environment rather than on a missing terminal.
403
+ */
404
+ export function isCiEnvironment(): boolean {
405
+ return ['CI', 'GITHUB_ACTIONS'].some((name) => {
406
+ const value = process.env[name]?.trim().toLowerCase();
407
+ return value === '1' || value === 'true' || value === 'yes';
408
+ });
409
+ }
410
+
398
411
  export function enforceSdkCompatibilityResponse(
399
412
  response: SdkCompatibilityResponse | null,
400
413
  ): void {
401
414
  if (!response) {
402
415
  return;
403
416
  }
417
+ // A version the server no longer supports fails everywhere, CI included.
404
418
  if (response.update_required) {
405
419
  throw new Error(response.message);
406
420
  }
407
421
  if (
408
- response.status === 'deprecated' ||
409
- response.status === 'update_available'
422
+ (response.status === 'deprecated' ||
423
+ response.status === 'update_available') &&
424
+ !isCiEnvironment()
410
425
  ) {
411
426
  process.stderr.write(`${response.message}\n`);
412
427
  }
@@ -195,7 +195,9 @@ export type PlayFetchResponse = PlayAuthoringFetchResponse;
195
195
  * `runtime: { timeout: '90m', size: 'standard' }`; duration values are whole minutes or hours, up to `4h`.
196
196
  * It differs from `ctx.tools.execute({ timeoutMs })`, which limits one provider call.
197
197
  *
198
- * @example Webhook with HMAC verification
198
+ * @example Webhook with HMAC verification. `secretEnv` is the Deepline Secret
199
+ * name configured with `deepline secrets set`, retained as the legacy field
200
+ * name for SDK compatibility.
199
201
  * ```typescript
200
202
  * definePlay('webhook-handler', handler, {
201
203
  * webhook: {
@@ -200,7 +200,7 @@ export const SDK_RELEASE = {
200
200
  // getters keep their established compatibility behavior.
201
201
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
202
202
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
203
- version: '0.3.124',
203
+ version: '0.3.126',
204
204
  updateSummary:
205
205
  'Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.',
206
206
  packageCapabilities: {
@@ -21,9 +21,10 @@ export const PROVIDER_MONITOR_BATCH_OPERATION_KINDS = [
21
21
  // operation is the durable, observable hand-off to the fenced Fleet
22
22
  // executor; it is not a second lifecycle verb or a raw provider create.
23
23
  'converge',
24
- // A named Batch owns a bounded mixed plan (sync/start/stop) under one
25
- // durable operation. It is intentionally distinct from manifest deploy:
26
- // omission/removal semantics belong to the Batch desired-set owner.
24
+ // A named Batch coordinates a bounded mixed plan (sync/start/stop) under
25
+ // one durable operation. It is intentionally distinct from manifest
26
+ // deploy: omission/removal semantics belong to the Batch desired set, not
27
+ // to monitor lifecycle ownership.
27
28
  'named_batch',
28
29
  ] as const;
29
30
 
@@ -127,7 +128,60 @@ export const PROVIDER_MONITOR_BATCH_RESULT_STATES = [
127
128
  export type ProviderMonitorBatchResultState =
128
129
  (typeof PROVIDER_MONITOR_BATCH_RESULT_STATES)[number];
129
130
 
130
- const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/;
131
+ export const PROVIDER_MONITOR_BATCH_IDEMPOTENCY_KEY_MAX_LENGTH = 96;
132
+ const IDEMPOTENCY_KEY = new RegExp(
133
+ `^[A-Za-z0-9][A-Za-z0-9._-]{0,${PROVIDER_MONITOR_BATCH_IDEMPOTENCY_KEY_MAX_LENGTH - 1}}$`,
134
+ );
135
+
136
+ export function validateProviderMonitorBatchIdempotencyKey(
137
+ value: string,
138
+ ): string {
139
+ const idempotencyKey = value.trim();
140
+ if (!IDEMPOTENCY_KEY.test(idempotencyKey)) {
141
+ throw new Error(
142
+ `idempotencyKey must be 1-${PROVIDER_MONITOR_BATCH_IDEMPOTENCY_KEY_MAX_LENGTH} characters of letters, numbers, dot, underscore, or hyphen.`,
143
+ );
144
+ }
145
+ return idempotencyKey;
146
+ }
147
+
148
+ /**
149
+ * Fleet control is the only durable monitor ownership boundary. A Batch is a
150
+ * deploy mechanism, so it never becomes a customer-facing owner or conflict.
151
+ * Keep this classifier in the provider-agnostic package so Fleet refusals use
152
+ * one durable result vocabulary without importing integration-layer errors.
153
+ */
154
+ export type ProviderMonitorOwnershipConflict = {
155
+ code: 'monitor_owned_by_fleet';
156
+ message: string;
157
+ };
158
+
159
+ export function providerMonitorOwnershipConflict(
160
+ error: unknown,
161
+ ): ProviderMonitorOwnershipConflict | null {
162
+ const value =
163
+ error && typeof error === 'object'
164
+ ? (error as { code?: unknown; message?: unknown })
165
+ : undefined;
166
+ const code = typeof value?.code === 'string' ? value.code : '';
167
+ const message =
168
+ typeof value?.message === 'string' ? value.message : String(error);
169
+ const text = `${code} ${message}`.toUpperCase();
170
+
171
+ if (
172
+ text.includes('MONITOR_MANAGED_BY_FLEET') ||
173
+ text.includes('MONITOR_FLEET_MONITOR_OWNED_BY_OTHER_FLEET') ||
174
+ text.includes('FLEET_SCOPE_REQUIRED') ||
175
+ /MANAGED BY (?:MONITOR )?FLEET/.test(text)
176
+ ) {
177
+ return {
178
+ code: 'monitor_owned_by_fleet',
179
+ message:
180
+ 'This monitor is managed by a Fleet. Change the Fleet definition instead.',
181
+ };
182
+ }
183
+ return null;
184
+ }
131
185
 
132
186
  function canonicalJson(value: unknown): string {
133
187
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
@@ -173,14 +227,11 @@ export function providerMonitorBatchOperationId(input: {
173
227
  idempotencyKey: string;
174
228
  }): string {
175
229
  const orgId = input.orgId.trim();
176
- const idempotencyKey = input.idempotencyKey.trim();
177
230
  if (!orgId)
178
231
  throw new Error('A monitor batch operation requires an organization.');
179
- if (!IDEMPOTENCY_KEY.test(idempotencyKey)) {
180
- throw new Error(
181
- 'idempotencyKey must be 1-96 characters of letters, numbers, dot, underscore, or hyphen.',
182
- );
183
- }
232
+ const idempotencyKey = validateProviderMonitorBatchIdempotencyKey(
233
+ input.idempotencyKey,
234
+ );
184
235
  return `monitor-batch:${orgId}:${idempotencyKey}`;
185
236
  }
186
237
 
@@ -23,6 +23,8 @@ const INLINE_SECRET_PATTERN = new RegExp(
23
23
  `\\b(${INLINE_SECRET_FIELD_NAME})(\\s*[:=]\\s*)([^\\s,;]+)`,
24
24
  'gi',
25
25
  );
26
+ const POSTGRES_CREDENTIAL_PATTERN =
27
+ /\b(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s]+@/gi;
26
28
 
27
29
  /** Whether a structured telemetry field name must never retain its value. */
28
30
  export function isSensitiveTelemetryFieldName(value: string): boolean {
@@ -59,6 +61,7 @@ function redactStructuredValue(
59
61
  function redactPlainTelemetryText(value: string): string {
60
62
  return value
61
63
  .replace(PRIVATE_KEY_PATTERN, '[REDACTED]')
64
+ .replace(POSTGRES_CREDENTIAL_PATTERN, '$1[REDACTED]@')
62
65
  .replace(AUTHORIZATION_CREDENTIAL_PATTERN, '$1$2$3 [REDACTED]')
63
66
  .replace(BEARER_CREDENTIAL_PATTERN, 'Bearer [REDACTED]')
64
67
  .replace(BASIC_CREDENTIAL_PATTERN, 'Basic [REDACTED]')
@@ -0,0 +1,174 @@
1
+ export type RuntimeConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ type RuntimeConsoleMethod = (...args: unknown[]) => void;
4
+ type RuntimeConsoleTarget = Console;
5
+
6
+ const RUNTIME_CONSOLE_PATCHED = Symbol.for(
7
+ 'deepline.runtime-console-logging.v1',
8
+ );
9
+ const MAX_VALUE_DEPTH = 5;
10
+ const MAX_STRING_LENGTH = 8_000;
11
+
12
+ function trim(value: string): string {
13
+ return value.length <= MAX_STRING_LENGTH
14
+ ? value
15
+ : `${value.slice(0, MAX_STRING_LENGTH)}…`;
16
+ }
17
+
18
+ function safeValue(
19
+ value: unknown,
20
+ seen = new WeakSet<object>(),
21
+ depth = 0,
22
+ ): unknown {
23
+ if (
24
+ value === null ||
25
+ typeof value === 'boolean' ||
26
+ typeof value === 'number'
27
+ ) {
28
+ return value;
29
+ }
30
+ if (typeof value === 'string') return trim(value);
31
+ if (typeof value === 'bigint') return String(value);
32
+ if (value instanceof Error) {
33
+ return {
34
+ name: value.name || 'Error',
35
+ message: trim(value.message),
36
+ ...(value.stack ? { stack: trim(value.stack) } : {}),
37
+ };
38
+ }
39
+ if (typeof value !== 'object') return String(value);
40
+ if (depth >= MAX_VALUE_DEPTH) return '[max-depth]';
41
+ if (seen.has(value)) return '[circular]';
42
+ seen.add(value);
43
+ if (Array.isArray(value)) {
44
+ const result = value
45
+ .slice(0, 100)
46
+ .map((item) => safeValue(item, seen, depth + 1));
47
+ if (value.length > 100) result.push('[truncated]');
48
+ seen.delete(value);
49
+ return result;
50
+ }
51
+ const result: Record<string, unknown> = {};
52
+ for (const [key, child] of Object.entries(value).slice(0, 100)) {
53
+ result[key] = safeValue(child, seen, depth + 1);
54
+ }
55
+ if (Object.keys(value).length > 100) result.__truncated__ = true;
56
+ seen.delete(value);
57
+ return result;
58
+ }
59
+
60
+ function parsedObject(value: unknown): Record<string, unknown> | null {
61
+ if (typeof value !== 'string') return null;
62
+ try {
63
+ const parsed: unknown = JSON.parse(value);
64
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
65
+ ? (parsed as Record<string, unknown>)
66
+ : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function consolePayload(
73
+ level: RuntimeConsoleLevel,
74
+ args: readonly unknown[],
75
+ ): Record<string, unknown> {
76
+ const parsed = args.length === 1 ? parsedObject(args[0]) : null;
77
+ if (parsed) {
78
+ return {
79
+ ...(safeValue(parsed) as Record<string, unknown>),
80
+ level,
81
+ };
82
+ }
83
+
84
+ const payload: Record<string, unknown> = { level };
85
+ const text: string[] = [];
86
+ for (const arg of args) {
87
+ if (typeof arg === 'string') {
88
+ text.push(arg);
89
+ continue;
90
+ }
91
+ if (arg instanceof Error) {
92
+ payload.error = safeValue(arg);
93
+ continue;
94
+ }
95
+ const value = safeValue(arg);
96
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
97
+ for (const [key, child] of Object.entries(value)) {
98
+ if (key === 'level') {
99
+ payload.consoleLevel = child;
100
+ } else if (key === 'message' && text.length === 0) {
101
+ payload.message ??= child;
102
+ } else if (key === 'message') {
103
+ payload.contextMessage = child;
104
+ } else {
105
+ payload[key] = child;
106
+ }
107
+ }
108
+ continue;
109
+ }
110
+ text.push(String(value));
111
+ }
112
+ if (text.length > 0 && payload.message === undefined) {
113
+ payload.message = trim(text.join(' '));
114
+ }
115
+ return payload;
116
+ }
117
+
118
+ /**
119
+ * Preserve the native console API while making Fly log records one-line and
120
+ * explicitly severity-bearing. Fly's log stream labels stdout and stderr as
121
+ * info, so the level has to travel in the application payload.
122
+ */
123
+ export function installRuntimeConsoleLogging(
124
+ target: RuntimeConsoleTarget = console,
125
+ ): void {
126
+ const metadata = target as Console & { [key: symbol]: unknown };
127
+ if (metadata[RUNTIME_CONSOLE_PATCHED] === true) return;
128
+ Object.defineProperty(metadata, RUNTIME_CONSOLE_PATCHED, {
129
+ configurable: false,
130
+ enumerable: false,
131
+ value: true,
132
+ writable: false,
133
+ });
134
+
135
+ const methods: Array<
136
+ readonly ['debug' | 'info' | 'warn' | 'error' | 'log', RuntimeConsoleLevel]
137
+ > = [
138
+ ['debug', 'debug'],
139
+ ['info', 'info'],
140
+ ['warn', 'warn'],
141
+ ['error', 'error'],
142
+ ['log', 'info'],
143
+ ];
144
+ for (const [method, level] of methods) {
145
+ const original = target[method] as RuntimeConsoleMethod;
146
+ target[method] = ((...args: unknown[]) => {
147
+ original(JSON.stringify(consolePayload(level, args)));
148
+ }) as Console[typeof method];
149
+ }
150
+ }
151
+
152
+ export function createRateLimitedRuntimeLogger(input: {
153
+ log: RuntimeConsoleMethod;
154
+ intervalMs?: number;
155
+ now?: () => number;
156
+ }): RuntimeConsoleMethod {
157
+ const intervalMs = input.intervalMs ?? 60_000;
158
+ const now = input.now ?? Date.now;
159
+ let lastLoggedAt = Number.NEGATIVE_INFINITY;
160
+ let suppressed = 0;
161
+
162
+ return (...args: unknown[]) => {
163
+ const current = now();
164
+ if (current - lastLoggedAt < intervalMs) {
165
+ suppressed += 1;
166
+ return;
167
+ }
168
+ const extra =
169
+ suppressed > 0 ? { suppressedSinceLastLog: suppressed } : null;
170
+ suppressed = 0;
171
+ lastLoggedAt = current;
172
+ input.log(...args, ...(extra ? [extra] : []));
173
+ };
174
+ }
@@ -64,8 +64,10 @@ type TelemetryEventBase = {
64
64
  export type TelemetryLogEvent = TelemetryEventBase & {
65
65
  telemetryKind: 'log';
66
66
  telemetryLevel: TelemetryLevel;
67
+ errorCode?: string;
67
68
  errorType?: string;
68
69
  errorMessage?: string;
70
+ errorStack?: string;
69
71
  };
70
72
 
71
73
  export type TelemetryMetricEvent = TelemetryEventBase & {
@@ -80,8 +82,10 @@ export type TelemetrySpanEvent = TelemetryEventBase & {
80
82
  telemetryLevel: 'info' | 'warn' | 'error';
81
83
  spanDurationMs: number;
82
84
  spanOutcome: TelemetrySpanOutcome;
85
+ errorCode?: string;
83
86
  errorType?: string;
84
87
  errorMessage?: string;
88
+ errorStack?: string;
85
89
  };
86
90
 
87
91
  export type TelemetryEvent =
@@ -293,12 +297,30 @@ function normalizeContext(context: TelemetryContext | undefined) {
293
297
 
294
298
  function errorFields(
295
299
  error: unknown,
296
- ): Partial<Pick<TelemetrySpanEvent, 'errorType' | 'errorMessage'>> {
300
+ options: { includeStack?: boolean } = {},
301
+ ): Partial<
302
+ Pick<
303
+ TelemetrySpanEvent,
304
+ 'errorCode' | 'errorType' | 'errorMessage' | 'errorStack'
305
+ >
306
+ > {
297
307
  if (error === undefined || error === null) return {};
298
308
  if (error instanceof Error) {
309
+ const code =
310
+ typeof (error as Error & { code?: unknown }).code === 'string'
311
+ ? (error as Error & { code: string }).code.trim()
312
+ : '';
299
313
  return {
314
+ ...(code ? { errorCode: code } : {}),
300
315
  errorType: error.name || 'Error',
301
316
  errorMessage: redactTelemetryString(error.message),
317
+ ...(options.includeStack !== false && error.stack
318
+ ? {
319
+ errorStack: String(
320
+ normalizeFieldValue('errorStack', error.stack) ?? error.stack,
321
+ ),
322
+ }
323
+ : {}),
302
324
  };
303
325
  }
304
326
  return {
@@ -420,7 +442,9 @@ export function createTelemetry(input: CreateTelemetryInput): Telemetry {
420
442
  triage: errorTriage(fields),
421
443
  telemetryKind: 'log',
422
444
  telemetryLevel: 'error',
423
- ...errorFields(error),
445
+ ...errorFields(error, {
446
+ includeStack: fields?.errorStack === undefined,
447
+ }),
424
448
  });
425
449
  },
426
450
  counter: (name, value = 1, fields) =>
@@ -77,7 +77,9 @@ function blocked(code: PlayAdmissionBlockedCode): PlayAdmissionFailure {
77
77
  message:
78
78
  code === PLAY_SECRET_UNAVAILABLE
79
79
  ? 'The Play configuration is unavailable. Repair it, then retry this delivery.'
80
- : 'This delivery needs attention before it can be retried.',
80
+ : code === PLAY_RUN_LAUNCH_CONFLICT
81
+ ? 'This run identity is already bound to a different launch. Repair the trigger identity before retrying this delivery.'
82
+ : 'This delivery needs attention before it can be retried.',
81
83
  };
82
84
  }
83
85
 
@@ -55,6 +55,26 @@ export class PlayRunLaunchConflictError extends Error {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Recognize the conflict across the mixed-version admission boundary.
60
+ *
61
+ * The Postgres adapter normally converts the database message into the typed
62
+ * error above. During a rolling deploy an older scheduler may still return the
63
+ * original message, so callers must recognize both forms before wrapping it as
64
+ * a generic scheduler failure.
65
+ */
66
+ export function isPlayRunLaunchConflictError(error: unknown): boolean {
67
+ if (!(error instanceof Error)) return false;
68
+ if (
69
+ error instanceof PlayRunLaunchConflictError ||
70
+ (error as Error & { code?: unknown }).code === 'PLAY_RUN_LAUNCH_CONFLICT' ||
71
+ error.name === 'PlayRunLaunchConflictError'
72
+ ) {
73
+ return true;
74
+ }
75
+ return /already exists with a different launch/i.test(error.message);
76
+ }
77
+
58
78
  /** A fenced replay named a Run that was never durably admitted. */
59
79
  export class PlayRunAdmissionReplayMissingError extends Error {
60
80
  readonly code = 'PLAY_RUN_ADMISSION_REPLAY_MISSING';
@@ -3,6 +3,7 @@ export { createPostgresPlayRunAdmissionPorts } from '../../play-runs/admission/p
3
3
  export {
4
4
  PlayRunAdmissionReplayMissingError,
5
5
  PlayRunLaunchConflictError,
6
+ isPlayRunLaunchConflictError,
6
7
  } from '../../play-runs/admission/contracts';
7
8
  export type {
8
9
  PlayRunAdmissionInput,
@@ -5,10 +5,12 @@ import {
5
5
  import {
6
6
  PlayRunAdmissionReplayMissingError,
7
7
  PlayRunLaunchConflictError,
8
+ isPlayRunLaunchConflictError,
8
9
  type PlayRunAdmissionInput,
9
10
  type PlayRunAdmissionReceipt,
10
11
  } from '../../play-runs/admission/contracts';
11
12
  import type { PlayRunAdmissionPorts } from '../../play-runs/admission/ports';
13
+ import { emitPlayRunLaunchConflictTelemetry } from '../../runtime-telemetry';
12
14
 
13
15
  /**
14
16
  * PostgreSQL/Absurd implementation of the admission seam. The delegated
@@ -36,14 +38,33 @@ export function createPostgresPlayRunAdmissionPorts(input: {
36
38
  );
37
39
  } catch (error) {
38
40
  if (isLaunchConflict(error)) {
39
- throw new PlayRunLaunchConflictError(admission.launch.runId, {
40
- cause: error,
41
+ const conflict =
42
+ error instanceof PlayRunLaunchConflictError
43
+ ? error
44
+ : new PlayRunLaunchConflictError(admission.launch.runId, {
45
+ cause: error,
46
+ });
47
+ emitPlayRunLaunchConflictTelemetry({
48
+ orgId: admission.launch.orgId,
49
+ runId: admission.launch.runId,
50
+ playName: admission.launch.playName,
51
+ triggerSource: admission.launch.triggerSource,
52
+ workflowFamilyKey: admission.launch.workflowFamilyKey,
53
+ revisionId: admission.launch.revisionId,
54
+ artifactHash: admission.launch.artifactHash,
55
+ graphHash: admission.launch.graphHash,
56
+ inputSha256: admission.launch.inputSha256,
57
+ error: conflict,
41
58
  });
59
+ throw conflict;
42
60
  }
43
61
  if (isExistingOnlyReplayMissing(error)) {
44
- throw new PlayRunAdmissionReplayMissingError(admission.launch.runId, {
45
- cause: error,
46
- });
62
+ throw new PlayRunAdmissionReplayMissingError(
63
+ admission.launch.runId,
64
+ {
65
+ cause: error,
66
+ },
67
+ );
47
68
  }
48
69
  throw error;
49
70
  }
@@ -54,14 +75,10 @@ export function createPostgresPlayRunAdmissionPorts(input: {
54
75
 
55
76
  function isExistingOnlyReplayMissing(error: unknown): boolean {
56
77
  return (
57
- error instanceof Error &&
58
- /existing-only admission/i.test(error.message)
78
+ error instanceof Error && /existing-only admission/i.test(error.message)
59
79
  );
60
80
  }
61
81
 
62
82
  function isLaunchConflict(error: unknown): boolean {
63
- return (
64
- error instanceof Error &&
65
- /already exists with a different launch/i.test(error.message)
66
- );
83
+ return isPlayRunLaunchConflictError(error);
67
84
  }