@oxygen-agent/cli 1.1010.650 → 1.1010.905

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 (114) hide show
  1. package/README.md +1 -1
  2. package/dist/auto-update.d.ts +129 -0
  3. package/dist/auto-update.js +392 -0
  4. package/dist/command-manifest.js +15 -1
  5. package/dist/credentials.d.ts +2 -0
  6. package/dist/credentials.js +6 -3
  7. package/dist/functions-commands.js +1 -1
  8. package/dist/http-client.js +28 -4
  9. package/dist/inbox-needs-reply-notice.d.ts +12 -0
  10. package/dist/inbox-needs-reply-notice.js +51 -0
  11. package/dist/index.js +756 -177
  12. package/dist/run-wait.d.ts +3 -1
  13. package/dist/run-wait.js +19 -5
  14. package/dist/skills.js +48 -22
  15. package/dist/streamed-file-import.d.ts +58 -0
  16. package/dist/streamed-file-import.js +115 -0
  17. package/dist/update.d.ts +29 -0
  18. package/dist/update.js +62 -16
  19. package/dist/workflow-plan-limit-notices.d.ts +8 -0
  20. package/dist/workflow-plan-limit-notices.js +28 -0
  21. package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
  22. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +50 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.js +94 -2
  24. package/node_modules/@oxygen/shared/dist/billing.d.ts +247 -37
  25. package/node_modules/@oxygen/shared/dist/billing.js +418 -45
  26. package/node_modules/@oxygen/shared/dist/capability-discovery.js +66 -6
  27. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
  28. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
  29. package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
  30. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
  31. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
  32. package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
  33. package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
  34. package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
  36. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
  37. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +27 -0
  38. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
  39. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
  40. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
  41. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +10 -1
  42. package/node_modules/@oxygen/shared/dist/feature-gates.js +12 -1
  43. package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
  44. package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
  45. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
  46. package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
  47. package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
  48. package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
  49. package/node_modules/@oxygen/shared/dist/index.d.ts +4 -23
  50. package/node_modules/@oxygen/shared/dist/index.js +4 -43
  51. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
  53. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
  54. package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
  55. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
  56. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
  57. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
  58. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +234 -0
  59. package/node_modules/@oxygen/shared/dist/plan-band.js +312 -0
  60. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
  61. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
  62. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
  63. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
  64. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +335 -126
  65. package/node_modules/@oxygen/shared/dist/plan-limits.js +277 -86
  66. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +158 -49
  67. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +139 -41
  68. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +42 -23
  69. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +56 -37
  70. package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
  71. package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
  72. package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
  73. package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
  74. package/node_modules/@oxygen/shared/dist/repricing.d.ts +257 -0
  75. package/node_modules/@oxygen/shared/dist/repricing.js +721 -0
  76. package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
  77. package/node_modules/@oxygen/shared/dist/semver.js +41 -0
  78. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +30 -0
  79. package/node_modules/@oxygen/shared/dist/sending-limits.js +43 -0
  80. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
  81. package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
  82. package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
  83. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
  84. package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
  85. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +33 -1
  86. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +71 -1
  87. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +68 -10
  88. package/node_modules/@oxygen/shared/dist/table-capacity.js +85 -4
  89. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
  90. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
  91. package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
  92. package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
  93. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
  94. package/node_modules/@oxygen/shared/dist/telemetry.js +41 -2
  95. package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
  96. package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
  97. package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
  98. package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
  99. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
  100. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  101. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  102. package/node_modules/@oxygen/shared/dist/version.js +14 -27
  103. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
  104. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
  105. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
  106. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
  107. package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
  108. package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
  109. package/node_modules/@oxygen/workflows/dist/index.js +152 -2
  110. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
  111. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
  112. package/package.json +1 -1
  113. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
  114. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
@@ -1,5 +1,10 @@
1
1
  export declare const WORKSPACE_VISUAL_SOURCE_MAX_BYTES = 1500000;
2
2
  export declare const WORKSPACE_VISUAL_BINARY_MAX_BYTES: number;
3
+ /**
4
+ * Retained workspace files (CRM record attachments). Table-import staging is not
5
+ * a workspace file and never reads these two ceilings: it stages under
6
+ * `imports/` through `presignImportUpload` up to the plan's import file limit.
7
+ */
3
8
  export declare const WORKSPACE_OPAQUE_FILE_MAX_BYTES: number;
4
9
  /** Platform abuse guard shared by every plan, not a storage entitlement. */
5
10
  export declare const WORKSPACE_RETAINED_FILES_MAX_BYTES: number;
@@ -6,6 +6,11 @@ import { OxygenError } from "./cli-result.js";
6
6
  import { resolveObjectStorageClient } from "./object-storage.js";
7
7
  export const WORKSPACE_VISUAL_SOURCE_MAX_BYTES = 1_500_000;
8
8
  export const WORKSPACE_VISUAL_BINARY_MAX_BYTES = 50 * 1024 * 1024;
9
+ /**
10
+ * Retained workspace files (CRM record attachments). Table-import staging is not
11
+ * a workspace file and never reads these two ceilings: it stages under
12
+ * `imports/` through `presignImportUpload` up to the plan's import file limit.
13
+ */
9
14
  export const WORKSPACE_OPAQUE_FILE_MAX_BYTES = 100 * 1024 * 1024;
10
15
  /** Platform abuse guard shared by every plan, not a storage entitlement. */
11
16
  export const WORKSPACE_RETAINED_FILES_MAX_BYTES = 1024 * 1024 * 1024;
@@ -121,7 +121,7 @@ export declare const workflowCodeV1Schema: {
121
121
  readonly timeout_ms: {
122
122
  readonly type: "integer";
123
123
  readonly minimum: 10;
124
- readonly maximum: 10000;
124
+ readonly maximum: 30000;
125
125
  readonly default: 1000;
126
126
  };
127
127
  readonly max_input_bytes: {
@@ -779,7 +779,7 @@ export declare const workflowGraphManifestSchema: {
779
779
  readonly timeout_ms: {
780
780
  readonly type: "integer";
781
781
  readonly minimum: 10;
782
- readonly maximum: 10000;
782
+ readonly maximum: 30000;
783
783
  readonly default: 1000;
784
784
  };
785
785
  readonly max_input_bytes: {
@@ -1569,7 +1569,7 @@ export declare const portableWorkflowDefinitionSchema: {
1569
1569
  readonly timeout_ms: {
1570
1570
  readonly type: "integer";
1571
1571
  readonly minimum: 10;
1572
- readonly maximum: 10000;
1572
+ readonly maximum: 30000;
1573
1573
  readonly default: 1000;
1574
1574
  };
1575
1575
  readonly max_input_bytes: {
@@ -162,8 +162,22 @@ export declare const WORKFLOW_CODE_V1_RUNTIME = "oxygen-js-v1";
162
162
  export declare const WORKFLOW_CODE_V1_MAX_SOURCE_BYTES: number;
163
163
  export declare const WORKFLOW_CODE_V1_MAX_INPUTS = 100;
164
164
  export declare const WORKFLOW_CODE_V1_MIN_TIMEOUT_MS = 10;
165
- export declare const WORKFLOW_CODE_V1_MAX_TIMEOUT_MS = 10000;
165
+ /**
166
+ * The longest a typed Code node may declare (`limits.timeout_ms`): 30 s on every
167
+ * plan (repricing 2026-09, decision L4.3, up from 10 s). It applies to this
168
+ * sandboxed microVM runtime only; the legacy synchronous `run_source` path keeps
169
+ * its own lower ceiling (`PURE_WORKFLOW_FUNCTION_MAX_TIMEOUT_MS`, proposal P-49).
170
+ */
171
+ export declare const WORKFLOW_CODE_V1_MAX_TIMEOUT_MS = 30000;
166
172
  export declare const WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS = 1000;
173
+ /**
174
+ * The wall-clock budget of one workflow run attempt on every plan: 30 minutes
175
+ * (repricing 2026-09, decision L4.3, up from 10). An attempt that reaches it is
176
+ * stopped and resumed from its durable checkpoints, not failed. The worker's
177
+ * default recipe timeout reads this; a deployment may still pin a lower value
178
+ * with `OXYGEN_WORKER_RECIPE_TIMEOUT_MS`.
179
+ */
180
+ export declare const WORKFLOW_RUN_ATTEMPT_TIMEOUT_MS: number;
167
181
  export declare const WORKFLOW_CODE_V1_MIN_INPUT_BYTES = 1000;
168
182
  export declare const WORKFLOW_CODE_V1_MAX_INPUT_BYTES = 2000000;
169
183
  export declare const WORKFLOW_CODE_V1_DEFAULT_INPUT_BYTES = 250000;
@@ -90,8 +90,22 @@ export const WORKFLOW_CODE_V1_RUNTIME = "oxygen-js-v1";
90
90
  export const WORKFLOW_CODE_V1_MAX_SOURCE_BYTES = 64 * 1_024;
91
91
  export const WORKFLOW_CODE_V1_MAX_INPUTS = 100;
92
92
  export const WORKFLOW_CODE_V1_MIN_TIMEOUT_MS = 10;
93
- export const WORKFLOW_CODE_V1_MAX_TIMEOUT_MS = 10_000;
93
+ /**
94
+ * The longest a typed Code node may declare (`limits.timeout_ms`): 30 s on every
95
+ * plan (repricing 2026-09, decision L4.3, up from 10 s). It applies to this
96
+ * sandboxed microVM runtime only; the legacy synchronous `run_source` path keeps
97
+ * its own lower ceiling (`PURE_WORKFLOW_FUNCTION_MAX_TIMEOUT_MS`, proposal P-49).
98
+ */
99
+ export const WORKFLOW_CODE_V1_MAX_TIMEOUT_MS = 30_000;
94
100
  export const WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS = 1_000;
101
+ /**
102
+ * The wall-clock budget of one workflow run attempt on every plan: 30 minutes
103
+ * (repricing 2026-09, decision L4.3, up from 10). An attempt that reaches it is
104
+ * stopped and resumed from its durable checkpoints, not failed. The worker's
105
+ * default recipe timeout reads this; a deployment may still pin a lower value
106
+ * with `OXYGEN_WORKER_RECIPE_TIMEOUT_MS`.
107
+ */
108
+ export const WORKFLOW_RUN_ATTEMPT_TIMEOUT_MS = 30 * 60 * 1000;
95
109
  export const WORKFLOW_CODE_V1_MIN_INPUT_BYTES = 1_000;
96
110
  export const WORKFLOW_CODE_V1_MAX_INPUT_BYTES = 2_000_000;
97
111
  export const WORKFLOW_CODE_V1_DEFAULT_INPUT_BYTES = 250_000;
@@ -341,6 +341,43 @@ export declare function nextCronRunAfter(input: {
341
341
  timezone?: string | null;
342
342
  after?: Date;
343
343
  }): Date;
344
+ /**
345
+ * The first slot of `cron` strictly after `after` that is also at least
346
+ * `minIntervalMinutes` after `lastFireAt`. This is how a plan's minimum cron
347
+ * interval (repricing 2026-09, decision L4.2) is enforced at fire time: a
348
+ * schedule that fires closer together than the plan allows keeps running, only
349
+ * slower, and is never deleted (proposal P-47). With no last fire or a minimum
350
+ * of one minute or less it is exactly `nextCronRunAfter`.
351
+ */
352
+ export declare function nextCronRunAfterMinInterval(input: {
353
+ cron: string;
354
+ timezone?: string | null;
355
+ after: Date;
356
+ lastFireAt?: Date | null;
357
+ minIntervalMinutes?: number | null;
358
+ }): Date;
359
+ /**
360
+ * The smallest gap in minutes between two consecutive fires of `cron`, which is
361
+ * what a plan's minimum cron interval is checked against (repricing 2026-09,
362
+ * decision L4.2). It is the smallest gap, never the average: `0,5 9 * * *`
363
+ * fires once a day on average but twice five minutes apart, so its gap is 5.
364
+ *
365
+ * Gaps are measured in real time in `timezone`. A daylight-saving change can
366
+ * bring two fires closer than their wall-clock times suggest (01:55 and 03:05
367
+ * are ten minutes apart on the night the clock skips from 02:00 to 03:00, and
368
+ * a repeated hour can fire 01:45 and then 01:00 again), so the fires around
369
+ * each change in the coming year are simulated as well.
370
+ *
371
+ * Returns null when the schedule never fires twice within the scan (for
372
+ * example `0 9 29 2 *` fires once every few years and has no meaningful gap
373
+ * for a minimum to bind). Throws on an unparseable expression, like
374
+ * `nextCronRunAfter`.
375
+ */
376
+ export declare function minCronGapMinutes(input: {
377
+ cron: string;
378
+ timezone?: string | null;
379
+ now?: Date;
380
+ }): number | null;
344
381
  export declare function transformStep(input: {
345
382
  id: string;
346
383
  description?: string;
@@ -449,6 +486,14 @@ export declare function assertRecipeBundleSafe(bundle: string): void;
449
486
  export declare function lintRecipeBundleSafety(bundle: string, path?: string): WorkflowLintIssue[];
450
487
  /** Comment/string-aware author feedback for the stricter oxygen-js-v1 runtime. */
451
488
  export declare function lintWorkflowCodeSourceSafety(source: string, path?: string): WorkflowLintIssue[];
489
+ /**
490
+ * The ceiling of the legacy synchronous `node:vm` path below. It stays at 10 s
491
+ * (and its callers pass 1 s) when the typed Code node rose to 30 s (repricing
492
+ * 2026-09, decision L4.3; proposal P-49): this path runs on the worker's own
493
+ * event loop, so a long synchronous script would stall every other job and its
494
+ * queue lock renewal. The typed Code node runs in a separate microVM instead.
495
+ */
496
+ export declare const PURE_WORKFLOW_FUNCTION_MAX_TIMEOUT_MS = 10000;
452
497
  export declare function runPureWorkflowFunction(input: {
453
498
  source: string;
454
499
  context: Record<string, unknown>;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as vm from "node:vm"; // skipcq: JS-C1003
3
- import { isWorkflowGraphManifest, WORKFLOW_CODE_V1_DEFAULT_OUTPUT_BYTES, WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS, WORKFLOW_CODE_V1_MAX_OUTPUT_BYTES, WORKFLOW_CODE_V1_MAX_TIMEOUT_MS, WORKFLOW_CODE_V1_MIN_OUTPUT_BYTES, WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, } from "./graph/types.js";
3
+ import { isWorkflowGraphManifest, WORKFLOW_CODE_V1_DEFAULT_OUTPUT_BYTES, WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS, WORKFLOW_CODE_V1_MAX_OUTPUT_BYTES, WORKFLOW_CODE_V1_MIN_OUTPUT_BYTES, WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, } from "./graph/types.js";
4
4
  import { lintWorkflowGraphManifest } from "./graph/lint.js";
5
5
  export * from "./usage-estimate.js";
6
6
  // Pure event-dispatch semantics, shared by the apps/web and apps/worker event
@@ -162,6 +162,148 @@ export function nextCronRunAfter(input) {
162
162
  }
163
163
  throw new Error(`Cron expression has no matching run time in the next ${MAX_CRON_LOOKAHEAD_DAYS} days.`);
164
164
  }
165
+ /**
166
+ * The first slot of `cron` strictly after `after` that is also at least
167
+ * `minIntervalMinutes` after `lastFireAt`. This is how a plan's minimum cron
168
+ * interval (repricing 2026-09, decision L4.2) is enforced at fire time: a
169
+ * schedule that fires closer together than the plan allows keeps running, only
170
+ * slower, and is never deleted (proposal P-47). With no last fire or a minimum
171
+ * of one minute or less it is exactly `nextCronRunAfter`.
172
+ */
173
+ export function nextCronRunAfterMinInterval(input) {
174
+ const minIntervalMs = Math.max(0, input.minIntervalMinutes ?? 0) * 60_000;
175
+ let after = input.after;
176
+ if (input.lastFireAt && minIntervalMs > 60_000) {
177
+ // One millisecond before the earliest allowed instant: nextCronRunAfter
178
+ // returns the first whole-minute slot strictly after it, so a slot exactly
179
+ // `minIntervalMinutes` after the last fire is allowed.
180
+ const earliestAllowed = input.lastFireAt.getTime() + minIntervalMs - 1;
181
+ if (earliestAllowed > after.getTime())
182
+ after = new Date(earliestAllowed);
183
+ }
184
+ return nextCronRunAfter({ cron: input.cron, timezone: input.timezone ?? null, after });
185
+ }
186
+ // How many calendar days minCronGapMinutes scans for two matching days in a
187
+ // row: two leap cycles, so a Feb 29 or a day-of-month/weekday coincidence is
188
+ // always seen.
189
+ const CRON_GAP_DAY_SCAN = 366 * 8 + 2;
190
+ // Real-time simulation around a daylight-saving change covers the day it falls
191
+ // in plus this many whole days either side (so the fire before a long gap that
192
+ // straddles the change is inside the window), and stops after this many fires.
193
+ const CRON_GAP_DST_WINDOW_DAYS = 1;
194
+ const CRON_GAP_DST_MAX_FIRES = 5_000;
195
+ /**
196
+ * The smallest gap in minutes between two consecutive fires of `cron`, which is
197
+ * what a plan's minimum cron interval is checked against (repricing 2026-09,
198
+ * decision L4.2). It is the smallest gap, never the average: `0,5 9 * * *`
199
+ * fires once a day on average but twice five minutes apart, so its gap is 5.
200
+ *
201
+ * Gaps are measured in real time in `timezone`. A daylight-saving change can
202
+ * bring two fires closer than their wall-clock times suggest (01:55 and 03:05
203
+ * are ten minutes apart on the night the clock skips from 02:00 to 03:00, and
204
+ * a repeated hour can fire 01:45 and then 01:00 again), so the fires around
205
+ * each change in the coming year are simulated as well.
206
+ *
207
+ * Returns null when the schedule never fires twice within the scan (for
208
+ * example `0 9 29 2 *` fires once every few years and has no meaningful gap
209
+ * for a minimum to bind). Throws on an unparseable expression, like
210
+ * `nextCronRunAfter`.
211
+ */
212
+ export function minCronGapMinutes(input) {
213
+ const schedule = parseCronSchedule(input.cron);
214
+ const timezone = input.timezone?.trim() || DEFAULT_WORKFLOW_CRON_TIMEZONE;
215
+ const fireMinutes = [...schedule.hours]
216
+ .flatMap((hour) => [...schedule.minutes].map((minute) => hour * 60 + minute))
217
+ .sort((a, b) => a - b);
218
+ let gap = Number.POSITIVE_INFINITY;
219
+ for (let index = 1; index < fireMinutes.length; index += 1) {
220
+ gap = Math.min(gap, (fireMinutes[index] ?? 0) - (fireMinutes[index - 1] ?? 0));
221
+ }
222
+ const dayGap = smallestMatchingDayGap(schedule);
223
+ const firstFire = fireMinutes[0];
224
+ const lastFire = fireMinutes[fireMinutes.length - 1];
225
+ if (dayGap !== null && firstFire !== undefined && lastFire !== undefined) {
226
+ gap = Math.min(gap, dayGap * 24 * 60 + firstFire - lastFire);
227
+ }
228
+ // A one-minute gap is the floor of the cron grammar; daylight saving cannot
229
+ // bring two fires closer than that.
230
+ if (gap > 1) {
231
+ const dstGap = smallestGapAroundDaylightSavingChanges(input.cron, timezone, input.now ?? new Date());
232
+ if (dstGap !== null)
233
+ gap = Math.min(gap, dstGap);
234
+ }
235
+ return Number.isFinite(gap) ? gap : null;
236
+ }
237
+ /** Fewest days between two calendar days the schedule's date fields both match. */
238
+ function smallestMatchingDayGap(schedule) {
239
+ // Calendar arithmetic only (no timezone): day-of-month, month and weekday
240
+ // matching is the same on every clock. A fixed leap-year start covers every
241
+ // weekday/day-of-month combination within the scan.
242
+ const start = Date.UTC(2024, 0, 1);
243
+ let previousMatch = null;
244
+ let smallest = null;
245
+ for (let day = 0; day < CRON_GAP_DAY_SCAN; day += 1) {
246
+ const date = new Date(start + day * 24 * 60 * 60 * 1000);
247
+ const matches = matchesCronDate(schedule, {
248
+ minute: 0,
249
+ hour: 0,
250
+ dayOfMonth: date.getUTCDate(),
251
+ month: date.getUTCMonth() + 1,
252
+ dayOfWeek: date.getUTCDay(),
253
+ });
254
+ if (!matches)
255
+ continue;
256
+ if (previousMatch !== null) {
257
+ const gap = day - previousMatch;
258
+ smallest = smallest === null ? gap : Math.min(smallest, gap);
259
+ if (smallest === 1)
260
+ return 1;
261
+ }
262
+ previousMatch = day;
263
+ }
264
+ return smallest;
265
+ }
266
+ /**
267
+ * The smallest real-time gap between fires in the days around each
268
+ * daylight-saving change in the year after `now`; null when the zone has no
269
+ * change in that year or no two fires fall in any window.
270
+ */
271
+ function smallestGapAroundDaylightSavingChanges(cron, timezone, now) {
272
+ const dayMs = 24 * 60 * 60 * 1000;
273
+ const start = Math.floor(now.getTime() / dayMs) * dayMs;
274
+ let smallest = null;
275
+ let previousOffset = zonedOffsetMinutes(new Date(start), timezone);
276
+ for (let day = 1; day <= 367; day += 1) {
277
+ const offset = zonedOffsetMinutes(new Date(start + day * dayMs), timezone);
278
+ if (offset === previousOffset)
279
+ continue;
280
+ previousOffset = offset;
281
+ const windowStart = new Date(start + (day - 1 - CRON_GAP_DST_WINDOW_DAYS) * dayMs);
282
+ const windowEnd = start + (day + CRON_GAP_DST_WINDOW_DAYS) * dayMs;
283
+ let fire = nextCronRunAfter({ cron, timezone, after: windowStart });
284
+ for (let fires = 0; fires < CRON_GAP_DST_MAX_FIRES && fire.getTime() <= windowEnd; fires += 1) {
285
+ const next = nextCronRunAfter({ cron, timezone, after: fire });
286
+ if (next.getTime() > windowEnd)
287
+ break;
288
+ const gap = Math.round((next.getTime() - fire.getTime()) / 60_000);
289
+ smallest = smallest === null ? gap : Math.min(smallest, gap);
290
+ fire = next;
291
+ }
292
+ }
293
+ return smallest;
294
+ }
295
+ /** Minutes the zone's wall clock is ahead of UTC at `date`. */
296
+ function zonedOffsetMinutes(date, timezone) {
297
+ const formatter = getZonedFormatter(timezone);
298
+ const parts = new Map();
299
+ for (const part of formatter.formatToParts(date)) {
300
+ if (part.type !== "literal")
301
+ parts.set(part.type, part.value);
302
+ }
303
+ const wallClock = Date.UTC(Number(parts.get("year")), Number(parts.get("month")) - 1, Number(parts.get("day")), Number(parts.get("hour")), Number(parts.get("minute")));
304
+ const instant = Math.floor(date.getTime() / 60_000) * 60_000;
305
+ return Math.round((wallClock - instant) / 60_000);
306
+ }
165
307
  export function transformStep(input) {
166
308
  return {
167
309
  __oxygen_workflow_step: true,
@@ -983,8 +1125,16 @@ export function lintWorkflowCodeSourceSafety(source, path = "$.source") {
983
1125
  }
984
1126
  return issues;
985
1127
  }
1128
+ /**
1129
+ * The ceiling of the legacy synchronous `node:vm` path below. It stays at 10 s
1130
+ * (and its callers pass 1 s) when the typed Code node rose to 30 s (repricing
1131
+ * 2026-09, decision L4.3; proposal P-49): this path runs on the worker's own
1132
+ * event loop, so a long synchronous script would stall every other job and its
1133
+ * queue lock renewal. The typed Code node runs in a separate microVM instead.
1134
+ */
1135
+ export const PURE_WORKFLOW_FUNCTION_MAX_TIMEOUT_MS = 10_000;
986
1136
  export async function runPureWorkflowFunction(input) {
987
- const timeoutMs = Math.max(WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, Math.min(input.timeoutMs ?? WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS, WORKFLOW_CODE_V1_MAX_TIMEOUT_MS));
1137
+ const timeoutMs = Math.max(WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, Math.min(input.timeoutMs ?? WORKFLOW_CODE_V1_DEFAULT_TIMEOUT_MS, PURE_WORKFLOW_FUNCTION_MAX_TIMEOUT_MS));
988
1138
  const maxOutputBytes = Math.max(WORKFLOW_CODE_V1_MIN_OUTPUT_BYTES, Math.min(input.maxOutputBytes ?? WORKFLOW_CODE_V1_DEFAULT_OUTPUT_BYTES, WORKFLOW_CODE_V1_MAX_OUTPUT_BYTES));
989
1139
  const issues = [];
990
1140
  validatePureFunctionSource(input.source, "$.source", (path, code, message) => {
@@ -3,7 +3,12 @@ export type WorkflowAutomationUsageEstimate = {
3
3
  scheduledRunsPer30Days: number | null;
4
4
  scheduledActionsPer30DaysFloor: number | null;
5
5
  dynamicRuntimeActions: boolean;
6
- perRowFanout: boolean;
6
+ /**
7
+ * Always false: row writes stopped billing per row (repricing 2026-09,
8
+ * decision 6.2). Kept for one release so older CLIs and pages that read it
9
+ * still parse; remove after that release.
10
+ */
11
+ perRowFanout: false;
7
12
  };
8
13
  export declare function estimateWorkflowRunAutomationActionsFloor(manifest: unknown): number;
9
14
  export declare function estimateWorkflowAutomationUsage(manifest: unknown, cron?: string | null): WorkflowAutomationUsageEstimate | null;
@@ -16,6 +21,8 @@ export type CronCadenceAssessment = {
16
21
  floorShareOfIncluded: number;
17
22
  basis: "plan_action_quota" | "plan_monthly_credits";
18
23
  includedCredits: number | null;
24
+ spendableCredits: number | null;
25
+ allowanceCredits: number | null;
19
26
  floorCreditsPer30Days: number | null;
20
27
  minimumViableIntervalMinutes: number | null;
21
28
  verdict: "fits" | "impossible";
@@ -27,6 +34,8 @@ export declare function assessCronCadenceViability(input: {
27
34
  overageEnabled: boolean;
28
35
  includedCredits?: number | null;
29
36
  creditsPerAction?: number | null;
37
+ /** Credits the workspace can spend right now (its available balance). */
38
+ spendableCredits?: number | null;
30
39
  }): CronCadenceAssessment | null;
31
40
  export type CronAggressiveness = {
32
41
  level: "aggressive" | "very_aggressive";
@@ -1,14 +1,14 @@
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).
1
+ // Static automation usage estimation shared by the web workflow page and the
2
+ // worker scheduler's admission gate. The unit is the workflow STEP: every step a
3
+ // live run executes bills one (repricing 2026-09, decision 6.1) and rows written
4
+ // inside a run are free (decision 6.2), so a row-writing step costs one step
5
+ // however many rows it writes. The per-run count derived from a manifest is a
6
+ // FLOOR, never a projection: recipe code emits one step per checkpoint (loops
7
+ // multiply) and a retried attempt bills again. Surfaces present these numbers as
8
+ // minimums; observed run history is the honest projection
9
+ // (projectObservedAutomationUsage below).
9
10
  import { loopBodyNodeIds } from "./graph/topology.js";
10
11
  import { isWorkflowGraphManifest, workflowGraphNodeKindBills, MAX_WORKFLOW_LOOP_ITERATIONS, } from "./graph/types.js";
11
- const ROW_FANOUT_TOOL_IDS = new Set(["oxygen.rows_upsert"]);
12
12
  function isRecord(value) {
13
13
  return typeof value === "object" && value !== null && !Array.isArray(value);
14
14
  }
@@ -64,21 +64,6 @@ function manifestHasDynamicRuntimeActions(manifest) {
64
64
  return graph.nodes.some((node) => node.hasRecipe
65
65
  || (node.kind === "loop" && !node.disabled && declaredLoopIterations(node.maxIterations) === null));
66
66
  }
67
- function manifestWritesRowsPerRun(manifest) {
68
- if (!isRecord(manifest))
69
- return false;
70
- const steps = Array.isArray(manifest.steps) ? manifest.steps : null;
71
- if (steps) {
72
- return steps.some((step) => isRecord(step) && step.kind === "tool" && typeof step.tool === "string" && ROW_FANOUT_TOOL_IDS.has(step.tool));
73
- }
74
- const graph = readWorkflowGraph(manifest);
75
- if (graph) {
76
- return graph.nodes.some((node) => (node.kind === "tool" && typeof node.tool === "string" && ROW_FANOUT_TOOL_IDS.has(node.tool))
77
- || node.recipeTools.some((tool) => ROW_FANOUT_TOOL_IDS.has(tool)));
78
- }
79
- const toolsUsed = Array.isArray(manifest.tools_used) ? manifest.tools_used : [];
80
- return toolsUsed.some((tool) => typeof tool === "string" && ROW_FANOUT_TOOL_IDS.has(tool));
81
- }
82
67
  function readWorkflowGraph(manifest) {
83
68
  if (!isWorkflowGraphManifest(manifest))
84
69
  return null;
@@ -194,7 +179,7 @@ export function estimateWorkflowAutomationUsage(manifest, cron) {
194
179
  scheduledRunsPer30Days,
195
180
  scheduledActionsPer30DaysFloor: scheduledRunsPer30Days === null ? null : scheduledRunsPer30Days * actionsPerRunFloor,
196
181
  dynamicRuntimeActions: manifestHasDynamicRuntimeActions(manifest),
197
- perRowFanout: manifestWritesRowsPerRun(manifest),
182
+ perRowFanout: false,
198
183
  };
199
184
  }
200
185
  // Fixed 30-day month, minute and hour fields treated independently; timezone
@@ -279,8 +264,15 @@ const MINUTES_PER_30_DAYS = 43_200;
279
264
  // Cadence-vs-allowance arithmetic for a cron trigger, using the manifest FLOOR
280
265
  // (see the header): the cheapest run this workflow can physically have. A
281
266
  // verdict of "impossible" is therefore a lower bound that already overruns the
282
- // plan — the real run cost is only ever higher, because row fan-out is added at
283
- // runtime and is unbounded.
267
+ // allowance — the real run cost is only ever higher (recipe checkpoints, loops
268
+ // over run-time collections and retries add steps).
269
+ //
270
+ // On the credit basis the allowance is the plan's monthly credits OR the
271
+ // balance the workspace can spend, whichever is larger (proposal P-65): at 0.05
272
+ // a step an every-minute, 5-step cron costs 10,800 credits a month, more than
273
+ // the $99 plan includes, and a workspace that funded that from top-ups must be
274
+ // able to arm it. The fire-time gate charges against the balance, so a funded
275
+ // cron is never paused mid-month either.
284
276
  //
285
277
  // Returns null when the question is not decidable or not meaningful:
286
278
  // no cron (on-demand), an unparseable cron, an unlimited allowance, or overage
@@ -299,6 +291,16 @@ export function assessCronCadenceViability(input) {
299
291
  && input.includedCredits > 0
300
292
  ? input.includedCredits
301
293
  : null;
294
+ const spendableCredits = typeof input.spendableCredits === "number"
295
+ && Number.isFinite(input.spendableCredits)
296
+ && input.spendableCredits > 0
297
+ ? input.spendableCredits
298
+ : null;
299
+ // A plan without monthly credits (free) is never judged here: its crons run on
300
+ // the balance and the fire-time gate is their only stop, as before.
301
+ const allowanceCredits = includedCredits === null
302
+ ? null
303
+ : Math.max(includedCredits, spendableCredits ?? 0);
302
304
  // Prefer a real action quota when the plan still has one; otherwise derive the
303
305
  // equivalent action budget from monthly credits.
304
306
  const basis = input.includedActions === null
@@ -306,8 +308,8 @@ export function assessCronCadenceViability(input) {
306
308
  : "plan_action_quota";
307
309
  const includedActions = basis === "plan_action_quota"
308
310
  ? input.includedActions
309
- : (includedCredits !== null && creditsPerAction !== null
310
- ? Math.floor(includedCredits / creditsPerAction)
311
+ : (allowanceCredits !== null && creditsPerAction !== null
312
+ ? Math.floor(allowanceCredits / creditsPerAction)
311
313
  : null);
312
314
  if (includedActions === null)
313
315
  return null;
@@ -327,6 +329,8 @@ export function assessCronCadenceViability(input) {
327
329
  floorShareOfIncluded: floorActionsPer30Days / includedActions,
328
330
  basis,
329
331
  includedCredits: basis === "plan_monthly_credits" ? includedCredits : null,
332
+ spendableCredits: basis === "plan_monthly_credits" ? spendableCredits : null,
333
+ allowanceCredits: basis === "plan_monthly_credits" ? allowanceCredits : null,
330
334
  floorCreditsPer30Days: basis === "plan_monthly_credits" && creditsPerAction !== null
331
335
  ? Math.round(floorActionsPer30Days * creditsPerAction * 1000) / 1000
332
336
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1010.650",
3
+ "version": "1.1010.905",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,64 +0,0 @@
1
- /**
2
- * PURE warm-up readiness rule: what daily cold volume a sending mailbox can
3
- * actually carry, given its warm-up age rather than a provider's opinion of it.
4
- *
5
- * WHY it lives in @oxygen/shared and not next to the other email-health rules in
6
- * @oxygen/integrations: the tenant rollup computes this per mailbox, and
7
- * @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
8
- * on tenant-db). Duplicating the thresholds so each package could own a copy is
9
- * exactly how two surfaces start recommending different numbers, so the rule is
10
- * defined once here and re-exported from
11
- * packages/integrations/src/email-health/health-state.ts, which stays the
12
- * email-health surface every caller reads.
13
- *
14
- * DIRECTIONAL, like everything else in the deliverability cluster: it produces a
15
- * recommendation and a launch-preview warning. It never clamps a cap, never
16
- * pauses a mailbox, and never changes what a sequence sends.
17
- */
18
- /** Below this warm-up day a mailbox should carry only the starter cold volume. */
19
- export declare const WARMUP_EARLY_DAY_LIMIT = 14;
20
- /** Below this warm-up day a mailbox should stay at the reduced cold volume. */
21
- export declare const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
22
- /** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
23
- export declare const WARMUP_EARLY_DAILY_CAP = 5;
24
- /** Recommended cold sends/day between day 14 and day 28. */
25
- export declare const WARMUP_ESTABLISHED_DAILY_CAP = 10;
26
- /** A warm-up health score under this is treated as "not ready for volume". */
27
- export declare const WARMUP_HEALTH_SCORE_FLOOR = 60;
28
- /** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
29
- export declare const HARD_BOUNCE_RATE_CEILING = 0.03;
30
- /** Below this send volume a bounce rate is noise, not a signal. */
31
- export declare const HARD_BOUNCE_RATE_MIN_SENDS = 20;
32
- /**
33
- * The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
34
- * or null when nothing in the evidence argues for holding volume back.
35
- *
36
- * Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
37
- * clock-free and identical on every surface.
38
- *
39
- * - warm-up day < 14 -> 5/day
40
- * - mailbox younger than 28 days, with either no active
41
- * warm-up or no warm-up day reported at all -> 5/day
42
- * - warm-up day < 28 -> 10/day
43
- * - warm-up health score < 60 -> at most 5/day
44
- * - otherwise -> null (no advice)
45
- *
46
- * The second rule is the one that matters for a freshly provisioned fleet: a
47
- * mailbox whose warm-up never started (state "unknown") reports no day at all,
48
- * and a rule keyed only on the day number would have said nothing about the exact
49
- * mailboxes most likely to get blocked.
50
- */
51
- export declare function recommendedDailyCapForWarmup(input: {
52
- warmupState?: string | null;
53
- warmupDay?: number | null;
54
- warmupHealthScore?: number | null;
55
- mailboxAgeDays?: number | null;
56
- }): number | null;
57
- /**
58
- * True when a hard-bounce count is high enough, over enough sends, to be a real
59
- * reputation problem rather than list noise.
60
- */
61
- export declare function hardBounceRateIsHigh(input: {
62
- hardBounces: number;
63
- coldSends: number;
64
- }): boolean;
@@ -1,90 +0,0 @@
1
- /**
2
- * PURE warm-up readiness rule: what daily cold volume a sending mailbox can
3
- * actually carry, given its warm-up age rather than a provider's opinion of it.
4
- *
5
- * WHY it lives in @oxygen/shared and not next to the other email-health rules in
6
- * @oxygen/integrations: the tenant rollup computes this per mailbox, and
7
- * @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
8
- * on tenant-db). Duplicating the thresholds so each package could own a copy is
9
- * exactly how two surfaces start recommending different numbers, so the rule is
10
- * defined once here and re-exported from
11
- * packages/integrations/src/email-health/health-state.ts, which stays the
12
- * email-health surface every caller reads.
13
- *
14
- * DIRECTIONAL, like everything else in the deliverability cluster: it produces a
15
- * recommendation and a launch-preview warning. It never clamps a cap, never
16
- * pauses a mailbox, and never changes what a sequence sends.
17
- */
18
- /** Below this warm-up day a mailbox should carry only the starter cold volume. */
19
- export const WARMUP_EARLY_DAY_LIMIT = 14;
20
- /** Below this warm-up day a mailbox should stay at the reduced cold volume. */
21
- export const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
22
- /** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
23
- export const WARMUP_EARLY_DAILY_CAP = 5;
24
- /** Recommended cold sends/day between day 14 and day 28. */
25
- export const WARMUP_ESTABLISHED_DAILY_CAP = 10;
26
- /** A warm-up health score under this is treated as "not ready for volume". */
27
- export const WARMUP_HEALTH_SCORE_FLOOR = 60;
28
- /** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
29
- export const HARD_BOUNCE_RATE_CEILING = 0.03;
30
- /** Below this send volume a bounce rate is noise, not a signal. */
31
- export const HARD_BOUNCE_RATE_MIN_SENDS = 20;
32
- /** Warm-up states in which a vendor is actively conditioning the mailbox. */
33
- const ACTIVE_WARMUP_STATES = new Set(["warming", "active"]);
34
- function finiteOrNull(value) {
35
- return typeof value === "number" && Number.isFinite(value) ? value : null;
36
- }
37
- /**
38
- * The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
39
- * or null when nothing in the evidence argues for holding volume back.
40
- *
41
- * Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
42
- * clock-free and identical on every surface.
43
- *
44
- * - warm-up day < 14 -> 5/day
45
- * - mailbox younger than 28 days, with either no active
46
- * warm-up or no warm-up day reported at all -> 5/day
47
- * - warm-up day < 28 -> 10/day
48
- * - warm-up health score < 60 -> at most 5/day
49
- * - otherwise -> null (no advice)
50
- *
51
- * The second rule is the one that matters for a freshly provisioned fleet: a
52
- * mailbox whose warm-up never started (state "unknown") reports no day at all,
53
- * and a rule keyed only on the day number would have said nothing about the exact
54
- * mailboxes most likely to get blocked.
55
- */
56
- export function recommendedDailyCapForWarmup(input) {
57
- const day = finiteOrNull(input.warmupDay);
58
- const ageDays = finiteOrNull(input.mailboxAgeDays);
59
- const healthScore = finiteOrNull(input.warmupHealthScore);
60
- const warmingNow = ACTIVE_WARMUP_STATES.has((input.warmupState ?? "").trim().toLowerCase());
61
- let cap = null;
62
- if (day !== null && day < WARMUP_EARLY_DAY_LIMIT) {
63
- cap = WARMUP_EARLY_DAILY_CAP;
64
- }
65
- else if (ageDays !== null &&
66
- ageDays < WARMUP_ESTABLISHED_DAY_LIMIT &&
67
- (day === null || !warmingNow)) {
68
- // A rail that reports "warming" but no day number proves nothing about how
69
- // far the ramp got, so age still governs. Gating this on !warmingNow alone
70
- // left exactly that mailbox — enrolled, young, no telemetry — with no advice.
71
- cap = WARMUP_EARLY_DAILY_CAP;
72
- }
73
- else if (day !== null && day < WARMUP_ESTABLISHED_DAY_LIMIT) {
74
- cap = WARMUP_ESTABLISHED_DAILY_CAP;
75
- }
76
- if (healthScore !== null && healthScore < WARMUP_HEALTH_SCORE_FLOOR) {
77
- cap = cap === null ? WARMUP_EARLY_DAILY_CAP : Math.min(cap, WARMUP_EARLY_DAILY_CAP);
78
- }
79
- return cap;
80
- }
81
- /**
82
- * True when a hard-bounce count is high enough, over enough sends, to be a real
83
- * reputation problem rather than list noise.
84
- */
85
- export function hardBounceRateIsHigh(input) {
86
- if (!Number.isFinite(input.coldSends) || input.coldSends < HARD_BOUNCE_RATE_MIN_SENDS) {
87
- return false;
88
- }
89
- return input.hardBounces / input.coldSends > HARD_BOUNCE_RATE_CEILING;
90
- }