@oxygen-agent/cli 1.152.15 → 1.162.10
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 +938 -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,318 @@
|
|
|
1
|
+
// Structured, non-secret attribution for Postgres / drizzle query failures.
|
|
2
|
+
//
|
|
3
|
+
// Background (OXY-44/OXY-46): worker error spans and logs previously carried
|
|
4
|
+
// only the raw drizzle `Failed query: <sql>...` message (truncated to ~500
|
|
5
|
+
// chars in Axiom), so an operator could not tell a Neon connect timeout from a
|
|
6
|
+
// statement timeout, schema drift, or another SQLSTATE without guessing from
|
|
7
|
+
// the truncated SQL. This classifier turns any pg/drizzle error into a small
|
|
8
|
+
// set of structured, groupable fields:
|
|
9
|
+
//
|
|
10
|
+
// - SQLSTATE / pg error code (e.g. "57014", "42P01")
|
|
11
|
+
// - normalized cause category (e.g. "statement_timeout", "schema_drift")
|
|
12
|
+
// - query kind + table (e.g. "select" on "workflow_triggers")
|
|
13
|
+
// - whether a retry could succeed (transient)
|
|
14
|
+
//
|
|
15
|
+
// HARD CONSTRAINT: customer row data and SQL *parameter values* must never leak
|
|
16
|
+
// into telemetry. We therefore only ever read schema-level identifiers (the
|
|
17
|
+
// table name) and the leading SQL verb from the *parameter-free* SQL, and we
|
|
18
|
+
// always strip drizzle's trailing `\nparams: [...]` tail. We never emit the SQL
|
|
19
|
+
// text itself or the parameter array.
|
|
20
|
+
//
|
|
21
|
+
// drizzle (>=0.36) wraps driver failures as a DrizzleQueryError whose message is
|
|
22
|
+
// `Failed query: <sql>\nparams: <params>`, with the original pg error on
|
|
23
|
+
// `.cause` (so the SQLSTATE lives on `error.cause.code`, not `error.code`).
|
|
24
|
+
// Connection-level failures (Neon cold-start connect timeouts, socket resets)
|
|
25
|
+
// surface before a query with a Node errno on `code`/`errno` and no SQLSTATE.
|
|
26
|
+
// SQLSTATEs that mean the code expects schema the database does not have — the
|
|
27
|
+
// "schema drift" signal OXY-44 specifically called out (undefined table/column/
|
|
28
|
+
// function/object/schema). Distinct from generic class-42 syntax/access errors.
|
|
29
|
+
const SCHEMA_DRIFT_SQLSTATES = new Set([
|
|
30
|
+
"42P01", // undefined_table
|
|
31
|
+
"42703", // undefined_column
|
|
32
|
+
"42883", // undefined_function
|
|
33
|
+
"42704", // undefined_object
|
|
34
|
+
"42P02", // undefined_parameter
|
|
35
|
+
"3F000", // invalid_schema_name
|
|
36
|
+
]);
|
|
37
|
+
// Node/undici errnos. These appear on `.code` (or `.errno`) for failures that
|
|
38
|
+
// happen before/around the TCP+TLS handshake, so they carry no SQLSTATE.
|
|
39
|
+
const CONNECT_TIMEOUT_ERRNOS = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
40
|
+
const CONNECTION_ERRNOS = new Set([
|
|
41
|
+
"ECONNRESET",
|
|
42
|
+
"ECONNREFUSED",
|
|
43
|
+
"ENOTFOUND",
|
|
44
|
+
"EAI_AGAIN",
|
|
45
|
+
"EPIPE",
|
|
46
|
+
"ENETUNREACH",
|
|
47
|
+
"UND_ERR_SOCKET",
|
|
48
|
+
]);
|
|
49
|
+
const CONNECT_TIMEOUT_MESSAGE = /timeout exceeded when trying to connect|connection terminated due to connection timeout|connect(?:ion)?\s+time(?:d?\s?)out/i;
|
|
50
|
+
const CONNECTION_MESSAGE = /connection terminated|connection reset|socket hang up|fetch failed|getaddrinfo|network timeout/i;
|
|
51
|
+
// Categories from which a retry on a later tick could plausibly succeed. This is
|
|
52
|
+
// a SQLSTATE-aware retry-eligibility signal and is intentionally independent of
|
|
53
|
+
// the worker span's conservative `outcome` gate (isTransientPersistenceError),
|
|
54
|
+
// which only suppresses error-rate monitors for self-healing connect failures.
|
|
55
|
+
const TRANSIENT_CAUSES = new Set([
|
|
56
|
+
"connect_timeout",
|
|
57
|
+
"connection",
|
|
58
|
+
"statement_timeout",
|
|
59
|
+
"admin_shutdown",
|
|
60
|
+
"too_many_connections",
|
|
61
|
+
"insufficient_resources",
|
|
62
|
+
"serialization",
|
|
63
|
+
"deadlock",
|
|
64
|
+
"transaction_rollback",
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* Classifies a pg/drizzle error into structured, non-secret attribution.
|
|
68
|
+
* Returns null when the error carries no SQL/connection signal at all (so
|
|
69
|
+
* non-DB errors never gain spurious `db.*` attributes). Never throws.
|
|
70
|
+
*/
|
|
71
|
+
export function describeSqlError(error) {
|
|
72
|
+
try {
|
|
73
|
+
const sqlstate = readSqlstate(error);
|
|
74
|
+
const errno = readErrno(error);
|
|
75
|
+
const message = readMessage(error);
|
|
76
|
+
const drizzle = isDrizzleQueryError(error, message);
|
|
77
|
+
let cause = sqlstate ? categoryFromSqlstate(sqlstate) ?? "unknown" : "unknown";
|
|
78
|
+
if (cause === "unknown") {
|
|
79
|
+
if (errno && CONNECT_TIMEOUT_ERRNOS.has(errno))
|
|
80
|
+
cause = "connect_timeout";
|
|
81
|
+
else if (errno && CONNECTION_ERRNOS.has(errno))
|
|
82
|
+
cause = "connection";
|
|
83
|
+
else if (CONNECT_TIMEOUT_MESSAGE.test(message))
|
|
84
|
+
cause = "connect_timeout";
|
|
85
|
+
else if (CONNECTION_MESSAGE.test(message))
|
|
86
|
+
cause = "connection";
|
|
87
|
+
}
|
|
88
|
+
const hasSignal = Boolean(sqlstate) || Boolean(errno) || cause !== "unknown" || drizzle;
|
|
89
|
+
if (!hasSignal)
|
|
90
|
+
return null;
|
|
91
|
+
const { kind, table } = extractQueryShape(readParamFreeSql(error, message));
|
|
92
|
+
// severity/schema/column/constraint/statusCode are schema-level identifiers
|
|
93
|
+
// or transport status codes — safe to surface. We deliberately do NOT read
|
|
94
|
+
// pg's `detail`, `hint`, or `where`: those embed customer row values (e.g.
|
|
95
|
+
// "Key (email)=(a@b.com) already exists") and would leak data (OXY-38).
|
|
96
|
+
return {
|
|
97
|
+
pgCode: sqlstate,
|
|
98
|
+
cause,
|
|
99
|
+
queryKind: kind,
|
|
100
|
+
table: table ?? readTableField(error),
|
|
101
|
+
transient: TRANSIENT_CAUSES.has(cause),
|
|
102
|
+
severity: readSeverity(error),
|
|
103
|
+
schema: readIdentifierField(error, "schema"),
|
|
104
|
+
column: readIdentifierField(error, "column"),
|
|
105
|
+
constraint: readIdentifierField(error, "constraint"),
|
|
106
|
+
statusCode: readStatusCode(error),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Log-field shape (snake_case) for merging into `errorFields(error)` payloads. */
|
|
114
|
+
export function sqlErrorFields(error) {
|
|
115
|
+
const a = describeSqlError(error);
|
|
116
|
+
if (!a)
|
|
117
|
+
return {};
|
|
118
|
+
return {
|
|
119
|
+
...(a.pgCode ? { sql_error_pg_code: a.pgCode } : {}),
|
|
120
|
+
sql_error_cause: a.cause,
|
|
121
|
+
...(a.queryKind ? { sql_error_query_kind: a.queryKind } : {}),
|
|
122
|
+
...(a.table ? { sql_error_table: a.table } : {}),
|
|
123
|
+
sql_error_transient: a.transient,
|
|
124
|
+
...(a.severity ? { sql_error_severity: a.severity } : {}),
|
|
125
|
+
...(a.schema ? { sql_error_schema: a.schema } : {}),
|
|
126
|
+
...(a.column ? { sql_error_column: a.column } : {}),
|
|
127
|
+
...(a.constraint ? { sql_error_constraint: a.constraint } : {}),
|
|
128
|
+
...(a.statusCode !== null ? { sql_error_status: a.statusCode } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Strips drizzle's `\nparams: <values>` segment from an error message or stack
|
|
133
|
+
* so SQL parameter values never reach logs/telemetry (OXY-46). Preserves the SQL
|
|
134
|
+
* text itself and any following stack frames; a no-op on non-drizzle text.
|
|
135
|
+
*
|
|
136
|
+
* drizzle's DrizzleQueryError carries the parameter array inside its `.message`
|
|
137
|
+
* (and therefore its `.stack`) as `Failed query: <sql>\nparams: <values>`, so
|
|
138
|
+
* the generic error serializers would otherwise emit those values verbatim.
|
|
139
|
+
*/
|
|
140
|
+
export function redactSqlParameters(text) {
|
|
141
|
+
if (!text.includes("Failed query:"))
|
|
142
|
+
return text;
|
|
143
|
+
return text.replace(/\n[ \t]*params[ \t]*:[\s\S]*?(?=\n[ \t]*at\s|$)/i, "");
|
|
144
|
+
}
|
|
145
|
+
/** Telemetry-attribute shape (dotted keys) for span error attribution. */
|
|
146
|
+
export function sqlErrorTelemetryAttributes(error) {
|
|
147
|
+
const a = describeSqlError(error);
|
|
148
|
+
if (!a)
|
|
149
|
+
return {};
|
|
150
|
+
return {
|
|
151
|
+
...(a.pgCode ? { "db.pg_code": a.pgCode } : {}),
|
|
152
|
+
"db.error.cause": a.cause,
|
|
153
|
+
...(a.queryKind ? { "db.error.query_kind": a.queryKind } : {}),
|
|
154
|
+
...(a.table ? { "db.error.table": a.table } : {}),
|
|
155
|
+
"db.error.transient": a.transient,
|
|
156
|
+
...(a.severity ? { "db.error.severity": a.severity } : {}),
|
|
157
|
+
...(a.schema ? { "db.error.schema": a.schema } : {}),
|
|
158
|
+
...(a.column ? { "db.error.column": a.column } : {}),
|
|
159
|
+
...(a.constraint ? { "db.error.constraint": a.constraint } : {}),
|
|
160
|
+
...(a.statusCode !== null ? { "db.error.status": a.statusCode } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function categoryFromSqlstate(code) {
|
|
164
|
+
if (code === "57014")
|
|
165
|
+
return "statement_timeout";
|
|
166
|
+
if (code === "53300")
|
|
167
|
+
return "too_many_connections";
|
|
168
|
+
if (code === "40001")
|
|
169
|
+
return "serialization";
|
|
170
|
+
if (code === "40P01")
|
|
171
|
+
return "deadlock";
|
|
172
|
+
if (SCHEMA_DRIFT_SQLSTATES.has(code))
|
|
173
|
+
return "schema_drift";
|
|
174
|
+
switch (code.slice(0, 2)) {
|
|
175
|
+
case "08": return "connection";
|
|
176
|
+
case "28": return "auth";
|
|
177
|
+
case "22": return "data_exception";
|
|
178
|
+
case "23": return "integrity_constraint";
|
|
179
|
+
case "40": return "transaction_rollback";
|
|
180
|
+
case "42": return "syntax_or_access";
|
|
181
|
+
case "53": return "insufficient_resources";
|
|
182
|
+
case "57": return "admin_shutdown";
|
|
183
|
+
case "58": return "internal";
|
|
184
|
+
case "XX": return "internal";
|
|
185
|
+
case "3F": return "schema_drift";
|
|
186
|
+
default: return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Walk the Error.cause chain (bounded) and return the first non-null value the
|
|
190
|
+
// reader extracts. drizzle wraps the pg error on `.cause`, and custom wrappers
|
|
191
|
+
// can nest deeper, so a single-level read misses the actionable DB fields when
|
|
192
|
+
// the chain is two or more deep (OXY-38).
|
|
193
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
194
|
+
function readCauseChain(error, read) {
|
|
195
|
+
let current = error;
|
|
196
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
|
197
|
+
if (!current || typeof current !== "object")
|
|
198
|
+
return null;
|
|
199
|
+
const value = read(current);
|
|
200
|
+
if (value !== null)
|
|
201
|
+
return value;
|
|
202
|
+
current = current.cause;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function readSqlstate(error) {
|
|
207
|
+
return readCauseChain(error, (obj) => {
|
|
208
|
+
const value = obj.code;
|
|
209
|
+
return typeof value === "string"
|
|
210
|
+
&& /^[0-9A-Z]{5}$/.test(value)
|
|
211
|
+
&& !CONNECTION_ERRNOS.has(value)
|
|
212
|
+
&& !CONNECT_TIMEOUT_ERRNOS.has(value)
|
|
213
|
+
? value
|
|
214
|
+
: null;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function readErrno(error) {
|
|
218
|
+
return readCauseChain(error, (obj) => {
|
|
219
|
+
for (const key of ["code", "errno"]) {
|
|
220
|
+
const value = obj[key];
|
|
221
|
+
if (typeof value === "string" && (CONNECTION_ERRNOS.has(value) || CONNECT_TIMEOUT_ERRNOS.has(value))) {
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// pg sets `error.table` for some failures (notably constraint violations). Used
|
|
229
|
+
// only as a fallback when the table cannot be parsed from the SQL. Identifiers
|
|
230
|
+
// are schema metadata, never customer row data.
|
|
231
|
+
function readTableField(error) {
|
|
232
|
+
return readIdentifierField(error, "table");
|
|
233
|
+
}
|
|
234
|
+
// pg sets schema/column/constraint/table to identifier names (never row
|
|
235
|
+
// values), so they are safe to surface for triage. Guarded by isSafeIdentifier
|
|
236
|
+
// as defence in depth against an unexpected driver placing free text there.
|
|
237
|
+
function readIdentifierField(error, key) {
|
|
238
|
+
return readCauseChain(error, (obj) => {
|
|
239
|
+
const value = obj[key];
|
|
240
|
+
return typeof value === "string" && isSafeIdentifier(value) ? value : null;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
function readSeverity(error) {
|
|
244
|
+
return readCauseChain(error, (obj) => {
|
|
245
|
+
const value = obj.severity;
|
|
246
|
+
// A short keyword (ERROR/FATAL/PANIC/WARNING), possibly localized. Cap the
|
|
247
|
+
// length and restrict to letters/spaces so it can never carry parameter
|
|
248
|
+
// values even if a driver mislabels a field.
|
|
249
|
+
return typeof value === "string" && value.length > 0 && value.length <= 32 && /^[A-Za-z _]+$/.test(value)
|
|
250
|
+
? value
|
|
251
|
+
: null;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function readStatusCode(error) {
|
|
255
|
+
return readCauseChain(error, (obj) => {
|
|
256
|
+
for (const key of ["statusCode", "status"]) {
|
|
257
|
+
const value = obj[key];
|
|
258
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) {
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function isDrizzleQueryError(error, message) {
|
|
266
|
+
return message.startsWith("Failed query:") || typeof readNestedString(error, ["query"]) === "string";
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Returns the parameter-free SQL for shape extraction: drizzle's `.query`
|
|
270
|
+
* (already param-free) when present, else the message with the `Failed query:`
|
|
271
|
+
* prefix removed and the `\nparams: [...]` tail stripped. Never returns the
|
|
272
|
+
* parameter array.
|
|
273
|
+
*/
|
|
274
|
+
function readParamFreeSql(error, message) {
|
|
275
|
+
const query = readNestedString(error, ["query"]);
|
|
276
|
+
if (query)
|
|
277
|
+
return query;
|
|
278
|
+
let sql = message;
|
|
279
|
+
const prefixIndex = sql.indexOf("Failed query:");
|
|
280
|
+
if (prefixIndex >= 0)
|
|
281
|
+
sql = sql.slice(prefixIndex + "Failed query:".length);
|
|
282
|
+
const paramsIndex = sql.search(/\n\s*params\s*:/i);
|
|
283
|
+
if (paramsIndex >= 0)
|
|
284
|
+
sql = sql.slice(0, paramsIndex);
|
|
285
|
+
return sql.trim();
|
|
286
|
+
}
|
|
287
|
+
const SQL_VERB = /^(select|insert|update|delete|with|merge|create|alter|drop|truncate)\b/i;
|
|
288
|
+
// Identifier after a table-introducing keyword: optionally double-quoted, with an
|
|
289
|
+
// optional `schema.table` qualification. Stops at the first non-identifier char,
|
|
290
|
+
// so it can never capture a value, a paren-subquery, or whitespace.
|
|
291
|
+
const TABLE_AFTER_KEYWORD = /\b(?:from|into|update|join|table)\s+("?[A-Za-z_][\w$]*"?(?:\s*\.\s*"?[A-Za-z_][\w$]*"?)?)/i;
|
|
292
|
+
function extractQueryShape(sql) {
|
|
293
|
+
const trimmed = sql.replace(/^[\s(]+/, "");
|
|
294
|
+
const kindMatch = SQL_VERB.exec(trimmed);
|
|
295
|
+
const kind = kindMatch?.[1]?.toLowerCase() ?? null;
|
|
296
|
+
const tableMatch = TABLE_AFTER_KEYWORD.exec(trimmed);
|
|
297
|
+
const rawTable = tableMatch?.[1]?.replace(/["\s]/g, "") ?? null;
|
|
298
|
+
const table = rawTable && isSafeIdentifier(rawTable) ? rawTable : null;
|
|
299
|
+
return { kind, table };
|
|
300
|
+
}
|
|
301
|
+
function isSafeIdentifier(value) {
|
|
302
|
+
return value.length > 0 && value.length <= 128 && /^[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)?$/.test(value);
|
|
303
|
+
}
|
|
304
|
+
function readMessage(error) {
|
|
305
|
+
if (error instanceof Error)
|
|
306
|
+
return error.message ?? "";
|
|
307
|
+
const value = readNestedString(error, ["message"]);
|
|
308
|
+
return value ?? "";
|
|
309
|
+
}
|
|
310
|
+
function readNestedString(error, path) {
|
|
311
|
+
let current = error;
|
|
312
|
+
for (const part of path) {
|
|
313
|
+
if (!current || typeof current !== "object")
|
|
314
|
+
return null;
|
|
315
|
+
current = current[part];
|
|
316
|
+
}
|
|
317
|
+
return typeof current === "string" ? current : null;
|
|
318
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SpanStatusCode, metrics, trace, } from "@opentelemetry/api";
|
|
2
2
|
import { errorId } from "./log.js";
|
|
3
3
|
import { normalizeTelemetryAttributes } from "./redaction.js";
|
|
4
|
+
import { redactSqlParameters, sqlErrorTelemetryAttributes } from "./sql-error.js";
|
|
4
5
|
import { OXYGEN_VERSION } from "./version.js";
|
|
5
6
|
const counterCache = new Map();
|
|
6
7
|
const histogramCache = new Map();
|
|
@@ -20,7 +21,13 @@ export async function withTelemetrySpan(tracerName, name, attributes, fn, option
|
|
|
20
21
|
}
|
|
21
22
|
else {
|
|
22
23
|
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
23
|
-
|
|
24
|
+
// Overwrite the initial `outcome: "started"` so ERROR-status spans are
|
|
25
|
+
// never left mislabelled as still-running; dashboards/reports that
|
|
26
|
+
// group by outcome would otherwise misclassify real faults (OXY-32).
|
|
27
|
+
span.setAttributes(normalizeTelemetryAttributes({
|
|
28
|
+
...errorTelemetryAttributes(error),
|
|
29
|
+
outcome: "error",
|
|
30
|
+
}));
|
|
24
31
|
}
|
|
25
32
|
throw error;
|
|
26
33
|
}
|
|
@@ -69,9 +76,17 @@ export function commonTelemetryAttributes(attributes) {
|
|
|
69
76
|
"deployment.environment": workerProcess
|
|
70
77
|
? process.env.FLY_ENVIRONMENT ?? process.env.NODE_ENV ?? null
|
|
71
78
|
: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
79
|
+
// deployment.sha stays the deploy artifact id: the Fly registry image ref on
|
|
80
|
+
// the worker (release cross-reference), VERCEL_GIT_COMMIT_SHA on the web. The
|
|
81
|
+
// worker's image ref is NOT a git commit, so deployment.git_sha carries the
|
|
82
|
+
// resolved commit separately and is the single field that means "the commit"
|
|
83
|
+
// on both surfaces (OXY-61). On the web both happen to equal the Vercel SHA.
|
|
72
84
|
"deployment.sha": workerProcess
|
|
73
85
|
? process.env.FLY_IMAGE_REF ?? process.env.VERCEL_GIT_COMMIT_SHA ?? null
|
|
74
86
|
: process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.FLY_IMAGE_REF ?? null,
|
|
87
|
+
"deployment.git_sha": workerProcess
|
|
88
|
+
? process.env.OXYGEN_GIT_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? null
|
|
89
|
+
: process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.OXYGEN_GIT_SHA ?? null,
|
|
75
90
|
"cloud.region": workerProcess
|
|
76
91
|
? process.env.FLY_REGION ?? process.env.VERCEL_REGION ?? null
|
|
77
92
|
: process.env.VERCEL_REGION ?? process.env.FLY_REGION ?? null,
|
|
@@ -104,19 +119,27 @@ function getHistogram(name) {
|
|
|
104
119
|
return histogram;
|
|
105
120
|
}
|
|
106
121
|
function errorTelemetryAttributes(error) {
|
|
122
|
+
// Structured SQL attribution (SQLSTATE, cause category, query kind/table,
|
|
123
|
+
// transient) when the error is a pg/drizzle failure; {} otherwise, so non-DB
|
|
124
|
+
// spans are unchanged. Never includes SQL text or parameter values (OXY-46).
|
|
125
|
+
const sqlAttributes = sqlErrorTelemetryAttributes(error);
|
|
107
126
|
if (error instanceof Error) {
|
|
108
127
|
// pg connect timeouts (and some driver errors) surface with an empty
|
|
109
128
|
// message; fall back to the error name so spans are never message-less.
|
|
110
|
-
|
|
129
|
+
// Strip drizzle's `\nparams: <values>` tail so parameter values stay out of
|
|
130
|
+
// telemetry (OXY-46).
|
|
131
|
+
const message = error.message.trim() ? redactSqlParameters(error.message) : error.name || "unknown_error";
|
|
111
132
|
return {
|
|
112
133
|
"error.id": errorId(error),
|
|
113
134
|
"error.name": error.name,
|
|
114
135
|
"error.message": message,
|
|
136
|
+
...sqlAttributes,
|
|
115
137
|
};
|
|
116
138
|
}
|
|
117
|
-
const text = String(error).trim();
|
|
139
|
+
const text = redactSqlParameters(String(error)).trim();
|
|
118
140
|
return {
|
|
119
141
|
"error.id": "non_error",
|
|
120
142
|
"error.message": text || "unknown_error",
|
|
143
|
+
...sqlAttributes,
|
|
121
144
|
};
|
|
122
145
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
2
|
-
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.162.10";
|
|
2
|
+
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.162.10";
|
|
2
2
|
// Bump this only when deployed CLI/API contracts require a newer CLI.
|
|
3
|
-
|
|
3
|
+
// 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
|
|
4
|
+
// (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
|
|
5
|
+
// removed the old /api/cli/linkedin/* routes — older CLIs would 404.
|
|
6
|
+
export const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
|
|
@@ -334,7 +334,6 @@ export declare function branchStep(input: {
|
|
|
334
334
|
export declare function isWorkflowDefinition(value: unknown): value is WorkflowDefinition;
|
|
335
335
|
export declare function isWorkflowManifest(value: unknown): value is WorkflowManifest;
|
|
336
336
|
export declare function isRecipeManifest(value: unknown): value is RecipeManifest;
|
|
337
|
-
export declare function isDurableRecipeManifest(value: unknown): value is RecipeManifest;
|
|
338
337
|
export declare function isAnyWorkflowManifest(value: unknown): value is AnyWorkflowManifest;
|
|
339
338
|
export declare function compileWorkflowDefinition(// skipcq: JS-R1005 -- compiler validates workflow metadata, trigger, steps, branch targets, and defaults together.
|
|
340
339
|
definition: WorkflowDefinition, options?: {
|
|
@@ -533,24 +532,6 @@ export declare const workflowManifestSchema: {
|
|
|
533
532
|
readonly additionalProperties: true;
|
|
534
533
|
readonly required: readonly ["manifest_version", "workflow", "steps", "source_hash", "compiler_version"];
|
|
535
534
|
};
|
|
536
|
-
export declare function getWorkflowApplySchema(): {
|
|
537
|
-
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
538
|
-
readonly title: "OXYGEN Workflow Apply Input";
|
|
539
|
-
readonly type: "object";
|
|
540
|
-
readonly additionalProperties: false;
|
|
541
|
-
readonly properties: {
|
|
542
|
-
readonly manifest: {
|
|
543
|
-
readonly $ref: "#/$defs/manifest";
|
|
544
|
-
};
|
|
545
|
-
};
|
|
546
|
-
readonly required: readonly ["manifest"];
|
|
547
|
-
readonly $defs: {
|
|
548
|
-
readonly manifest: {
|
|
549
|
-
readonly type: "object";
|
|
550
|
-
readonly additionalProperties: true;
|
|
551
|
-
};
|
|
552
|
-
};
|
|
553
|
-
};
|
|
554
535
|
export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" | "trigger" | "manifest" | "all"): {
|
|
555
536
|
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
556
537
|
readonly title: "OXYGEN Workflow Apply Input";
|
|
@@ -31,6 +31,10 @@ const UNSAFE_RECIPE_BUNDLE_PATTERNS = [
|
|
|
31
31
|
{ token: "setTimeout", pattern: /\bsetTimeout\b/ },
|
|
32
32
|
{ token: "setInterval", pattern: /\bsetInterval\b/ }, // skipcq: SCT-A000
|
|
33
33
|
];
|
|
34
|
+
/** A valid workflow / recipe / trigger status. Optional everywhere it appears. */
|
|
35
|
+
function isValidWorkflowStatus(value) {
|
|
36
|
+
return value === "active" || value === "disabled";
|
|
37
|
+
}
|
|
34
38
|
export function defineWorkflow(input) {
|
|
35
39
|
return {
|
|
36
40
|
__oxygen_workflow_definition: true,
|
|
@@ -140,9 +144,6 @@ export function isRecipeManifest(value) {
|
|
|
140
144
|
&& value.manifest_version === WORKFLOW_MANIFEST_VERSION
|
|
141
145
|
&& value.compiler_version === DURABLE_RECIPE_COMPILER_VERSION;
|
|
142
146
|
}
|
|
143
|
-
export function isDurableRecipeManifest(value) {
|
|
144
|
-
return isRecipeManifest(value);
|
|
145
|
-
}
|
|
146
147
|
export function isAnyWorkflowManifest(value) {
|
|
147
148
|
return isWorkflowManifest(value) || isRecipeManifest(value);
|
|
148
149
|
}
|
|
@@ -248,7 +249,7 @@ value, options = {}) {
|
|
|
248
249
|
add("$.workflow.id", "invalid_workflow_id", "Workflow id is required.");
|
|
249
250
|
if (!isNonEmptyString(workflow.name))
|
|
250
251
|
add("$.workflow.name", "invalid_workflow_name", "Workflow name is required.");
|
|
251
|
-
if (workflow.status !== undefined && workflow.status
|
|
252
|
+
if (workflow.status !== undefined && !isValidWorkflowStatus(workflow.status)) {
|
|
252
253
|
add("$.workflow.status", "invalid_workflow_status", "Workflow status must be active or disabled.");
|
|
253
254
|
}
|
|
254
255
|
}
|
|
@@ -371,7 +372,7 @@ value, options = {}) {
|
|
|
371
372
|
add("$.workflow.id", "invalid_workflow_id", "Recipe id is required.");
|
|
372
373
|
if (!isNonEmptyString(workflow.name))
|
|
373
374
|
add("$.workflow.name", "invalid_workflow_name", "Recipe name is required.");
|
|
374
|
-
if (workflow.status !== undefined && workflow.status
|
|
375
|
+
if (workflow.status !== undefined && !isValidWorkflowStatus(workflow.status)) {
|
|
375
376
|
add("$.workflow.status", "invalid_workflow_status", "Recipe status must be active or disabled.");
|
|
376
377
|
}
|
|
377
378
|
}
|
|
@@ -904,9 +905,6 @@ export const workflowManifestSchema = {
|
|
|
904
905
|
additionalProperties: true,
|
|
905
906
|
required: ["manifest_version", "workflow", "steps", "source_hash", "compiler_version"],
|
|
906
907
|
};
|
|
907
|
-
export function getWorkflowApplySchema() {
|
|
908
|
-
return workflowApplySchema;
|
|
909
|
-
}
|
|
910
908
|
export function getWorkflowSchema(subject = "apply") {
|
|
911
909
|
const schemas = {
|
|
912
910
|
apply: workflowApplySchema,
|
|
@@ -1042,13 +1040,20 @@ function formatSchemaTypes(types) {
|
|
|
1042
1040
|
function jsonEqual(left, right) {
|
|
1043
1041
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
1044
1042
|
}
|
|
1043
|
+
// Optional idempotency_key_path (webhook + event triggers): when present it must
|
|
1044
|
+
// be a non-empty string. `label` distinguishes the trigger type in the message.
|
|
1045
|
+
function validateOptionalIdempotencyKeyPath(value, path, label, add) {
|
|
1046
|
+
if (value !== undefined && value !== null && !isNonEmptyString(value)) {
|
|
1047
|
+
add(`${path}.idempotency_key_path`, "invalid_idempotency_key_path", `${label} idempotency path is invalid.`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1045
1050
|
function validateTrigger(// skipcq: JS-R1005
|
|
1046
1051
|
value, path, add) {
|
|
1047
1052
|
if (!isRecord(value)) {
|
|
1048
1053
|
add(path, "invalid_trigger", "Workflow trigger must be an object.");
|
|
1049
1054
|
return;
|
|
1050
1055
|
}
|
|
1051
|
-
if (value.status !== undefined && value.status
|
|
1056
|
+
if (value.status !== undefined && !isValidWorkflowStatus(value.status)) {
|
|
1052
1057
|
add(`${path}.status`, "invalid_trigger_status", "Trigger status must be active or disabled.");
|
|
1053
1058
|
}
|
|
1054
1059
|
if (value.type === "api")
|
|
@@ -1057,11 +1062,7 @@ value, path, add) {
|
|
|
1057
1062
|
if (!isNonEmptyString(value.trigger_id)) {
|
|
1058
1063
|
add(`${path}.trigger_id`, "invalid_trigger_id", "Webhook trigger_id is required.");
|
|
1059
1064
|
}
|
|
1060
|
-
|
|
1061
|
-
&& value.idempotency_key_path !== null
|
|
1062
|
-
&& !isNonEmptyString(value.idempotency_key_path)) {
|
|
1063
|
-
add(`${path}.idempotency_key_path`, "invalid_idempotency_key_path", "Webhook idempotency path is invalid.");
|
|
1064
|
-
}
|
|
1065
|
+
validateOptionalIdempotencyKeyPath(value.idempotency_key_path, path, "Webhook", add);
|
|
1065
1066
|
return;
|
|
1066
1067
|
}
|
|
1067
1068
|
if (value.type === "cron") {
|
|
@@ -1085,11 +1086,7 @@ value, path, add) {
|
|
|
1085
1086
|
if (!isNonEmptyString(value.event)) {
|
|
1086
1087
|
add(`${path}.event`, "invalid_event_type", "Event trigger event is required.");
|
|
1087
1088
|
}
|
|
1088
|
-
|
|
1089
|
-
&& value.idempotency_key_path !== null
|
|
1090
|
-
&& !isNonEmptyString(value.idempotency_key_path)) {
|
|
1091
|
-
add(`${path}.idempotency_key_path`, "invalid_idempotency_key_path", "Event idempotency path is invalid.");
|
|
1092
|
-
}
|
|
1089
|
+
validateOptionalIdempotencyKeyPath(value.idempotency_key_path, path, "Event", add);
|
|
1093
1090
|
if (value.filters !== undefined)
|
|
1094
1091
|
validateEventFilters(value.filters, `${path}.filters`, add);
|
|
1095
1092
|
return;
|