@oxygen-agent/cli 1.336.5 → 1.346.5
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/command-manifest.js +7 -6
- package/dist/index.js +436 -55
- package/node_modules/@oxygen/shared/dist/custom-http-safety.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/custom-http-safety.js +52 -1
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.js +36 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/schedule-label.js +157 -0
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +9 -0
- package/node_modules/@oxygen/shared/dist/suppression-entries.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/suppression-entries.js +40 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/tags.js +33 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-status-change.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-status-change.js +6 -2
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +7 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +35 -0
- package/package.json +1 -1
|
@@ -15,4 +15,36 @@ export declare function assertCustomHttpResolvedHostAllowed(input: {
|
|
|
15
15
|
resolveHostname?: CustomHttpResolveHostname;
|
|
16
16
|
}): Promise<CustomHttpResolvedAddress[]>;
|
|
17
17
|
export declare function normalizeCustomHttpUrlHost(hostname: string): string;
|
|
18
|
+
export type PinnedHostLookupEntry = {
|
|
19
|
+
address: string;
|
|
20
|
+
family: number;
|
|
21
|
+
};
|
|
22
|
+
export type PinnedHostLookup = (hostname: string, options: {
|
|
23
|
+
all?: boolean;
|
|
24
|
+
family?: number;
|
|
25
|
+
} | undefined, callback: (error: NodeJS.ErrnoException | null, address?: string | PinnedHostLookupEntry[], family?: number) => void) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Build the `connect.lookup` for a dispatcher pinned to guard-validated
|
|
28
|
+
* addresses (DNS-rebinding TOCTOU closure).
|
|
29
|
+
*
|
|
30
|
+
* Node's dns.lookup callback contract has TWO conventions and the pinned lookup
|
|
31
|
+
* must honor BOTH: a plain lookup gets `(err, address, family)`, while an
|
|
32
|
+
* `{all: true}` lookup — which undici's connect path always issues when
|
|
33
|
+
* autoSelectFamily is available (Node >= 20) — gets `(err, [{address, family}])`.
|
|
34
|
+
* `@types/node` types `net.LookupFunction` scalar-only, so a scalar-only
|
|
35
|
+
* implementation typechecks and then fails EVERY pinned fetch at runtime with
|
|
36
|
+
* `ERR_INVALID_IP_ADDRESS: Invalid IP address: undefined` (the 2026-07-15
|
|
37
|
+
* custom_http outage). The regression tests exercise a real socket through a
|
|
38
|
+
* real undici Agent precisely because the compiler cannot catch this.
|
|
39
|
+
*
|
|
40
|
+
* All validated addresses are returned (not just the first) so the runtime can
|
|
41
|
+
* fall back across records, sorted IPv4-first: some runtimes (Vercel serverless
|
|
42
|
+
* functions) have no IPv6 egress, and autoSelectFamily attempts addresses in
|
|
43
|
+
* the order given.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createPinnedHostLookup(input: {
|
|
46
|
+
pinnedHost: string;
|
|
47
|
+
addresses: readonly CustomHttpResolvedAddress[];
|
|
48
|
+
mismatchMessage: string;
|
|
49
|
+
}): PinnedHostLookup;
|
|
18
50
|
export declare function isBlockedCustomHttpHost(host: string): boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
|
+
import { describeErrorWithCauses } from "./error-redaction.js";
|
|
3
4
|
export class CustomHttpUrlSafetyError extends Error {
|
|
4
5
|
reason;
|
|
5
6
|
details;
|
|
@@ -41,7 +42,9 @@ export async function assertCustomHttpResolvedHostAllowed(input) {
|
|
|
41
42
|
catch (error) {
|
|
42
43
|
throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host could not be resolved.", {
|
|
43
44
|
host,
|
|
44
|
-
|
|
45
|
+
// Keep the DNS error code (ENOTFOUND/EAI_AGAIN/...) — `message` alone is
|
|
46
|
+
// often the unactionable "fetch failed"-style wrapper.
|
|
47
|
+
reason: describeErrorWithCauses(error),
|
|
45
48
|
});
|
|
46
49
|
}
|
|
47
50
|
if (addresses.length === 0) {
|
|
@@ -63,6 +66,54 @@ export function normalizeCustomHttpUrlHost(hostname) {
|
|
|
63
66
|
const host = hostname.toLowerCase().replace(/\.+$/, "");
|
|
64
67
|
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
65
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the `connect.lookup` for a dispatcher pinned to guard-validated
|
|
71
|
+
* addresses (DNS-rebinding TOCTOU closure).
|
|
72
|
+
*
|
|
73
|
+
* Node's dns.lookup callback contract has TWO conventions and the pinned lookup
|
|
74
|
+
* must honor BOTH: a plain lookup gets `(err, address, family)`, while an
|
|
75
|
+
* `{all: true}` lookup — which undici's connect path always issues when
|
|
76
|
+
* autoSelectFamily is available (Node >= 20) — gets `(err, [{address, family}])`.
|
|
77
|
+
* `@types/node` types `net.LookupFunction` scalar-only, so a scalar-only
|
|
78
|
+
* implementation typechecks and then fails EVERY pinned fetch at runtime with
|
|
79
|
+
* `ERR_INVALID_IP_ADDRESS: Invalid IP address: undefined` (the 2026-07-15
|
|
80
|
+
* custom_http outage). The regression tests exercise a real socket through a
|
|
81
|
+
* real undici Agent precisely because the compiler cannot catch this.
|
|
82
|
+
*
|
|
83
|
+
* All validated addresses are returned (not just the first) so the runtime can
|
|
84
|
+
* fall back across records, sorted IPv4-first: some runtimes (Vercel serverless
|
|
85
|
+
* functions) have no IPv6 egress, and autoSelectFamily attempts addresses in
|
|
86
|
+
* the order given.
|
|
87
|
+
*/
|
|
88
|
+
export function createPinnedHostLookup(input) {
|
|
89
|
+
const pinnedHost = normalizeCustomHttpUrlHost(input.pinnedHost);
|
|
90
|
+
const entries = input.addresses
|
|
91
|
+
.map((entry) => ({ address: entry.address, family: entry.family ?? isIP(entry.address) }))
|
|
92
|
+
.filter((entry) => entry.family === 4 || entry.family === 6)
|
|
93
|
+
.sort((a, b) => a.family - b.family);
|
|
94
|
+
return (hostname, options, callback) => {
|
|
95
|
+
if (normalizeCustomHttpUrlHost(hostname) !== pinnedHost) {
|
|
96
|
+
const error = new Error(input.mismatchMessage);
|
|
97
|
+
error.code = "ERR_OXYGEN_PINNED_LOOKUP_HOST_MISMATCH";
|
|
98
|
+
callback(error);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const family = options?.family === 4 || options?.family === 6 ? options.family : null;
|
|
102
|
+
const matching = family === null ? entries : entries.filter((entry) => entry.family === family);
|
|
103
|
+
const first = matching[0];
|
|
104
|
+
if (!first) {
|
|
105
|
+
const error = new Error(`No pinned address available for host "${hostname}".`);
|
|
106
|
+
error.code = "ERR_OXYGEN_PINNED_LOOKUP_NO_ADDRESS";
|
|
107
|
+
callback(error);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (options?.all) {
|
|
111
|
+
callback(null, matching);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
callback(null, first.address, first.family);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
66
117
|
export function isBlockedCustomHttpHost(host) {
|
|
67
118
|
return host === "localhost"
|
|
68
119
|
|| host.endsWith(".localhost")
|
|
@@ -78,3 +78,18 @@ export declare function readWorkflowStepFailure(error: unknown): WorkflowStepFai
|
|
|
78
78
|
*/
|
|
79
79
|
export declare const AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE = "automation_actions_exceeded";
|
|
80
80
|
export declare function isAutomationUsageQuotaError(error: unknown): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Node network primitives bury the actionable failure in `error.cause`: undici's
|
|
83
|
+
* fetch rejects with a bare TypeError "fetch failed" and puts the real
|
|
84
|
+
* ENOTFOUND/ECONNREFUSED/ERR_INVALID_IP_ADDRESS underneath. Recording only
|
|
85
|
+
* `error.message` made the Appsaavy custom_http outage (2026-07-15) undiagnosable
|
|
86
|
+
* from operation events — these helpers exist so catch sites can persist the
|
|
87
|
+
* whole chain. Callers owning secrets must still redact each message.
|
|
88
|
+
*/
|
|
89
|
+
export type ErrorCauseEntry = {
|
|
90
|
+
name: string;
|
|
91
|
+
code: string | null;
|
|
92
|
+
message: string;
|
|
93
|
+
};
|
|
94
|
+
export declare function errorCauseChain(error: unknown, maxDepth?: number): ErrorCauseEntry[];
|
|
95
|
+
export declare function describeErrorWithCauses(error: unknown, maxDepth?: number): string;
|
|
@@ -215,6 +215,42 @@ export function isAutomationUsageQuotaError(error) {
|
|
|
215
215
|
&& typeof error === "object"
|
|
216
216
|
&& error.code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE;
|
|
217
217
|
}
|
|
218
|
+
const MAX_CAUSE_DEPTH = 4;
|
|
219
|
+
const MAX_CAUSE_MESSAGE_LENGTH = 200;
|
|
220
|
+
export function errorCauseChain(error, maxDepth = MAX_CAUSE_DEPTH) {
|
|
221
|
+
const chain = [];
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
let current = error;
|
|
224
|
+
while (current !== null && current !== undefined && chain.length < maxDepth && !seen.has(current)) {
|
|
225
|
+
seen.add(current);
|
|
226
|
+
chain.push(describeCauseEntry(current));
|
|
227
|
+
current = current instanceof Error || isRecord(current) ? current.cause : undefined;
|
|
228
|
+
}
|
|
229
|
+
return chain;
|
|
230
|
+
}
|
|
231
|
+
export function describeErrorWithCauses(error, maxDepth = MAX_CAUSE_DEPTH) {
|
|
232
|
+
return errorCauseChain(error, maxDepth)
|
|
233
|
+
.map((entry) => (entry.code ? `${entry.code}: ${entry.message}` : entry.message))
|
|
234
|
+
.join(" <- ");
|
|
235
|
+
}
|
|
236
|
+
function describeCauseEntry(value) {
|
|
237
|
+
if (value instanceof Error) {
|
|
238
|
+
const code = value.code;
|
|
239
|
+
return {
|
|
240
|
+
name: value.name,
|
|
241
|
+
code: typeof code === "string" ? code : null,
|
|
242
|
+
message: truncate(value.message, MAX_CAUSE_MESSAGE_LENGTH),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (isRecord(value)) {
|
|
246
|
+
return {
|
|
247
|
+
name: typeof value.name === "string" ? value.name : "object",
|
|
248
|
+
code: typeof value.code === "string" ? value.code : null,
|
|
249
|
+
message: truncate(typeof value.message === "string" ? value.message : JSON.stringify(value) ?? "", MAX_CAUSE_MESSAGE_LENGTH),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { name: typeof value, code: null, message: truncate(String(value), MAX_CAUSE_MESSAGE_LENGTH) };
|
|
253
|
+
}
|
|
218
254
|
function truncate(value, maxLength) {
|
|
219
255
|
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
|
220
256
|
}
|
|
@@ -25,10 +25,13 @@ export * from "./networks.js";
|
|
|
25
25
|
export * from "./recipes.js";
|
|
26
26
|
export * from "./sequence-template.js";
|
|
27
27
|
export * from "./sequences.js";
|
|
28
|
+
export * from "./suppression-entries.js";
|
|
28
29
|
export * from "./log.js";
|
|
29
30
|
export * from "./provider-request-outcomes.js";
|
|
31
|
+
export * from "./schedule-label.js";
|
|
30
32
|
export * from "./signup-lead-deliveries.js";
|
|
31
33
|
export * from "./sql-error.js";
|
|
34
|
+
export * from "./tags.js";
|
|
32
35
|
export * from "./telemetry.js";
|
|
33
36
|
export * from "./tenant-database-secret.js";
|
|
34
37
|
export * from "./timing.js";
|
|
@@ -25,10 +25,13 @@ export * from "./networks.js";
|
|
|
25
25
|
export * from "./recipes.js";
|
|
26
26
|
export * from "./sequence-template.js";
|
|
27
27
|
export * from "./sequences.js";
|
|
28
|
+
export * from "./suppression-entries.js";
|
|
28
29
|
export * from "./log.js";
|
|
29
30
|
export * from "./provider-request-outcomes.js";
|
|
31
|
+
export * from "./schedule-label.js";
|
|
30
32
|
export * from "./signup-lead-deliveries.js";
|
|
31
33
|
export * from "./sql-error.js";
|
|
34
|
+
export * from "./tags.js";
|
|
32
35
|
export * from "./telemetry.js";
|
|
33
36
|
export * from "./tenant-database-secret.js";
|
|
34
37
|
export * from "./timing.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function humanizeCronSchedule(cron: string): string | null;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Human-readable labels for the common cron shapes OXYGEN schedules actually
|
|
2
|
+
// use ("Every 5 minutes", "Daily at 10:00", "Mon at 09:30"). Returns null for
|
|
3
|
+
// anything exotic — callers MUST fall back to the raw cron string
|
|
4
|
+
// (`humanizeCronSchedule(cron) ?? cron`) and keep the raw expression available
|
|
5
|
+
// as secondary text/tooltip. Timezone is rendered by callers as a separate
|
|
6
|
+
// suffix; it is never folded into the label because cron fields are already
|
|
7
|
+
// expressed in the trigger's own timezone.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free on purpose: consumed by web, CLI, and the MCP widgets
|
|
10
|
+
// (packages/mcp-server depends only on @oxygen/shared).
|
|
11
|
+
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
12
|
+
const MINUTE_RANGE = { min: 0, max: 59 };
|
|
13
|
+
const HOUR_RANGE = { min: 0, max: 23 };
|
|
14
|
+
const DAY_OF_MONTH_RANGE = { min: 1, max: 31 };
|
|
15
|
+
const MONTH_RANGE = { min: 1, max: 12 };
|
|
16
|
+
const DAY_OF_WEEK_RANGE = { min: 0, max: 7 };
|
|
17
|
+
// Expand one cron field to its sorted concrete values, or null when invalid.
|
|
18
|
+
// Same grammar as estimateCronRunsPer30Days: *, N, N-M, with optional /step,
|
|
19
|
+
// comma-joined; dow 7 normalizes to 0 (Sunday).
|
|
20
|
+
function expandCronField(field, range) {
|
|
21
|
+
const values = new Set();
|
|
22
|
+
for (const part of field.split(",")) {
|
|
23
|
+
const trimmed = part.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
return null;
|
|
26
|
+
const match = trimmed.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/);
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
const rangePart = match[1];
|
|
30
|
+
const stepPart = match[2];
|
|
31
|
+
if (!rangePart)
|
|
32
|
+
return null;
|
|
33
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
34
|
+
if (!Number.isInteger(step) || step <= 0)
|
|
35
|
+
return null;
|
|
36
|
+
let start = range.min;
|
|
37
|
+
let end = range.max;
|
|
38
|
+
if (rangePart !== "*") {
|
|
39
|
+
const [startRaw, endRaw] = rangePart.split("-");
|
|
40
|
+
start = Number(startRaw);
|
|
41
|
+
end = endRaw === undefined ? start : Number(endRaw);
|
|
42
|
+
}
|
|
43
|
+
if (!Number.isInteger(start)
|
|
44
|
+
|| !Number.isInteger(end)
|
|
45
|
+
|| start < range.min
|
|
46
|
+
|| end > range.max
|
|
47
|
+
|| end < start) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
for (let value = start; value <= end; value += step) {
|
|
51
|
+
const normalized = range.max === 7 && value === 7 ? 0 : value;
|
|
52
|
+
values.add(normalized);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...values].sort((a, b) => a - b);
|
|
56
|
+
}
|
|
57
|
+
function coversFullRange(values, range) {
|
|
58
|
+
return values.length === range.max - range.min + 1
|
|
59
|
+
// dow spans 0-7 but 7 aliases to 0, so 7 distinct values are "every day".
|
|
60
|
+
|| (range.max === 7 && values.length === 7);
|
|
61
|
+
}
|
|
62
|
+
// A set like {0, g, 2g, …} covering the field via a uniform gap g (g > 1).
|
|
63
|
+
function uniformStep(values, rangeSize) {
|
|
64
|
+
if (values.length < 2 || values[0] !== 0)
|
|
65
|
+
return null;
|
|
66
|
+
if (rangeSize % values.length !== 0)
|
|
67
|
+
return null;
|
|
68
|
+
const gap = rangeSize / values.length;
|
|
69
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
70
|
+
if (values[i] !== i * gap)
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return gap;
|
|
74
|
+
}
|
|
75
|
+
function pad2(value) {
|
|
76
|
+
return String(value).padStart(2, "0");
|
|
77
|
+
}
|
|
78
|
+
function timeOfDay(hour, minute) {
|
|
79
|
+
return `${pad2(hour)}:${pad2(minute)}`;
|
|
80
|
+
}
|
|
81
|
+
// Mon-first display order (cron's 0 is Sunday).
|
|
82
|
+
function dayNames(days) {
|
|
83
|
+
return days
|
|
84
|
+
.slice()
|
|
85
|
+
.sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7))
|
|
86
|
+
.map((day) => DAY_NAMES[day])
|
|
87
|
+
.join(", ");
|
|
88
|
+
}
|
|
89
|
+
export function humanizeCronSchedule(cron) {
|
|
90
|
+
const fields = cron.trim().split(/\s+/);
|
|
91
|
+
if (fields.length !== 5)
|
|
92
|
+
return null;
|
|
93
|
+
const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;
|
|
94
|
+
const minutes = expandCronField(minuteField, MINUTE_RANGE);
|
|
95
|
+
const hours = expandCronField(hourField, HOUR_RANGE);
|
|
96
|
+
const daysOfMonth = expandCronField(dayOfMonthField, DAY_OF_MONTH_RANGE);
|
|
97
|
+
const months = expandCronField(monthField, MONTH_RANGE);
|
|
98
|
+
const daysOfWeek = expandCronField(dayOfWeekField, DAY_OF_WEEK_RANGE);
|
|
99
|
+
if (!minutes || !hours || !daysOfMonth || !months || !daysOfWeek)
|
|
100
|
+
return null;
|
|
101
|
+
// Month-restricted schedules read poorly as one-liners — raw cron is clearer.
|
|
102
|
+
if (!coversFullRange(months, MONTH_RANGE))
|
|
103
|
+
return null;
|
|
104
|
+
const everyDayOfMonth = coversFullRange(daysOfMonth, DAY_OF_MONTH_RANGE);
|
|
105
|
+
const everyDayOfWeek = coversFullRange(daysOfWeek, DAY_OF_WEEK_RANGE);
|
|
106
|
+
const everyHour = coversFullRange(hours, HOUR_RANGE);
|
|
107
|
+
const everyMinute = coversFullRange(minutes, MINUTE_RANGE);
|
|
108
|
+
// Sub-hourly cadences: "Every minute" / "Every N minutes".
|
|
109
|
+
if (everyHour && everyDayOfMonth && everyDayOfWeek) {
|
|
110
|
+
if (everyMinute)
|
|
111
|
+
return "Every minute";
|
|
112
|
+
const minuteGap = uniformStep(minutes, 60);
|
|
113
|
+
if (minuteGap !== null)
|
|
114
|
+
return `Every ${minuteGap} minutes`;
|
|
115
|
+
if (minutes.length === 1) {
|
|
116
|
+
const minute = minutes[0];
|
|
117
|
+
return minute === 0 ? "Hourly" : `Hourly at :${pad2(minute)}`;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (minutes.length !== 1)
|
|
122
|
+
return null;
|
|
123
|
+
const minute = minutes[0];
|
|
124
|
+
// Multi-hour cadences: "Every 6 hours" (at :MM when the minute is offset).
|
|
125
|
+
if (everyDayOfMonth && everyDayOfWeek) {
|
|
126
|
+
const hourGap = uniformStep(hours, 24);
|
|
127
|
+
if (hourGap !== null && hours.length > 1) {
|
|
128
|
+
return minute === 0
|
|
129
|
+
? `Every ${hourGap} hours`
|
|
130
|
+
: `Every ${hourGap} hours at :${pad2(minute)}`;
|
|
131
|
+
}
|
|
132
|
+
// A short list of fixed times: "Daily at 09:00, 17:00".
|
|
133
|
+
if (hours.length >= 2 && hours.length <= 3) {
|
|
134
|
+
return `Daily at ${hours.map((hour) => timeOfDay(hour, minute)).join(", ")}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (hours.length !== 1)
|
|
138
|
+
return null;
|
|
139
|
+
const time = timeOfDay(hours[0], minute);
|
|
140
|
+
if (everyDayOfMonth) {
|
|
141
|
+
if (everyDayOfWeek)
|
|
142
|
+
return `Daily at ${time}`;
|
|
143
|
+
const dowKey = daysOfWeek.join(",");
|
|
144
|
+
if (dowKey === "1,2,3,4,5")
|
|
145
|
+
return `Weekdays at ${time}`;
|
|
146
|
+
if (dowKey === "0,6")
|
|
147
|
+
return `Weekends at ${time}`;
|
|
148
|
+
if (daysOfWeek.length <= 3)
|
|
149
|
+
return `${dayNames(daysOfWeek)} at ${time}`;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
// Restricted day-of-month: only the single-day monthly shape stays readable.
|
|
153
|
+
if (everyDayOfWeek && daysOfMonth.length === 1) {
|
|
154
|
+
return `Monthly on day ${daysOfMonth[0]} at ${time}`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
@@ -3,6 +3,7 @@ export type TagColor = (typeof TAG_COLORS)[number];
|
|
|
3
3
|
export declare const DEFAULT_TAG_COLOR: TagColor;
|
|
4
4
|
export declare function isTagColor(value: unknown): value is TagColor;
|
|
5
5
|
export declare function tagColorForIndex(index: number): TagColor;
|
|
6
|
+
export declare function tagColorForLabel(label: string): TagColor;
|
|
6
7
|
export declare const SELECT_COLUMN_SEMANTICS: readonly ["select", "single_select", "multi_select", "tags", "status"];
|
|
7
8
|
export type SelectColumnSemantic = (typeof SELECT_COLUMN_SEMANTICS)[number];
|
|
8
9
|
export declare function isSelectColumnSemantic(semanticType: string | null | undefined): boolean;
|
|
@@ -27,6 +27,15 @@ export function tagColorForIndex(index) {
|
|
|
27
27
|
const palette = TAG_COLORS.filter((color) => color !== "gray");
|
|
28
28
|
return palette[Math.abs(index) % palette.length] ?? DEFAULT_TAG_COLOR;
|
|
29
29
|
}
|
|
30
|
+
// Deterministic color for a free-form label (workspace tags): same tag, same
|
|
31
|
+
// color on every surface, with no stored color to sync.
|
|
32
|
+
export function tagColorForLabel(label) {
|
|
33
|
+
let hash = 0;
|
|
34
|
+
for (let index = 0; index < label.length; index += 1) {
|
|
35
|
+
hash = (hash * 31 + label.charCodeAt(index)) | 0;
|
|
36
|
+
}
|
|
37
|
+
return tagColorForIndex(Math.abs(hash));
|
|
38
|
+
}
|
|
30
39
|
// Semantic types (the column's `semanticType`) that present a fixed option set.
|
|
31
40
|
// `select`/`status` are single-value; `multi_select`/`tags` are array-valued.
|
|
32
41
|
// `single_select` is accepted as an alias of `select`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type SuppressionEntryKind = "email" | "domain" | "linkedin" | "invalid";
|
|
2
|
+
export type ClassifiedSuppressionEntry = {
|
|
3
|
+
kind: SuppressionEntryKind;
|
|
4
|
+
/** The ledger key to store: canonical profile URL / verbatim URN / lowercased email or domain. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** The original entry as pasted (trimmed), echoed back for rejection reporting. */
|
|
7
|
+
entry: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A LinkedIn member id as Unipile surfaces it (e.g. "ACoAADXaTD4B…"): the "AC"
|
|
11
|
+
* prefix plus base64url payload. Anchored and length-bounded so a short word that
|
|
12
|
+
* happens to start with "AC" never lands on the contact ledger; the charset has no
|
|
13
|
+
* dot or slash, so a URN can never collide with the domain or URL branches.
|
|
14
|
+
*/
|
|
15
|
+
export declare const LINKEDIN_MEMBER_URN_PATTERN: RegExp;
|
|
16
|
+
/**
|
|
17
|
+
* Classify one pasted entry by shape. Order matters:
|
|
18
|
+
* 1. LinkedIn profile URL (any scheme/www/pub form) -> linkedin, keyed by the
|
|
19
|
+
* canonical https://www.linkedin.com/in/<handle> form the sequencer enrolls on.
|
|
20
|
+
* 2. Contains "@" -> email (lowercased).
|
|
21
|
+
* 3. LinkedIn member URN -> linkedin, keyed verbatim (opaque + case-sensitive,
|
|
22
|
+
* matching normalizeLeadProviderId's trim-only contract).
|
|
23
|
+
* 4. Any other URL-shaped string -> invalid. A non-profile linkedin.com link
|
|
24
|
+
* (Sales Navigator, company page) or a random website URL is NOT a domain
|
|
25
|
+
* block — classifying it as one used to store garbage whole-domain blocks.
|
|
26
|
+
* 5. Dotted, whitespace-free -> domain (lowercased).
|
|
27
|
+
* 6. Everything else -> invalid.
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifySuppressionEntry(entry: string): ClassifiedSuppressionEntry;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { looksLikeUrlIdentifier, normalizeLinkedinProfileUrl } from "./linkedin-url.js";
|
|
2
|
+
/**
|
|
3
|
+
* A LinkedIn member id as Unipile surfaces it (e.g. "ACoAADXaTD4B…"): the "AC"
|
|
4
|
+
* prefix plus base64url payload. Anchored and length-bounded so a short word that
|
|
5
|
+
* happens to start with "AC" never lands on the contact ledger; the charset has no
|
|
6
|
+
* dot or slash, so a URN can never collide with the domain or URL branches.
|
|
7
|
+
*/
|
|
8
|
+
export const LINKEDIN_MEMBER_URN_PATTERN = /^AC[A-Za-z0-9_-]{8,}$/;
|
|
9
|
+
/**
|
|
10
|
+
* Classify one pasted entry by shape. Order matters:
|
|
11
|
+
* 1. LinkedIn profile URL (any scheme/www/pub form) -> linkedin, keyed by the
|
|
12
|
+
* canonical https://www.linkedin.com/in/<handle> form the sequencer enrolls on.
|
|
13
|
+
* 2. Contains "@" -> email (lowercased).
|
|
14
|
+
* 3. LinkedIn member URN -> linkedin, keyed verbatim (opaque + case-sensitive,
|
|
15
|
+
* matching normalizeLeadProviderId's trim-only contract).
|
|
16
|
+
* 4. Any other URL-shaped string -> invalid. A non-profile linkedin.com link
|
|
17
|
+
* (Sales Navigator, company page) or a random website URL is NOT a domain
|
|
18
|
+
* block — classifying it as one used to store garbage whole-domain blocks.
|
|
19
|
+
* 5. Dotted, whitespace-free -> domain (lowercased).
|
|
20
|
+
* 6. Everything else -> invalid.
|
|
21
|
+
*/
|
|
22
|
+
export function classifySuppressionEntry(entry) {
|
|
23
|
+
const trimmed = typeof entry === "string" ? entry.trim() : "";
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
return { kind: "invalid", key: "", entry: trimmed };
|
|
26
|
+
const profile = normalizeLinkedinProfileUrl(trimmed);
|
|
27
|
+
if (profile)
|
|
28
|
+
return { kind: "linkedin", key: profile.normalized, entry: trimmed };
|
|
29
|
+
if (trimmed.includes("@"))
|
|
30
|
+
return { kind: "email", key: trimmed.toLowerCase(), entry: trimmed };
|
|
31
|
+
if (LINKEDIN_MEMBER_URN_PATTERN.test(trimmed))
|
|
32
|
+
return { kind: "linkedin", key: trimmed, entry: trimmed };
|
|
33
|
+
if (looksLikeUrlIdentifier(trimmed) || trimmed.includes("/")) {
|
|
34
|
+
return { kind: "invalid", key: "", entry: trimmed };
|
|
35
|
+
}
|
|
36
|
+
if (trimmed.includes(".") && !/\s/.test(trimmed)) {
|
|
37
|
+
return { kind: "domain", key: trimmed.toLowerCase(), entry: trimmed };
|
|
38
|
+
}
|
|
39
|
+
return { kind: "invalid", key: "", entry: trimmed };
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Primitive kinds that participate in the workspace tag union today. */
|
|
2
|
+
export declare const TAG_KINDS: readonly ["knowledge_page", "sequence", "table", "workflow", "recipe"];
|
|
3
|
+
export type TagKind = (typeof TAG_KINDS)[number];
|
|
4
|
+
export declare function isTagKind(value: unknown): value is TagKind;
|
|
5
|
+
/** Hard cap per tagged item — matches the wiki's long-standing limit. */
|
|
6
|
+
export declare const MAX_TAGS_PER_ITEM = 50;
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a raw tag array: lowercase, trim, drop non-strings/empties, dedupe,
|
|
9
|
+
* cap at {@link MAX_TAGS_PER_ITEM}. Callers that must reject non-arrays with a
|
|
10
|
+
* typed error (the wiki does) validate the shape first and delegate here.
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizeTagList(values: readonly unknown[]): string[];
|
|
13
|
+
/** Lenient variant: `undefined`/`null`/non-arrays normalize to `[]`. */
|
|
14
|
+
export declare function normalizeTags(value: unknown): string[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Workspace tags (Tags primitive, ADR 0010): free-form lowercase labels shared
|
|
2
|
+
// across primitives so campaign knowledge links across stores — a sequence, its
|
|
3
|
+
// campaign-learnings wiki page, the source table, and the workflow that feeds it
|
|
4
|
+
// can all carry `q3-outbound`. Storage stays per-store (`tags text[]` + GIN,
|
|
5
|
+
// the 0009/0025 pattern); this module is the one vocabulary: which kinds
|
|
6
|
+
// participate, and how a tag list is normalized before it is written anywhere.
|
|
7
|
+
/** Primitive kinds that participate in the workspace tag union today. */
|
|
8
|
+
export const TAG_KINDS = [
|
|
9
|
+
"knowledge_page",
|
|
10
|
+
"sequence",
|
|
11
|
+
"table",
|
|
12
|
+
"workflow",
|
|
13
|
+
"recipe",
|
|
14
|
+
];
|
|
15
|
+
export function isTagKind(value) {
|
|
16
|
+
return typeof value === "string" && TAG_KINDS.includes(value);
|
|
17
|
+
}
|
|
18
|
+
/** Hard cap per tagged item — matches the wiki's long-standing limit. */
|
|
19
|
+
export const MAX_TAGS_PER_ITEM = 50;
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a raw tag array: lowercase, trim, drop non-strings/empties, dedupe,
|
|
22
|
+
* cap at {@link MAX_TAGS_PER_ITEM}. Callers that must reject non-arrays with a
|
|
23
|
+
* typed error (the wiki does) validate the shape first and delegate here.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeTagList(values) {
|
|
26
|
+
return Array.from(new Set(values.map((tag) => (typeof tag === "string" ? tag.trim().toLowerCase() : "")).filter(Boolean))).slice(0, MAX_TAGS_PER_ITEM);
|
|
27
|
+
}
|
|
28
|
+
/** Lenient variant: `undefined`/`null`/non-arrays normalize to `[]`. */
|
|
29
|
+
export function normalizeTags(value) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return [];
|
|
32
|
+
return normalizeTagList(value);
|
|
33
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.346.5";
|
|
2
2
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
3
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
4
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|
|
@@ -31,7 +31,7 @@ export type WorkflowStatusChangeActor = {
|
|
|
31
31
|
label: string;
|
|
32
32
|
};
|
|
33
33
|
export type WorkflowStatusChange = {
|
|
34
|
-
status: "active" | "disabled";
|
|
34
|
+
status: "active" | "disabled" | "archived";
|
|
35
35
|
/** ISO-8601 UTC. */
|
|
36
36
|
at: string;
|
|
37
37
|
source: WorkflowStatusChangeSource;
|
|
@@ -62,7 +62,7 @@ export function parseWorkflowStatusChange(raw) {
|
|
|
62
62
|
return null;
|
|
63
63
|
const record = raw;
|
|
64
64
|
const status = record.status;
|
|
65
|
-
if (status !== "active" && status !== "disabled")
|
|
65
|
+
if (status !== "active" && status !== "disabled" && status !== "archived")
|
|
66
66
|
return null;
|
|
67
67
|
const at = record.at;
|
|
68
68
|
if (typeof at !== "string" || Number.isNaN(Date.parse(at)))
|
|
@@ -110,7 +110,11 @@ export function formatWorkflowStatusChangeTimestamp(at) {
|
|
|
110
110
|
* "who turned this off?" is identical wherever the customer looks.
|
|
111
111
|
*/
|
|
112
112
|
export function describeWorkflowStatusChange(change) {
|
|
113
|
-
const verb = change.status === "disabled"
|
|
113
|
+
const verb = change.status === "disabled"
|
|
114
|
+
? "Disabled"
|
|
115
|
+
: change.status === "archived"
|
|
116
|
+
? "Archived"
|
|
117
|
+
: "Enabled";
|
|
114
118
|
const when = formatWorkflowStatusChangeTimestamp(change.at);
|
|
115
119
|
// Auto-pause is not a "who" — Oxygen paused it, and the reason carries the why.
|
|
116
120
|
const by = change.source === "auto_pause"
|
|
@@ -23,6 +23,13 @@ export declare function assessCronCadenceViability(input: {
|
|
|
23
23
|
includedActions: number | null;
|
|
24
24
|
overageEnabled: boolean;
|
|
25
25
|
}): CronCadenceAssessment | null;
|
|
26
|
+
export type CronAggressiveness = {
|
|
27
|
+
level: "aggressive" | "very_aggressive";
|
|
28
|
+
approxIntervalMinutes: number;
|
|
29
|
+
runsPer30Days: number;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function assessCronAggressiveness(cron: string | null | undefined): CronAggressiveness | null;
|
|
26
33
|
export type ObservedAutomationUsageProjection = {
|
|
27
34
|
runSample: number;
|
|
28
35
|
actionsPerRunAvg: number;
|
|
@@ -164,6 +164,41 @@ export function assessCronCadenceViability(input) {
|
|
|
164
164
|
verdict: floorActionsPer30Days > input.includedActions ? "impossible" : "fits",
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
|
+
// Warn (never block) below this cadence; the billing gate above stays the
|
|
168
|
+
// only hard stop. 15-minute syncs are common and intentional — they pass.
|
|
169
|
+
const AGGRESSIVE_INTERVAL_MINUTES = 15;
|
|
170
|
+
// Near-continuous schedules are almost always a mistake.
|
|
171
|
+
const VERY_AGGRESSIVE_INTERVAL_MINUTES = 2;
|
|
172
|
+
// Pure frequency check, independent of plan/allowance: flags schedules that
|
|
173
|
+
// fire more often than every 15 minutes so users confirm the cadence is
|
|
174
|
+
// intentional before it burns automation actions. Null = nothing to warn
|
|
175
|
+
// about (interval >= 15 min, on-demand, or unparseable cron).
|
|
176
|
+
export function assessCronAggressiveness(cron) {
|
|
177
|
+
if (!cron)
|
|
178
|
+
return null;
|
|
179
|
+
const runsPer30Days = estimateCronRunsPer30Days(cron);
|
|
180
|
+
if (runsPer30Days === null || runsPer30Days <= 0)
|
|
181
|
+
return null;
|
|
182
|
+
const approxIntervalMinutes = Math.max(1, Math.round(MINUTES_PER_30_DAYS / runsPer30Days));
|
|
183
|
+
if (approxIntervalMinutes >= AGGRESSIVE_INTERVAL_MINUTES)
|
|
184
|
+
return null;
|
|
185
|
+
const runs = runsPer30Days.toLocaleString("en-US");
|
|
186
|
+
const interval = approxIntervalMinutes === 1 ? "minute" : `${approxIntervalMinutes} minutes`;
|
|
187
|
+
if (approxIntervalMinutes <= VERY_AGGRESSIVE_INTERVAL_MINUTES) {
|
|
188
|
+
return {
|
|
189
|
+
level: "very_aggressive",
|
|
190
|
+
approxIntervalMinutes,
|
|
191
|
+
runsPer30Days,
|
|
192
|
+
message: `Runs about every ${interval} (~${runs} runs/30d). This is almost always unintentionally fast and can exhaust the monthly automation allowance within days — strongly consider slowing it down.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
level: "aggressive",
|
|
197
|
+
approxIntervalMinutes,
|
|
198
|
+
runsPer30Days,
|
|
199
|
+
message: `Runs about every ${interval} (~${runs} runs/30d). Schedules faster than every 15 minutes burn automation actions quickly — make sure this cadence is intentional.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
167
202
|
export function projectObservedAutomationUsage(input) {
|
|
168
203
|
const billedRuns = input.perRunActions.filter((actions) => Number.isFinite(actions) && actions > 0);
|
|
169
204
|
if (billedRuns.length === 0)
|