@oxygen-agent/cli 1.287.12 → 1.310.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-values.d.ts +18 -0
  3. package/dist/cli-values.js +66 -0
  4. package/dist/credentials.d.ts +22 -2
  5. package/dist/credentials.js +80 -17
  6. package/dist/help.js +1 -1
  7. package/dist/http-client.d.ts +2 -0
  8. package/dist/http-client.js +68 -30
  9. package/dist/index.js +789 -243
  10. package/dist/knowledge-mirror.d.ts +10 -0
  11. package/dist/knowledge-mirror.js +18 -0
  12. package/dist/run-wait.js +2 -26
  13. package/dist/runtime.d.ts +63 -4
  14. package/dist/runtime.js +113 -3
  15. package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
  16. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
  17. package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
  18. package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
  19. package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
  20. package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
  21. package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
  22. package/node_modules/@oxygen/shared/dist/index.js +7 -1
  23. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
  24. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
  29. package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
  30. package/node_modules/@oxygen/shared/dist/log.js +65 -6
  31. package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
  32. package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
  33. package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
  34. package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
  35. package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/timing.js +12 -0
  37. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
  39. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  40. package/node_modules/@oxygen/shared/dist/version.js +33 -2
  41. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
  42. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  43. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
  44. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
  45. package/package.json +1 -1
@@ -15,6 +15,9 @@ export type LogContext = {
15
15
  surface?: "mcp" | "cli" | "web" | "worker" | undefined;
16
16
  };
17
17
  export declare function withLogContext<T>(ctx: LogContext, fn: () => T): T;
18
+ export declare function enterLogContext(ctx: LogContext): void;
19
+ export type LogSink = (record: Record<string, unknown>) => void;
20
+ export declare function setLogSink(sink: LogSink | null): void;
18
21
  export declare function log(level: LogLevel, msg: string, fields?: Record<string, unknown>): void;
19
22
  export declare function errorId(err: unknown): string;
20
23
  export declare function errorFields(err: unknown): Record<string, unknown>;
@@ -7,11 +7,63 @@ export function withLogContext(ctx, fn) {
7
7
  const merged = { ...(store.getStore() ?? {}), ...ctx };
8
8
  return store.run(merged, fn);
9
9
  }
10
+ // Next.js route handlers resolve identity imperatively — there is no callback to
11
+ // wrap at the call site, so `withLogContext`'s run-a-callback shape does not fit.
12
+ // This stamps the ambient context for the rest of the request. Without it, every
13
+ // log() emitted deep inside an /api/cli request landed with no surface, no
14
+ // trace_id, and no org_id — only the hand-stamped `cli.command_failed` line was
15
+ // joinable (181k surface-less rows/24h on 2026-07-11, OXY-3712).
16
+ //
17
+ // It MUTATES the active store object rather than calling `enterWith`. `enterWith`
18
+ // rebinds the store only for the async resource that calls it: when a stamper is
19
+ // an async function that `await`s first — `requireCliIdentity` awaits token
20
+ // validation before it stamps — the rebind dies with that resource and never
21
+ // reaches the caller's continuation, so the route logged on. The store object is
22
+ // shared by reference with the caller's context, so assigning into it is visible
23
+ // for the rest of the request. `withLogContext` allocates a fresh object per
24
+ // call, so a concurrent request cannot see this one's identity.
25
+ export function enterLogContext(ctx) {
26
+ const active = store.getStore();
27
+ if (active) {
28
+ Object.assign(active, ctx);
29
+ return;
30
+ }
31
+ store.enterWith({ ...ctx });
32
+ }
10
33
  function getLogContext() {
11
34
  return store.getStore() ?? {};
12
35
  }
36
+ const CANONICAL_SURFACES = new Set(["mcp", "cli", "web", "worker"]);
37
+ // Call-site fields deliberately win over the ambient context (a caller must be able to
38
+ // override org_id, provider, run_id...). `surface` is the one exception: it is a RESERVED
39
+ // dimension that every surface-bucketed monitor and dashboard groups on, and `LogContext`
40
+ // closes it to four values — but `fields` is `Record<string, unknown>`, so a free-form
41
+ // label typechecks and then CLOBBERS the true surface.
42
+ //
43
+ // That is not hypothetical: `crm_signal_bridge.dispatch_failed` passed
44
+ // `surface: "crm_signal_bridge"` and landed 167 rows/30d in a junk bucket no dashboard
45
+ // counts, while the web request that emitted them lost its surface entirely (OXY-3879).
46
+ //
47
+ // So the label is demoted to `component` — kept, never dropped — and the context's surface
48
+ // is restored. A row with no ambient surface stays surface-less rather than inventing a
49
+ // bucket: honest, and countable as the dark spot it is.
50
+ function reserveSurfaceDimension(merged, ctx) {
51
+ const surface = merged.surface;
52
+ if (typeof surface !== "string" || CANONICAL_SURFACES.has(surface))
53
+ return merged;
54
+ return {
55
+ ...merged,
56
+ component: merged.component ?? surface,
57
+ surface: ctx.surface,
58
+ };
59
+ }
60
+ let logSink = null;
61
+ export function setLogSink(sink) {
62
+ logSink = sink;
63
+ }
13
64
  export function log(level, msg, fields) {
14
- const line = JSON.stringify({
65
+ const context = getLogContext();
66
+ const record = {
15
67
  ts: new Date().toISOString(),
16
68
  level,
17
69
  msg,
@@ -25,11 +77,9 @@ export function log(level, msg, fields) {
25
77
  sha: process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.OXYGEN_GIT_SHA ?? null,
26
78
  region: process.env.VERCEL_REGION ?? null,
27
79
  env: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
28
- ...sanitizeLogFields({
29
- ...getLogContext(),
30
- ...fields,
31
- }),
32
- });
80
+ ...sanitizeLogFields(reserveSurfaceDimension({ ...context, ...fields }, context)),
81
+ };
82
+ const line = JSON.stringify(record);
33
83
  if (level === "error") {
34
84
  console.error(line);
35
85
  }
@@ -39,6 +89,15 @@ export function log(level, msg, fields) {
39
89
  else {
40
90
  console.log(line);
41
91
  }
92
+ if (!logSink)
93
+ return;
94
+ try {
95
+ logSink(record);
96
+ }
97
+ catch {
98
+ // Telemetry must never break the code it observes: a failing sink degrades
99
+ // to stdout-only, which is exactly the pre-sink behavior.
100
+ }
42
101
  }
43
102
  // Short, deterministic id grouping similar errors. Hashes name + message +
44
103
  // the first stack frame so the same TypeError at the same location dedupes
@@ -1,3 +1,4 @@
1
1
  export type PrimitiveTelemetryAttribute = string | number | boolean | string[] | number[] | boolean[];
2
2
  export declare function sanitizeLogFields(fields: Record<string, unknown> | undefined): Record<string, unknown>;
3
3
  export declare function normalizeTelemetryAttributes(attributes: Record<string, unknown> | undefined): Record<string, PrimitiveTelemetryAttribute>;
4
+ export declare function redactSecretsInString(value: string): string;
@@ -5,8 +5,20 @@ const OMITTED_KEY_PATTERN = /^(body|payload|prompt|prompts|raw_prompt|raw_prompt
5
5
  // fields — most commonly an Authorization header dumped into error_message /
6
6
  // error_stack. This matches the HTTP bearer scheme followed by its token (JWT /
7
7
  // base64 / opaque, including our `oxy_live_`/`oxy_sess_` prefixes) anywhere in a
8
- // string.
9
- const BEARER_TOKEN_PATTERN = /\bBearer\s+[\w.~+/=-]+/gi;
8
+ // string. The scheme match stays case-insensitive because a real leaked header
9
+ // may well be lowercase.
10
+ //
11
+ // The lookahead exists because case-insensitivity also made this match English
12
+ // prose: the static 401 body "A CLI bearer token is required." became "A CLI
13
+ // Bearer[REDACTED] is required." in every log line, which *inverts* the message —
14
+ // a redaction marker tells the reader a credential was sent and scrubbed, when in
15
+ // truth none was sent at all. So decline the match when the credential-shaped run
16
+ // is exactly the word "token"/"tokens": neither carries secret material, and both
17
+ // are far too short to trip the prod sweep (case-sensitive, 12+ chars after
18
+ // "Bearer "). The inner lookahead keeps a credential that merely *starts* with
19
+ // those letters — `Bearer token_AbC123`, `Bearer token.AbC123` — fully redacted,
20
+ // since there the run continues into the token charset.
21
+ const BEARER_TOKEN_PATTERN = /\bBearer\s+(?!tokens?(?![\w.~+/=-]))[\w.~+/=-]+/gi;
10
22
  // OXY-139: the prod log-hygiene sweep also flags `sk-…` API keys and DB
11
23
  // connection URLs that ride along inside ordinary (non-secret-named) fields, so
12
24
  // keyed + Bearer redaction is not enough. We scrub the two shapes that carry real
@@ -135,7 +147,7 @@ function sanitizeAttributeKey(key) {
135
147
  // every prod-sweep regex (`Bearer `, `sk-…`, `postgres://…`) — finds nothing, so
136
148
  // redaction stays idempotent. Sharing the global regexes is safe because
137
149
  // String.replace resets their lastIndex between calls.
138
- function redactSecretsInString(value) {
150
+ export function redactSecretsInString(value) {
139
151
  return value
140
152
  .replace(BEARER_TOKEN_PATTERN, "Bearer[REDACTED]")
141
153
  .replace(DB_URL_PATTERN, "[REDACTED_DB_URL]")
@@ -36,7 +36,7 @@ export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
36
36
  * a signal — it's resolved on demand by the dispatcher via a connection branch,
37
37
  * `condition: "already_connected"`.)
38
38
  */
39
- export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
39
+ export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "whatsapp_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
40
40
  export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
41
41
  /**
42
42
  * Email engagement signals that ONLY arrive via the Instantly webhook
@@ -165,8 +165,16 @@ export type SequenceSendWindow = {
165
165
  /** row_values key holding the lead's IANA timezone (timezone_mode="recipient"). */
166
166
  recipient_timezone_column?: string;
167
167
  };
168
- /** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
169
- export declare const MAX_STEP_VARIANTS = 5;
168
+ /**
169
+ * Base content + up to this many alternates per A/B step (base counts as
170
+ * variant "a", so the ceiling is "a"–"z" — Instantly-class A/Z testing).
171
+ * NEVER raise past 26: sequenceVariantLabel wraps modulo the alphabet, so a
172
+ * 27th variant would alias back to "a" and corrupt per-variant stats keying.
173
+ * Auto-winner semantics at high N are unchanged and intentional: a decision
174
+ * waits until EVERY active variant clears its min-send/min-conversion
175
+ * thresholds, so a very wide test decides slowly (deterministic + reversible).
176
+ */
177
+ export declare const MAX_STEP_VARIANTS = 26;
170
178
  /**
171
179
  * Metrics a step's opt-in auto-winner (A/B auto-optimize) can decide on. `reply`
172
180
  * is the only metric today — open/click require tracking domains (a gated,
@@ -39,6 +39,7 @@ export const SEQUENCE_CHANNELS = ["linkedin", "email", "whatsapp"];
39
39
  export const SEQUENCE_SIGNALS = [
40
40
  "linkedin_connected",
41
41
  "linkedin_replied",
42
+ "whatsapp_replied",
42
43
  "email_sent",
43
44
  "email_opened",
44
45
  "email_clicked",
@@ -243,8 +244,16 @@ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
243
244
  }
244
245
  return null;
245
246
  }
246
- /** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
247
- export const MAX_STEP_VARIANTS = 5;
247
+ /**
248
+ * Base content + up to this many alternates per A/B step (base counts as
249
+ * variant "a", so the ceiling is "a"–"z" — Instantly-class A/Z testing).
250
+ * NEVER raise past 26: sequenceVariantLabel wraps modulo the alphabet, so a
251
+ * 27th variant would alias back to "a" and corrupt per-variant stats keying.
252
+ * Auto-winner semantics at high N are unchanged and intentional: a decision
253
+ * waits until EVERY active variant clears its min-send/min-conversion
254
+ * thresholds, so a very wide test decides slowly (deterministic + reversible).
255
+ */
256
+ export const MAX_STEP_VARIANTS = 26;
248
257
  /**
249
258
  * Metrics a step's opt-in auto-winner (A/B auto-optimize) can decide on. `reply`
250
259
  * is the only metric today — open/click require tracking domains (a gated,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Timing primitives shared across every OXYGEN package.
3
+ */
4
+ /**
5
+ * Resolve after `ms` milliseconds. The canonical, behavior-free delay used by
6
+ * retry/backoff/poll loops throughout the codebase — previously duplicated as a
7
+ * local `sleep` in a dozen files. No jitter, cap, or cancellation: callers that
8
+ * need those compose them around this primitive.
9
+ */
10
+ export declare function sleep(ms: number): Promise<void>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Timing primitives shared across every OXYGEN package.
3
+ */
4
+ /**
5
+ * Resolve after `ms` milliseconds. The canonical, behavior-free delay used by
6
+ * retry/backoff/poll loops throughout the codebase — previously duplicated as a
7
+ * local `sleep` in a dozen files. No jitter, cap, or cancellation: callers that
8
+ * need those compose them around this primitive.
9
+ */
10
+ export function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonical structural type guards for @oxygen/shared.
3
+ *
4
+ * `isRecord` was the most-duplicated type guard in the repo (OXY-3979): five
5
+ * byte-equivalent module-local copies across packages/mcp-server/src/tools
6
+ * alone (plus more elsewhere), in two provably-equivalent variants that differ
7
+ * only in how they reject `null` — `Boolean(value)` vs `value !== null`, which
8
+ * agree for every value `typeof value === "object"` admits. This is the one
9
+ * home; import it instead of re-declaring.
10
+ */
11
+ /**
12
+ * True when `value` is a non-null, non-array object usable as
13
+ * `Record<string, unknown>`. Rejects `null`, arrays, and every primitive.
14
+ */
15
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Canonical structural type guards for @oxygen/shared.
3
+ *
4
+ * `isRecord` was the most-duplicated type guard in the repo (OXY-3979): five
5
+ * byte-equivalent module-local copies across packages/mcp-server/src/tools
6
+ * alone (plus more elsewhere), in two provably-equivalent variants that differ
7
+ * only in how they reject `null` — `Boolean(value)` vs `value !== null`, which
8
+ * agree for every value `typeof value === "object"` admits. This is the one
9
+ * home; import it instead of re-declaring.
10
+ */
11
+ /**
12
+ * True when `value` is a non-null, non-array object usable as
13
+ * `Record<string, unknown>`. Rejects `null`, arrays, and every primitive.
14
+ */
15
+ export function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
@@ -1,2 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.287.12";
1
+ export declare const OXYGEN_VERSION = "1.310.0";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
+ export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.298.0";
@@ -1,8 +1,39 @@
1
- export const OXYGEN_VERSION = "1.287.12";
2
- // Bump this only when deployed CLI/API contracts require a newer CLI.
1
+ export const OXYGEN_VERSION = "1.310.0";
2
+ // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
+ // operational route. Raising it hard-rejects every older CLI from the entire
4
+ // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
5
+ //
6
+ // OXYGEN_MINIMUM_CLI_VERSION <= the `latest` dist-tag of @oxygen-agent/cli
7
+ //
8
+ // The CLI is published by .github/workflows/publish-cli-npm.yml, which fires on a
9
+ // *successful production deployment* — the npm publish is always AFTER the server
10
+ // that enforces this floor is already live. A floor above npm's newest CLI is
11
+ // therefore not a race we might lose, it is a guaranteed lockout: every customer's
12
+ // CLI is rejected with `cli_update_required`, and the `oxygen update` the error
13
+ // prints can only reinstall the exact version being rejected. If that publish then
14
+ // fails (npm outage, `npm audit` advisory, CI timeout), the lockout is permanent —
15
+ // on a product whose primary surface is the CLI.
16
+ //
17
+ // So floors LAG one release: publish the CLI in release N, raise the floor to it in
18
+ // release N+1. And a contract change that breaks ONE surface belongs on that
19
+ // surface — pass `minimumCliVersion` to requireCliIdentity/requireCliOrWebIdentity
20
+ // (see MANAGED_INBOX_MINIMUM_CLI_VERSION). A per-route floor fails one command
21
+ // safely; a global floor fails the whole product. OXY-4091.
22
+ //
3
23
  // 1.181.0: paid table action runs and background columns run require
4
24
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
5
25
  // 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
6
26
  // (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
7
27
  // removed the old /api/cli/linkedin/* routes — older CLIs would 404.
8
28
  export const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
29
+ // Per-surface floor for the whitelabel/managed-inbox purchase path, enforced only
30
+ // by /api/cli/managed-inboxes/subscribe.
31
+ //
32
+ // 1.298.0: whitelabel inbox subscribe moved from Oxygen credits to a USD-billed
33
+ // Stripe subscription. Older CLIs send `zapshield` (gone), omit the registrant
34
+ // address the registrar now requires, and read `credits_required` from a preview
35
+ // that no longer returns it — so they would mis-render the price of a PAID order.
36
+ // Refusing them is right; refusing them EVERYWHERE was not. This shipped as a
37
+ // 117-minor raise of the global floor inside an unrelated feature commit and armed
38
+ // a product-wide lockout that nothing would have caught before prod (OXY-4091).
39
+ export const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.298.0";
@@ -1,3 +1,4 @@
1
+ export * from "./usage-estimate.js";
1
2
  export declare const WORKFLOW_MANIFEST_VERSION = 1;
2
3
  export declare const WORKFLOW_COMPILER_VERSION = "oxygen-workflows-v1";
3
4
  export declare const DURABLE_RECIPE_COMPILER_VERSION = "oxygen-recipes-v2";
@@ -840,4 +841,3 @@ export declare function evaluateWorkflowRunSpendCap(input: {
840
841
  creditsUsed: number;
841
842
  estimatedCredits?: number | null;
842
843
  }): WorkflowSpendCapDecision;
843
- export {};
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as vm from "node:vm"; // skipcq: JS-C1003
3
+ export * from "./usage-estimate.js";
3
4
  export const WORKFLOW_MANIFEST_VERSION = 1;
4
5
  export const WORKFLOW_COMPILER_VERSION = "oxygen-workflows-v1";
5
6
  export const DURABLE_RECIPE_COMPILER_VERSION = "oxygen-recipes-v2";
@@ -0,0 +1,41 @@
1
+ export type WorkflowAutomationUsageEstimate = {
2
+ actionsPerRunFloor: number;
3
+ scheduledRunsPer30Days: number | null;
4
+ scheduledActionsPer30DaysFloor: number | null;
5
+ dynamicRuntimeActions: boolean;
6
+ perRowFanout: boolean;
7
+ };
8
+ export declare function estimateWorkflowRunAutomationActionsFloor(manifest: unknown): number;
9
+ export declare function estimateWorkflowAutomationUsage(manifest: unknown, cron?: string | null): WorkflowAutomationUsageEstimate | null;
10
+ export declare function estimateCronRunsPer30Days(cron: string): number | null;
11
+ export type CronCadenceAssessment = {
12
+ scheduledRunsPer30Days: number;
13
+ actionsPerRunFloor: number;
14
+ floorActionsPer30Days: number;
15
+ includedActions: number;
16
+ floorShareOfIncluded: number;
17
+ minimumViableIntervalMinutes: number | null;
18
+ verdict: "fits" | "impossible";
19
+ };
20
+ export declare function assessCronCadenceViability(input: {
21
+ manifest: unknown;
22
+ cron: string | null | undefined;
23
+ includedActions: number | null;
24
+ overageEnabled: boolean;
25
+ }): CronCadenceAssessment | null;
26
+ export type ObservedAutomationUsageProjection = {
27
+ runSample: number;
28
+ actionsPerRunAvg: number;
29
+ projectedActionsPer30Days: number | null;
30
+ projectedExhaustionAt: string | null;
31
+ projectedOverAllowance: boolean;
32
+ };
33
+ export declare function projectObservedAutomationUsage(input: {
34
+ perRunActions: number[];
35
+ scheduledRunsPer30Days: number | null;
36
+ includedActions: number | null;
37
+ remainingActions: number | null;
38
+ overageEnabled: boolean;
39
+ windowEnd?: Date | string | null;
40
+ now?: Date;
41
+ }): ObservedAutomationUsageProjection | null;
@@ -0,0 +1,203 @@
1
+ // Static automation-action usage estimation shared by the web workflow page
2
+ // and the worker scheduler's admission gate. The per-run count derived from a
3
+ // manifest is a FLOOR, never a projection: runtime metering bills 1 action per
4
+ // EMITTED checkpoint (recipe code loops multiply), 1 per row written by
5
+ // oxygen.rows_upsert (see estimateAutomationActionsForTool in
6
+ // @oxygen/integrations automation-usage.ts), and retried attempts bill again.
7
+ // Surfaces must present these numbers as minimums; observed run history is the
8
+ // honest projection (projectObservedAutomationUsage below).
9
+ const ROW_FANOUT_TOOL_IDS = new Set(["oxygen.rows_upsert"]);
10
+ function isRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ // Tolerant of raw DB json (the scheduler feeds unvalidated manifest rows):
14
+ // steps-array manifests count 1 per step; everything else is treated as a
15
+ // recipe shape (1 + max(visual checkpoints, declared tools)). Byte-compatible
16
+ // with the two prior copies in workflow-scheduler.ts and the web page.
17
+ export function estimateWorkflowRunAutomationActionsFloor(manifest) {
18
+ if (!isRecord(manifest))
19
+ return 1;
20
+ const steps = Array.isArray(manifest.steps) ? manifest.steps : null;
21
+ if (steps)
22
+ return Math.max(1, steps.length);
23
+ const visualPlan = isRecord(manifest.visual_plan) ? manifest.visual_plan : null;
24
+ const visualSteps = Array.isArray(visualPlan?.steps) ? visualPlan.steps : [];
25
+ const checkpointCount = visualSteps.filter((step) => isRecord(step) && (step.kind !== "branch" || typeof step.checkpoint_id === "string")).length;
26
+ const toolsUsed = Array.isArray(manifest.tools_used) ? manifest.tools_used : [];
27
+ return 1 + Math.max(checkpointCount, toolsUsed.length, 0);
28
+ }
29
+ function manifestIsRecipeShaped(manifest) {
30
+ return isRecord(manifest) && !Array.isArray(manifest.steps);
31
+ }
32
+ function manifestWritesRowsPerRun(manifest) {
33
+ if (!isRecord(manifest))
34
+ return false;
35
+ const steps = Array.isArray(manifest.steps) ? manifest.steps : null;
36
+ if (steps) {
37
+ return steps.some((step) => isRecord(step) && step.kind === "tool" && typeof step.tool === "string" && ROW_FANOUT_TOOL_IDS.has(step.tool));
38
+ }
39
+ const toolsUsed = Array.isArray(manifest.tools_used) ? manifest.tools_used : [];
40
+ return toolsUsed.some((tool) => typeof tool === "string" && ROW_FANOUT_TOOL_IDS.has(tool));
41
+ }
42
+ export function estimateWorkflowAutomationUsage(manifest, cron) {
43
+ if (!isRecord(manifest))
44
+ return null;
45
+ const actionsPerRunFloor = estimateWorkflowRunAutomationActionsFloor(manifest);
46
+ const scheduledRunsPer30Days = cron ? estimateCronRunsPer30Days(cron) : null;
47
+ return {
48
+ actionsPerRunFloor,
49
+ scheduledRunsPer30Days,
50
+ scheduledActionsPer30DaysFloor: scheduledRunsPer30Days === null ? null : scheduledRunsPer30Days * actionsPerRunFloor,
51
+ dynamicRuntimeActions: manifestIsRecipeShaped(manifest),
52
+ perRowFanout: manifestWritesRowsPerRun(manifest),
53
+ };
54
+ }
55
+ // Fixed 30-day month, minute and hour fields treated independently; timezone
56
+ // is deliberately ignored — the result sizes a schedule, it does not simulate
57
+ // a calendar. Unparseable or non-5-field crons return null ("on demand").
58
+ export function estimateCronRunsPer30Days(cron) {
59
+ const fields = cron.trim().split(/\s+/);
60
+ const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField, ...extraFields] = fields;
61
+ if (extraFields.length > 0
62
+ || !minuteField
63
+ || !hourField
64
+ || !dayOfMonthField
65
+ || !monthField
66
+ || !dayOfWeekField) {
67
+ return null;
68
+ }
69
+ const minuteCount = countCronFieldValues(minuteField, 0, 59);
70
+ const hourCount = countCronFieldValues(hourField, 0, 23);
71
+ const dayOfMonthCount = countCronFieldValues(dayOfMonthField, 1, 31);
72
+ const monthCount = countCronFieldValues(monthField, 1, 12);
73
+ const dayOfWeekCount = countCronFieldValues(dayOfWeekField, 0, 7);
74
+ if (minuteCount === null
75
+ || hourCount === null
76
+ || dayOfMonthCount === null
77
+ || monthCount === null
78
+ || dayOfWeekCount === null) {
79
+ return null;
80
+ }
81
+ const monthFactor = monthField === "*" ? 1 : Math.min(1, monthCount / 12);
82
+ const dayOfMonthFactor = dayOfMonthField === "*" ? 1 : Math.min(1, dayOfMonthCount / 30);
83
+ const dayOfWeekFactor = dayOfWeekField === "*" ? 1 : Math.min(1, dayOfWeekCount / 7);
84
+ // Cron OR-semantics: when both day fields are restricted, a slot fires if
85
+ // either matches, so the factors add (capped at daily).
86
+ const dayFactor = dayOfMonthField !== "*" && dayOfWeekField !== "*"
87
+ ? Math.min(1, dayOfMonthFactor + dayOfWeekFactor)
88
+ : Math.min(dayOfMonthFactor, dayOfWeekFactor);
89
+ const runs = minuteCount * hourCount * 30 * monthFactor * dayFactor;
90
+ return Math.max(1, Math.round(runs));
91
+ }
92
+ function countCronFieldValues(field, min, max) {
93
+ const values = new Set();
94
+ for (const part of field.split(",")) {
95
+ const trimmed = part.trim();
96
+ if (!trimmed)
97
+ return null;
98
+ const match = trimmed.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/);
99
+ if (!match)
100
+ return null;
101
+ const rangePart = match[1];
102
+ const stepPart = match[2];
103
+ if (!rangePart)
104
+ return null;
105
+ const step = stepPart ? Number(stepPart) : 1;
106
+ if (!Number.isInteger(step) || step <= 0)
107
+ return null;
108
+ let start = min;
109
+ let end = max;
110
+ if (rangePart !== "*") {
111
+ const [startRaw, endRaw] = rangePart.split("-");
112
+ start = Number(startRaw);
113
+ end = endRaw === undefined ? start : Number(endRaw);
114
+ }
115
+ if (!Number.isInteger(start)
116
+ || !Number.isInteger(end)
117
+ || start < min
118
+ || end > max
119
+ || end < start) {
120
+ return null;
121
+ }
122
+ for (let value = start; value <= end; value += step) {
123
+ values.add(normalizeCronFieldValue(value, min, max));
124
+ }
125
+ }
126
+ return values.size;
127
+ }
128
+ function normalizeCronFieldValue(value, min, max) {
129
+ if (min === 0 && max === 7 && value === 7)
130
+ return 0;
131
+ return value;
132
+ }
133
+ const MINUTES_PER_30_DAYS = 43_200;
134
+ // Cadence-vs-allowance arithmetic for a cron trigger, using the manifest FLOOR
135
+ // (see the header): the cheapest run this workflow can physically have. A
136
+ // verdict of "impossible" is therefore a lower bound that already overruns the
137
+ // plan — the real run cost is only ever higher, because row fan-out is added at
138
+ // runtime and is unbounded.
139
+ //
140
+ // Returns null when the question is not decidable or not meaningful:
141
+ // no cron (on-demand), an unparseable cron, an unlimited allowance, or overage
142
+ // enabled (the org has opted into paying past the cap, so a cadence that
143
+ // overruns it bills instead of failing).
144
+ export function assessCronCadenceViability(input) {
145
+ if (!input.cron || input.includedActions === null || input.overageEnabled)
146
+ return null;
147
+ if (!Number.isFinite(input.includedActions) || input.includedActions <= 0)
148
+ return null;
149
+ const scheduledRunsPer30Days = estimateCronRunsPer30Days(input.cron);
150
+ if (scheduledRunsPer30Days === null)
151
+ return null;
152
+ const actionsPerRunFloor = estimateWorkflowRunAutomationActionsFloor(input.manifest);
153
+ const floorActionsPer30Days = scheduledRunsPer30Days * actionsPerRunFloor;
154
+ const runsAllowedPer30Days = Math.floor(input.includedActions / actionsPerRunFloor);
155
+ return {
156
+ scheduledRunsPer30Days,
157
+ actionsPerRunFloor,
158
+ floorActionsPer30Days,
159
+ includedActions: input.includedActions,
160
+ floorShareOfIncluded: floorActionsPer30Days / input.includedActions,
161
+ minimumViableIntervalMinutes: runsAllowedPer30Days > 0
162
+ ? Math.ceil(MINUTES_PER_30_DAYS / runsAllowedPer30Days)
163
+ : null,
164
+ verdict: floorActionsPer30Days > input.includedActions ? "impossible" : "fits",
165
+ };
166
+ }
167
+ export function projectObservedAutomationUsage(input) {
168
+ const billedRuns = input.perRunActions.filter((actions) => Number.isFinite(actions) && actions > 0);
169
+ if (billedRuns.length === 0)
170
+ return null;
171
+ const total = billedRuns.reduce((sum, actions) => sum + actions, 0);
172
+ const avg = total / billedRuns.length;
173
+ const projected = input.scheduledRunsPer30Days === null
174
+ ? null
175
+ : Math.round(avg * input.scheduledRunsPer30Days);
176
+ const overAllowance = projected !== null
177
+ && input.includedActions !== null
178
+ && !input.overageEnabled
179
+ && projected > input.includedActions;
180
+ let exhaustionAt = null;
181
+ if (projected !== null
182
+ && projected > 0
183
+ && input.remainingActions !== null
184
+ && !input.overageEnabled) {
185
+ const perDay = projected / 30;
186
+ if (perDay > 0) {
187
+ const days = Math.max(0, input.remainingActions) / perDay;
188
+ const now = input.now ?? new Date();
189
+ const exhaustion = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
190
+ const windowEnd = input.windowEnd ? new Date(input.windowEnd) : null;
191
+ exhaustionAt = windowEnd && !Number.isNaN(windowEnd.getTime()) && exhaustion > windowEnd
192
+ ? null
193
+ : exhaustion.toISOString();
194
+ }
195
+ }
196
+ return {
197
+ runSample: billedRuns.length,
198
+ actionsPerRunAvg: Math.round(avg),
199
+ projectedActionsPer30Days: projected,
200
+ projectedExhaustionAt: exhaustionAt,
201
+ projectedOverAllowance: overAllowance,
202
+ };
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.287.12",
3
+ "version": "1.310.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",