@oxygen-agent/cli 1.152.15 → 1.160.18
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 +2 -2
- package/dist/index.js +841 -127
- package/dist/transcript.d.ts +21 -0
- package/dist/transcript.js +208 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +54 -31
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +15 -219
- package/node_modules/@oxygen/shared/dist/log.js +12 -4
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +238 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +501 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +318 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +26 -3
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +5 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +0 -19
- package/node_modules/@oxygen/workflows/dist/index.js +16 -19
- package/package.json +1 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { OxygenError } from "./index.js";
|
|
2
|
+
import { renderLinkedInTemplate } from "./linkedin-sequences.js";
|
|
3
|
+
/**
|
|
4
|
+
* Multichannel sequence DSL — the shared contract validated identically by CLI,
|
|
5
|
+
* MCP, API, and web. A sequence is an ordered list of steps applied to each
|
|
6
|
+
* enrolled lead, spanning LinkedIn (executed natively by the sequencer dispatch
|
|
7
|
+
* engine) and email (native send through a mailbox, or delegated to a bound
|
|
8
|
+
* Instantly campaign). Three control kinds make it cross-channel:
|
|
9
|
+
*
|
|
10
|
+
* - wait_for_signal — gate until a provider signal fires (LinkedIn connection
|
|
11
|
+
* accepted, email opened/replied, ...) or timeout_days elapse; generalizes
|
|
12
|
+
* the LinkedIn-only wait_for_connection gate.
|
|
13
|
+
* - branch — evaluate a condition over the signals an enrollment has
|
|
14
|
+
* accumulated and jump to a target step (then) or fall through (else).
|
|
15
|
+
* - stop — explicit terminal step, the only way to give a branch's two arms
|
|
16
|
+
* genuinely distinct endings.
|
|
17
|
+
*
|
|
18
|
+
* A LinkedIn-only sequence (channels = ['linkedin'], no email steps) is exactly
|
|
19
|
+
* the behavior of the original LinkedIn sequencer, so the same engine and tables
|
|
20
|
+
* back both. This module is the single home for the schema; the legacy
|
|
21
|
+
* `linkedin-sequences.ts` validator remains for back-compat reads of stored
|
|
22
|
+
* LinkedIn-only definitions.
|
|
23
|
+
*
|
|
24
|
+
* Email steps: email_send/email_reply send natively through one of the
|
|
25
|
+
* sequence's mailboxes (Gmail API / Microsoft Graph) — Oxygen owns the send.
|
|
26
|
+
* email_enroll/move/stop delegate to a bound Instantly campaign (legacy, removed
|
|
27
|
+
* once native send fully replaces them); Instantly owns send cadence, warmup,
|
|
28
|
+
* and inbox rotation while Oxygen owns the cross-channel timeline + reply
|
|
29
|
+
* suppression.
|
|
30
|
+
*/
|
|
31
|
+
export const SEQUENCE_CHANNELS = ["linkedin", "email"];
|
|
32
|
+
/**
|
|
33
|
+
* Signals an enrollment accumulates from provider webhooks. wait_for_signal gates
|
|
34
|
+
* and signal-branch conditions read these. linkedin_connected also advances the
|
|
35
|
+
* legacy wait_for_connection gate. (The lead's current connection *degree* is NOT
|
|
36
|
+
* a signal — it's resolved on demand by the dispatcher via a connection branch,
|
|
37
|
+
* `condition: "already_connected"`.)
|
|
38
|
+
*/
|
|
39
|
+
export const SEQUENCE_SIGNALS = [
|
|
40
|
+
"linkedin_connected",
|
|
41
|
+
"linkedin_replied",
|
|
42
|
+
"email_sent",
|
|
43
|
+
"email_opened",
|
|
44
|
+
"email_clicked",
|
|
45
|
+
"email_replied",
|
|
46
|
+
"email_bounced",
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* row_values keys an enrollment's email may live under, in send-precedence order.
|
|
50
|
+
* The dispatcher picks the FIRST present key as the recipient address; the
|
|
51
|
+
* enrollment-lookup queries OR across all of them (order-independent there).
|
|
52
|
+
* Single source of truth so the send path and the lookup path can't drift.
|
|
53
|
+
*/
|
|
54
|
+
export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
|
|
55
|
+
export const SEQUENCE_STEP_KINDS = [
|
|
56
|
+
// linkedin channel (native dispatch)
|
|
57
|
+
"visit_profile",
|
|
58
|
+
"invite",
|
|
59
|
+
"wait_for_connection",
|
|
60
|
+
"message",
|
|
61
|
+
"inmail",
|
|
62
|
+
// email channel
|
|
63
|
+
"email_send",
|
|
64
|
+
"email_reply",
|
|
65
|
+
"email_enroll",
|
|
66
|
+
"email_move",
|
|
67
|
+
"email_stop",
|
|
68
|
+
// control (channel-agnostic)
|
|
69
|
+
"wait",
|
|
70
|
+
"wait_for_signal",
|
|
71
|
+
"branch",
|
|
72
|
+
"stop",
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Conditions a connection branch routes on. Both need live LinkedIn access, so
|
|
76
|
+
* the dispatcher (not the planner) resolves them over time:
|
|
77
|
+
* - connection_accepted: wait until the invite is accepted (or timeout_days) then
|
|
78
|
+
* route accepted → then_id, timeout → else_id.
|
|
79
|
+
* - already_connected: resolve the lead's current 1st-degree state at entry and
|
|
80
|
+
* route connected → then_id, not → else_id (no waiting).
|
|
81
|
+
*/
|
|
82
|
+
export const SEQUENCE_CONNECTION_BRANCH_CONDITIONS = ["connection_accepted", "already_connected"];
|
|
83
|
+
const MAX_SEQUENCE_STEPS = 50;
|
|
84
|
+
const MAX_TEMPLATE_LENGTH = 8_000;
|
|
85
|
+
const MAX_NOTE_LENGTH = 300;
|
|
86
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
87
|
+
/** Which channel a step executes on, or null for channel-agnostic control steps. */
|
|
88
|
+
export function sequenceStepChannel(step) {
|
|
89
|
+
return channelForKind(step.kind);
|
|
90
|
+
}
|
|
91
|
+
/** Distinct channels a sequence touches (drives the sequence's channels[] tag). */
|
|
92
|
+
export function sequenceChannels(definition) {
|
|
93
|
+
const channels = new Set();
|
|
94
|
+
for (const step of definition.steps) {
|
|
95
|
+
const channel = sequenceStepChannel(step);
|
|
96
|
+
if (channel)
|
|
97
|
+
channels.add(channel);
|
|
98
|
+
}
|
|
99
|
+
return [...channels];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate + normalize a raw sequence definition. Assigns stable ids to steps
|
|
103
|
+
* that omit one (s{index}), verifies branch/gate targets resolve, and enforces
|
|
104
|
+
* per-step shapes + channel restrictions. Returns the normalized definition or
|
|
105
|
+
* throws OxygenError("invalid_sequence") with per-step issues. Pure.
|
|
106
|
+
*/
|
|
107
|
+
export function validateSequenceDefinition(input, options = {}) {
|
|
108
|
+
const issues = [];
|
|
109
|
+
const rawSteps = collectSteps(input, issues);
|
|
110
|
+
// Pass 1: normalize each step + assign ids.
|
|
111
|
+
const normalized = [];
|
|
112
|
+
const usedIds = new Set();
|
|
113
|
+
rawSteps.forEach((rawStep, index) => {
|
|
114
|
+
const step = normalizeStep(rawStep, index, options, issues);
|
|
115
|
+
if (!step)
|
|
116
|
+
return;
|
|
117
|
+
if (usedIds.has(step.id)) {
|
|
118
|
+
issues.push({ path: `steps[${index}].id`, message: `Duplicate step id '${step.id}'.` });
|
|
119
|
+
}
|
|
120
|
+
usedIds.add(step.id);
|
|
121
|
+
normalized.push(step);
|
|
122
|
+
});
|
|
123
|
+
// Pass 2: branch targets must resolve to an existing step id (connection
|
|
124
|
+
// branches route by then_id/else_id, signal branches by then/else).
|
|
125
|
+
normalized.forEach((step, index) => {
|
|
126
|
+
if (step.kind !== "branch")
|
|
127
|
+
return;
|
|
128
|
+
if (typeof step.condition === "string") {
|
|
129
|
+
for (const [key, target] of [["then_id", step.then_id], ["else_id", step.else_id]]) {
|
|
130
|
+
if (target && !usedIds.has(target)) {
|
|
131
|
+
issues.push({ path: `steps[${index}].${key}`, message: `branch target '${target}' is not a step id.` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
for (const [key, target] of [["then", step.then], ["else", step.else]]) {
|
|
137
|
+
if (target !== null && !usedIds.has(target)) {
|
|
138
|
+
issues.push({ path: `steps[${index}].${key}`, message: `branch target '${target}' is not a step id.` });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (issues.length > 0) {
|
|
144
|
+
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
145
|
+
}
|
|
146
|
+
return { steps: normalized };
|
|
147
|
+
}
|
|
148
|
+
/** Non-throwing variant for lint surfaces. */
|
|
149
|
+
export function lintSequenceDefinition(input, options = {}) {
|
|
150
|
+
try {
|
|
151
|
+
validateSequenceDefinition(input, options);
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error instanceof OxygenError && error.details && typeof error.details === "object") {
|
|
156
|
+
const issues = error.details.issues;
|
|
157
|
+
if (Array.isArray(issues))
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function collectSteps(input, issues) {
|
|
164
|
+
const record = isRecord(input) ? input : null;
|
|
165
|
+
const steps = record?.steps;
|
|
166
|
+
if (!Array.isArray(steps)) {
|
|
167
|
+
issues.push({ path: "steps", message: "steps must be an array." });
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
if (steps.length === 0) {
|
|
171
|
+
issues.push({ path: "steps", message: "A sequence needs at least one step." });
|
|
172
|
+
}
|
|
173
|
+
if (steps.length > MAX_SEQUENCE_STEPS) {
|
|
174
|
+
issues.push({ path: "steps", message: `A sequence may have at most ${MAX_SEQUENCE_STEPS} steps.` });
|
|
175
|
+
}
|
|
176
|
+
return steps;
|
|
177
|
+
}
|
|
178
|
+
function normalizeStep(// skipcq: JS-R1005 -- discriminated DSL: one normalizer per step kind.
|
|
179
|
+
raw, index, options, issues) {
|
|
180
|
+
const path = `steps[${index}]`;
|
|
181
|
+
if (!isRecord(raw)) {
|
|
182
|
+
issues.push({ path, message: "Each step must be an object." });
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const kind = raw.kind;
|
|
186
|
+
if (typeof kind !== "string" || !SEQUENCE_STEP_KINDS.includes(kind)) {
|
|
187
|
+
issues.push({ path: `${path}.kind`, message: `kind must be one of: ${SEQUENCE_STEP_KINDS.join(", ")}.` });
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const id = normalizeId(raw.id, index, path, issues);
|
|
191
|
+
const stepKind = kind;
|
|
192
|
+
const channel = channelForKind(stepKind);
|
|
193
|
+
if (channel && options.allowedChannels && !options.allowedChannels.includes(channel)) {
|
|
194
|
+
issues.push({ path: `${path}.channel`, message: `channel '${channel}' is not enabled for this sequence.` });
|
|
195
|
+
}
|
|
196
|
+
switch (stepKind) {
|
|
197
|
+
case "visit_profile":
|
|
198
|
+
return { id, channel: "linkedin", kind: "visit_profile" };
|
|
199
|
+
case "invite": {
|
|
200
|
+
const note = optionalTemplate(raw.note_template, `${path}.note_template`, MAX_NOTE_LENGTH, issues);
|
|
201
|
+
return note !== undefined
|
|
202
|
+
? { id, channel: "linkedin", kind: "invite", note_template: note }
|
|
203
|
+
: { id, channel: "linkedin", kind: "invite" };
|
|
204
|
+
}
|
|
205
|
+
case "wait_for_connection": {
|
|
206
|
+
const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
|
|
207
|
+
? 14
|
|
208
|
+
: positiveInt(raw.timeout_days, `${path}.timeout_days`, issues) ?? 14;
|
|
209
|
+
return {
|
|
210
|
+
id,
|
|
211
|
+
channel: "linkedin",
|
|
212
|
+
kind: "wait_for_connection",
|
|
213
|
+
timeout_days: timeoutDays,
|
|
214
|
+
on_timeout: raw.on_timeout === "continue" ? "continue" : "stop",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
case "message": {
|
|
218
|
+
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
219
|
+
return { id, channel: "linkedin", kind: "message", template: template ?? "" };
|
|
220
|
+
}
|
|
221
|
+
case "inmail": {
|
|
222
|
+
const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
|
|
223
|
+
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
224
|
+
return { id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "" };
|
|
225
|
+
}
|
|
226
|
+
case "email_enroll": {
|
|
227
|
+
const subsequenceId = optionalString(raw.subsequence_id, `${path}.subsequence_id`, issues);
|
|
228
|
+
return subsequenceId !== undefined
|
|
229
|
+
? { id, channel: "email", kind: "email_enroll", subsequence_id: subsequenceId }
|
|
230
|
+
: { id, channel: "email", kind: "email_enroll" };
|
|
231
|
+
}
|
|
232
|
+
case "email_move": {
|
|
233
|
+
const subsequenceId = optionalString(raw.subsequence_id, `${path}.subsequence_id`, issues);
|
|
234
|
+
if (subsequenceId === undefined) {
|
|
235
|
+
issues.push({ path: `${path}.subsequence_id`, message: "email_move requires subsequence_id." });
|
|
236
|
+
}
|
|
237
|
+
return { id, channel: "email", kind: "email_move", subsequence_id: subsequenceId ?? "" };
|
|
238
|
+
}
|
|
239
|
+
case "email_send": {
|
|
240
|
+
const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
|
|
241
|
+
const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
|
|
242
|
+
return { id, channel: "email", kind: "email_send", subject_template: subject ?? "", body_template: body ?? "" };
|
|
243
|
+
}
|
|
244
|
+
case "email_reply": {
|
|
245
|
+
const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
|
|
246
|
+
return { id, channel: "email", kind: "email_reply", body_template: body ?? "" };
|
|
247
|
+
}
|
|
248
|
+
case "email_stop":
|
|
249
|
+
return { id, channel: "email", kind: "email_stop" };
|
|
250
|
+
case "wait": {
|
|
251
|
+
const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
|
|
252
|
+
const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
|
|
253
|
+
if ((days ?? 0) + (hours ?? 0) <= 0) {
|
|
254
|
+
issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
id,
|
|
258
|
+
kind: "wait",
|
|
259
|
+
...(days !== undefined ? { days } : {}),
|
|
260
|
+
...(hours !== undefined ? { hours } : {}),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
case "wait_for_signal": {
|
|
264
|
+
const signal = normalizeSignal(raw.signal, `${path}.signal`, issues);
|
|
265
|
+
const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
|
|
266
|
+
? 7
|
|
267
|
+
: positiveInt(raw.timeout_days, `${path}.timeout_days`, issues) ?? 7;
|
|
268
|
+
return {
|
|
269
|
+
id,
|
|
270
|
+
kind: "wait_for_signal",
|
|
271
|
+
signal: signal ?? "email_replied",
|
|
272
|
+
timeout_days: timeoutDays,
|
|
273
|
+
on_timeout: raw.on_timeout === "continue" ? "continue" : "stop",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
case "branch": {
|
|
277
|
+
// String condition → LinkedIn connection branch (preserve verbatim; the
|
|
278
|
+
// dispatcher resolves accept-wait / degree over time, routing then_id/else_id).
|
|
279
|
+
if (typeof raw.condition === "string") {
|
|
280
|
+
if (!SEQUENCE_CONNECTION_BRANCH_CONDITIONS.includes(raw.condition)) {
|
|
281
|
+
issues.push({
|
|
282
|
+
path: `${path}.condition`,
|
|
283
|
+
message: `string condition must be one of: ${SEQUENCE_CONNECTION_BRANCH_CONDITIONS.join(", ")} (or a signal-condition object).`,
|
|
284
|
+
});
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const conditionStr = raw.condition;
|
|
288
|
+
const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
|
|
289
|
+
? undefined
|
|
290
|
+
: positiveInt(raw.timeout_days, `${path}.timeout_days`, issues);
|
|
291
|
+
const thenId = normalizeTargetRef(raw.then_id ?? raw.then, `${path}.then_id`, issues);
|
|
292
|
+
const elseId = normalizeTargetRef(raw.else_id ?? raw.else, `${path}.else_id`, issues);
|
|
293
|
+
return {
|
|
294
|
+
id,
|
|
295
|
+
kind: "branch",
|
|
296
|
+
condition: conditionStr,
|
|
297
|
+
...(timeoutDays !== undefined
|
|
298
|
+
? { timeout_days: timeoutDays }
|
|
299
|
+
: conditionStr === "connection_accepted" ? { timeout_days: 14 } : {}),
|
|
300
|
+
...(thenId !== null ? { then_id: thenId } : {}),
|
|
301
|
+
...(elseId !== null ? { else_id: elseId } : {}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// Object condition → signal branch. Accept then_id/else_id as aliases.
|
|
305
|
+
const condition = normalizeCondition(raw.condition, `${path}.condition`, issues);
|
|
306
|
+
return {
|
|
307
|
+
id,
|
|
308
|
+
kind: "branch",
|
|
309
|
+
condition: condition ?? { signal: "email_replied", present: true },
|
|
310
|
+
then: normalizeTargetRef(raw.then ?? raw.then_id, `${path}.then`, issues),
|
|
311
|
+
else: normalizeTargetRef(raw.else ?? raw.else_id, `${path}.else`, issues),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
case "stop":
|
|
315
|
+
return { id, kind: "stop" };
|
|
316
|
+
default:
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function channelForKind(kind) {
|
|
321
|
+
if (kind === "wait" || kind === "wait_for_signal" || kind === "branch" || kind === "stop")
|
|
322
|
+
return null;
|
|
323
|
+
if (kind === "email_send" || kind === "email_reply" ||
|
|
324
|
+
kind === "email_enroll" || kind === "email_move" || kind === "email_stop")
|
|
325
|
+
return "email";
|
|
326
|
+
return "linkedin";
|
|
327
|
+
}
|
|
328
|
+
function normalizeId(value, index, path, issues) {
|
|
329
|
+
if (value === undefined || value === null)
|
|
330
|
+
return `s${index}`;
|
|
331
|
+
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
|
332
|
+
issues.push({ path: `${path}.id`, message: "id must match [A-Za-z0-9_-]{1,64}." });
|
|
333
|
+
return `s${index}`;
|
|
334
|
+
}
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
function normalizeSignal(value, path, issues) {
|
|
338
|
+
if (typeof value !== "string" || !SEQUENCE_SIGNALS.includes(value)) {
|
|
339
|
+
issues.push({ path, message: `signal must be one of: ${SEQUENCE_SIGNALS.join(", ")}.` });
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
function normalizeTargetRef(value, path, issues) {
|
|
345
|
+
if (value === undefined || value === null)
|
|
346
|
+
return null;
|
|
347
|
+
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
|
348
|
+
issues.push({ path, message: "must be a step id or null." });
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
function normalizeCondition(raw, path, issues, depth = 0) {
|
|
354
|
+
if (depth > 6) {
|
|
355
|
+
issues.push({ path, message: "condition nests too deeply." });
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
if (!isRecord(raw)) {
|
|
359
|
+
issues.push({ path, message: "condition must be an object." });
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
if (typeof raw.signal === "string") {
|
|
363
|
+
const signal = normalizeSignal(raw.signal, `${path}.signal`, issues);
|
|
364
|
+
if (!signal)
|
|
365
|
+
return undefined;
|
|
366
|
+
return { signal, present: raw.present === false ? false : true };
|
|
367
|
+
}
|
|
368
|
+
if (Array.isArray(raw.all)) {
|
|
369
|
+
return { all: normalizeConditionList(raw.all, `${path}.all`, issues, depth) };
|
|
370
|
+
}
|
|
371
|
+
if (Array.isArray(raw.any)) {
|
|
372
|
+
return { any: normalizeConditionList(raw.any, `${path}.any`, issues, depth) };
|
|
373
|
+
}
|
|
374
|
+
if (raw.not !== undefined) {
|
|
375
|
+
const inner = normalizeCondition(raw.not, `${path}.not`, issues, depth + 1);
|
|
376
|
+
return inner ? { not: inner } : undefined;
|
|
377
|
+
}
|
|
378
|
+
issues.push({ path, message: "condition must be {signal}, {all}, {any}, or {not}." });
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
function normalizeConditionList(raw, path, issues, depth) {
|
|
382
|
+
if (raw.length === 0) {
|
|
383
|
+
issues.push({ path, message: "must have at least one condition." });
|
|
384
|
+
}
|
|
385
|
+
const out = [];
|
|
386
|
+
raw.forEach((entry, index) => {
|
|
387
|
+
const condition = normalizeCondition(entry, `${path}[${index}]`, issues, depth + 1);
|
|
388
|
+
if (condition)
|
|
389
|
+
out.push(condition);
|
|
390
|
+
});
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
function requiredTemplate(value, path, issues) {
|
|
394
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
395
|
+
issues.push({ path, message: "is required and must be a non-empty string." });
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
if (value.length > MAX_TEMPLATE_LENGTH) {
|
|
399
|
+
issues.push({ path, message: `must be at most ${MAX_TEMPLATE_LENGTH} characters.` });
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
function optionalTemplate(value, path, maxLength, issues) {
|
|
405
|
+
if (value === undefined || value === null)
|
|
406
|
+
return undefined;
|
|
407
|
+
if (typeof value !== "string") {
|
|
408
|
+
issues.push({ path, message: "must be a string." });
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
if (value.length > maxLength) {
|
|
412
|
+
issues.push({ path, message: `must be at most ${maxLength} characters.` });
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
function optionalString(value, path, issues) {
|
|
418
|
+
if (value === undefined || value === null)
|
|
419
|
+
return undefined;
|
|
420
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
421
|
+
issues.push({ path, message: "must be a non-empty string." });
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
}
|
|
426
|
+
function positiveInt(value, path, issues) {
|
|
427
|
+
const num = Number(value);
|
|
428
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
429
|
+
issues.push({ path, message: "must be a positive integer." });
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
return num;
|
|
433
|
+
}
|
|
434
|
+
function optionalNonNegativeInt(value, path, issues) {
|
|
435
|
+
if (value === undefined || value === null)
|
|
436
|
+
return undefined;
|
|
437
|
+
const num = Number(value);
|
|
438
|
+
if (!Number.isInteger(num) || num < 0) {
|
|
439
|
+
issues.push({ path, message: "must be a non-negative integer." });
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
return num;
|
|
443
|
+
}
|
|
444
|
+
/** Total delay in milliseconds a wait step introduces. */
|
|
445
|
+
export function sequenceWaitStepDelayMs(step) {
|
|
446
|
+
const days = step.days ?? 0;
|
|
447
|
+
const hours = step.hours ?? 0;
|
|
448
|
+
return (days * 24 + hours) * 60 * 60 * 1000;
|
|
449
|
+
}
|
|
450
|
+
/** Render a {{column}} template against a row's values (reuses the LinkedIn impl). */
|
|
451
|
+
export function renderSequenceTemplate(template, values) {
|
|
452
|
+
return renderLinkedInTemplate(template, values);
|
|
453
|
+
}
|
|
454
|
+
/** Column keys referenced by {{...}} placeholders across every step's copy. */
|
|
455
|
+
export function sequenceTemplateVariables(definition) {
|
|
456
|
+
const vars = new Set();
|
|
457
|
+
const scan = (template) => {
|
|
458
|
+
if (!template)
|
|
459
|
+
return;
|
|
460
|
+
for (const match of template.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
|
|
461
|
+
if (match[1])
|
|
462
|
+
vars.add(match[1]);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
for (const step of definition.steps) {
|
|
466
|
+
if (step.kind === "invite")
|
|
467
|
+
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);
|
|
473
|
+
}
|
|
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
|
+
}
|
|
481
|
+
return [...vars];
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Evaluate a condition against the set of signals an enrollment has fired.
|
|
485
|
+
* Used by the dispatch planner for branch steps. Pure.
|
|
486
|
+
*/
|
|
487
|
+
export function evaluateSequenceCondition(condition, firedSignals) {
|
|
488
|
+
const fired = firedSignals instanceof Set ? firedSignals : new Set(firedSignals);
|
|
489
|
+
if ("signal" in condition) {
|
|
490
|
+
const present = fired.has(condition.signal);
|
|
491
|
+
return condition.present === false ? !present : present;
|
|
492
|
+
}
|
|
493
|
+
if ("all" in condition)
|
|
494
|
+
return condition.all.every((c) => evaluateSequenceCondition(c, fired));
|
|
495
|
+
if ("any" in condition)
|
|
496
|
+
return condition.any.some((c) => evaluateSequenceCondition(c, fired));
|
|
497
|
+
return !evaluateSequenceCondition(condition.not, fired);
|
|
498
|
+
}
|
|
499
|
+
function isRecord(value) {
|
|
500
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
501
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Normalized failure category, derived from SQLSTATE first, then errno/message. */
|
|
2
|
+
export type SqlErrorCause = "connect_timeout" | "connection" | "statement_timeout" | "admin_shutdown" | "too_many_connections" | "insufficient_resources" | "auth" | "schema_drift" | "data_exception" | "integrity_constraint" | "serialization" | "deadlock" | "transaction_rollback" | "syntax_or_access" | "internal" | "unknown";
|
|
3
|
+
export type SqlErrorAttribution = {
|
|
4
|
+
/** Postgres SQLSTATE (5 chars) when present; null for connection-level errnos. */
|
|
5
|
+
pgCode: string | null;
|
|
6
|
+
cause: SqlErrorCause;
|
|
7
|
+
/** Leading SQL verb (select/insert/update/...) parsed from parameter-free SQL. */
|
|
8
|
+
queryKind: string | null;
|
|
9
|
+
/** Schema-qualified table identifier (schema metadata, never row data). */
|
|
10
|
+
table: string | null;
|
|
11
|
+
/** True when a retry on the next tick could plausibly succeed. */
|
|
12
|
+
transient: boolean;
|
|
13
|
+
/** pg severity keyword (ERROR/FATAL/PANIC/WARNING); null when absent. */
|
|
14
|
+
severity: string | null;
|
|
15
|
+
/** Schema name from the pg error (schema metadata, never row data). */
|
|
16
|
+
schema: string | null;
|
|
17
|
+
/** Column name implicated by the failure (NOT NULL / type violations). */
|
|
18
|
+
column: string | null;
|
|
19
|
+
/** Constraint name implicated by the failure (unique/foreign-key/check). */
|
|
20
|
+
constraint: string | null;
|
|
21
|
+
/** Neon/HTTP transport status code (numeric) when the failure carries one. */
|
|
22
|
+
statusCode: number | null;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Classifies a pg/drizzle error into structured, non-secret attribution.
|
|
26
|
+
* Returns null when the error carries no SQL/connection signal at all (so
|
|
27
|
+
* non-DB errors never gain spurious `db.*` attributes). Never throws.
|
|
28
|
+
*/
|
|
29
|
+
export declare function describeSqlError(error: unknown): SqlErrorAttribution | null;
|
|
30
|
+
/** Log-field shape (snake_case) for merging into `errorFields(error)` payloads. */
|
|
31
|
+
export declare function sqlErrorFields(error: unknown): Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* Strips drizzle's `\nparams: <values>` segment from an error message or stack
|
|
34
|
+
* so SQL parameter values never reach logs/telemetry (OXY-46). Preserves the SQL
|
|
35
|
+
* text itself and any following stack frames; a no-op on non-drizzle text.
|
|
36
|
+
*
|
|
37
|
+
* drizzle's DrizzleQueryError carries the parameter array inside its `.message`
|
|
38
|
+
* (and therefore its `.stack`) as `Failed query: <sql>\nparams: <values>`, so
|
|
39
|
+
* the generic error serializers would otherwise emit those values verbatim.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactSqlParameters(text: string): string;
|
|
42
|
+
/** Telemetry-attribute shape (dotted keys) for span error attribution. */
|
|
43
|
+
export declare function sqlErrorTelemetryAttributes(error: unknown): Record<string, unknown>;
|