@oxygen-agent/cli 1.177.0 → 1.184.3
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.
- package/README.md +1 -1
- package/dist/index.js +531 -7
- package/node_modules/@oxygen/shared/dist/budget-scopes.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/budget-scopes.js +9 -0
- package/node_modules/@oxygen/shared/dist/cell-format.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cell-format.js +26 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +9 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +91 -1
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -15
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.js +78 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +15 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +4 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +22 -0
- package/node_modules/@oxygen/workflows/dist/index.js +165 -24
- package/package.json +1 -1
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const PUBLIC_BUDGET_SCOPES: readonly ["org", "table", "workflow_trigger", "monitor"];
|
|
2
|
+
export type PublicBudgetScope = (typeof PUBLIC_BUDGET_SCOPES)[number];
|
|
3
|
+
export declare function formatPublicBudgetScopes(): string;
|
|
4
|
+
export declare const PUBLIC_BUDGET_SCOPE_DESCRIPTION = "Cap scope: org (whole workspace), table (a single table), workflow_trigger (one workflow trigger), or monitor (one scheduled workflow).";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const PUBLIC_BUDGET_SCOPES = ["org", "table", "workflow_trigger", "monitor"];
|
|
2
|
+
export function formatPublicBudgetScopes() {
|
|
3
|
+
const scopes = [...PUBLIC_BUDGET_SCOPES];
|
|
4
|
+
const last = scopes.pop();
|
|
5
|
+
if (!last)
|
|
6
|
+
return "";
|
|
7
|
+
return scopes.length === 0 ? last : `${scopes.join(", ")}, or ${last}`;
|
|
8
|
+
}
|
|
9
|
+
export const PUBLIC_BUDGET_SCOPE_DESCRIPTION = "Cap scope: org (whole workspace), table (a single table), workflow_trigger (one workflow trigger), or monitor (one scheduled workflow).";
|
|
@@ -39,6 +39,12 @@ export type CellFormatOptions = {
|
|
|
39
39
|
* escaping, ellipsis budgets) belong outside this function.
|
|
40
40
|
*/
|
|
41
41
|
export declare function formatCellForDisplay(value: unknown, column: CellColumnLike | null | undefined, options: CellFormatOptions): string;
|
|
42
|
+
/**
|
|
43
|
+
* Flatten markdown into a single line of readable plain text — for grid cells,
|
|
44
|
+
* CLI tables, and MCP previews where the rendered markdown would be noise.
|
|
45
|
+
* Drops syntax markers; keeps the words.
|
|
46
|
+
*/
|
|
47
|
+
export declare function markdownToPlainText(markdown: string): string;
|
|
42
48
|
/**
|
|
43
49
|
* Decide whether the same value-level + column-level guards used inside
|
|
44
50
|
* `formatCellForDisplay`'s text path should rescue this string. Exposed so
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* version strings, phone numbers, URLs): {@link rescueNumericText} returns
|
|
15
15
|
* null for those.
|
|
16
16
|
*/
|
|
17
|
+
import { isMarkdownColumnSemantic } from "./select-options.js";
|
|
17
18
|
const DEFAULT_LOCALE = "en-US";
|
|
18
19
|
const INTEGER_RE = /^-?\d{1,15}$/;
|
|
19
20
|
const DECIMAL_RE = /^-?\d{1,15}(?:\.\d{1,9})?$/;
|
|
@@ -65,6 +66,11 @@ export function formatCellForDisplay(value, column, options) {
|
|
|
65
66
|
return formatTimestamp(value, options.surface);
|
|
66
67
|
if (dataType === "numeric")
|
|
67
68
|
return formatNumeric(value, locale, options.surface);
|
|
69
|
+
// Markdown cells collapse to a single-line plain-text preview for the grid,
|
|
70
|
+
// CLI, and MCP. The web record view renders the full markdown separately.
|
|
71
|
+
if (isMarkdownColumnSemantic(column?.semanticType ?? column?.semantic_type)) {
|
|
72
|
+
return markdownToPlainText(typeof value === "string" ? value : String(value));
|
|
73
|
+
}
|
|
68
74
|
if (isEnrichmentPayload(value) && columnHasWrapperKind(column)) {
|
|
69
75
|
const wrapped = readEnrichmentValue(value);
|
|
70
76
|
if (wrapped !== undefined) {
|
|
@@ -94,6 +100,26 @@ export function formatCellForDisplay(value, column, options) {
|
|
|
94
100
|
}
|
|
95
101
|
return String(value);
|
|
96
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Flatten markdown into a single line of readable plain text — for grid cells,
|
|
105
|
+
* CLI tables, and MCP previews where the rendered markdown would be noise.
|
|
106
|
+
* Drops syntax markers; keeps the words.
|
|
107
|
+
*/
|
|
108
|
+
export function markdownToPlainText(markdown) {
|
|
109
|
+
return markdown
|
|
110
|
+
.replace(/```[\s\S]*?```/g, " ") // fenced code blocks
|
|
111
|
+
.replace(/`([^`]+)`/g, "$1") // inline code
|
|
112
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
|
|
113
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → label
|
|
114
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings
|
|
115
|
+
.replace(/^\s*>\s?/gm, "") // blockquotes
|
|
116
|
+
.replace(/^\s*[-*+]\s+/gm, "") // bullet markers
|
|
117
|
+
.replace(/^\s*\d+\.\s+/gm, "") // ordered markers
|
|
118
|
+
.replace(/^\s*[-*_]{3,}\s*$/gm, " ") // horizontal rules
|
|
119
|
+
.replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1") // bold / italic / strike
|
|
120
|
+
.replace(/\s+/g, " ")
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
97
123
|
/**
|
|
98
124
|
* Best-effort parse of a string that looks numeric (with or without thousands
|
|
99
125
|
* grouping, with or without dotted IP-style separators) into a finite number.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
2
2
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
3
3
|
export * from "./billing.js";
|
|
4
|
+
export * from "./budget-scopes.js";
|
|
4
5
|
export * from "./cell-format.js";
|
|
5
6
|
export * from "./cli-envelope.js";
|
|
6
7
|
export * from "./cli-result.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
2
2
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
3
3
|
export * from "./billing.js";
|
|
4
|
+
export * from "./budget-scopes.js";
|
|
4
5
|
export * from "./cell-format.js";
|
|
5
6
|
export * from "./cli-envelope.js";
|
|
6
7
|
export * from "./cli-result.js";
|
|
@@ -8,6 +8,13 @@ export type SelectColumnSemantic = (typeof SELECT_COLUMN_SEMANTICS)[number];
|
|
|
8
8
|
export declare function isSelectColumnSemantic(semanticType: string | null | undefined): boolean;
|
|
9
9
|
export declare function isMultiSelectSemantic(semanticType: string | null | undefined): boolean;
|
|
10
10
|
export declare function isStatusSemantic(semanticType: string | null | undefined): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* `markdown` is a semantic over the `text` dataType (storage stays text): the
|
|
13
|
+
* cell holds a long markdown body that renders as a "page" in the record view
|
|
14
|
+
* and as a compact plain-text preview in the grid. Notes, summaries, and call
|
|
15
|
+
* transcripts all use this.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isMarkdownColumnSemantic(semanticType: string | null | undefined): boolean;
|
|
11
18
|
export type SelectOption = {
|
|
12
19
|
id: string;
|
|
13
20
|
value: string;
|
|
@@ -46,6 +46,15 @@ export function isMultiSelectSemantic(semanticType) {
|
|
|
46
46
|
export function isStatusSemantic(semanticType) {
|
|
47
47
|
return semanticType === "status";
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* `markdown` is a semantic over the `text` dataType (storage stays text): the
|
|
51
|
+
* cell holds a long markdown body that renders as a "page" in the record view
|
|
52
|
+
* and as a compact plain-text preview in the grid. Notes, summaries, and call
|
|
53
|
+
* transcripts all use this.
|
|
54
|
+
*/
|
|
55
|
+
export function isMarkdownColumnSemantic(semanticType) {
|
|
56
|
+
return semanticType === "markdown";
|
|
57
|
+
}
|
|
49
58
|
const MAX_OPTIONS = 200;
|
|
50
59
|
// Normalize a raw options array (from a column `definition.options`, CLI/MCP, or
|
|
51
60
|
// the editor) into canonical SelectOptions: dedupe by value, default colors,
|
|
@@ -35,8 +35,12 @@ export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
|
|
|
35
35
|
* a signal — it's resolved on demand by the dispatcher via a connection branch,
|
|
36
36
|
* `condition: "already_connected"`.)
|
|
37
37
|
*/
|
|
38
|
-
export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced"];
|
|
38
|
+
export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
|
|
39
39
|
export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
|
|
40
|
+
/** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
|
|
41
|
+
export declare const SEQUENCE_EXTERNAL_SIGNALS: readonly ["company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
|
|
42
|
+
export type SequenceExternalSignal = (typeof SEQUENCE_EXTERNAL_SIGNALS)[number];
|
|
43
|
+
export declare function isExternalSequenceSignal(value: string): value is SequenceExternalSignal;
|
|
40
44
|
/**
|
|
41
45
|
* row_values keys an enrollment's email may live under, in send-precedence order.
|
|
42
46
|
* The dispatcher picks the FIRST present key as the recipient address; the
|
|
@@ -44,6 +48,31 @@ export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
|
|
|
44
48
|
* Single source of truth so the send path and the lookup path can't drift.
|
|
45
49
|
*/
|
|
46
50
|
export declare const SEQUENCE_EMAIL_COLUMN_KEYS: readonly ["email", "email_address", "work_email", "primary_email", "Email"];
|
|
51
|
+
/**
|
|
52
|
+
* A per-step / per-sequence send window. `days` are ISO weekdays (1=Mon … 7=Sun)
|
|
53
|
+
* on which sends are allowed; `start`/`end` are "HH:MM" local times. The window
|
|
54
|
+
* is evaluated in `timezone` by default; when `timezone_mode === "recipient"`
|
|
55
|
+
* the dispatcher reads the lead's IANA timezone from
|
|
56
|
+
* `recipient_timezone_column` in the enrolled row (falling back to `timezone`
|
|
57
|
+
* when the column is missing/invalid), so a 9-5 window lands in each
|
|
58
|
+
* recipient's local morning rather than the operator's. Unlike the LinkedIn
|
|
59
|
+
* per-account working hours, this gate also governs the native + Instantly email
|
|
60
|
+
* track (which otherwise has no send window at all).
|
|
61
|
+
*/
|
|
62
|
+
export type SequenceSendWindow = {
|
|
63
|
+
timezone: string;
|
|
64
|
+
/** Allowed ISO weekdays, 1=Mon … 7=Sun. Empty/absent → all days. */
|
|
65
|
+
days?: number[];
|
|
66
|
+
/** "HH:MM" inclusive start of the daily window. */
|
|
67
|
+
start: string;
|
|
68
|
+
/** "HH:MM" exclusive end of the daily window. */
|
|
69
|
+
end: string;
|
|
70
|
+
timezone_mode?: "fixed" | "recipient";
|
|
71
|
+
/** row_values key holding the lead's IANA timezone (timezone_mode="recipient"). */
|
|
72
|
+
recipient_timezone_column?: string;
|
|
73
|
+
};
|
|
74
|
+
/** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
|
|
75
|
+
export declare const MAX_STEP_VARIANTS = 5;
|
|
47
76
|
export declare const SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "message", "inmail", "email_send", "email_reply", "email_enroll", "email_move", "email_stop", "wait", "wait_for_signal", "branch", "stop"];
|
|
48
77
|
export type SequenceStepKind = (typeof SEQUENCE_STEP_KINDS)[number];
|
|
49
78
|
export type SequenceLinkedInVisitProfileStep = {
|
|
@@ -70,6 +99,10 @@ export type SequenceLinkedInMessageStep = {
|
|
|
70
99
|
channel: "linkedin";
|
|
71
100
|
kind: "message";
|
|
72
101
|
template: string;
|
|
102
|
+
/** A/B alternates. Each overrides the base `template`; the base is variant "a". */
|
|
103
|
+
variants?: {
|
|
104
|
+
template?: string;
|
|
105
|
+
}[];
|
|
73
106
|
};
|
|
74
107
|
export type SequenceLinkedInInMailStep = {
|
|
75
108
|
id: string;
|
|
@@ -77,6 +110,11 @@ export type SequenceLinkedInInMailStep = {
|
|
|
77
110
|
kind: "inmail";
|
|
78
111
|
subject_template: string;
|
|
79
112
|
template: string;
|
|
113
|
+
/** A/B alternates over subject/body; the base is variant "a". */
|
|
114
|
+
variants?: {
|
|
115
|
+
subject_template?: string;
|
|
116
|
+
template?: string;
|
|
117
|
+
}[];
|
|
80
118
|
};
|
|
81
119
|
export type SequenceEmailEnrollStep = {
|
|
82
120
|
id: string;
|
|
@@ -109,6 +147,13 @@ export type SequenceEmailSendStep = {
|
|
|
109
147
|
subject_template: string;
|
|
110
148
|
/** Body. Supports {{column}} interpolation. */
|
|
111
149
|
body_template: string;
|
|
150
|
+
/** A/B alternates over subject/body; the base is variant "a". */
|
|
151
|
+
variants?: {
|
|
152
|
+
subject_template?: string;
|
|
153
|
+
body_template?: string;
|
|
154
|
+
}[];
|
|
155
|
+
/** Per-step send window (overrides the sequence-level email_send_window). */
|
|
156
|
+
send_window?: SequenceSendWindow;
|
|
112
157
|
};
|
|
113
158
|
/** Threaded follow-up in the same email thread as the lead's prior email_send. */
|
|
114
159
|
export type SequenceEmailReplyStep = {
|
|
@@ -117,6 +162,12 @@ export type SequenceEmailReplyStep = {
|
|
|
117
162
|
kind: "email_reply";
|
|
118
163
|
/** Reply body. Supports {{column}} interpolation. */
|
|
119
164
|
body_template: string;
|
|
165
|
+
/** A/B alternates over the reply body; the base is variant "a". */
|
|
166
|
+
variants?: {
|
|
167
|
+
body_template?: string;
|
|
168
|
+
}[];
|
|
169
|
+
/** Per-step send window (overrides the sequence-level email_send_window). */
|
|
170
|
+
send_window?: SequenceSendWindow;
|
|
120
171
|
};
|
|
121
172
|
export type SequenceWaitStep = {
|
|
122
173
|
id: string;
|
|
@@ -236,3 +287,42 @@ export declare function sequenceTemplateVariables(definition: SequenceDefinition
|
|
|
236
287
|
* Used by the dispatch planner for branch steps. Pure.
|
|
237
288
|
*/
|
|
238
289
|
export declare function evaluateSequenceCondition(condition: SequenceSignalCondition, firedSignals: Iterable<string>): boolean;
|
|
290
|
+
/** Variant label for an index: 0 → "a" (the base step), 1 → "b", … */
|
|
291
|
+
export declare function sequenceVariantLabel(index: number): string;
|
|
292
|
+
type StepVariantContent = Record<string, string>;
|
|
293
|
+
/**
|
|
294
|
+
* The ordered list of fully-resolved content variants for a step: index 0 is the
|
|
295
|
+
* base step ("a"); each declared alternate is merged OVER the base (so a variant
|
|
296
|
+
* that sets only `subject_template` keeps the base body). Returns [] for steps
|
|
297
|
+
* with no copy.
|
|
298
|
+
*/
|
|
299
|
+
export declare function sequenceStepVariantContents(step: SequenceStep): StepVariantContent[];
|
|
300
|
+
/** Number of A/B variants a step carries (1 = no test). */
|
|
301
|
+
export declare function sequenceStepVariantCount(step: SequenceStep): number;
|
|
302
|
+
/**
|
|
303
|
+
* Deterministically assign one variant to an enrollment for a step. The same
|
|
304
|
+
* (key, step) always resolves to the same variant — replayable, evenly
|
|
305
|
+
* distributed, and previewable in a dry run before any send. `key` is the
|
|
306
|
+
* enrollment id.
|
|
307
|
+
*/
|
|
308
|
+
export declare function selectStepVariant(step: SequenceStep, key: string): {
|
|
309
|
+
variantId: string;
|
|
310
|
+
index: number;
|
|
311
|
+
content: StepVariantContent;
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Resolve which IANA timezone a send window evaluates in for a given lead row.
|
|
315
|
+
* "recipient" mode reads the lead's tz from `recipient_timezone_column`, falling
|
|
316
|
+
* back to the window's fixed `timezone` when the column is absent/blank.
|
|
317
|
+
*/
|
|
318
|
+
export declare function resolveSendWindowTimezone(window: SequenceSendWindow, rowValues: Record<string, unknown> | null | undefined): string;
|
|
319
|
+
/**
|
|
320
|
+
* Is `now` inside the send window, evaluated in `timezone` (already resolved for
|
|
321
|
+
* recipient mode via resolveSendWindowTimezone)? Pure; Intl only
|
|
322
|
+
* (client-bundle-safe). An unresolvable timezone or malformed window fails OPEN
|
|
323
|
+
* (returns true) so a bad tz never permanently strands an enrollment — the hard
|
|
324
|
+
* send caps still bound it. A window with no `days` allows every weekday;
|
|
325
|
+
* start>end wraps past midnight.
|
|
326
|
+
*/
|
|
327
|
+
export declare function isWithinSendWindow(window: SequenceSendWindow, now: Date, timezone?: string): boolean;
|
|
328
|
+
export {};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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,230 @@ 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 && 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
|
+
const days = normalizeWeekdays(raw.days, `${path}.days`, issues);
|
|
718
|
+
const mode = raw.timezone_mode === "recipient" ? "recipient" : "fixed";
|
|
719
|
+
const column = mode === "recipient"
|
|
720
|
+
? optionalString(raw.recipient_timezone_column, `${path}.recipient_timezone_column`, issues)
|
|
721
|
+
: undefined;
|
|
722
|
+
if (mode === "recipient" && !column) {
|
|
723
|
+
issues.push({ path: `${path}.recipient_timezone_column`, message: 'recipient_timezone_column is required when timezone_mode is "recipient".' });
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
timezone: timezone ?? "UTC",
|
|
727
|
+
...(days ? { days } : {}),
|
|
728
|
+
start: start ?? "09:00",
|
|
729
|
+
end: end ?? "17:00",
|
|
730
|
+
timezone_mode: mode,
|
|
731
|
+
...(column ? { recipient_timezone_column: column } : {}),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function normalizeHhmm(value, path, issues) {
|
|
735
|
+
if (value === undefined || value === null) {
|
|
736
|
+
issues.push({ path, message: 'is required as "HH:MM".' });
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
if (typeof value !== "string" || hhmmToMinutes(value) === null) {
|
|
740
|
+
issues.push({ path, message: 'must be "HH:MM" (00:00–23:59).' });
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
return value.trim();
|
|
744
|
+
}
|
|
745
|
+
function normalizeWeekdays(value, path, issues) {
|
|
746
|
+
if (value === undefined || value === null)
|
|
747
|
+
return undefined;
|
|
748
|
+
if (!Array.isArray(value)) {
|
|
749
|
+
issues.push({ path, message: "days must be an array of ISO weekdays (1=Mon … 7=Sun)." });
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
const out = [];
|
|
753
|
+
for (const entry of value) {
|
|
754
|
+
const num = Number(entry);
|
|
755
|
+
if (!Number.isInteger(num) || num < 1 || num > 7) {
|
|
756
|
+
issues.push({ path, message: "each day must be an integer 1–7 (1=Mon … 7=Sun)." });
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (!out.includes(num))
|
|
760
|
+
out.push(num);
|
|
761
|
+
}
|
|
762
|
+
return out.length > 0 ? out.sort((a, b) => a - b) : undefined;
|
|
763
|
+
}
|
|
499
764
|
function isRecord(value) {
|
|
500
765
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
501
766
|
}
|
|
@@ -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 | undefined): 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>;
|