@oxygen-agent/cli 1.766.0 → 1.799.1

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 (26) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +55 -20
  3. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +18 -0
  4. package/node_modules/@oxygen/shared/dist/error-redaction.js +24 -0
  5. package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
  6. package/node_modules/@oxygen/shared/dist/index.js +1 -0
  7. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +13 -0
  8. package/node_modules/@oxygen/shared/dist/table-capacity.js +14 -0
  9. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  10. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  11. package/node_modules/@oxygen/workflows/dist/graph/diff.d.ts +33 -0
  12. package/node_modules/@oxygen/workflows/dist/graph/diff.js +75 -0
  13. package/node_modules/@oxygen/workflows/dist/graph/expression.js +17 -1
  14. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +1 -0
  15. package/node_modules/@oxygen/workflows/dist/graph/index.js +1 -0
  16. package/node_modules/@oxygen/workflows/dist/graph/lint.js +30 -28
  17. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +2 -2
  18. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +1 -1
  19. package/node_modules/@oxygen/workflows/dist/graph/remap.js +0 -5
  20. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +7 -8
  21. package/node_modules/@oxygen/workflows/dist/graph/types.js +0 -4
  22. package/node_modules/@oxygen/workflows/dist/index.js +0 -1
  23. package/node_modules/@oxygen/workflows/dist/portable.js +0 -9
  24. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +5 -0
  25. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -6
  26. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.766.0
37
+ Version: 1.799.1
package/dist/index.js CHANGED
@@ -6998,7 +6998,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6998
6998
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
6999
6999
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
7000
7000
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
7001
- .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web.")
7001
+ .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
7002
7002
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
7003
7003
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
7004
7004
  .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai and picks the data type (text, or jsonb with an output schema); pair it with --kind research to search the web per row instead. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
@@ -8216,7 +8216,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8216
8216
  .command("limits")
8217
8217
  .description("Plan-tier limits, spend-safety defaults, and storage capacity posture.")
8218
8218
  .addCommand(new Command("show")
8219
- .description("Show effective plan-tier limits, spend-safety defaults, and uncapped-by-plan storage posture.")
8219
+ .description("Show limits and Tables capacity usage: 3M rows/Table, 25M/workspace, and 20/30 GiB PostgreSQL warning/limit; S3 excluded. Recovery: https://oxygen-agent.com/docs/safety/billing.")
8220
8220
  .option("--json", "Print a JSON envelope.")
8221
8221
  .action(async (options) => {
8222
8222
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
@@ -14645,6 +14645,7 @@ Run completion:
14645
14645
  .option("--approved", "Authorize autonomous tool calls for this revision.")
14646
14646
  .option("--max-credits <n>", "Optional explicit positive ceiling for each autonomous delivery; omit to use the plan-tier default.")
14647
14647
  .option("--draft", "Save without going live: keeps the running version serving every trigger. Publish later by applying again without --draft.")
14648
+ .option("--review", "Show what publishing this would change against the version now serving triggers — added, removed and modified steps, what leaves Oxygen, the accounts used, and whether it needs standing authority. Writes nothing.")
14648
14649
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
14649
14650
  .option("--json", "Print a JSON envelope.")
14650
14651
  .action(async (options) => {
@@ -14659,6 +14660,7 @@ Run completion:
14659
14660
  // byte-identical to what every previous CLI sent, so behaviour
14660
14661
  // against an older server is unchanged rather than merely equivalent.
14661
14662
  ...(options.draft ? { publish: false } : {}),
14663
+ ...(options.review ? { review: true } : {}),
14662
14664
  ...(options.approved ? { approved: true } : {}),
14663
14665
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
14664
14666
  },
@@ -14720,13 +14722,21 @@ Run completion:
14720
14722
  .description("List workflow automations.")
14721
14723
  .addOption(new Option("--include-archived", "Deprecated no-op retained for compatibility.").hideHelp())
14722
14724
  .option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
14725
+ .option("--node-testable", "Only canonical graph workflows, the ones `workflows call --node` can scope a test to. Legacy recipes and v1 step lists own their own execution order and are excluded.")
14726
+ .option("--search <text>", "Match on name, slug, trigger event, or an integration the workflow uses — so \"meeting\" or \"hubspot\" finds it without knowing what someone named it.")
14723
14727
  .option("--json", "Print a JSON envelope.")
14728
+ .addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\n")
14724
14729
  .action(async (options) => {
14725
14730
  await handleAsyncAction("workflows list", options, async () => {
14726
14731
  const params = new URLSearchParams();
14727
14732
  const tag = readOption(options.tag);
14728
14733
  if (tag)
14729
14734
  params.set("tag", tag);
14735
+ if (options.nodeTestable)
14736
+ params.set("node_testable", "true");
14737
+ const search = readOption(options.search);
14738
+ if (search)
14739
+ params.set("search", search);
14730
14740
  const qs = params.toString() ? `?${params.toString()}` : "";
14731
14741
  const data = await requestOxygen(`/api/cli/workflows${qs}`);
14732
14742
  if (!options.json)
@@ -14783,39 +14793,52 @@ Run completion:
14783
14793
  }), options));
14784
14794
  }))
14785
14795
  .addCommand(new Command("call")
14786
- .description("Enqueue a workflow run asynchronously and directly without simulating its trigger. Follow the returned run_id with `oxygen workflows tail <run_id>`.")
14796
+ .description("Run a workflow — or preview it first. `--preview` shows every step that leaves Oxygen, the accounts it would use, and what one run costs, without running anything. Enqueues asynchronously without simulating the trigger; follow the returned run_id with `oxygen workflows tail <run_id>`.")
14787
14797
  .argument("[workflow]", "Workflow id, slug, or name.")
14788
14798
  .option("--workflow <workflow>", "Workflow id, slug, or name.")
14789
14799
  .option("--workflow-id <workflow_id>", "Workflow id or slug.")
14790
14800
  .option("--workflow-name <workflow_name>", "Workflow name.")
14791
14801
  .option("--input-json <json>", "Workflow input object. Defaults to {}.")
14792
- .requiredOption("--mode <mode>", "Execution mode. smoke-test/dry-run: 0 credits, no paid provider calls or external writes; internal reads still execute. live: may spend/write and requires approval + cap.")
14802
+ .option("--mode <mode>", "Execution mode. smoke-test/dry-run: 0 credits, no paid provider calls or external writes; internal reads still execute. live: may spend/write and requires approval + cap. Not needed with --preview, which runs nothing.")
14793
14803
  .option("--idempotency-key <key>", "Optional idempotency key.")
14794
14804
  .option("--max-credits <n>", "Required credit ceiling for live calls.")
14795
14805
  .option("--approved", "Required for live calls after inspecting a dry run.")
14796
14806
  .option("--revision <n>", "Run a specific saved version instead of the live one. Dry-run and smoke-test only: publish a version to run it live.")
14797
14807
  .option("--node <node_id>", "Test one node: runs that node plus only the predecessors it needs, from the same saved graph. Dry-run and smoke-test only.")
14808
+ .option("--preview", "Show what a live run of this exact version would do — every step that leaves Oxygen, the accounts it would use, and the minimum number of billable steps and credits it would cost — without running anything. AI and enrichment steps bill per row and can cost more than that floor. Needs no --mode: it always describes a live run.")
14798
14809
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
14799
14810
  .option("--json", "Print a JSON envelope.")
14800
14811
  .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
14801
14812
  .action(async (workflowArg, options) => {
14802
14813
  const maxCredits = readPositiveNumber(options.maxCredits);
14803
14814
  const revisionVersion = readPositiveNumber(options.revision);
14804
- await handleAsyncAction("workflows call", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
14805
- method: "POST",
14806
- body: {
14807
- workflow: readOption(workflowArg) ?? readOption(options.workflow),
14808
- ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
14809
- ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
14810
- input: options.inputJson ? parseJsonObject(options.inputJson) : {},
14811
- ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
14812
- ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
14813
- ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
14814
- ...(options.approved ? { approved: true } : {}),
14815
- ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
14816
- ...(readOption(options.node) ? { test_node_id: readOption(options.node) } : {}),
14817
- },
14818
- }), options));
14815
+ await handleAsyncAction("workflows call", options, async () => {
14816
+ // --mode stopped being a commander requiredOption so that --preview,
14817
+ // which executes nothing, no longer forces the caller to pick a mode
14818
+ // the server then discards. A real call still needs one. Thrown
14819
+ // INSIDE handleAsyncAction so it prints the CLI's error envelope and
14820
+ // exits 2 like any other usage error — thrown outside, it escaped as
14821
+ // an unhandled rejection and showed the user a Node stack trace.
14822
+ if (!options.preview && !readOption(options.mode)) {
14823
+ throw new OxygenError("invalid_request", "--mode is required. Use smoke-test, dry-run, or live. To see what a live run would do without running it, use --preview — it runs nothing and needs no mode.", { exitCode: 2 });
14824
+ }
14825
+ return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
14826
+ method: "POST",
14827
+ body: {
14828
+ workflow: readOption(workflowArg) ?? readOption(options.workflow),
14829
+ ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
14830
+ ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
14831
+ input: options.inputJson ? parseJsonObject(options.inputJson) : {},
14832
+ ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
14833
+ ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
14834
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
14835
+ ...(options.approved ? { approved: true } : {}),
14836
+ ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
14837
+ ...(readOption(options.node) ? { test_node_id: readOption(options.node) } : {}),
14838
+ ...(options.preview ? { preview: true } : {}),
14839
+ },
14840
+ }), options);
14841
+ });
14819
14842
  }))
14820
14843
  .addCommand(new Command("webhooks")
14821
14844
  .description("Workflow webhook trigger utilities.")
@@ -15014,9 +15037,12 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
15014
15037
  .option("--workflow <workflow>", "Workflow id, slug, or name.")
15015
15038
  .option("--workflow-id <workflow_id>", "Workflow id or slug.")
15016
15039
  .option("--status <status>", "Filter by queued, running, waiting, awaiting_approval, completed, failed, canceling, or canceled.")
15040
+ .option("--mode <mode>", "Filter by execution mode: smoke_test, dry_run, or live.")
15041
+ .option("--cursor <cursor>", "Continue from a previous page. Pass the nextCursor the last response returned.")
15017
15042
  .option("--limit <n>", "Maximum runs to return. Defaults to 50.")
15018
15043
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
15019
15044
  .option("--json", "Print a JSON envelope.")
15045
+ .addHelpText("after", "\nPaging is anchored to the exact last run of the previous page, so runs arriving while you page are never skipped or repeated. A null nextCursor means the history ended.\n")
15020
15046
  .action(async (options) => {
15021
15047
  await handleAsyncAction("workflows runs", options, async () => {
15022
15048
  const query = new URLSearchParams();
@@ -15025,6 +15051,10 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
15025
15051
  query.set("workflow", workflow);
15026
15052
  if (readOption(options.status))
15027
15053
  query.set("status", readOption(options.status) ?? "");
15054
+ if (readOption(options.mode))
15055
+ query.set("mode", readOption(options.mode) ?? "");
15056
+ if (readOption(options.cursor))
15057
+ query.set("cursor", readOption(options.cursor) ?? "");
15028
15058
  const limit = readPositiveInt(options.limit);
15029
15059
  if (limit)
15030
15060
  query.set("limit", String(limit));
@@ -15094,11 +15124,16 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
15094
15124
  .addCommand(new Command("cancel")
15095
15125
  .description("Cancel a queued or running workflow run.")
15096
15126
  .argument("<run_id>", "Workflow run UUID.")
15127
+ .option("--preview", "Show what cancelling would and would not undo — steps in flight, and any external write already dispatched that cancelling cannot recall. Cancels nothing.")
15097
15128
  .option("--json", "Print a JSON envelope.")
15129
+ .addHelpText("after", "\nCancelling stops further steps; it cannot recall a step already dispatched to a provider. Use --preview first when the run may be mid-write.\n")
15098
15130
  .action(async (runId, options) => {
15099
15131
  await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
15100
15132
  method: "POST",
15101
- body: { run_id: runId },
15133
+ body: {
15134
+ run_id: runId,
15135
+ ...(options.preview ? { preview: true } : {}),
15136
+ },
15102
15137
  }));
15103
15138
  }))
15104
15139
  .addCommand(new Command("approvals")
@@ -78,6 +78,24 @@ 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
+ * The credit-era sibling of the code above, and the one most callers actually want.
83
+ *
84
+ * `automation_actions_exceeded` can only be thrown when the plan carries a non-null
85
+ * `monthlyAutomationActions`. Pricing Model 2.0 ([v1.330.0]) set that field to null on
86
+ * every base plan, so on current plans the admission gate in captureAutomationActions()
87
+ * is unreachable and assertAutomationActionsAvailable() refuses with `insufficient_credits`
88
+ * instead — actions bill AUTOMATION_ACTION_CREDITS each out of the one credit pool.
89
+ *
90
+ * Both codes mean the same thing to a caller: "this automation work was REFUSED admission,
91
+ * the customer must feel it, and the run must stop cleanly." Anything else is a metering
92
+ * write that failed and must be stepped over. Matching only the quota code is how a
93
+ * credit-exhausted cron ends up rethrowing out of the scheduler's enqueue loop instead of
94
+ * taking the graceful pause path — abandoning the lease and every remaining due trigger
95
+ * for that tenant's sweep.
96
+ */
97
+ export declare const AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE = "insufficient_credits";
98
+ export declare function isAutomationAdmissionRefusal(error: unknown): boolean;
81
99
  /**
82
100
  * Node network primitives bury the actionable failure in `error.cause`: undici's
83
101
  * fetch rejects with a bare TypeError "fetch failed" and puts the real
@@ -215,6 +215,30 @@ export function isAutomationUsageQuotaError(error) {
215
215
  && typeof error === "object"
216
216
  && error.code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE;
217
217
  }
218
+ /**
219
+ * The credit-era sibling of the code above, and the one most callers actually want.
220
+ *
221
+ * `automation_actions_exceeded` can only be thrown when the plan carries a non-null
222
+ * `monthlyAutomationActions`. Pricing Model 2.0 ([v1.330.0]) set that field to null on
223
+ * every base plan, so on current plans the admission gate in captureAutomationActions()
224
+ * is unreachable and assertAutomationActionsAvailable() refuses with `insufficient_credits`
225
+ * instead — actions bill AUTOMATION_ACTION_CREDITS each out of the one credit pool.
226
+ *
227
+ * Both codes mean the same thing to a caller: "this automation work was REFUSED admission,
228
+ * the customer must feel it, and the run must stop cleanly." Anything else is a metering
229
+ * write that failed and must be stepped over. Matching only the quota code is how a
230
+ * credit-exhausted cron ends up rethrowing out of the scheduler's enqueue loop instead of
231
+ * taking the graceful pause path — abandoning the lease and every remaining due trigger
232
+ * for that tenant's sweep.
233
+ */
234
+ export const AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE = "insufficient_credits";
235
+ export function isAutomationAdmissionRefusal(error) {
236
+ if (!error || typeof error !== "object")
237
+ return false;
238
+ const code = error.code;
239
+ return code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE
240
+ || code === AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE;
241
+ }
218
242
  const MAX_CAUSE_DEPTH = 4;
219
243
  const MAX_CAUSE_MESSAGE_LENGTH = 200;
220
244
  export function errorCauseChain(error, maxDepth = MAX_CAUSE_DEPTH) {
@@ -57,6 +57,7 @@ export * from "./sequences.js";
57
57
  export * from "./suppression-entries.js";
58
58
  export * from "./dnc-identities.js";
59
59
  export * from "./table-limits.js";
60
+ export * from "./table-capacity.js";
60
61
  export * from "./log.js";
61
62
  export * from "./axiom-field-budget.js";
62
63
  export { redactSecretsInString, sanitizeLogFields } from "./redaction.js";
@@ -57,6 +57,7 @@ export * from "./sequences.js";
57
57
  export * from "./suppression-entries.js";
58
58
  export * from "./dnc-identities.js";
59
59
  export * from "./table-limits.js";
60
+ export * from "./table-capacity.js";
60
61
  export * from "./log.js";
61
62
  export * from "./axiom-field-budget.js";
62
63
  // Narrow, deliberate export (ADR 0014): lets telemetry emitters regression-test
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Plan-independent infrastructure envelope for durable Workspace Tables.
3
+ * Plans do not sell different storage entitlements; every workspace gets the
4
+ * same safety boundary and plans continue to differ through operation/rate and
5
+ * credit limits.
6
+ */
7
+ export declare const WORKSPACE_TABLE_CAPACITY: Readonly<{
8
+ tableRowLimit: 3000000;
9
+ workspaceRowLimit: 25000000;
10
+ workspaceDatabaseWarningBytes: number;
11
+ workspaceDatabaseLimitBytes: number;
12
+ }>;
13
+ export declare const WORKSPACE_TABLE_DATABASE_STORAGE_SCOPE: "postgres_workspace_tables_heap_indexes_toast";
@@ -0,0 +1,14 @@
1
+ const GIB = 1024 ** 3;
2
+ /**
3
+ * Plan-independent infrastructure envelope for durable Workspace Tables.
4
+ * Plans do not sell different storage entitlements; every workspace gets the
5
+ * same safety boundary and plans continue to differ through operation/rate and
6
+ * credit limits.
7
+ */
8
+ export const WORKSPACE_TABLE_CAPACITY = Object.freeze({
9
+ tableRowLimit: 3_000_000,
10
+ workspaceRowLimit: 25_000_000,
11
+ workspaceDatabaseWarningBytes: 20 * GIB,
12
+ workspaceDatabaseLimitBytes: 30 * GIB,
13
+ });
14
+ export const WORKSPACE_TABLE_DATABASE_STORAGE_SCOPE = "postgres_workspace_tables_heap_indexes_toast";
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.766.0";
1
+ export declare const OXYGEN_VERSION = "1.799.1";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.766.0";
1
+ export const OXYGEN_VERSION = "1.799.1";
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:
@@ -0,0 +1,33 @@
1
+ import type { WorkflowGraphManifest } from "./types.js";
2
+ /**
3
+ * What changed between the revision currently serving triggers and the one about
4
+ * to replace it.
5
+ *
6
+ * Publishing is the moment a workflow stops being a draft and starts acting on
7
+ * the world, and until now the author approved it having been shown a credit cap
8
+ * and nothing else. "Approve this" is not a review if the thing being approved
9
+ * is invisible. This is the comparison that makes the review honest.
10
+ *
11
+ * Canvas position is deliberately NOT a change. `ui` carries x/y, so dragging a
12
+ * node two pixels would otherwise report the graph as modified and train the
13
+ * author to skim a diff that cries wolf — which is worse than no diff, because
14
+ * it looks like diligence.
15
+ */
16
+ export type WorkflowGraphNodeChange = {
17
+ nodeId: string;
18
+ /** The author's own step name, which is what the canvas shows. */
19
+ label: string;
20
+ change: "added" | "removed" | "modified";
21
+ /** For a modified node, the top-level fields that actually differ. */
22
+ changedFields?: string[];
23
+ };
24
+ export type WorkflowGraphDiff = {
25
+ triggerChanged: boolean;
26
+ inputSchemaChanged: boolean;
27
+ nodes: WorkflowGraphNodeChange[];
28
+ edgesAdded: number;
29
+ edgesRemoved: number;
30
+ /** True when nothing semantic differs — a re-publish of the same graph. */
31
+ unchanged: boolean;
32
+ };
33
+ export declare function diffWorkflowGraphManifests(before: WorkflowGraphManifest, after: WorkflowGraphManifest): WorkflowGraphDiff;
@@ -0,0 +1,75 @@
1
+ function label(node) {
2
+ return node.name?.trim() || node.id;
3
+ }
4
+ /** Everything about a node except where it sits on the canvas. */
5
+ function semanticNode(node) {
6
+ const { ui: _ui, ...rest } = node;
7
+ return rest;
8
+ }
9
+ function stable(value) {
10
+ if (value === undefined)
11
+ return "undefined";
12
+ if (value === null || typeof value !== "object")
13
+ return JSON.stringify(value) ?? "null";
14
+ if (Array.isArray(value))
15
+ return `[${value.map(stable).join(",")}]`;
16
+ const entries = Object.entries(value)
17
+ .filter(([, entry]) => entry !== undefined)
18
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
19
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
20
+ }
21
+ function changedFields(before, after) {
22
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
23
+ return [...keys]
24
+ .filter((key) => stable(before[key]) !== stable(after[key]))
25
+ .sort();
26
+ }
27
+ function edgeKey(edge) {
28
+ return `${edge.source}->${edge.target}#${edge.source_handle ?? ""}`;
29
+ }
30
+ export function diffWorkflowGraphManifests(before, after) {
31
+ const beforeNodes = new Map(before.nodes.map((node) => [node.id, node]));
32
+ const afterNodes = new Map(after.nodes.map((node) => [node.id, node]));
33
+ const changes = [];
34
+ // Reported in the AFTER graph's order so the review reads like the canvas the
35
+ // author is looking at; removals are appended, since they have no position in it.
36
+ for (const node of after.nodes) {
37
+ const previous = beforeNodes.get(node.id);
38
+ if (!previous) {
39
+ changes.push({ nodeId: node.id, label: label(node), change: "added" });
40
+ continue;
41
+ }
42
+ const fields = changedFields(semanticNode(previous), semanticNode(node));
43
+ if (fields.length > 0) {
44
+ changes.push({
45
+ nodeId: node.id,
46
+ label: label(node),
47
+ change: "modified",
48
+ changedFields: fields,
49
+ });
50
+ }
51
+ }
52
+ for (const node of before.nodes) {
53
+ if (!afterNodes.has(node.id)) {
54
+ changes.push({ nodeId: node.id, label: label(node), change: "removed" });
55
+ }
56
+ }
57
+ const beforeEdges = new Set(before.edges.map(edgeKey));
58
+ const afterEdges = new Set(after.edges.map(edgeKey));
59
+ const edgesAdded = [...afterEdges].filter((key) => !beforeEdges.has(key)).length;
60
+ const edgesRemoved = [...beforeEdges].filter((key) => !afterEdges.has(key)).length;
61
+ const triggerChanged = stable(before.trigger) !== stable(after.trigger);
62
+ const inputSchemaChanged = stable(before.input_schema) !== stable(after.input_schema);
63
+ return {
64
+ triggerChanged,
65
+ inputSchemaChanged,
66
+ nodes: changes,
67
+ edgesAdded,
68
+ edgesRemoved,
69
+ unchanged: !triggerChanged
70
+ && !inputSchemaChanged
71
+ && changes.length === 0
72
+ && edgesAdded === 0
73
+ && edgesRemoved === 0,
74
+ };
75
+ }
@@ -24,12 +24,28 @@ export class WorkflowValueCoercionError extends Error {
24
24
  this.name = "WorkflowValueCoercionError";
25
25
  }
26
26
  }
27
+ /**
28
+ * Build the JSON that would satisfy an unresolved `trigger.input.*` path.
29
+ *
30
+ * The product already knows the exact dotted path it could not resolve, so
31
+ * printing only the path makes the reader reconstruct by hand what the error
32
+ * could have handed them. Only `trigger.input.*` is answerable this way — a
33
+ * `steps.*` ref is produced by an earlier node, not by the caller, so no input
34
+ * would fix it and suggesting one would send the reader down a dead end.
35
+ */
36
+ function unresolvedRefRemedy(path) {
37
+ const parts = path.split(".");
38
+ if (parts.length < 3 || parts[0] !== "trigger" || parts[1] !== "input")
39
+ return "";
40
+ const skeleton = parts.slice(2).reduceRight((inner, key) => ({ [key]: inner }), "…");
41
+ return ` Supply it with --input-json '${JSON.stringify(skeleton)}'.`;
42
+ }
27
43
  /** A direct mapping named data the run does not carry; sending undefined is never intentional. */
28
44
  export class WorkflowValueRefUnresolvedError extends Error {
29
45
  path;
30
46
  code = "workflow_value_ref_unresolved";
31
47
  constructor(path) {
32
- super(`Mapped field '${path}' did not resolve in this run.`);
48
+ super(`Mapped field '${path}' did not resolve in this run.${unresolvedRefRemedy(path)}`);
33
49
  this.path = path;
34
50
  this.name = "WorkflowValueRefUnresolvedError";
35
51
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  export * from "./types.js";
16
16
  export * from "./topology.js";
17
+ export * from "./diff.js";
17
18
  export * from "./expression.js";
18
19
  export * from "./params.js";
19
20
  export * from "./remap.js";
@@ -14,6 +14,7 @@
14
14
  */
15
15
  export * from "./types.js";
16
16
  export * from "./topology.js";
17
+ export * from "./diff.js";
17
18
  export * from "./expression.js";
18
19
  export * from "./params.js";
19
20
  export * from "./remap.js";
@@ -24,7 +24,6 @@ const NODE_KINDS = new Set([
24
24
  "wait",
25
25
  "approval",
26
26
  "code",
27
- "workflow",
28
27
  ]);
29
28
  const MERGE_STRATEGIES = new Set(["append", "first", "wait_all"]);
30
29
  const STEP_EFFECTS = new Set(["none", "external_read", "external_write"]);
@@ -334,19 +333,6 @@ node, path, add, scope, options) {
334
333
  case "code":
335
334
  lintCodeNode(node, path, add, scope, options);
336
335
  return;
337
- case "workflow":
338
- if (!isNonEmptyString(node.workflow_id)) {
339
- add(`${path}.workflow_id`, "invalid_workflow_ref", "Workflow node workflow_id is required.");
340
- }
341
- validateValueRefRecord(node.input, `${path}.input`, add, scope);
342
- // Authoring-time refusal, not just a runtime one. A sub-workflow needs its
343
- // own durable run, lease, spend ceiling and cancellation semantics, none of
344
- // which exist yet — so the worker rejects the node terminally. Without this
345
- // rule a user could save the graph, arm a cron trigger on it, and discover
346
- // the refusal at 03:00 on every delivery instead of at `workflows apply`.
347
- // Remove BOTH refusals together when child runs ship.
348
- add(`${path}.kind`, "unsupported_node_kind", "Sub-workflow nodes cannot run yet. Inline the child workflow's nodes for now.");
349
- return;
350
336
  default:
351
337
  return;
352
338
  }
@@ -886,6 +872,15 @@ function lintLoopNode(node, path, add, scope) {
886
872
  if (node.concurrency !== undefined && !isPositiveInteger(node.concurrency)) {
887
873
  add(`${path}.concurrency`, "invalid_loop_target", "Loop concurrency must be a positive integer.");
888
874
  }
875
+ else if (typeof node.concurrency === "number" && node.concurrency > 1) {
876
+ // First-release runtime cut (founder-approved 2026-08-17). The worker runs
877
+ // loop bodies sequentially and always has; `concurrency` was accepted and
878
+ // then ignored, which is the worst of both — the author reads a promise of
879
+ // parallelism, the run delivers none, and nothing says so. Refusing is the
880
+ // honest contract until parallel bodies can interleave lease renewal, spend
881
+ // accounting and per-iteration rows without getting the money wrong.
882
+ add(`${path}.concurrency`, "invalid_loop_target", "Loop bodies run one item at a time. Remove concurrency, or set it to 1.");
883
+ }
889
884
  }
890
885
  function lintSetNode(node, path, add, scope) {
891
886
  const fields = asArray(node.fields);
@@ -932,19 +927,31 @@ function lintApprovalNode(node, path, add, scope) {
932
927
  function lintWaitNode(node, path, add, scope) {
933
928
  const hasDuration = node.duration_seconds !== undefined;
934
929
  const hasUntil = node.until !== undefined;
935
- if (hasDuration === hasUntil) {
936
- add(path, "invalid_wait", "Wait node requires exactly one of duration_seconds or until.");
930
+ // First-release runtime cut (founder-approved 2026-08-17): duration waits only.
931
+ //
932
+ // `until` never did what its name promises. The worker re-evaluates it on a
933
+ // poll against the run's IMMUTABLE input, so it can only ever become true if it
934
+ // was already true — it cannot observe an event, a table write, or anything
935
+ // that happens after the run started. Offering it advertises a transition
936
+ // source that does not exist. Event-driven waits come back when there is a
937
+ // subscription behind them; until then a graph that needs one uses an approval
938
+ // node or a trigger.
939
+ //
940
+ // Existing revisions carrying `until` keep executing unchanged — this refuses
941
+ // the SAVE, not the historical run.
942
+ if (hasUntil) {
943
+ add(`${path}.until`, "invalid_wait", "Wait until is not available. Use a duration wait, or an approval node to pause for a decision.");
937
944
  return;
938
945
  }
939
- if (hasDuration) {
940
- if (typeof node.duration_seconds !== "number"
941
- || !Number.isFinite(node.duration_seconds)
942
- || node.duration_seconds <= 0) {
943
- add(`${path}.duration_seconds`, "invalid_wait", "Wait duration_seconds must be a positive number.");
944
- }
946
+ if (!hasDuration) {
947
+ add(path, "invalid_wait", "Wait node requires duration_seconds.");
945
948
  return;
946
949
  }
947
- validateValueRef(node.until, `${path}.until`, add, scope);
950
+ if (typeof node.duration_seconds !== "number"
951
+ || !Number.isFinite(node.duration_seconds)
952
+ || node.duration_seconds <= 0) {
953
+ add(`${path}.duration_seconds`, "invalid_wait", "Wait duration_seconds must be a positive number.");
954
+ }
948
955
  }
949
956
  function isPositiveInteger(value) {
950
957
  return typeof value === "number" && Number.isInteger(value) && value > 0;
@@ -1092,11 +1099,6 @@ function graphNodeValueRefs(node, path) {
1092
1099
  ref,
1093
1100
  path: `${path}.configuration.${name}`,
1094
1101
  }));
1095
- case "workflow":
1096
- return Object.entries(node.input).map(([name, ref]) => ({
1097
- ref,
1098
- path: `${path}.input.${name}`,
1099
- }));
1100
1102
  default:
1101
1103
  return [];
1102
1104
  }
@@ -204,7 +204,7 @@ export declare const workflowGraphManifestSchema: {
204
204
  readonly type: "string";
205
205
  };
206
206
  readonly kind: {
207
- readonly enum: readonly ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code", "workflow"];
207
+ readonly enum: readonly ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code"];
208
208
  };
209
209
  readonly ui: {
210
210
  readonly type: "object";
@@ -994,7 +994,7 @@ export declare const portableWorkflowDefinitionSchema: {
994
994
  readonly type: "string";
995
995
  };
996
996
  readonly kind: {
997
- readonly enum: readonly ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code", "workflow"];
997
+ readonly enum: readonly ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code"];
998
998
  };
999
999
  readonly ui: {
1000
1000
  readonly type: "object";
@@ -156,7 +156,7 @@ const nodeSchema = {
156
156
  name: { type: "string" },
157
157
  description: { type: "string" },
158
158
  kind: {
159
- enum: ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code", "workflow"],
159
+ enum: ["trigger", "tool", "filter", "switch", "loop", "merge", "set", "wait", "approval", "code"],
160
160
  },
161
161
  ui: {
162
162
  type: "object",
@@ -185,11 +185,6 @@ export function remapNodeRefs(node, ids) {
185
185
  return node.until === undefined
186
186
  ? node
187
187
  : { ...node, until: remapValueRef(node.until, ids) };
188
- case "workflow":
189
- return {
190
- ...node,
191
- input: Object.fromEntries(Object.entries(node.input).map(([key, ref]) => [key, remapValueRef(ref, ids)])),
192
- };
193
188
  // Missed when this module was first written, because the kind list was typed
194
189
  // from memory instead of read off the WorkflowGraphNode union — so a
195
190
  // duplicated approval node kept pointing at the original, silently, which is
@@ -22,6 +22,12 @@ export type WorkflowTestScope = {
22
22
  export type WorkflowTestScopeReceipt = WorkflowTestScope & {
23
23
  source_hash: string;
24
24
  planned_node_ids: string[];
25
+ /**
26
+ * The nodes this scope deliberately excludes. Recorded so "the downstream step
27
+ * did not run" is something the product states, not something a reader infers
28
+ * from a missing row in the step list.
29
+ */
30
+ excluded_node_ids?: string[];
25
31
  };
26
32
  export declare const RESERVED_NODE_IDS: ReadonlySet<string>;
27
33
  /**
@@ -376,13 +382,7 @@ export type WorkflowGraphNode =
376
382
  request?: Record<string, WorkflowValueRef>;
377
383
  })
378
384
  /** Legacy source, imported durable recipe, or typed oxygen-js-v1 code. */
379
- | WorkflowGraphCodeNode
380
- /** Calls another workflow as a child run. */
381
- | (WorkflowGraphNodeBase & {
382
- kind: "workflow";
383
- workflow_id: string;
384
- input: Record<string, WorkflowValueRef>;
385
- });
385
+ | WorkflowGraphCodeNode;
386
386
  export type WorkflowGraphNodeKind = WorkflowGraphNode["kind"];
387
387
  /** Narrow a node union member by its `kind` discriminant. */
388
388
  export type WorkflowGraphNodeOfKind<K extends WorkflowGraphNodeKind> = Extract<WorkflowGraphNode, {
@@ -475,5 +475,4 @@ export declare function isTriggerNode(node: WorkflowGraphNode): node is Workflow
475
475
  export declare function isToolNode(node: WorkflowGraphNode): node is WorkflowGraphNodeOfKind<"tool">;
476
476
  export declare function isSwitchNode(node: WorkflowGraphNode): node is WorkflowGraphNodeOfKind<"switch">;
477
477
  export declare function isLoopNode(node: WorkflowGraphNode): node is WorkflowGraphNodeOfKind<"loop">;
478
- export declare function isWorkflowSubgraphNode(node: WorkflowGraphNode): node is WorkflowGraphNodeOfKind<"workflow">;
479
478
  export {};
@@ -65,7 +65,6 @@ export const MAX_WORKFLOW_LOOP_ITERATIONS = 1_000;
65
65
  */
66
66
  export const BILLABLE_WORKFLOW_GRAPH_NODE_KINDS = new Set([
67
67
  "tool",
68
- "workflow",
69
68
  ]);
70
69
  /** Whether a v2 node of this kind draws an automation action when it executes. */
71
70
  export function workflowGraphNodeKindBills(kind) {
@@ -161,6 +160,3 @@ export function isSwitchNode(node) {
161
160
  export function isLoopNode(node) {
162
161
  return isNodeOfKind(node, "loop");
163
162
  }
164
- export function isWorkflowSubgraphNode(node) {
165
- return isNodeOfKind(node, "workflow");
166
- }
@@ -1938,7 +1938,6 @@ export function workflowManifestCanInvokeTool(manifest) {
1938
1938
  && manifest.tools_used.some((toolId) => isNonEmptyString(toolId));
1939
1939
  const viaGraph = Array.isArray(manifest.nodes)
1940
1940
  && manifest.nodes.some((node) => isRecord(node) && (node.kind === "tool"
1941
- || node.kind === "workflow"
1942
1941
  || (isRecord(node.recipe)
1943
1942
  && Array.isArray(node.recipe.tools_used)
1944
1943
  && node.recipe.tools_used.some((toolId) => isNonEmptyString(toolId)))));
@@ -69,13 +69,6 @@ function portableNodeIssue(node, index) {
69
69
  message: "Legacy execution context cannot be represented as a portable editable graph.",
70
70
  };
71
71
  }
72
- if (node.kind === "workflow") {
73
- return {
74
- path: `$.graph.nodes.${index}`,
75
- code: "workflow_node_not_portable",
76
- message: "Child-workflow nodes are not executable yet and cannot be exported as a portable definition.",
77
- };
78
- }
79
72
  return null;
80
73
  }
81
74
  function valueRefUsesWorkspaceAsset(ref) {
@@ -114,8 +107,6 @@ function nodeUsesWorkspaceAsset(node) {
114
107
  return node.code !== undefined
115
108
  ? Object.values(node.code.inputs).some(valueRefUsesWorkspaceAsset)
116
109
  : Object.values(node.configuration ?? {}).some(valueRefUsesWorkspaceAsset);
117
- case "workflow":
118
- return Object.values(node.input).some(valueRefUsesWorkspaceAsset);
119
110
  case "trigger":
120
111
  case "merge":
121
112
  return false;
@@ -14,6 +14,9 @@ export type CronCadenceAssessment = {
14
14
  floorActionsPer30Days: number;
15
15
  includedActions: number;
16
16
  floorShareOfIncluded: number;
17
+ basis: "plan_action_quota" | "plan_monthly_credits";
18
+ includedCredits: number | null;
19
+ floorCreditsPer30Days: number | null;
17
20
  minimumViableIntervalMinutes: number | null;
18
21
  verdict: "fits" | "impossible";
19
22
  };
@@ -22,6 +25,8 @@ export declare function assessCronCadenceViability(input: {
22
25
  cron: string | null | undefined;
23
26
  includedActions: number | null;
24
27
  overageEnabled: boolean;
28
+ includedCredits?: number | null;
29
+ creditsPerAction?: number | null;
25
30
  }): CronCadenceAssessment | null;
26
31
  export type CronAggressiveness = {
27
32
  level: "aggressive" | "very_aggressive";
@@ -287,26 +287,53 @@ const MINUTES_PER_30_DAYS = 43_200;
287
287
  // enabled (the org has opted into paying past the cap, so a cadence that
288
288
  // overruns it bills instead of failing).
289
289
  export function assessCronCadenceViability(input) {
290
- if (!input.cron || input.includedActions === null || input.overageEnabled)
290
+ if (!input.cron || input.overageEnabled)
291
291
  return null;
292
- if (!Number.isFinite(input.includedActions) || input.includedActions <= 0)
292
+ const creditsPerAction = typeof input.creditsPerAction === "number"
293
+ && Number.isFinite(input.creditsPerAction)
294
+ && input.creditsPerAction > 0
295
+ ? input.creditsPerAction
296
+ : null;
297
+ const includedCredits = typeof input.includedCredits === "number"
298
+ && Number.isFinite(input.includedCredits)
299
+ && input.includedCredits > 0
300
+ ? input.includedCredits
301
+ : null;
302
+ // Prefer a real action quota when the plan still has one; otherwise derive the
303
+ // equivalent action budget from monthly credits.
304
+ const basis = input.includedActions === null
305
+ ? "plan_monthly_credits"
306
+ : "plan_action_quota";
307
+ const includedActions = basis === "plan_action_quota"
308
+ ? input.includedActions
309
+ : (includedCredits !== null && creditsPerAction !== null
310
+ ? Math.floor(includedCredits / creditsPerAction)
311
+ : null);
312
+ if (includedActions === null)
313
+ return null;
314
+ if (!Number.isFinite(includedActions) || includedActions <= 0)
293
315
  return null;
294
316
  const scheduledRunsPer30Days = estimateCronRunsPer30Days(input.cron);
295
317
  if (scheduledRunsPer30Days === null)
296
318
  return null;
297
319
  const actionsPerRunFloor = estimateWorkflowRunAutomationActionsFloor(input.manifest);
298
320
  const floorActionsPer30Days = scheduledRunsPer30Days * actionsPerRunFloor;
299
- const runsAllowedPer30Days = Math.floor(input.includedActions / actionsPerRunFloor);
321
+ const runsAllowedPer30Days = Math.floor(includedActions / actionsPerRunFloor);
300
322
  return {
301
323
  scheduledRunsPer30Days,
302
324
  actionsPerRunFloor,
303
325
  floorActionsPer30Days,
304
- includedActions: input.includedActions,
305
- floorShareOfIncluded: floorActionsPer30Days / input.includedActions,
326
+ includedActions,
327
+ floorShareOfIncluded: floorActionsPer30Days / includedActions,
328
+ basis,
329
+ includedCredits: basis === "plan_monthly_credits" ? includedCredits : null,
330
+ floorCreditsPer30Days: basis === "plan_monthly_credits" && creditsPerAction !== null
331
+ ? Math.round(floorActionsPer30Days * creditsPerAction * 1000) / 1000
332
+ : null,
306
333
  minimumViableIntervalMinutes: runsAllowedPer30Days > 0
307
334
  ? Math.ceil(MINUTES_PER_30_DAYS / runsAllowedPer30Days)
308
335
  : null,
309
- verdict: floorActionsPer30Days > input.includedActions ? "impossible" : "fits",
336
+ verdict: floorActionsPer30Days > includedActions ? "impossible" : "fits",
310
337
  };
311
338
  }
312
339
  // Warn (never block) below this cadence; the billing gate above stays the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.766.0",
3
+ "version": "1.799.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",