@oxygen-agent/cli 1.177.1 → 1.209.6

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/README.md +1 -1
  2. package/dist/http-client.js +6 -4
  3. package/dist/index.d.ts +0 -1
  4. package/dist/index.js +1144 -24
  5. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +41 -0
  6. package/node_modules/@oxygen/shared/dist/billing.d.ts +28 -6
  7. package/node_modules/@oxygen/shared/dist/billing.js +41 -0
  8. package/node_modules/@oxygen/shared/dist/budget-scopes.d.ts +4 -0
  9. package/node_modules/@oxygen/shared/dist/budget-scopes.js +9 -0
  10. package/node_modules/@oxygen/shared/dist/cell-format.d.ts +6 -0
  11. package/node_modules/@oxygen/shared/dist/cell-format.js +26 -0
  12. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
  13. package/node_modules/@oxygen/shared/dist/index.js +2 -0
  14. package/node_modules/@oxygen/shared/dist/networks.d.ts +21 -0
  15. package/node_modules/@oxygen/shared/dist/networks.js +25 -0
  16. package/node_modules/@oxygen/shared/dist/search-vocab.d.ts +1 -1
  17. package/node_modules/@oxygen/shared/dist/select-options.d.ts +7 -0
  18. package/node_modules/@oxygen/shared/dist/select-options.js +9 -0
  19. package/node_modules/@oxygen/shared/dist/sequences.d.ts +91 -1
  20. package/node_modules/@oxygen/shared/dist/sequences.js +288 -15
  21. package/node_modules/@oxygen/shared/dist/signup-lead-webhook.d.ts +39 -0
  22. package/node_modules/@oxygen/shared/dist/signup-lead-webhook.js +78 -0
  23. package/node_modules/@oxygen/shared/dist/sql-error.d.ts +12 -0
  24. package/node_modules/@oxygen/shared/dist/sql-error.js +15 -0
  25. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
  26. package/node_modules/@oxygen/shared/dist/version.js +4 -2
  27. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +2 -5
  28. package/node_modules/@oxygen/workflows/dist/index.d.ts +23 -0
  29. package/node_modules/@oxygen/workflows/dist/index.js +199 -24
  30. package/oxygen.js +2 -0
  31. package/package.json +2 -2
@@ -44,7 +44,32 @@ export const SEQUENCE_SIGNALS = [
44
44
  "email_clicked",
45
45
  "email_replied",
46
46
  "email_bounced",
47
+ // External GTM signals — recorded onto an enrollment by an Oxygen monitor,
48
+ // enrichment column, or the published `sequences signal` callable (NOT a
49
+ // provider webhook). branch / wait_for_signal consume these identically to
50
+ // engagement signals, so a funding/hiring/job-change event can pause,
51
+ // re-target, or advance a *running* sequence. The lookup that produced the
52
+ // signal is the metered+approved step; recording the signal is free internal
53
+ // state and never itself sends.
54
+ "company_hiring",
55
+ "company_raised_funds",
56
+ "job_change",
57
+ "new_hire",
58
+ "web_visit",
59
+ "intent",
47
60
  ];
61
+ /** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
62
+ export const SEQUENCE_EXTERNAL_SIGNALS = [
63
+ "company_hiring",
64
+ "company_raised_funds",
65
+ "job_change",
66
+ "new_hire",
67
+ "web_visit",
68
+ "intent",
69
+ ];
70
+ export function isExternalSequenceSignal(value) {
71
+ return SEQUENCE_EXTERNAL_SIGNALS.includes(value);
72
+ }
48
73
  /**
49
74
  * row_values keys an enrollment's email may live under, in send-precedence order.
50
75
  * The dispatcher picks the FIRST present key as the recipient address; the
@@ -52,6 +77,8 @@ export const SEQUENCE_SIGNALS = [
52
77
  * Single source of truth so the send path and the lookup path can't drift.
53
78
  */
54
79
  export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
80
+ /** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
81
+ export const MAX_STEP_VARIANTS = 5;
55
82
  export const SEQUENCE_STEP_KINDS = [
56
83
  // linkedin channel (native dispatch)
57
84
  "visit_profile",
@@ -216,12 +243,20 @@ raw, index, options, issues) {
216
243
  }
217
244
  case "message": {
218
245
  const template = requiredTemplate(raw.template, `${path}.template`, issues);
219
- return { id, channel: "linkedin", kind: "message", template: template ?? "" };
246
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
247
+ return {
248
+ id, channel: "linkedin", kind: "message", template: template ?? "",
249
+ ...(variants ? { variants: variants } : {}),
250
+ };
220
251
  }
221
252
  case "inmail": {
222
253
  const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
223
254
  const template = requiredTemplate(raw.template, `${path}.template`, issues);
224
- return { id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "" };
255
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues);
256
+ return {
257
+ id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "",
258
+ ...(variants ? { variants: variants } : {}),
259
+ };
225
260
  }
226
261
  case "email_enroll": {
227
262
  const subsequenceId = optionalString(raw.subsequence_id, `${path}.subsequence_id`, issues);
@@ -239,11 +274,23 @@ raw, index, options, issues) {
239
274
  case "email_send": {
240
275
  const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
241
276
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
242
- return { id, channel: "email", kind: "email_send", subject_template: subject ?? "", body_template: body ?? "" };
277
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template"], issues);
278
+ const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
279
+ return {
280
+ id, channel: "email", kind: "email_send", subject_template: subject ?? "", body_template: body ?? "",
281
+ ...(variants ? { variants: variants } : {}),
282
+ ...(sendWindow ? { send_window: sendWindow } : {}),
283
+ };
243
284
  }
244
285
  case "email_reply": {
245
286
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
246
- return { id, channel: "email", kind: "email_reply", body_template: body ?? "" };
287
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template"], issues);
288
+ const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
289
+ return {
290
+ id, channel: "email", kind: "email_reply", body_template: body ?? "",
291
+ ...(variants ? { variants: variants } : {}),
292
+ ...(sendWindow ? { send_window: sendWindow } : {}),
293
+ };
247
294
  }
248
295
  case "email_stop":
249
296
  return { id, channel: "email", kind: "email_stop" };
@@ -465,18 +512,12 @@ export function sequenceTemplateVariables(definition) {
465
512
  for (const step of definition.steps) {
466
513
  if (step.kind === "invite")
467
514
  scan(step.note_template);
468
- if (step.kind === "message")
469
- scan(step.template);
470
- if (step.kind === "inmail") {
471
- scan(step.subject_template);
472
- scan(step.template);
515
+ // Scan base + every A/B variant's copy so a column referenced only in a
516
+ // variant still surfaces in the start preview's variable-resolution check.
517
+ for (const content of sequenceStepVariantContents(step)) {
518
+ for (const value of Object.values(content))
519
+ scan(value);
473
520
  }
474
- if (step.kind === "email_send") {
475
- scan(step.subject_template);
476
- scan(step.body_template);
477
- }
478
- if (step.kind === "email_reply")
479
- scan(step.body_template);
480
521
  }
481
522
  return [...vars];
482
523
  }
@@ -496,6 +537,238 @@ export function evaluateSequenceCondition(condition, firedSignals) {
496
537
  return condition.any.some((c) => evaluateSequenceCondition(c, fired));
497
538
  return !evaluateSequenceCondition(condition.not, fired);
498
539
  }
540
+ // ===== A/B variants =====
541
+ const VARIANT_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
542
+ /** Variant label for an index: 0 → "a" (the base step), 1 → "b", … */
543
+ export function sequenceVariantLabel(index) {
544
+ return VARIANT_ALPHABET[index % VARIANT_ALPHABET.length] ?? "a";
545
+ }
546
+ /** Deterministic, replayable FNV-1a hash → uint32. Stable across processes. */
547
+ function hashVariantKey(value) {
548
+ let hash = 0x811c9dc5;
549
+ for (let i = 0; i < value.length; i += 1) {
550
+ hash ^= value.charCodeAt(i);
551
+ hash = Math.imul(hash, 0x01000193);
552
+ }
553
+ return hash >>> 0;
554
+ }
555
+ function baseVariantContent(step) {
556
+ switch (step.kind) {
557
+ case "email_send": return { subject_template: step.subject_template, body_template: step.body_template };
558
+ case "email_reply": return { body_template: step.body_template };
559
+ case "message": return { template: step.template };
560
+ case "inmail": return { subject_template: step.subject_template, template: step.template };
561
+ default: return null;
562
+ }
563
+ }
564
+ /**
565
+ * The ordered list of fully-resolved content variants for a step: index 0 is the
566
+ * base step ("a"); each declared alternate is merged OVER the base (so a variant
567
+ * that sets only `subject_template` keeps the base body). Returns [] for steps
568
+ * with no copy.
569
+ */
570
+ export function sequenceStepVariantContents(step) {
571
+ const base = baseVariantContent(step);
572
+ if (!base)
573
+ return [];
574
+ const variants = step.variants;
575
+ if (!variants || variants.length === 0)
576
+ return [base];
577
+ return [base, ...variants.map((variant) => ({ ...base, ...variant }))];
578
+ }
579
+ /** Number of A/B variants a step carries (1 = no test). */
580
+ export function sequenceStepVariantCount(step) {
581
+ return sequenceStepVariantContents(step).length;
582
+ }
583
+ /**
584
+ * Deterministically assign one variant to an enrollment for a step. The same
585
+ * (key, step) always resolves to the same variant — replayable, evenly
586
+ * distributed, and previewable in a dry run before any send. `key` is the
587
+ * enrollment id.
588
+ */
589
+ export function selectStepVariant(step, key) {
590
+ const contents = sequenceStepVariantContents(step);
591
+ if (contents.length === 0)
592
+ return { variantId: "a", index: 0, content: {} };
593
+ const index = contents.length > 1 ? hashVariantKey(`${key}:${step.id}`) % contents.length : 0;
594
+ const content = contents[index] ?? contents[0] ?? {};
595
+ return { variantId: sequenceVariantLabel(index), index, content };
596
+ }
597
+ // ===== Send windows =====
598
+ function hhmmToMinutes(value) {
599
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
600
+ if (!match)
601
+ return null;
602
+ const hours = Number(match[1]);
603
+ const minutes = Number(match[2]);
604
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59)
605
+ return null;
606
+ return hours * 60 + minutes;
607
+ }
608
+ const ISO_WEEKDAY = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
609
+ /**
610
+ * Resolve which IANA timezone a send window evaluates in for a given lead row.
611
+ * "recipient" mode reads the lead's tz from `recipient_timezone_column`, falling
612
+ * back to the window's fixed `timezone` when the column is absent/blank.
613
+ */
614
+ export function resolveSendWindowTimezone(window, rowValues) {
615
+ if (window.timezone_mode === "recipient" && window.recipient_timezone_column) {
616
+ const raw = rowValues?.[window.recipient_timezone_column];
617
+ if (typeof raw === "string" && raw.trim())
618
+ return raw.trim();
619
+ }
620
+ return window.timezone;
621
+ }
622
+ /**
623
+ * Is `now` inside the send window, evaluated in `timezone` (already resolved for
624
+ * recipient mode via resolveSendWindowTimezone)? Pure; Intl only
625
+ * (client-bundle-safe). An unresolvable timezone or malformed window fails OPEN
626
+ * (returns true) so a bad tz never permanently strands an enrollment — the hard
627
+ * send caps still bound it. A window with no `days` allows every weekday;
628
+ * start>end wraps past midnight.
629
+ */
630
+ export function isWithinSendWindow(window, now, timezone) {
631
+ const tz = timezone?.trim() ? timezone.trim() : window.timezone;
632
+ const parts = tzParts(now, tz) ?? tzParts(now, window.timezone) ?? tzParts(now, "UTC");
633
+ if (!parts)
634
+ return true; // unresolvable tz → fail open (caps still apply)
635
+ const startM = hhmmToMinutes(window.start);
636
+ const endM = hhmmToMinutes(window.end);
637
+ if (startM === null || endM === null)
638
+ return true; // malformed window → fail open
639
+ if (window.days && window.days.length > 0 && !window.days.includes(parts.isoWeekday))
640
+ return false;
641
+ if (startM === endM)
642
+ return false; // empty window
643
+ const minutes = parts.hour * 60 + parts.minute;
644
+ return startM < endM ? minutes >= startM && minutes < endM : minutes >= startM || minutes < endM;
645
+ }
646
+ function tzParts(now, tz) {
647
+ try {
648
+ const formatted = new Intl.DateTimeFormat("en-US", {
649
+ timeZone: tz, hour12: false, weekday: "short", hour: "2-digit", minute: "2-digit",
650
+ }).formatToParts(now);
651
+ let hour = 0;
652
+ let minute = 0;
653
+ let weekday = "Mon";
654
+ for (const part of formatted) {
655
+ if (part.type === "hour")
656
+ hour = Number(part.value) % 24;
657
+ else if (part.type === "minute")
658
+ minute = Number(part.value);
659
+ else if (part.type === "weekday")
660
+ weekday = part.value;
661
+ }
662
+ return { hour, minute, isoWeekday: ISO_WEEKDAY[weekday] ?? 1 };
663
+ }
664
+ catch {
665
+ return null;
666
+ }
667
+ }
668
+ // ===== Validation helpers for variants + send windows =====
669
+ function normalizeVariants(raw, path, fields, issues) {
670
+ if (raw === undefined || raw === null)
671
+ return undefined;
672
+ if (!Array.isArray(raw)) {
673
+ issues.push({ path, message: "variants must be an array." });
674
+ return undefined;
675
+ }
676
+ if (raw.length === 0)
677
+ return undefined;
678
+ if (raw.length > MAX_STEP_VARIANTS - 1) {
679
+ issues.push({ path, message: `at most ${MAX_STEP_VARIANTS - 1} alternate variants (the base step is variant "a").` });
680
+ }
681
+ const out = [];
682
+ raw.slice(0, MAX_STEP_VARIANTS - 1).forEach((entry, i) => {
683
+ if (!isRecord(entry)) {
684
+ issues.push({ path: `${path}[${i}]`, message: "each variant must be an object." });
685
+ return;
686
+ }
687
+ const variant = {};
688
+ for (const field of fields) {
689
+ const value = entry[field];
690
+ if (value === undefined || value === null)
691
+ continue;
692
+ const template = requiredTemplate(value, `${path}[${i}].${field}`, issues);
693
+ if (template !== undefined)
694
+ variant[field] = template;
695
+ }
696
+ if (Object.keys(variant).length === 0) {
697
+ issues.push({ path: `${path}[${i}]`, message: `a variant must override at least one of: ${fields.join(", ")}.` });
698
+ }
699
+ else {
700
+ out.push(variant);
701
+ }
702
+ });
703
+ return out.length > 0 ? out : undefined;
704
+ }
705
+ function normalizeSendWindow(raw, path, issues) {
706
+ if (raw === undefined || raw === null)
707
+ return undefined;
708
+ if (!isRecord(raw)) {
709
+ issues.push({ path, message: "send_window must be an object." });
710
+ return undefined;
711
+ }
712
+ const timezone = typeof raw.timezone === "string" && raw.timezone.trim() ? raw.timezone.trim() : undefined;
713
+ if (!timezone)
714
+ issues.push({ path: `${path}.timezone`, message: "timezone (IANA, e.g. America/New_York) is required." });
715
+ const start = normalizeHhmm(raw.start, `${path}.start`, issues);
716
+ const end = normalizeHhmm(raw.end, `${path}.end`, issues);
717
+ // A zero-width window (start === end) is never open — isWithinSendWindow
718
+ // returns false for every instant — so a step carrying it defers
719
+ // (outside_send_window) on every dispatch and the enrollment silently stalls
720
+ // forever (nothing sends, so the send cap never advances it). Reject it at
721
+ // write time instead of letting the misconfiguration strand the sequence.
722
+ if (start !== undefined && end !== undefined && hhmmToMinutes(start) === hhmmToMinutes(end)) {
723
+ issues.push({ path: `${path}.end`, message: "must differ from start — a zero-width send_window never sends." });
724
+ }
725
+ const days = normalizeWeekdays(raw.days, `${path}.days`, issues);
726
+ const mode = raw.timezone_mode === "recipient" ? "recipient" : "fixed";
727
+ const column = mode === "recipient"
728
+ ? optionalString(raw.recipient_timezone_column, `${path}.recipient_timezone_column`, issues)
729
+ : undefined;
730
+ if (mode === "recipient" && !column) {
731
+ issues.push({ path: `${path}.recipient_timezone_column`, message: 'recipient_timezone_column is required when timezone_mode is "recipient".' });
732
+ }
733
+ return {
734
+ timezone: timezone ?? "UTC",
735
+ ...(days ? { days } : {}),
736
+ start: start ?? "09:00",
737
+ end: end ?? "17:00",
738
+ timezone_mode: mode,
739
+ ...(column ? { recipient_timezone_column: column } : {}),
740
+ };
741
+ }
742
+ function normalizeHhmm(value, path, issues) {
743
+ if (value === undefined || value === null) {
744
+ issues.push({ path, message: 'is required as "HH:MM".' });
745
+ return undefined;
746
+ }
747
+ if (typeof value !== "string" || hhmmToMinutes(value) === null) {
748
+ issues.push({ path, message: 'must be "HH:MM" (00:00–23:59).' });
749
+ return undefined;
750
+ }
751
+ return value.trim();
752
+ }
753
+ function normalizeWeekdays(value, path, issues) {
754
+ if (value === undefined || value === null)
755
+ return undefined;
756
+ if (!Array.isArray(value)) {
757
+ issues.push({ path, message: "days must be an array of ISO weekdays (1=Mon … 7=Sun)." });
758
+ return undefined;
759
+ }
760
+ const out = [];
761
+ for (const entry of value) {
762
+ const num = Number(entry);
763
+ if (!Number.isInteger(num) || num < 1 || num > 7) {
764
+ issues.push({ path, message: "each day must be an integer 1–7 (1=Mon … 7=Sun)." });
765
+ continue;
766
+ }
767
+ if (!out.includes(num))
768
+ out.push(num);
769
+ }
770
+ return out.length > 0 ? out.sort((a, b) => a - b) : undefined;
771
+ }
499
772
  function isRecord(value) {
500
773
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
501
774
  }
@@ -0,0 +1,39 @@
1
+ /** Request timeout shared by the direct emit and the durable worker retry. */
2
+ export declare const SIGNUP_LEAD_WEBHOOK_TIMEOUT_MS = 10000;
3
+ /**
4
+ * Classification of a signup-lead webhook target URL. Drives which internal
5
+ * Oxygen secret header (if any) is attached, and doubles as a log field on the
6
+ * emit path.
7
+ */
8
+ export type SignupLeadWebhookTarget = "missing" | "external" | "oxygen_table_webhook" | "oxygen_workflow_webhook" | "oxygen_internal" | "invalid_url";
9
+ /** True for the canonical Oxygen apex domain and its subdomains only. */
10
+ export declare function isOxygenHostname(hostname: string): boolean;
11
+ /**
12
+ * Classify a webhook target URL so the caller can decide whether to attach the
13
+ * internal table/workflow secret. Only genuine oxygen-agent.com hosts receive
14
+ * internal secrets; external hosts with Oxygen-looking paths are "external" and
15
+ * must never be trusted with the internal webhook secret.
16
+ */
17
+ export declare function classifySignupLeadWebhookTarget(value?: string | null): SignupLeadWebhookTarget;
18
+ /** HMAC-SHA256 hex digest of the request body keyed by the webhook secret. */
19
+ export declare function signSignupLeadWebhookBody(secret: string, body: string): string;
20
+ export type BuildSignupLeadWebhookHeadersInput = {
21
+ /** The destination URL — classified to decide the internal secret header. */
22
+ webhookUrl: string;
23
+ /** The flattened signup-lead payload (already serialized into `body`). */
24
+ payload: Record<string, unknown>;
25
+ /** The exact serialized request body the signature is computed over. */
26
+ body: string;
27
+ /** The shared webhook secret, or null/undefined to send the request unsigned. */
28
+ secret: string | null | undefined;
29
+ /** Injectable clock for the signature timestamp (defaults to the current time). */
30
+ now?: Date;
31
+ };
32
+ /**
33
+ * Build the complete header set for a signup-lead webhook request. Produces
34
+ * identical output for the request-time emit and the durable worker retry so
35
+ * the two transports stay in lockstep. Header values fall back to safe defaults
36
+ * for the worker's untyped (DB-sourced) payload; for the web emitter's typed
37
+ * payload the fallbacks are no-ops.
38
+ */
39
+ export declare function buildSignupLeadWebhookHeaders(input: BuildSignupLeadWebhookHeadersInput): Record<string, string>;
@@ -0,0 +1,78 @@
1
+ import { createHmac } from "node:crypto";
2
+ // Shared transport contract for the signup-lead webhook. Both the request-time
3
+ // direct emitter (apps/web/src/lib/signup-lead-webhook.ts) and the durable
4
+ // worker retry queue (apps/worker/src/signup-lead-delivery-queue.ts) build the
5
+ // same HMAC signature, Oxygen table/workflow secret headers, timeout, and
6
+ // target classification. This module is the single source of truth so the two
7
+ // paths cannot drift when the webhook auth/header contract changes (OXY-679).
8
+ /** Request timeout shared by the direct emit and the durable worker retry. */
9
+ export const SIGNUP_LEAD_WEBHOOK_TIMEOUT_MS = 10_000;
10
+ /** True for the canonical Oxygen apex domain and its subdomains only. */
11
+ export function isOxygenHostname(hostname) {
12
+ return (hostname === "oxygen-agent.com" || hostname.endsWith(".oxygen-agent.com"));
13
+ }
14
+ /**
15
+ * Classify a webhook target URL so the caller can decide whether to attach the
16
+ * internal table/workflow secret. Only genuine oxygen-agent.com hosts receive
17
+ * internal secrets; external hosts with Oxygen-looking paths are "external" and
18
+ * must never be trusted with the internal webhook secret.
19
+ */
20
+ export function classifySignupLeadWebhookTarget(value) {
21
+ const target = typeof value === "string" ? value.trim() : "";
22
+ if (!target)
23
+ return "missing";
24
+ try {
25
+ const url = new URL(target);
26
+ if (!isOxygenHostname(url.hostname))
27
+ return "external";
28
+ if (url.pathname.startsWith("/api/webhooks/tables/")) {
29
+ return "oxygen_table_webhook";
30
+ }
31
+ if (url.pathname.startsWith("/api/webhooks/workflows/")) {
32
+ return "oxygen_workflow_webhook";
33
+ }
34
+ return "oxygen_internal";
35
+ }
36
+ catch {
37
+ return "invalid_url";
38
+ }
39
+ }
40
+ /** HMAC-SHA256 hex digest of the request body keyed by the webhook secret. */
41
+ export function signSignupLeadWebhookBody(secret, body) {
42
+ return createHmac("sha256", secret).update(body).digest("hex");
43
+ }
44
+ /**
45
+ * Build the complete header set for a signup-lead webhook request. Produces
46
+ * identical output for the request-time emit and the durable worker retry so
47
+ * the two transports stay in lockstep. Header values fall back to safe defaults
48
+ * for the worker's untyped (DB-sourced) payload; for the web emitter's typed
49
+ * payload the fallbacks are no-ops.
50
+ */
51
+ export function buildSignupLeadWebhookHeaders(input) {
52
+ const { webhookUrl, payload, body, secret } = input;
53
+ const now = input.now ?? new Date();
54
+ const headers = {
55
+ "content-type": "application/json",
56
+ "x-event-id": headerString(payload.event_id) ?? "",
57
+ "x-event-type": headerString(payload.type) ?? "signup_lead.event",
58
+ "x-event-time": headerString(payload.occurred_at) ?? now.toISOString(),
59
+ "x-oxygen-signup-source": headerString(payload.source) ?? "clerk",
60
+ };
61
+ if (secret) {
62
+ headers["x-oxygen-webhook-signature"] =
63
+ `sha256=${signSignupLeadWebhookBody(secret, body)}`;
64
+ headers["x-oxygen-webhook-signature-algorithm"] = "hmac-sha256";
65
+ headers["x-oxygen-webhook-signature-timestamp"] = String(Math.floor(now.getTime() / 1000));
66
+ const target = classifySignupLeadWebhookTarget(webhookUrl);
67
+ if (target === "oxygen_table_webhook") {
68
+ headers["x-oxygen-table-webhook-secret"] = secret;
69
+ }
70
+ else if (target === "oxygen_workflow_webhook") {
71
+ headers["x-oxygen-workflow-secret"] = secret;
72
+ }
73
+ }
74
+ return headers;
75
+ }
76
+ function headerString(value) {
77
+ return typeof value === "string" && value.trim() ? value.trim() : null;
78
+ }
@@ -33,5 +33,17 @@ export declare function sqlErrorFields(error: unknown): Record<string, unknown>;
33
33
  * the generic error serializers would otherwise emit those values verbatim.
34
34
  */
35
35
  export declare function redactSqlParameters(text: string): string;
36
+ /**
37
+ * True when the error is a Postgres serialization failure (40001) or deadlock
38
+ * (40P01) — the two "the transaction was aborted as a concurrency victim, retry
39
+ * the whole transaction" outcomes. Postgres aborts exactly one transaction in
40
+ * such a conflict and lets the other commit, so re-running the victim's
41
+ * transaction almost always succeeds once the winner has committed. Callers use
42
+ * this both to drive a bounded transaction retry and to surface a typed,
43
+ * retryable error (instead of an opaque unexpected_error) when retries are
44
+ * exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
45
+ * 40001/40P01 knowledge lives in one place. Never throws.
46
+ */
47
+ export declare function isRetryableConcurrencyError(error?: unknown): boolean;
36
48
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
37
49
  export declare function sqlErrorTelemetryAttributes(error: unknown): Record<string, unknown>;
@@ -146,6 +146,21 @@ export function redactSqlParameters(text) {
146
146
  return text;
147
147
  return text.replace(/\n[ \t]*params[ \t]*:[\s\S]*?(?=\n[ \t]*at\s|$)/i, "");
148
148
  }
149
+ /**
150
+ * True when the error is a Postgres serialization failure (40001) or deadlock
151
+ * (40P01) — the two "the transaction was aborted as a concurrency victim, retry
152
+ * the whole transaction" outcomes. Postgres aborts exactly one transaction in
153
+ * such a conflict and lets the other commit, so re-running the victim's
154
+ * transaction almost always succeeds once the winner has committed. Callers use
155
+ * this both to drive a bounded transaction retry and to surface a typed,
156
+ * retryable error (instead of an opaque unexpected_error) when retries are
157
+ * exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
158
+ * 40001/40P01 knowledge lives in one place. Never throws.
159
+ */
160
+ export function isRetryableConcurrencyError(error) {
161
+ const cause = describeSqlError(error)?.cause;
162
+ return cause === "serialization" || cause === "deadlock";
163
+ }
149
164
  /** Telemetry-attribute shape (dotted keys) for span error attribution. */
150
165
  export function sqlErrorTelemetryAttributes(error) {
151
166
  const attribution = describeSqlError(error);
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.177.1";
2
- export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
1
+ export declare const OXYGEN_VERSION = "1.209.6";
2
+ export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,6 +1,8 @@
1
- export const OXYGEN_VERSION = "1.177.1";
1
+ export const OXYGEN_VERSION = "1.209.6";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
+ // 1.181.0: paid table action runs and background columns run require
4
+ // approved=true in addition to max_credits; older CLIs cannot send the flag.
3
5
  // 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
4
6
  // (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
5
7
  // removed the old /api/cli/linkedin/* routes — older CLIs would 404.
6
- export const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
8
+ export const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -5,10 +5,7 @@ export const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS = [
5
5
  "auto_paused_at",
6
6
  "auto_pause_reason",
7
7
  ];
8
+ const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEY_SET = new Set(WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS);
8
9
  export function clearWorkflowTriggerAutoPauseMetadata(metadata) {
9
- const next = { ...metadata };
10
- for (const key of WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS) {
11
- delete next[key];
12
- }
13
- return next;
10
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => !WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEY_SET.has(key)));
14
11
  }
@@ -67,6 +67,7 @@ export type WorkflowToolStepManifest = {
67
67
  effect: WorkflowStepEffect;
68
68
  mode?: WorkflowMode;
69
69
  payload_source: string;
70
+ max_credits?: number;
70
71
  };
71
72
  export type WorkflowBranchStepManifest = {
72
73
  kind: "branch";
@@ -121,6 +122,7 @@ export type WorkflowManifest = {
121
122
  trigger?: WorkflowTriggerManifest;
122
123
  input_schema?: JsonSchema;
123
124
  steps: WorkflowStepManifest[];
125
+ max_credits?: number;
124
126
  source_hash: string;
125
127
  compiler_version: string;
126
128
  created_at: string;
@@ -139,6 +141,7 @@ export type RecipeManifest = {
139
141
  bundle_format: "esm";
140
142
  tools_used: string[];
141
143
  visual_plan?: RecipeVisualPlanManifest;
144
+ max_credits?: number;
142
145
  source_hash: string;
143
146
  compiler_version: typeof DURABLE_RECIPE_COMPILER_VERSION;
144
147
  created_at: string;
@@ -219,6 +222,7 @@ export type WorkflowDefinition = {
219
222
  specification?: string;
220
223
  trigger?: WorkflowTriggerDefinition;
221
224
  inputSchema?: JsonSchema;
225
+ maxCredits?: number;
222
226
  steps: WorkflowStepDefinition[];
223
227
  };
224
228
  export type WorkflowTriggerDefinition = WorkflowTriggerManifest;
@@ -237,6 +241,7 @@ export type WorkflowToolStepDefinition = {
237
241
  tool: string;
238
242
  effect?: WorkflowStepEffect;
239
243
  mode?: WorkflowMode;
244
+ maxCredits?: number;
240
245
  payload: WorkflowFunction;
241
246
  };
242
247
  export type WorkflowBranchStepDefinition = {
@@ -270,6 +275,7 @@ export declare function defineWorkflow(input: {
270
275
  specification?: string;
271
276
  trigger?: WorkflowTriggerDefinition;
272
277
  inputSchema?: JsonSchema;
278
+ maxCredits?: number;
273
279
  steps: WorkflowStepDefinition[];
274
280
  }): WorkflowDefinition;
275
281
  export declare function apiTrigger(input?: {
@@ -310,8 +316,22 @@ export declare function toolStep(input: {
310
316
  tool: string;
311
317
  effect?: WorkflowStepEffect;
312
318
  mode?: WorkflowMode;
319
+ maxCredits?: number;
313
320
  payload: WorkflowFunction;
314
321
  }): WorkflowToolStepDefinition;
322
+ /**
323
+ * Conditional step. `condition` is evaluated; control then flows by mode:
324
+ *
325
+ * - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
326
+ * between the branch and `then` are skipped); falsy skips straight to the end
327
+ * of the workflow (no step after the branch runs) and the run completes.
328
+ * - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
329
+ * chosen target, the remaining steps run sequentially through the end.
330
+ * - then + else + join — real if/else: the non-chosen arm is skipped and `join`
331
+ * is the convergence point where shared downstream work resumes.
332
+ *
333
+ * All targets must be ids of later steps (branches are forward-only).
334
+ */
315
335
  export declare function branchStep(input: {
316
336
  id: string;
317
337
  description?: string;
@@ -339,6 +359,7 @@ export declare function buildRecipeManifest(input: {
339
359
  bundle: string;
340
360
  toolsUsed: string[];
341
361
  visualPlan?: RecipeVisualPlanManifest;
362
+ maxCredits?: number;
342
363
  sourceHash?: string;
343
364
  createdAt?: Date;
344
365
  }): RecipeManifest;
@@ -804,8 +825,10 @@ export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" |
804
825
  };
805
826
  };
806
827
  };
828
+ export declare function computeWorkflowPlanHash(input: Record<string, unknown>): string;
807
829
  export declare const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
808
830
  export declare function readWorkflowRunMaxCredits(metadata: Record<string, unknown> | null | undefined): number | null;
831
+ export declare function readWorkflowRunPreApprovedLabels(metadata: Record<string, unknown> | null | undefined): string[];
809
832
  export declare function readManagedToolRunCredits(output: unknown): number;
810
833
  export type WorkflowSpendCapDecision = {
811
834
  allowed: boolean;